import { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { useAuth } from 'react-oidc-context'; import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types'; import { generateQuiz } from '../services/geminiService'; import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants'; import { Peer, DataConnection, PeerOptions } from 'peerjs'; import { uniqueNamesGenerator, adjectives, animals } from 'unique-names-generator'; const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001'; const SESSION_STORAGE_KEY = 'kaboot_session'; const DRAFT_QUIZ_KEY = 'kaboot_draft_quiz'; const STATE_SYNC_INTERVAL = 5000; // ICE server configuration for WebRTC NAT traversal // TURN servers are required for peers behind restrictive NATs/firewalls const getIceServers = (): RTCIceServer[] => { const servers: RTCIceServer[] = [ // Google's public STUN servers (free, for NAT discovery) { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, ]; // Add TURN server if configured (required for restrictive NATs) const turnUrl = import.meta.env.VITE_TURN_URL; const turnUsername = import.meta.env.VITE_TURN_USERNAME; const turnCredential = import.meta.env.VITE_TURN_CREDENTIAL; if (turnUrl) { servers.push({ urls: turnUrl, username: turnUsername || '', credential: turnCredential || '', }); } return servers; }; const getPeerOptions = (id?: string): PeerOptions => { const options: PeerOptions = { config: { iceServers: getIceServers(), iceCandidatePoolSize: 10, }, debug: import.meta.env.DEV ? 2 : 0, // Errors + warnings in dev }; return options; }; interface StoredSession { pin: string; role: GameRole; hostSecret?: string; playerName?: string; playerId?: string; } const getStoredSession = (): StoredSession | null => { try { const stored = localStorage.getItem(SESSION_STORAGE_KEY); return stored ? JSON.parse(stored) : null; } catch { return null; } }; const storeSession = (session: StoredSession) => { localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session)); }; const clearStoredSession = () => { localStorage.removeItem(SESSION_STORAGE_KEY); }; interface DraftQuiz { quiz: Quiz; topic?: string; sourceQuizId?: string; } const getDraftQuiz = (): DraftQuiz | null => { try { const stored = sessionStorage.getItem(DRAFT_QUIZ_KEY); return stored ? JSON.parse(stored) : null; } catch { return null; } }; const storeDraftQuiz = (draft: DraftQuiz) => { sessionStorage.setItem(DRAFT_QUIZ_KEY, JSON.stringify(draft)); }; const clearDraftQuiz = () => { sessionStorage.removeItem(DRAFT_QUIZ_KEY); }; export const useGame = () => { const navigate = useNavigate(); const location = useLocation(); const auth = useAuth(); const [role, setRole] = useState('HOST'); const [gameState, setGameState] = useState('LANDING'); const [quiz, setQuiz] = useState(null); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); const [players, setPlayers] = useState([]); const [timeLeft, setTimeLeft] = useState(0); const [error, setError] = useState(null); const [hasAnswered, setHasAnswered] = useState(false); const [gamePin, setGamePin] = useState(null); const [currentCorrectShape, setCurrentCorrectShape] = useState(null); const [lastPointsEarned, setLastPointsEarned] = useState(null); const [lastAnswerCorrect, setLastAnswerCorrect] = useState(null); const [selectedOption, setSelectedOption] = useState(null); const [currentPlayerScore, setCurrentPlayerScore] = useState(0); const [currentStreak, setCurrentStreak] = useState(0); const [currentPlayerId, setCurrentPlayerId] = useState(null); const [currentPlayerName, setCurrentPlayerName] = useState(null); const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null); const [sourceQuizId, setSourceQuizId] = useState(null); const [gameConfig, setGameConfig] = useState(DEFAULT_GAME_CONFIG); const [firstCorrectPlayerId, setFirstCorrectPlayerId] = useState(null); const [hostSecret, setHostSecret] = useState(null); const [isReconnecting, setIsReconnecting] = useState(false); const [presenterId, setPresenterId] = useState(null); const timerRef = useRef | null>(null); const syncTimerRef = useRef | null>(null); const peerRef = useRef(null); const connectionsRef = useRef>(new Map()); const hostConnectionRef = useRef(null); const timeLeftRef = useRef(0); const playersRef = useRef([]); const currentQuestionIndexRef = useRef(0); const quizRef = useRef(null); const gameConfigRef = useRef(DEFAULT_GAME_CONFIG); const gamePinRef = useRef(null); const hostSecretRef = useRef(null); const gameStateRef = useRef("LANDING"); const firstCorrectPlayerIdRef = useRef(null); const currentCorrectShapeRef = useRef(null); const presenterIdRef = useRef(null); useEffect(() => { timeLeftRef.current = timeLeft; }, [timeLeft]); useEffect(() => { playersRef.current = players; }, [players]); useEffect(() => { currentQuestionIndexRef.current = currentQuestionIndex; }, [currentQuestionIndex]); useEffect(() => { quizRef.current = quiz; }, [quiz]); useEffect(() => { gameConfigRef.current = gameConfig; }, [gameConfig]); useEffect(() => { gamePinRef.current = gamePin; }, [gamePin]); useEffect(() => { hostSecretRef.current = hostSecret; }, [hostSecret]); useEffect(() => { gameStateRef.current = gameState; }, [gameState]); useEffect(() => { firstCorrectPlayerIdRef.current = firstCorrectPlayerId; }, [firstCorrectPlayerId]); useEffect(() => { currentCorrectShapeRef.current = currentCorrectShape; }, [currentCorrectShape]); useEffect(() => { presenterIdRef.current = presenterId; }, [presenterId]); const isInitializingFromUrl = useRef(false); useEffect(() => { if (isInitializingFromUrl.current) return; if (auth.isLoading) return; const getTargetPath = () => { if ( location.pathname === '/callback' || location.pathname.startsWith('/shared/') || location.pathname === '/upgrade' || location.pathname === '/payment/success' || location.pathname === '/payment/cancel' ) { return null; } switch (gameState) { case 'LANDING': if (gamePin && location.pathname.startsWith('/play/')) { return `/play/${gamePin}`; } return '/'; case 'CREATING': case 'GENERATING': return '/create'; case 'EDITING': return sourceQuizId ? `/edit/${sourceQuizId}` : '/edit/draft'; case 'LOBBY': case 'COUNTDOWN': case 'QUESTION': case 'REVEAL': case 'SCOREBOARD': case 'PODIUM': case 'HOST_RECONNECTED': if (gamePin) { return role === 'HOST' ? `/host/${gamePin}` : `/play/${gamePin}`; } return '/'; case 'DISCONNECTED': case 'WAITING_TO_REJOIN': if (gamePin) { return `/play/${gamePin}`; } return '/'; default: return '/'; } }; const targetPath = getTargetPath(); if (targetPath !== null && location.pathname !== targetPath) { const useReplace = ['COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD'].includes(gameState); navigate(targetPath + location.search, { replace: useReplace }); } }, [gameState, gamePin, role, navigate, location.pathname, location.search, sourceQuizId, auth.isLoading]); useEffect(() => { if (auth.isLoading) return; const isLibraryQuizRoute = /^\/edit\/[a-zA-Z0-9-]+$/.test(location.pathname) && location.pathname !== '/edit/draft'; if (isLibraryQuizRoute && !auth.isAuthenticated) { clearDraftQuiz(); auth.signinRedirect(); } }, [auth.isLoading, auth.isAuthenticated, location.pathname]); const generateGamePin = () => { const chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; let pin = ''; for (let i = 0; i < 6; i++) { pin += chars.charAt(Math.floor(Math.random() * chars.length)); } return pin; }; const generateRandomName = (): string => { return uniqueNamesGenerator({ dictionaries: [adjectives, animals], separator: ' ', style: 'capital', length: 2, }); }; const syncGameState = useCallback(async () => { if (!gamePinRef.current || !hostSecretRef.current) return; if (gameStateRef.current === 'LANDING' || gameStateRef.current === 'EDITING' || gameStateRef.current === 'CREATING' || gameStateRef.current === 'GENERATING') return; try { await fetch(`${BACKEND_URL}/api/games/${gamePinRef.current}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-Host-Secret': hostSecretRef.current, }, body: JSON.stringify({ gameState: gameStateRef.current, currentQuestionIndex: currentQuestionIndexRef.current, firstCorrectPlayerId: firstCorrectPlayerIdRef.current, players: playersRef.current, }), }); } catch (err) { console.error('Failed to sync game state:', err); } }, []); const createGameSession = async (pin: string, peerId: string, quizData: Quiz, config: GameConfig): Promise<{ hostSecret: string; pin: string } | null> => { const maxRetries = 5; let currentPin = pin; for (let attempt = 0; attempt < maxRetries; attempt++) { try { const response = await fetch(`${BACKEND_URL}/api/games`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pin: currentPin, hostPeerId: peerId, quiz: quizData, gameConfig: config, }), }); if (response.status === 409) { currentPin = generateGamePin(); continue; } if (!response.ok) { console.error('Failed to create game session'); return null; } const data = await response.json(); return { hostSecret: data.hostSecret, pin: currentPin }; } catch (err) { console.error('Error creating game session:', err); return null; } } console.error('Failed to create game after max retries'); return null; }; const updateHostPeerId = async (pin: string, secret: string, newPeerId: string) => { try { await fetch(`${BACKEND_URL}/api/games/${pin}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-Host-Secret': secret, }, body: JSON.stringify({ hostPeerId: newPeerId }), }); } catch (err) { console.error('Error updating host peer ID:', err); } }; const fetchHostSession = async (pin: string, secret: string) => { try { const response = await fetch(`${BACKEND_URL}/api/games/${pin}/host`, { headers: { 'X-Host-Secret': secret }, }); if (!response.ok) return null; return response.json(); } catch { return null; } }; const fetchGameInfo = async (pin: string) => { try { const response = await fetch(`${BACKEND_URL}/api/games/${pin}`); if (!response.ok) return null; return response.json(); } catch { return null; } }; const deleteGameSession = async (pin: string, secret: string) => { try { await fetch(`${BACKEND_URL}/api/games/${pin}`, { method: 'DELETE', headers: { 'X-Host-Secret': secret }, }); } catch (err) { console.error('Error deleting game session:', err); } }; const handleHostDataRef = useRef<(conn: DataConnection, data: NetworkMessage) => void>(() => {}); const handleClientDataRef = useRef<(data: NetworkMessage) => void>(() => {}); const setupHostPeer = (pin: string, onReady: (peerId: string) => void) => { const peer = new Peer(`kaboot-${pin}`, getPeerOptions()); peerRef.current = peer; peer.on('open', (id) => { onReady(id); }); peer.on('connection', (conn) => { conn.on('data', (data: any) => { handleHostDataRef.current(conn, data); }); conn.on('close', () => { connectionsRef.current.delete(conn.peer); }); }); peer.on('error', (err) => { if (err.type === 'unavailable-id') { peer.destroy(); const newPeer = new Peer(getPeerOptions()); peerRef.current = newPeer; newPeer.on('open', (id) => { onReady(id); }); newPeer.on('connection', (conn) => { conn.on('data', (data: any) => { handleHostDataRef.current(conn, data); }); }); newPeer.on('error', () => { setError("Network error. Please reload."); setGameState('LANDING'); clearStoredSession(); }); } else { setError("Network error. Please reload."); setGameState('LANDING'); clearStoredSession(); } }); }; const initializeHostGame = async (newQuiz: Quiz, hostParticipates: boolean = true) => { setQuiz(newQuiz); const initialPin = generateGamePin(); setGamePin(initialPin); setupHostPeer(initialPin, async (peerId) => { const result = await createGameSession(initialPin, peerId, newQuiz, gameConfigRef.current); if (!result) { setError("Failed to create game. Please try again."); setGameState('LANDING'); return; } const { hostSecret: secret, pin } = result; if (pin !== initialPin) { setGamePin(pin); } setHostSecret(secret); storeSession({ pin, role: 'HOST', hostSecret: secret }); if (hostParticipates) { const hostPlayer: Player = { id: 'host', name: 'Host', score: 0, previousScore: 0, streak: 0, lastAnswerCorrect: null, selectedShape: null, pointsBreakdown: null, isBot: false, avatarSeed: Math.random(), color: PLAYER_COLORS[0] }; setPlayers([hostPlayer]); setCurrentPlayerId('host'); setCurrentPlayerName('Host'); } else { setPlayers([]); setCurrentPlayerId(null); setCurrentPlayerName(null); } setGameState('LOBBY'); syncTimerRef.current = setInterval(syncGameState, STATE_SYNC_INTERVAL); }); }; const reconnectAsHost = async (session: StoredSession) => { if (!session.hostSecret) { clearStoredSession(); return; } setIsReconnecting(true); setRole('HOST'); setGamePin(session.pin); setGameState('DISCONNECTED'); // Show loading state on disconnected screen setCurrentPlayerName("Host"); const hostData = await fetchHostSession(session.pin, session.hostSecret); if (!hostData) { clearStoredSession(); setIsReconnecting(false); setGameState('LANDING'); return; } setHostSecret(session.hostSecret); setQuiz(hostData.quiz); setGameConfig(hostData.gameConfig); setCurrentQuestionIndex(hostData.currentQuestionIndex || 0); setFirstCorrectPlayerId(hostData.firstCorrectPlayerId || null); const hostPlayer = (hostData.players || []).find((p: Player) => p.id === 'host'); if (hostPlayer) { setCurrentPlayerId('host'); setCurrentPlayerName('Host'); setCurrentPlayerScore(hostPlayer.score); setCurrentStreak(hostPlayer.streak); setPlayers(hostData.players || []); if (hostPlayer.lastAnswerCorrect !== null) { setHasAnswered(true); if (hostPlayer.pointsBreakdown) { setLastPointsEarned(hostPlayer.pointsBreakdown.total); } } } else { setPlayers([]); setCurrentPlayerName('Host'); // Ensure name is set for DisconnectedScreen } setupHostPeer(session.pin, async (peerId) => { if (peerId !== hostData.hostPeerId) { await updateHostPeerId(session.pin, session.hostSecret!, peerId); } if (hostData.gameState === 'LOBBY') { setGameState('LOBBY'); } else if (hostData.gameState === 'PODIUM') { setGameState('PODIUM'); } else { setGameState('HOST_RECONNECTED'); } setIsReconnecting(false); syncTimerRef.current = setInterval(syncGameState, STATE_SYNC_INTERVAL); }); }; const reconnectAsClient = async (session: StoredSession) => { if (!session.playerName) { clearStoredSession(); return; } setIsReconnecting(true); setRole('CLIENT'); setGamePin(session.pin); setCurrentPlayerName(session.playerName); setGameState('WAITING_TO_REJOIN'); // Show "Welcome Back" screen early const gameInfo = await fetchGameInfo(session.pin); if (!gameInfo) { setGameState('DISCONNECTED'); setIsReconnecting(false); return; } if (gameInfo.gameState === 'PODIUM') { clearStoredSession(); setIsReconnecting(false); setGameState('LANDING'); return; } const peer = new Peer(getPeerOptions()); peerRef.current = peer; peer.on('open', (id) => { setCurrentPlayerId(id); storeSession({ ...session, playerId: id }); connectToHost(peer, gameInfo.hostPeerId, session.playerName!, true, session.playerId); }); peer.on('error', () => { setError("Network error"); setIsReconnecting(false); setGameState('DISCONNECTED'); }); }; useEffect(() => { const initializeFromUrl = async () => { const path = location.pathname; if (path === '/callback' || path.startsWith('/shared/')) { return; } const hostMatch = path.match(/^\/host\/([A-Z0-9]+)$/i); const playMatch = path.match(/^\/play\/([A-Z0-9]+)$/i); const session = getStoredSession(); const pinFromUrl = hostMatch ? hostMatch[1] : (playMatch ? playMatch[1] : null); if (pinFromUrl && session && session.pin === pinFromUrl) { isInitializingFromUrl.current = true; if (session.role === 'HOST') { await reconnectAsHost(session); } else { await reconnectAsClient(session); } isInitializingFromUrl.current = false; return; } if (hostMatch) { navigate('/', { replace: true }); return; } if (playMatch) { const urlPin = playMatch[1]; if (session && session.pin === urlPin && session.role === 'CLIENT' && session.playerName) { isInitializingFromUrl.current = true; await reconnectAsClient(session); isInitializingFromUrl.current = false; return; } setGamePin(urlPin); return; } if (path === '/create') { isInitializingFromUrl.current = true; setGameState('CREATING'); setRole('HOST'); isInitializingFromUrl.current = false; return; } if (path === '/edit/draft') { const draft = getDraftQuiz(); if (draft) { isInitializingFromUrl.current = true; setRole('HOST'); setQuiz(draft.quiz); setSourceQuizId(null); if (draft.topic !== undefined) { setPendingQuizToSave({ quiz: draft.quiz, topic: draft.topic }); } setGameState('EDITING'); isInitializingFromUrl.current = false; } else { navigate('/', { replace: true }); } return; } const editMatch = path.match(/^\/edit\/([a-zA-Z0-9-]+)$/); if (editMatch) { const quizId = editMatch[1]; const draft = getDraftQuiz(); if (draft && draft.sourceQuizId === quizId) { isInitializingFromUrl.current = true; setRole('HOST'); setQuiz(draft.quiz); setSourceQuizId(quizId); setGameState('EDITING'); isInitializingFromUrl.current = false; } else { navigate('/', { replace: true }); } return; } if (session) { if (session.role === 'HOST') { reconnectAsHost(session); } else { reconnectAsClient(session); } } }; initializeFromUrl(); return () => { if (timerRef.current) clearInterval(timerRef.current); if (syncTimerRef.current) clearInterval(syncTimerRef.current); if (peerRef.current) peerRef.current.destroy(); }; }, []); const uploadDocument = async (file: File, useOcr: boolean = false): Promise => { if (!auth.user?.access_token) { throw new Error('Authentication required to upload documents'); } const formData = new FormData(); formData.append('document', file); formData.append('useOcr', String(useOcr)); const response = await fetch(`${BACKEND_URL}/api/upload`, { method: 'POST', headers: { 'Authorization': `Bearer ${auth.user.access_token}`, }, body: formData }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to upload document'); } return response.json(); }; const startQuizGen = async (options: { topic?: string; questionCount?: number; files?: File[]; useOcr?: boolean; aiProvider?: 'gemini' | 'openrouter' | 'openai' | 'system'; apiKey?: string; geminiModel?: string; openRouterModel?: string; openAIModel?: string; }) => { try { setGameState('GENERATING'); setError(null); setRole('HOST'); let documents: ProcessedDocument[] | undefined; if (options.files && options.files.length > 0) { documents = await Promise.all( options.files.map(file => uploadDocument(file, options.useOcr)) ); } const generateOptions: GenerateQuizOptions = { topic: options.topic, questionCount: options.questionCount, documents, aiProvider: options.aiProvider, apiKey: options.apiKey, geminiModel: options.geminiModel, openRouterModel: options.openRouterModel, openAIModel: options.openAIModel, accessToken: options.aiProvider === 'system' ? auth.user?.access_token : undefined, }; const generatedQuiz = await generateQuiz(generateOptions); const saveLabel = options.topic || options.files?.map(f => f.name).join(', ') || ''; setPendingQuizToSave({ quiz: generatedQuiz, topic: saveLabel }); setQuiz(generatedQuiz); storeDraftQuiz({ quiz: generatedQuiz, topic: saveLabel }); setGameState('EDITING'); } catch (e) { const message = e instanceof Error ? e.message : "Failed to generate quiz."; setError(message); setGameState('LANDING'); } }; const dismissSavePrompt = () => { setPendingQuizToSave(null); }; const startManualCreation = () => { setRole('HOST'); setGameState('CREATING'); }; const cancelCreation = () => { setGameState('LANDING'); }; const finalizeManualQuiz = (manualQuiz: Quiz, saveToLibrary: boolean = false) => { if (saveToLibrary) { setPendingQuizToSave({ quiz: manualQuiz, topic: '' }); } initializeHostGame(manualQuiz); }; const loadSavedQuiz = (savedQuiz: Quiz, quizId?: string) => { setRole('HOST'); setQuiz(savedQuiz); setSourceQuizId(quizId || null); storeDraftQuiz({ quiz: savedQuiz, sourceQuizId: quizId }); setGameState('EDITING'); }; const updateQuizFromEditor = (updatedQuiz: Quiz) => { setQuiz(updatedQuiz); setPendingQuizToSave(prev => prev ? { ...prev, quiz: updatedQuiz } : { quiz: updatedQuiz, topic: '' }); const currentDraft = getDraftQuiz(); storeDraftQuiz({ quiz: updatedQuiz, topic: currentDraft?.topic, sourceQuizId: currentDraft?.sourceQuizId }); }; const startGameFromEditor = (finalQuiz: Quiz, config: GameConfig) => { setQuiz(finalQuiz); setGameConfig(config); clearDraftQuiz(); initializeHostGame(finalQuiz, config.hostParticipates); }; const backFromEditor = () => { setQuiz(null); setPendingQuizToSave(null); setSourceQuizId(null); clearDraftQuiz(); setGameState('LANDING'); }; const handleHostData = (conn: DataConnection, data: NetworkMessage) => { if (data.type === 'JOIN') { const payload = data.payload as { name: string; reconnect?: boolean; previousId?: string }; connectionsRef.current.set(conn.peer, conn); const existingByPreviousId = payload.previousId ? playersRef.current.find(p => p.id === payload.previousId) : null; const existingByName = playersRef.current.find(p => p.name === payload.name && p.id !== 'host'); const reconnectedPlayer = existingByPreviousId || existingByName; console.log('[HOST] JOIN received:', { name: payload.name, previousId: payload.previousId, reconnect: payload.reconnect, foundByPreviousId: !!existingByPreviousId, foundByName: !!existingByName, reconnectedPlayer: reconnectedPlayer ? { id: reconnectedPlayer.id, hasAnswered: reconnectedPlayer.lastAnswerCorrect !== null, lastAnswerCorrect: reconnectedPlayer.lastAnswerCorrect, selectedShape: reconnectedPlayer.selectedShape } : null, allPlayers: playersRef.current.map(p => ({ id: p.id, name: p.name, lastAnswerCorrect: p.lastAnswerCorrect })) }); let assignedName = payload.name; if (!reconnectedPlayer && gameConfigRef.current.randomNamesEnabled) { assignedName = generateRandomName(); } let updatedPlayers = playersRef.current; let newPlayer: Player | null = null; if (reconnectedPlayer) { updatedPlayers = playersRef.current.map(p => p.id === reconnectedPlayer.id ? { ...p, id: conn.peer } : p); setPlayers(updatedPlayers); assignedName = reconnectedPlayer.name; if (presenterIdRef.current === reconnectedPlayer.id) { setPresenterId(conn.peer); } } else if (!playersRef.current.find(p => p.id === conn.peer)) { const colorIndex = playersRef.current.length % PLAYER_COLORS.length; newPlayer = { id: conn.peer, name: assignedName, score: 0, previousScore: 0, streak: 0, lastAnswerCorrect: null, selectedShape: null, pointsBreakdown: null, isBot: false, avatarSeed: Math.random(), color: PLAYER_COLORS[colorIndex] }; updatedPlayers = [...playersRef.current, newPlayer]; setPlayers(updatedPlayers); const realPlayers = updatedPlayers.filter(p => p.id !== 'host'); if (!gameConfigRef.current.hostParticipates && realPlayers.length === 1 && !presenterIdRef.current) { setPresenterId(conn.peer); broadcast({ type: 'PRESENTER_CHANGED', payload: { presenterId: conn.peer } }); } } const currentState = gameStateRef.current; const currentQuiz = quizRef.current; const currentIndex = currentQuestionIndexRef.current; const currentQuestion = currentQuiz?.questions[currentIndex]; const welcomePayload: any = { playerId: conn.peer, quizTitle: currentQuiz?.title || 'Kaboot', players: updatedPlayers, gameState: currentState, currentQuestionIndex: currentIndex, totalQuestions: currentQuiz?.questions.length || 0, timeLeft: timeLeftRef.current, score: 0, streak: 0, hasAnswered: false, lastAnswerCorrect: null, selectedShape: null, assignedName, presenterId: presenterIdRef.current, }; if (currentQuestion) { welcomePayload.questionText = currentQuestion.text; const correctOpt = currentQuestion.options.find(o => o.isCorrect); welcomePayload.correctShape = correctOpt?.shape; welcomePayload.options = currentQuestion.options.map(o => ({ ...o, isCorrect: false })); } if (reconnectedPlayer) { welcomePayload.score = reconnectedPlayer.score; welcomePayload.streak = reconnectedPlayer.streak; welcomePayload.hasAnswered = reconnectedPlayer.lastAnswerCorrect !== null; welcomePayload.lastAnswerCorrect = reconnectedPlayer.lastAnswerCorrect; welcomePayload.selectedShape = reconnectedPlayer.selectedShape; if (reconnectedPlayer.pointsBreakdown) { welcomePayload.lastPointsEarned = reconnectedPlayer.pointsBreakdown.total; } } console.log('[HOST] Sending WELCOME:', { hasAnswered: welcomePayload.hasAnswered, lastAnswerCorrect: welcomePayload.lastAnswerCorrect, selectedShape: welcomePayload.selectedShape, gameState: welcomePayload.gameState }); conn.send({ type: 'WELCOME', payload: welcomePayload }); } if (data.type === 'ANSWER') { const { playerId, isCorrect, selectedShape } = data.payload; console.log('[HOST] ANSWER received:', { playerId, isCorrect, selectedShape }); const currentPlayer = playersRef.current.find(p => p.id === playerId); console.log('[HOST] Current player:', currentPlayer ? { id: currentPlayer.id, lastAnswerCorrect: currentPlayer.lastAnswerCorrect } : 'NOT FOUND'); if (!currentPlayer || currentPlayer.lastAnswerCorrect !== null) { console.log('[HOST] Ignoring answer - player not found or already answered'); return; } const isFirstCorrect = isCorrect && firstCorrectPlayerId === null; if (isFirstCorrect) { setFirstCorrectPlayerId(playerId); } const newStreak = isCorrect ? currentPlayer.streak + 1 : 0; const playerRank = getPlayerRank(playerId, playersRef.current); const breakdown = calculatePointsWithBreakdown({ isCorrect, timeLeftMs: timeLeftRef.current, questionTimeMs: QUESTION_TIME_MS, streak: newStreak, playerRank, isFirstCorrect, config: gameConfigRef.current, currentQuestionIndex: currentQuestionIndexRef.current, }); const newScore = Math.max(0, currentPlayer.score + breakdown.total); const updatedPlayers = playersRef.current.map(p => { if (p.id !== playerId) return p; return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, selectedShape, pointsBreakdown: breakdown }; }); playersRef.current = updatedPlayers; setPlayers(updatedPlayers); conn.send({ type: 'RESULT', payload: { isCorrect, scoreAdded: breakdown.total, newScore, breakdown } }); const allAnswered = updatedPlayers.every(p => p.lastAnswerCorrect !== null); if (allAnswered && gameStateRef.current === 'QUESTION') { endQuestion(); } } if (data.type === 'ADVANCE') { const { action } = data.payload; if (conn.peer !== presenterIdRef.current) { console.log('[HOST] ADVANCE rejected - not from presenter'); return; } if (action === 'START' && gameStateRef.current === 'LOBBY') { startHostGame(); } else if (action === 'NEXT') { if (gameStateRef.current === 'REVEAL') { showScoreboard(); } else if (gameStateRef.current === 'SCOREBOARD') { nextQuestion(); } } else if (action === 'SCOREBOARD' && gameStateRef.current === 'REVEAL') { showScoreboard(); } } if (data.type === 'LEAVE') { const playerId = conn.peer; const updatedPlayers = playersRef.current.filter(p => p.id !== playerId); playersRef.current = updatedPlayers; setPlayers(updatedPlayers); connectionsRef.current.delete(playerId); if (presenterIdRef.current === playerId) { const realPlayers = updatedPlayers.filter(p => p.id !== 'host'); const newPresenter = realPlayers.length > 0 ? realPlayers[0].id : null; setPresenterId(newPresenter); broadcast({ type: 'PRESENTER_CHANGED', payload: { presenterId: newPresenter } }); } broadcast({ type: 'PLAYER_LEFT', payload: { playerId } }); } }; useEffect(() => { handleHostDataRef.current = handleHostData; handleClientDataRef.current = handleClientData; }); const broadcast = (msg: NetworkMessage) => { connectionsRef.current.forEach(conn => { if (conn.open) conn.send(msg); }); }; const startHostGame = () => { setCurrentQuestionIndex(0); broadcast({ type: 'GAME_START', payload: {} }); startCountdown(); }; const resumeGame = () => { broadcast({ type: 'GAME_START', payload: {} }); startCountdown(true); }; const startCountdown = (isResume: boolean = false) => { setGameState('COUNTDOWN'); broadcast({ type: 'START_COUNTDOWN', payload: { duration: 3 } }); let count = 3; setTimeLeft(count); if (timerRef.current) clearInterval(timerRef.current); timerRef.current = setInterval(() => { count--; setTimeLeft(count); if (count <= 0) { if (timerRef.current) clearInterval(timerRef.current); startQuestion(isResume); } }, 1000); }; const startQuestion = (isResume: boolean = false) => { setGameState('QUESTION'); setTimeLeft(QUESTION_TIME_MS); setFirstCorrectPlayerId(null); if (isResume) { const hostPlayer = playersRef.current.find(p => p.id === 'host'); const hostAlreadyAnswered = hostPlayer?.lastAnswerCorrect !== null; if (hostAlreadyAnswered) { setHasAnswered(true); } else { setHasAnswered(false); setLastPointsEarned(null); setSelectedOption(null); } } else { setHasAnswered(false); setLastPointsEarned(null); setSelectedOption(null); setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null, selectedShape: null, pointsBreakdown: null }))); } const currentQuiz = quizRef.current; const currentIndex = currentQuestionIndexRef.current; if (currentQuiz) { const currentQ = currentQuiz.questions[currentIndex]; const options = currentQ.options || []; const correctOpt = options.find(o => o.isCorrect); const correctShape = correctOpt?.shape || 'triangle'; setCurrentCorrectShape(correctShape); const optionsForClient = options.map(o => ({ ...o, isCorrect: false })); broadcast({ type: 'QUESTION_START', payload: { totalQuestions: currentQuiz.questions.length, currentQuestionIndex: currentIndex, timeLimit: QUESTION_TIME, correctShape, questionText: currentQ.text, options: optionsForClient } }); } if (timerRef.current) clearInterval(timerRef.current); let tickCount = 0; timerRef.current = setInterval(() => { setTimeLeft(prev => { if (prev <= 100) { endQuestion(); return 0; } const newTime = prev - 100; tickCount++; if (tickCount % 10 === 0) { broadcast({ type: 'TIME_SYNC', payload: { timeLeft: newTime } }); } return newTime; }); }, 100); }; const endQuestion = () => { if (timerRef.current) clearInterval(timerRef.current); const unansweredPlayers = playersRef.current.filter(p => p.lastAnswerCorrect === null); if (unansweredPlayers.length > 0) { const updatedPlayers = playersRef.current.map(p => { if (p.lastAnswerCorrect !== null) return p; const playerRank = getPlayerRank(p.id, playersRef.current); const breakdown = calculatePointsWithBreakdown({ isCorrect: false, timeLeftMs: 0, questionTimeMs: QUESTION_TIME_MS, streak: 0, playerRank, isFirstCorrect: false, config: gameConfigRef.current, currentQuestionIndex: currentQuestionIndexRef.current, }); const newScore = Math.max(0, p.score + breakdown.total); if (p.id === 'host') { setLastPointsEarned(breakdown.total); setLastAnswerCorrect(false); setCurrentPlayerScore(newScore); setCurrentStreak(0); } else { const conn = connectionsRef.current.get(p.id); if (conn?.open) { conn.send({ type: 'RESULT', payload: { isCorrect: false, scoreAdded: breakdown.total, newScore, breakdown } }); } } return { ...p, score: newScore, previousScore: p.score, streak: 0, lastAnswerCorrect: false, pointsBreakdown: breakdown, }; }); setPlayers(updatedPlayers); } setGameState('REVEAL'); broadcast({ type: 'TIME_UP', payload: {} }); }; const showScoreboard = () => { setGameState('SCOREBOARD'); broadcast({ type: 'SHOW_SCOREBOARD', payload: { players: playersRef.current } }); }; const nextQuestion = () => { const currentQuiz = quizRef.current; const currentIndex = currentQuestionIndexRef.current; if (!currentQuiz) return; if (currentIndex < currentQuiz.questions.length - 1) { setCurrentQuestionIndex(prev => prev + 1); setTimeout(() => startCountdown(), 0); } else { setGameState('PODIUM'); broadcast({ type: 'GAME_OVER', payload: { players } }); if (gamePinRef.current && hostSecretRef.current) { deleteGameSession(gamePinRef.current, hostSecretRef.current); } clearStoredSession(); } }; const connectToHost = (peer: Peer, hostPeerId: string, playerName: string, isReconnect: boolean = false, previousId?: string) => { const conn = peer.connect(hostPeerId); hostConnectionRef.current = conn; conn.on('open', () => { conn.send({ type: 'JOIN', payload: { name: playerName, reconnect: isReconnect, previousId } }); setIsReconnecting(false); }); conn.on('data', (data: any) => handleClientDataRef.current(data)); conn.on('close', () => { if (gameStateRef.current !== 'PODIUM') { setGameState('DISCONNECTED'); } }); conn.on('error', () => { if (gameStateRef.current !== 'PODIUM') { setGameState('DISCONNECTED'); } }); setTimeout(() => { if (!conn.open && gameStateRef.current === 'LANDING') { setError("Could not connect to host"); setIsReconnecting(false); } }, 5000); }; const joinGame = async (pin: string, name: string) => { setRole('CLIENT'); setError(null); setGamePin(pin); setCurrentPlayerName(name); const gameInfo = await fetchGameInfo(pin); if (!gameInfo) { setError("Game not found. Check the PIN."); return; } const peer = new Peer(getPeerOptions()); peerRef.current = peer; peer.on('open', (id) => { setCurrentPlayerId(id); storeSession({ pin, role: 'CLIENT', playerName: name, playerId: id }); connectToHost(peer, gameInfo.hostPeerId, name, false); }); peer.on('error', () => { setError("Network error"); }); }; const attemptReconnect = async () => { const session = getStoredSession(); if (!session || !session.playerName || !session.pin) { clearStoredSession(); setGameState('LANDING'); return; } setIsReconnecting(true); setError(null); const gameInfo = await fetchGameInfo(session.pin); if (!gameInfo) { setError("Game no longer exists"); setIsReconnecting(false); clearStoredSession(); setGameState('LANDING'); return; } if (gameInfo.gameState === 'PODIUM') { setError("Game has ended"); setIsReconnecting(false); clearStoredSession(); setGameState('LANDING'); return; } if (peerRef.current) { peerRef.current.destroy(); } const peer = new Peer(getPeerOptions()); peerRef.current = peer; peer.on('open', (id) => { const previousId = session.playerId; setCurrentPlayerId(id); storeSession({ ...session, playerId: id }); connectToHost(peer, gameInfo.hostPeerId, session.playerName!, true, previousId); }); peer.on('error', () => { setError("Network error. Try again."); setIsReconnecting(false); }); }; const goHomeFromDisconnected = () => { clearStoredSession(); if (peerRef.current) { peerRef.current.destroy(); } setGamePin(null); setGameState('LANDING'); setError(null); }; const handleClientData = (data: NetworkMessage) => { if (data.type === 'WELCOME') { const payload = data.payload; console.log('[CLIENT] Received WELCOME:', { hasAnswered: payload.hasAnswered, lastAnswerCorrect: payload.lastAnswerCorrect, selectedShape: payload.selectedShape, gameState: payload.gameState }); if (payload.score !== undefined) { setCurrentPlayerScore(payload.score); } if (payload.streak !== undefined) { setCurrentStreak(payload.streak); } if (payload.hasAnswered !== undefined) { setHasAnswered(payload.hasAnswered); } if (payload.lastPointsEarned !== undefined) { setLastPointsEarned(payload.lastPointsEarned); } if (payload.lastAnswerCorrect !== undefined) { setLastAnswerCorrect(payload.lastAnswerCorrect); } if (payload.currentQuestionIndex !== undefined) { setCurrentQuestionIndex(payload.currentQuestionIndex); } if (payload.correctShape) { setCurrentCorrectShape(payload.correctShape); } if (payload.timeLeft !== undefined) { setTimeLeft(payload.timeLeft); } if (payload.players) { setPlayers(payload.players); } if (payload.assignedName) { setCurrentPlayerName(payload.assignedName); const session = getStoredSession(); if (session) { storeSession({ ...session, playerName: payload.assignedName }); } } if (payload.selectedShape && payload.options) { const matchedOption = payload.options.find(opt => opt.shape === payload.selectedShape); if (matchedOption) { setSelectedOption(matchedOption); } } if (payload.presenterId !== undefined) { setPresenterId(payload.presenterId); } if (payload.questionText && payload.options && payload.totalQuestions !== undefined) { const questions: Question[] = []; for (let i = 0; i < payload.totalQuestions; i++) { if (i === payload.currentQuestionIndex) { questions.push({ id: `q-${i}`, text: payload.questionText, options: payload.options, timeLimit: QUESTION_TIME }); } else { questions.push({ id: `loading-${i}`, text: '', options: [], timeLimit: 0 }); } } setQuiz({ title: payload.quizTitle, questions }); } else { setQuiz({ title: payload.quizTitle, questions: [] }); } const serverGameState = payload.gameState; const playerHasAnswered = payload.hasAnswered; if (serverGameState && serverGameState !== 'LOBBY') { if (serverGameState === 'QUESTION' || serverGameState === 'COUNTDOWN') { setGameState(serverGameState); if (!playerHasAnswered && serverGameState === 'QUESTION') { if (timerRef.current) clearInterval(timerRef.current); timerRef.current = setInterval(() => setTimeLeft(prev => Math.max(0, prev - 100)), 100); } } else if (serverGameState === 'REVEAL') { setGameState('REVEAL'); } else if (serverGameState === 'SCOREBOARD') { setGameState('SCOREBOARD'); } else if (serverGameState === 'PODIUM') { setGameState('PODIUM'); } else { setGameState('LOBBY'); } } else { setGameState('LOBBY'); setHasAnswered(false); } } if (data.type === 'START_COUNTDOWN') { setGameState('COUNTDOWN'); setTimeLeft(data.payload.duration); if (timerRef.current) clearInterval(timerRef.current); timerRef.current = setInterval(() => { setTimeLeft(prev => Math.max(0, prev - 1)); }, 1000); } if (data.type === 'QUESTION_START') { setGameState('QUESTION'); setHasAnswered(false); setLastPointsEarned(null); setLastAnswerCorrect(null); setSelectedOption(null); setCurrentQuestionIndex(data.payload.currentQuestionIndex); setTimeLeft(data.payload.timeLimit * 1000); setCurrentCorrectShape(data.payload.correctShape); setQuiz(prev => { const tempQuestions = prev ? [...prev.questions] : []; while (tempQuestions.length < data.payload.totalQuestions) { tempQuestions.push({ id: `loading-${tempQuestions.length}`, text: '', options: [], timeLimit: 0 }); } tempQuestions[data.payload.currentQuestionIndex] = { id: `q-${data.payload.currentQuestionIndex}`, text: data.payload.questionText, options: data.payload.options, timeLimit: data.payload.timeLimit }; return { title: prev?.title || 'Quiz', questions: tempQuestions }; }); if (timerRef.current) clearInterval(timerRef.current); timerRef.current = setInterval(() => setTimeLeft(prev => Math.max(0, prev - 100)), 100); } if (data.type === 'RESULT') { setLastPointsEarned(data.payload.scoreAdded); setLastAnswerCorrect(data.payload.isCorrect); setCurrentPlayerScore(data.payload.newScore); if (data.payload.isCorrect) { setCurrentStreak(prev => prev + 1); } else { setCurrentStreak(0); } } if (data.type === 'TIME_SYNC') { setTimeLeft(data.payload.timeLeft); } if (data.type === 'TIME_UP') { console.log('[CLIENT] TIME_UP received, current state:', { hasAnswered, lastAnswerCorrect, lastPointsEarned, selectedOption: selectedOption?.shape }); if (timerRef.current) clearInterval(timerRef.current); if (gameStateRef.current !== 'WAITING_TO_REJOIN') { setGameState('REVEAL'); } } if (data.type === 'SHOW_SCOREBOARD') { setGameState('SCOREBOARD'); setPlayers(data.payload.players); } if (data.type === 'GAME_OVER') { setGameState('PODIUM'); setPlayers(data.payload.players); clearStoredSession(); } if (data.type === 'PRESENTER_CHANGED') { setPresenterId(data.payload.presenterId); } if (data.type === 'KICKED') { if (hostConnectionRef.current) { hostConnectionRef.current.close(); hostConnectionRef.current = null; } if (peerRef.current) { peerRef.current.destroy(); peerRef.current = null; } clearStoredSession(); if (timerRef.current) clearInterval(timerRef.current); setError(data.payload.reason || 'You were kicked from the game'); setGamePin(null); setQuiz(null); setPlayers([]); setCurrentPlayerId(null); setCurrentPlayerName(null); setGameState('LANDING'); navigate('/', { replace: true }); } if (data.type === 'PLAYER_LEFT') { setPlayers(prev => prev.filter(p => p.id !== data.payload.playerId)); } }; const handleAnswer = (arg: boolean | AnswerOption) => { if (hasAnswered || gameState !== 'QUESTION') return; if (role === 'HOST' && !gameConfigRef.current.hostParticipates) return; setHasAnswered(true); if (role === 'HOST') { const option = arg as AnswerOption; const isCorrect = option.isCorrect; setSelectedOption(option); const hostPlayer = playersRef.current.find(p => p.id === 'host'); const currentStrk = hostPlayer?.streak || 0; const newStreak = isCorrect ? currentStrk + 1 : 0; const isFirstCorrect = isCorrect && firstCorrectPlayerId === null; if (isFirstCorrect) { setFirstCorrectPlayerId('host'); } const playerRank = getPlayerRank('host', playersRef.current); const breakdown = calculatePointsWithBreakdown({ isCorrect, timeLeftMs: timeLeftRef.current, questionTimeMs: QUESTION_TIME_MS, streak: newStreak, playerRank, isFirstCorrect, config: gameConfigRef.current, currentQuestionIndex: currentQuestionIndexRef.current, }); setLastPointsEarned(breakdown.total); setLastAnswerCorrect(isCorrect); const newScore = Math.max(0, (hostPlayer?.score || 0) + breakdown.total); setCurrentPlayerScore(newScore); setCurrentStreak(newStreak); const updatedPlayers = playersRef.current.map(p => { if (p.id !== 'host') return p; return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, selectedShape: option.shape, pointsBreakdown: breakdown }; }); playersRef.current = updatedPlayers; setPlayers(updatedPlayers); const allAnswered = updatedPlayers.every(p => p.lastAnswerCorrect !== null); if (allAnswered && gameStateRef.current === 'QUESTION') { endQuestion(); } } else { const option = arg as AnswerOption; setSelectedOption(option); // Use ref to avoid stale closure - currentCorrectShape state may not be updated yet const isCorrect = option.shape === currentCorrectShapeRef.current; console.log('[CLIENT] Answering:', { selectedShape: option.shape, currentCorrectShape: currentCorrectShapeRef.current, isCorrect }); hostConnectionRef.current?.send({ type: 'ANSWER', payload: { playerId: peerRef.current?.id, isCorrect, selectedShape: option.shape } }); } }; const endGame = () => { if (gamePinRef.current && hostSecretRef.current) { deleteGameSession(gamePinRef.current, hostSecretRef.current); } clearStoredSession(); if (timerRef.current) clearInterval(timerRef.current); if (syncTimerRef.current) clearInterval(syncTimerRef.current); if (peerRef.current) peerRef.current.destroy(); setGamePin(null); setQuiz(null); setPlayers([]); setGameState('LANDING'); navigate('/', { replace: true }); }; useEffect(() => { if (role === 'HOST' && (gameState === 'SCOREBOARD' || gameState === 'PODIUM')) { broadcast({ type: gameState === 'SCOREBOARD' ? 'SHOW_SCOREBOARD' : 'GAME_OVER', payload: { players } }); } }, [gameState, players, role]); const setPresenterPlayer = (playerId: string | null) => { if (role !== 'HOST') return; setPresenterId(playerId); broadcast({ type: 'PRESENTER_CHANGED', payload: { presenterId: playerId } }); }; const kickPlayer = (playerId: string) => { if (role !== 'HOST') return; if (playerId === 'host') return; const conn = connectionsRef.current.get(playerId); if (conn?.open) { conn.send({ type: 'KICKED', payload: { reason: 'You were kicked by the host' } }); conn.close(); } connectionsRef.current.delete(playerId); const updatedPlayers = playersRef.current.filter(p => p.id !== playerId); playersRef.current = updatedPlayers; setPlayers(updatedPlayers); if (presenterIdRef.current === playerId) { setPresenterId(null); broadcast({ type: 'PRESENTER_CHANGED', payload: { presenterId: null } }); } broadcast({ type: 'PLAYER_LEFT', payload: { playerId } }); }; const leaveGame = () => { if (role !== 'CLIENT') return; if (hostConnectionRef.current?.open) { hostConnectionRef.current.send({ type: 'LEAVE', payload: {} }); hostConnectionRef.current.close(); } hostConnectionRef.current = null; if (peerRef.current) { peerRef.current.destroy(); peerRef.current = null; } clearStoredSession(); if (timerRef.current) clearInterval(timerRef.current); setGamePin(null); setQuiz(null); setPlayers([]); setCurrentPlayerId(null); setCurrentPlayerName(null); setGameState('LANDING'); navigate('/', { replace: true }); }; const sendAdvance = (action: 'START' | 'NEXT' | 'SCOREBOARD') => { if (role !== 'CLIENT' || !hostConnectionRef.current) return; hostConnectionRef.current.send({ type: 'ADVANCE', payload: { action } }); }; return { role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, lastAnswerCorrect, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig, pendingQuizToSave, dismissSavePrompt, sourceQuizId, isReconnecting, currentPlayerName, presenterId, startQuizGen, startManualCreation, cancelCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard, updateQuizFromEditor, startGameFromEditor, backFromEditor, endGame, attemptReconnect, goHomeFromDisconnected, resumeGame, setPresenterPlayer, sendAdvance, kickPlayer, leaveGame }; };