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 && (
)}
{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}}
{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 (
);
}
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 */}
{/* 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)} />
>
)}
>
);
}