/**
* useArcadeBridge
*
* Owns the full RN ↔ WebView bridge for Dubs Arcade pool integration.
* Replaces the manual sessionToken / gameStartTime / handleMessage / injectJavaScript
* wiring that previously lived inline in each game's index.tsx.
*
* PROTOCOL VERSION: dubsArcade 1.0
*
* Usage:
*
* const { webviewRef, handleMessage, triggerPlay, lastResult } = useArcadeBridge({
* poolId,
* startAttempt,
* submitScore,
* canPlay,
* onScoreSubmitted: (result) => { refetchPool(); },
* onError: (err) => { console.warn(err); },
* });
*
*
* Play
*/
import { useRef, useState, useCallback } from 'react';
import type { StartAttemptResult, SubmitScoreResult } from '../types';
// react-native-webview is a peer dependency of apps using this SDK;
// we only need its ref type for the public interface.
// Using `any` here avoids forcing apps that don't use WebView to install it.
type WebView = any;
const PROTOCOL_VERSION = '1.0';
// ─── Types ──────────────────────────────────────────────────────────────────
export interface UseArcadeBridgeOptions {
/** Whether the player currently has an entry with lives remaining */
canPlay: boolean;
/** From useArcadeGame — starts a new life on the server */
startAttempt: () => Promise;
/** From useArcadeGame — submits a score for a completed life */
submitScore: (
sessionToken: string,
score: number,
durationMs?: number,
) => Promise;
/** Called when a new life starts (after startAttempt succeeds and session is injected) */
onPlayStarted?: () => void;
/** Called after a score is successfully submitted */
onScoreSubmitted?: (result: SubmitScoreResult) => void;
/** Called on any bridge error (network, invalid message, etc.) */
onError?: (err: Error) => void;
}
export interface UseArcadeBridgeResult {
/** Attach to */
webviewRef: React.RefObject;
/** Pass to */
handleMessage: (event: any) => void;
/**
* Call when the player taps PLAY in your UI.
* Calls startAttempt() then injects the session token + ARCADE_START into the WebView.
*/
triggerPlay: () => Promise;
/** The result of the most recent submitted score, or null */
lastResult: SubmitScoreResult | null;
/** True while a startAttempt or submitScore request is in flight */
bridgeLoading: boolean;
}
// ─── Hook ───────────────────────────────────────────────────────────────────
export function useArcadeBridge({
canPlay,
startAttempt,
submitScore,
onPlayStarted,
onScoreSubmitted,
onError,
}: UseArcadeBridgeOptions): UseArcadeBridgeResult {
const webviewRef = useRef(null);
// Active session state — kept in refs so handleMessage callbacks stay stable
const sessionTokenRef = useRef(null);
const gameStartTimeRef = useRef(0);
// Keep canPlay in a ref so TAP_PLAY handler always sees the latest value
const canPlayRef = useRef(canPlay);
canPlayRef.current = canPlay;
const [lastResult, setLastResult] = useState(null);
const [bridgeLoading, setBridgeLoading] = useState(false);
// ── Inject session token + fire ARCADE_START ──────────────────────────────
const injectSession = useCallback((token: string, attemptNumber: number) => {
webviewRef.current?.injectJavaScript(`
window.ARCADE_SESSION_TOKEN = ${JSON.stringify(token)};
window.ARCADE_ATTEMPT_NUMBER = ${attemptNumber};
window._ARCADE_GAME_START_TIME = Date.now();
window.dispatchEvent(new Event('ARCADE_START'));
true;
`);
}, []);
// ── triggerPlay ───────────────────────────────────────────────────────────
const triggerPlay = useCallback(async () => {
if (!canPlay) return;
setBridgeLoading(true);
try {
const result = await startAttempt();
sessionTokenRef.current = result.sessionToken;
gameStartTimeRef.current = Date.now();
setLastResult(null);
injectSession(result.sessionToken, result.attemptNumber);
onPlayStarted?.();
} catch (err) {
const e = err instanceof Error ? err : new Error(String(err));
onError?.(e);
} finally {
setBridgeLoading(false);
}
}, [canPlay, startAttempt, injectSession, onPlayStarted, onError]);
// ── handleMessage ─────────────────────────────────────────────────────────
const handleMessage = useCallback(
async (event: any) => {
let data: Record;
// Parse + validate protocol envelope
try {
data = JSON.parse(event.nativeEvent.data);
} catch {
return; // non-JSON postMessages from the game's own code — ignore
}
// If dubsArcade version is present it must match — if absent, accept anyway
// (backward-compat: old game builds don't include the envelope yet)
if (data.dubsArcade !== undefined && data.dubsArcade !== PROTOCOL_VERSION) return;
switch (data.type) {
case 'TAP_PLAY': {
if (canPlayRef.current) {
triggerPlay();
}
return;
}
case 'GAME_OVER': {
const token = sessionTokenRef.current;
if (!token) return; // no active session — stale message, ignore
const score: number = typeof data.score === 'number' ? data.score : 0;
// Use server-start time as ground truth; fall back to game's reported duration
const duration =
gameStartTimeRef.current > 0
? Date.now() - gameStartTimeRef.current
: typeof data.durationMs === 'number'
? data.durationMs
: undefined;
// Clear immediately to prevent double-submit on re-render
sessionTokenRef.current = null;
gameStartTimeRef.current = 0;
setBridgeLoading(true);
try {
const result = await submitScore(token, score, duration);
setLastResult(result);
onScoreSubmitted?.(result);
} catch (err) {
const e = err instanceof Error ? err : new Error(String(err));
onError?.(e);
} finally {
setBridgeLoading(false);
}
return;
}
default:
// Unknown message type from a future protocol version — ignore gracefully
return;
}
},
[triggerPlay, submitScore, onScoreSubmitted, onError],
);
return { webviewRef, handleMessage, triggerPlay, lastResult, bridgeLoading };
}