import { useState, useEffect, useRef } from 'react'; import { useNavigate, useLocation, Navigate } from 'react-router-dom'; import treeBg from '../assets/tree_bg_svg.svg'; const STAGE_SIZE = 11; const API = import.meta.env.VITE_API_URL; const NAV_BTN = { padding: '14px 40px', borderRadius: 999, fontFamily: 'system-ui,sans-serif', fontSize: 17, fontWeight: 600, cursor: 'pointer', border: '1.5px solid #b6d8f5', background: '#e8f4fd', color: '#1a73e8', }; const FORFEIT_BTN = { padding: '14px 28px', borderRadius: 999, fontFamily: 'system-ui,sans-serif', fontSize: 14, fontWeight: 600, cursor: 'pointer', border: '1.5px solid #e57373', background: '#fff', color: '#c62828', }; function authHeaders() { const token = sessionStorage.getItem('ranked_token'); return token ? { Authorization: `Bearer ${token}` } : {}; } function SpeechBubbleIcon({ size = 44 }) { return ( ); } function GlobalStyles() { return ( ); } function OpponentProgressStrip({ progress, total, name, dced, disconnected, emoteBubble, stageLabel }) { const prevProgressRef = useRef(progress); const [nameFlashing, setNameFlashing] = useState(false); useEffect(() => { if (progress > prevProgressRef.current) { setNameFlashing(true); const t = setTimeout(() => setNameFlashing(false), 500); prevProgressRef.current = progress; return () => clearTimeout(t); } prevProgressRef.current = progress; }, [progress]); const items = []; for (let qi = 0; qi < total; qi++) { const filled = qi < progress; items.push(
{qi + 1}
); if (qi < total - 1) { items.push(
); } } return (
{emoteBubble && (
{emoteBubble.text}
)}
{name} {dced && DC'd} {!dced && disconnected && disconnected?} {stageLabel && Stage {stageLabel}}
{items}
); } function ProgressStrip({ total, current, answers, onJump }) { const [fillCounts, setFillCounts] = useState({}); const prevAnswersRef = useRef({}); useEffect(() => { const updates = {}; for (let qi = 0; qi < total; qi++) { if (answers[qi] != null && prevAnswersRef.current[qi] == null) updates[qi] = true; } if (Object.keys(updates).length > 0) { setFillCounts(prev => { const next = { ...prev }; for (const qi of Object.keys(updates)) next[Number(qi)] = (prev[Number(qi)] || 0) + 1; return next; }); } prevAnswersRef.current = answers; }, [answers, total]); const items = []; for (let qi = 0; qi < total; qi++) { const active = qi === current, answered = answers[qi] != null, filled = answered; const popCount = fillCounts[qi] || 0; items.push( ); if (qi < total - 1) items.push(
); } return
{items}
; } function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump, opponentProgress, opponentName, opponentDced, opponentConnected, onForfeit, emoteBubble, stageNum, timerDisplay, retryBanner, opponentStage, grade }) { const [pulsingIdx, setPulsingIdx] = useState(null); const [hoverIdx, setHoverIdx] = useState(null); const [timerFlash, setTimerFlash] = useState(false); const lastPulseRef = useRef(-1); useEffect(() => { const handler = (e) => { if (e.target.tagName === 'INPUT') return; const i = +e.key - 1; if (i >= 0 && i < q.choices.length) { onSelect(i); return; } if ((e.key === 'Enter' || e.key === 'ArrowRight') && qIdx < STAGE_SIZE - 1) onNext(); if (e.key === 'ArrowLeft' && qIdx > 0) onBack(); }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [q.choices.length, qIdx, onSelect, onNext, onBack]); useEffect(() => { if (!timerDisplay) return; const parts = timerDisplay.split(':'); if (parts.length !== 2) return; const totalSeconds = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); if (totalSeconds > 0 && totalSeconds % 30 === 0 && totalSeconds !== lastPulseRef.current) { lastPulseRef.current = totalSeconds; setTimerFlash(true); const t = setTimeout(() => setTimerFlash(false), 500); return () => clearTimeout(t); } }, [timerDisplay]); return (
{retryBanner != null && (
{retryBanner === 1 ? '1 answer incorrect' : `${retryBanner} answers incorrect`} — fix them and resubmit
)}

Stage {stageNum ?? 1} · Question {qIdx + 1} of {STAGE_SIZE}

{grade && ( {grade} )}
{timerDisplay && {timerDisplay}}
You

{q.question}

{q.choices.map((choice, i) => { const sel = selected === i; const hov = hoverIdx === i && !sel; return ( ); })}
{qIdx > 0 && } {qIdx === STAGE_SIZE - 1 ? ( ) : ( )}
); } function SubmittingOverlay() { return (
Done.
); } function EmotePicker({ open, onToggle, onSend, onQuickChat }) { return (
{open && (
{/* Section 1: 2×4 emote grid */}
{Array.from({ length: 8 }, (_, i) => ( ))}
{/* Section 2: 1×4 emoji row */}
{Array.from({ length: 4 }, (_, i) => ( ))}
{/* Section 3: 2×4 quick-chat grid */}
{['1','2','3','4','5','6','7','8'].map((label) => ( ))}
)} {/* Toggle button */}
); } function WrongAnswerCard({ notif, onDismiss }) { const [fading, setFading] = useState(false); const dismissRef = useRef(onDismiss); dismissRef.current = onDismiss; useEffect(() => { const t1 = setTimeout(() => setFading(true), 7500); const t2 = setTimeout(() => dismissRef.current(notif.id), 8000); return () => { clearTimeout(t1); clearTimeout(t2); }; }, [notif.id]); return (
{notif.opponentName} got this question WRONG!
{notif.question}
{notif.choices.map((choice, i) => (
{choice}
))}
); } function WrongAnswerNotifications({ notifications, onDismiss }) { if (!notifications.length) return null; return (
{notifications.map(n => (
))}
); } function MinecraftChat({ messages, myUserId, gameId, emotePickerOpen, onChatOpen }) { const [inputActive, setInputActive] = useState(false); const [inputValue, setInputValue] = useState(''); const [flashKey, setFlashKey] = useState(null); const inputRef = useRef(null); const prevLenRef = useRef(0); const inputActiveRef = useRef(false); inputActiveRef.current = inputActive; const onChatOpenRef = useRef(onChatOpen); onChatOpenRef.current = onChatOpen; // Close chat when emote picker opens useEffect(() => { if (emotePickerOpen) { setInputActive(false); setInputValue(''); } }, [emotePickerOpen]); useEffect(() => { if (messages.length > prevLenRef.current && !inputActiveRef.current) { const newest = messages[messages.length - 1]; if (newest && newest.userId !== myUserId) { const k = `${newest.sentAt}-${newest.userId}`; setFlashKey(k); setTimeout(() => setFlashKey(null), 1000); } } prevLenRef.current = messages.length; }, [messages.length, myUserId]); useEffect(() => { function handler(e) { if (e.key === '/' && e.target.tagName !== 'INPUT' && !inputActiveRef.current) { e.preventDefault(); setInputActive(true); onChatOpenRef.current?.(); } } window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, []); useEffect(() => { if (inputActive) inputRef.current?.focus(); }, [inputActive]); async function handleSend() { const trimmed = inputValue.trim(); setInputActive(false); setInputValue(''); if (!trimmed) return; try { await fetch(`${API}/game/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ gameId, message: trimmed }), }); } catch {} } function handleKeyDown(e) { if (e.key === 'Enter') { e.preventDefault(); handleSend(); } if (e.key === 'Escape') { setInputActive(false); setInputValue(''); } } const shown = messages.slice(-6); return (
{shown.map((msg, i) => { const isMe = msg.userId === myUserId; const k = `${msg.sentAt}-${msg.userId}`; const isFlash = k === flashKey; return (
{msg.username} : {msg.text}
); })} {inputActive ? ( setInputValue(e.target.value)} onKeyDown={handleKeyDown} onBlur={() => { setInputActive(false); setInputValue(''); }} placeholder="Press Enter to send, Esc to cancel" maxLength={120} style={{ width: '100%', background: 'rgba(0,0,0,0.7)', color: '#fff', border: 'none', outline: 'none', padding: '6px 8px', fontFamily: 'system-ui,sans-serif', fontSize: 13, boxSizing: 'border-box', }} /> ) : (
{ setInputActive(true); onChatOpenRef.current?.(); }} style={{ width: '100%', background: 'rgba(0,0,0,0.55)', color: 'rgba(255,255,255,0.4)', padding: '8px 12px', borderRadius: 4, fontSize: 13, fontFamily: 'system-ui,sans-serif', cursor: 'text', boxSizing: 'border-box', userSelect: 'none', }} > Press / to chat...
)}
); } function DefeatOverlay({ opponentName, onViewResults }) { return (
💀

Defeated!

{opponentName} finished before you.

); } function WaitingScreen({ opponentName, opponentProgress, opponentDced, opponentConnected, onForfeit, emoteBubble, opponentStage }) { return (
{opponentDced ?
🔌
:
}

{opponentDced ? `${opponentName} disconnected!` : `Waiting for ${opponentName}...`}

{opponentDced ? 'Awarding victory...' : 'You finished! Now waiting for your opponent to complete.'}

); } function ForfeitConfirmModal({ onConfirm, onCancel }) { return (
🏳️

Forfeit?

This counts as a loss and will reduce your ELO.

); } function CompleteScreen({ won, opponentName, duration, eloChange, newElo, opponentDced, youDced, onDashboard, onPlayAgain }) { const [reveal, setReveal] = useState(false); useEffect(() => { const t = setTimeout(() => setReveal(true), 60); return () => clearTimeout(t); }, []); const fmtDuration = duration > 0 ? `${String(Math.floor(duration / 1000 / 60)).padStart(2, '0')}:${String(Math.floor(duration / 1000) % 60).padStart(2, '0')}` : '—'; const accent = won ? '#f5c518' : '#dc2626'; const accentGlow = won ? 'rgba(245,197,24,0.45)' : 'rgba(220,38,38,0.5)'; const accentDim = won ? 'rgba(245,197,24,0.09)' : 'rgba(220,38,38,0.09)'; const accentHov = won ? 'rgba(245,197,24,0.18)' : 'rgba(220,38,38,0.18)'; const eloVal = eloChange !== 0 ? `${eloChange > 0 ? '+' : ''}${eloChange}` : '—'; const eloColor = eloChange > 0 ? '#22c55e' : eloChange < 0 ? '#ef4444' : '#555'; const stats = [ { label: 'Time', value: fmtDuration, color: '#b8bcd8' }, { label: 'ELO', value: eloVal, color: eloColor }, { label: 'Rating', value: newElo != null ? newElo.toLocaleString() : '—', color: '#b8bcd8' }, ]; const opponentLine = won ? (opponentDced ? 'OPPONENT DISCONNECTED' : 'YOU DEFEATED') : 'DEFEATED BY'; return (
{/* Atmospheric top-glow */}
{/* Hard vignette on edges */}
{/* Subtle scanline texture */}
{/* Flash on mount */}
{/* Content */}
{/* Eyebrow */}
Ranked Match
{/* Icon */}
{won ? '🏆' : '💀'}
{/* Title */}
{won ? 'VICTORY' : 'DEFEAT'}
{/* Accent rule */}
{/* Opponent */}
{opponentLine}
{opponentName}
{/* Stats */}
{stats.map(s => (
{s.label}
{s.value}
))}
{/* Actions */}
); } function StageTransition({ onNext }) { useEffect(() => { const id = setTimeout(onNext, 1500); return () => clearTimeout(id); }, [onNext]); return (

Stage 1 Complete

Stage 2 — Get Ready

); } export default function RankedGame() { const navigate = useNavigate(); const { state } = useLocation(); // Router state is lost on hard reload — try to reconstruct from sessionStorage let effectiveState = state; if (!effectiveState?.gameId || !effectiveState?.questions) { try { const savedId = sessionStorage.getItem('ranked_active_game'); if (savedId) { const savedInit = sessionStorage.getItem(`ranked_init_${savedId}`); if (savedInit) effectiveState = { gameId: savedId, ...JSON.parse(savedInit) }; } } catch {} } if (!effectiveState?.gameId || !effectiveState?.questions) { return ; } const { gameId, questions: initialQuestionsObj, opponentName: initOppName, opponentAvatar: initOppAvatar, myName: initMyName, myAvatar: initMyAvatar, grade } = effectiveState; const [phase, setPhase] = useState(() => { try { const saved = sessionStorage.getItem('rg_phase_' + gameId); if (saved && saved !== 'intro1') return saved; } catch {} return 'intro1'; }); const [stage, setStage] = useState(() => { try { const v = sessionStorage.getItem('ranked_stage_' + gameId); return v ? parseInt(v, 10) : 1; } catch { return 1; } }); const [stage1Questions] = useState(() => initialQuestionsObj?.stage1 ?? []); const [stage2Questions] = useState(() => initialQuestionsObj?.stage2 ?? []); const questions = stage === 1 ? stage1Questions : stage2Questions; const [qIdx, setQIdx] = useState(() => { try { const v = sessionStorage.getItem('ranked_qidx_' + gameId); return v ? parseInt(v, 10) : 0; } catch { return 0; } }); const [answers, setAnswers] = useState(() => { try { const v = sessionStorage.getItem('ranked_answers_' + gameId); return v ? JSON.parse(v) : {}; } catch { return {}; } }); const [selected, setSelected] = useState(() => { try { const savedAnswers = sessionStorage.getItem('ranked_answers_' + gameId); const savedQIdx = sessionStorage.getItem('ranked_qidx_' + gameId); const parsedAnswers = savedAnswers ? JSON.parse(savedAnswers) : {}; const parsedQIdx = savedQIdx ? parseInt(savedQIdx, 10) : 0; return parsedAnswers[parsedQIdx] ?? null; } catch { return null; } }); const [opponentProgress, setOpponentProgress] = useState(0); const [opponentStage, setOpponentStage] = useState(1); const opponentStageRef = useRef(1); const [opponentName] = useState(initOppName); const [opponentAvatar] = useState(initOppAvatar); const [playerWon, setPlayerWon] = useState(null); const [showDefeatOverlay, setShowDefeatOverlay] = useState(false); const [startTime] = useState(() => Date.now()); const [duration, setDuration] = useState(0); const [eloChange, setEloChange] = useState(0); const [newElo, setNewElo] = useState(null); const [opponentDced, setOpponentDced] = useState(false); const [youDced, setYouDced] = useState(false); const [opponentConnected, setOpponentConnected] = useState(true); const [showForfeitConfirm, setShowForfeitConfirm] = useState(false); const [wrongCount, setWrongCount] = useState(0); const [resultsViewQ, setResultsViewQ] = useState(0); // Emote state const [emotePickerOpen, setEmotePickerOpen] = useState(false); const [opponentEmoteBubble, setOpponentEmoteBubble] = useState(null); const lastDisplayedEmoteRef = useRef(null); // Wrong answer notifications const [wrongAnswerNotifs, setWrongAnswerNotifs] = useState([]); const opponentPrevStateRef = useRef({ currentQuestion: 0, correctAnswers: 0 }); const [chatMessages, setChatMessages] = useState([]); const [myUserId] = useState(() => { try { const token = sessionStorage.getItem('ranked_token'); if (!token) return null; return JSON.parse(atob(token.split('.')[1])).userId; } catch { return null; } }); const pollRef = useRef(null); const completedRef = useRef(false); const submittedRef = useRef(false); const [retryBanner, setRetryBanner] = useState(null); const [timerDisplay, setTimerDisplay] = useState('00:00'); const timerRef = useRef(null); const timerStartRef = useRef(null); const stageRef = useRef(1); stageRef.current = stage; const questionsRef = useRef([]); questionsRef.current = questions; const ssRestoredRef = useRef(sessionStorage.getItem('ranked_qidx_' + gameId) !== null); const firstPollDoneRef = useRef(false); useEffect(() => { sessionStorage.setItem('ranked_answers_' + gameId, JSON.stringify(answers)); }, [answers]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { sessionStorage.setItem('ranked_qidx_' + gameId, String(qIdx)); }, [qIdx]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { sessionStorage.setItem('ranked_stage_' + gameId, String(stage)); }, [stage]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { sessionStorage.setItem('rg_phase_' + gameId, phase); }, [phase]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (phase === 'done') { const id = setTimeout(() => setPhase('waiting'), 1200); return () => clearTimeout(id); } }, [phase]); useEffect(() => { if (!sessionStorage.getItem('ranked_init_' + gameId)) { sessionStorage.setItem('ranked_active_game', gameId); sessionStorage.setItem('ranked_init_' + gameId, JSON.stringify({ questions: initialQuestionsObj, opponentName: initOppName, opponentAvatar: initOppAvatar, myName: initMyName, myAvatar: initMyAvatar, grade, })); } const savedTimerStart = sessionStorage.getItem('ranked_timer_start_' + gameId); const savedPhaseForTimer = sessionStorage.getItem('rg_phase_' + gameId); if (savedTimerStart) { timerStartRef.current = parseInt(savedTimerStart, 10); } else if (savedPhaseForTimer && savedPhaseForTimer !== 'intro1') { // Restored session that's past the intro — start timer now timerStartRef.current = Date.now(); sessionStorage.setItem('ranked_timer_start_' + gameId, String(timerStartRef.current)); } // else: timer starts when user clicks Next on intro1, not on mount timerRef.current = setInterval(() => { if (!timerStartRef.current) return; const ms = Date.now() - timerStartRef.current; const s = Math.floor(ms / 1000); setTimerDisplay(`${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`); }, 1000); pollGameStatus(); return () => { clearInterval(pollRef.current); clearInterval(timerRef.current); }; }, []); // eslint-disable-line react-hooks/exhaustive-deps function clearGameStorage() { ['answers', 'qidx', 'stage', 'timer_start', 'init'].forEach(k => sessionStorage.removeItem(`ranked_${k}_${gameId}`) ); sessionStorage.removeItem('rg_phase_' + gameId); sessionStorage.removeItem('rg_timerstart_' + gameId); if (sessionStorage.getItem('ranked_active_game') === gameId) sessionStorage.removeItem('ranked_active_game'); } function submitProgress(currentQuestion, correctAnswers, finished, timeSpentMs) { fetch(`${API}/game/progress`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ gameId, currentQuestion, correctAnswers, finished, timeSpentMs, stage: stageRef.current }), }).catch(() => {}); } function pollGameStatus() { pollRef.current = setInterval(() => { if (completedRef.current) { clearInterval(pollRef.current); return; } fetch(`${API}/game/status/${gameId}`, { headers: authHeaders() }) .then(r => r.json()) .then(data => { if (data.opponentDced) setOpponentDced(true); if (data.youDced) setYouDced(true); if (data.opponentConnected === false) setOpponentConnected(false); else if (data.opponentConnected === true && !data.opponentDced) setOpponentConnected(true); if (data.status === 'completed') { clearInterval(pollRef.current); clearInterval(timerRef.current); const you = data.players.you; const won = data.youWon === true; completedRef.current = true; setDuration(you.timeSpentMs || (Date.now() - startTime)); setEloChange(you.eloChange || 0); setNewElo(you.newElo ?? null); clearGameStorage(); setPlayerWon(won); if (won) { setPhase('complete'); } else { setShowDefeatOverlay(true); } } else { const opp = data.players.opponent; const prevOpp = opponentPrevStateRef.current; // Wrong answer detection if ( opp.currentQuestion === prevOpp.currentQuestion + 1 && opp.correctAnswers === prevOpp.correctAnswers ) { const wrongQ = questionsRef.current[prevOpp.currentQuestion]; if (wrongQ) { const id = Date.now() + Math.random(); setWrongAnswerNotifs(prev => [...prev, { id, question: wrongQ.question, correctIndex: wrongQ.correct, choices: wrongQ.choices, opponentName, timestamp: Date.now(), }].slice(-3)); setTimeout(() => { setWrongAnswerNotifs(prev => prev.filter(n => n.id !== id)); }, 8200); } } opponentPrevStateRef.current = { currentQuestion: opp.currentQuestion, correctAnswers: opp.correctAnswers }; setOpponentProgress(opp.currentQuestion + 1); const newOppStage = opp.currentStage ?? 1; if (newOppStage !== opponentStageRef.current) { opponentStageRef.current = newOppStage; setOpponentStage(newOppStage); opponentPrevStateRef.current = { currentQuestion: 0, correctAnswers: 0 }; } // Emote bubble if (opp.lastEmote && opp.lastEmote !== lastDisplayedEmoteRef.current) { lastDisplayedEmoteRef.current = opp.lastEmote; const key = Date.now(); setOpponentEmoteBubble({ text: opp.lastEmote, key }); setTimeout(() => setOpponentEmoteBubble(null), 3100); } if (data.chatMessages) { setChatMessages(data.chatMessages); } if (!firstPollDoneRef.current && !ssRestoredRef.current) { const sp = data.players.you.savedProgress; if (sp) { if (sp.currentQuestion > 0) setQIdx(sp.currentQuestion); if (sp.stage > 1) setStage(sp.stage); } } firstPollDoneRef.current = true; } }) .catch(() => {}); }, 1000); } function handleNext() { const newAnswers = { ...answers, [qIdx]: selected }; setAnswers(newAnswers); const correctCount = questions.filter((q, i) => newAnswers[i] === q.correct).length; if (qIdx < STAGE_SIZE - 1) { submitProgress(qIdx + 1, correctCount, false); const next = qIdx + 1; setQIdx(next); setSelected(newAnswers[next] ?? null); } else { const wc = STAGE_SIZE - correctCount; setWrongCount(wc); setResultsViewQ(0); setPhase(phase === 'questions1' ? 'results1' : 'results2'); } } function handleBack() { const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers; setAnswers(newAnswers); const prev = qIdx - 1; const correctCount = questions.filter((q, i) => newAnswers[i] === q.correct).length; submitProgress(prev, correctCount, false); setQIdx(prev); setSelected(newAnswers[prev] ?? null); } function handleJump(targetIdx) { const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers; setAnswers(newAnswers); const correctCount = questions.filter((q, i) => newAnswers[i] === q.correct).length; submitProgress(targetIdx, correctCount, false); setQIdx(targetIdx); setSelected(newAnswers[targetIdx] ?? null); } async function handleForfeit() { setShowForfeitConfirm(false); clearInterval(pollRef.current); clearInterval(timerRef.current); completedRef.current = true; try { const r = await fetch(`${API}/game/forfeit`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ gameId }), }); if (r.ok) { const data = await r.json(); setEloChange(data.eloChange ?? 0); setNewElo(data.newElo ?? null); } } catch {} clearGameStorage(); setDuration(0); setPlayerWon(false); setPhase('complete'); } async function handleSendEmote(emote) { setEmotePickerOpen(false); try { await fetch(`${API}/game/emote`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ gameId, emote }), }); } catch {} } async function handleSendQuickChat(text) { setEmotePickerOpen(false); try { await fetch(`${API}/game/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ gameId, message: text }), }); } catch {} } function dismissNotif(id) { setWrongAnswerNotifs(prev => prev.filter(n => n.id !== id)); } const showMultiplayerUI = phase === 'questions1' || phase === 'questions2' || phase === 'waiting'; const resultsQuestion = questions[resultsViewQ] ?? questions[0]; const resultsUserAns = answers[resultsViewQ]; return ( <> {phase === 'intro1' && (

You are ready to begin Stage 1 in Mathematics.

Answer the next 11 questions to complete this stage.

)} {(phase === 'questions1' || phase === 'questions2') && ( setShowForfeitConfirm(true)} emoteBubble={opponentEmoteBubble} stageNum={stage} timerDisplay={timerDisplay} retryBanner={retryBanner} opponentStage={opponentStage} grade={grade} /> )} {(phase === 'results1' || phase === 'results2') && (

Mathematics

{wrongCount > 0 && (

{wrongCount} {wrongCount === 1 ? 'question was' : 'questions were'} incorrect.

)}

{resultsQuestion?.question}

{(resultsQuestion?.choices ?? []).map((choice, i) => { const sel = resultsUserAns === i; return ( ); })}
Your answer: {resultsUserAns != null ? resultsQuestion?.choices[resultsUserAns] : '—'}

{phase === 'results1' ? 'Stage 1' : 'Stage 2'}

{Array.from({ length: STAGE_SIZE }, (_, i) => { const active = i === resultsViewQ; return ( ); })}
{wrongCount === 0 ? ( ) : ( )}
)} {phase === 'intro2' && (

You are ready to begin Stage 2 in Mathematics.

Answer the next 11 questions to complete this stage.

)} {phase === 'done' && } {phase === 'waiting' && ( setShowForfeitConfirm(true)} emoteBubble={opponentEmoteBubble} opponentStage={opponentStage} /> )} {phase === 'complete' && ( { clearGameStorage(); navigate('/dashboard'); }} onPlayAgain={() => { clearGameStorage(); navigate('/queue', { state: { grade } }); }} /> )} {showDefeatOverlay && ( { setShowDefeatOverlay(false); setPhase('complete'); }} /> )} {showForfeitConfirm && ( setShowForfeitConfirm(false)} /> )} {showMultiplayerUI && ( <> setEmotePickerOpen(v => !v)} onSend={handleSendEmote} onQuickChat={handleSendQuickChat} /> setEmotePickerOpen(false)} /> )} ); }