/** * 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 }; }