Fix stuff

This commit is contained in:
Joey Yakimowich-Payne 2026-01-14 09:07:20 -07:00
commit 32696ad33d
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
13 changed files with 2194 additions and 110 deletions

View file

@ -5,6 +5,33 @@ import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBrea
import { Peer, DataConnection } from 'peerjs';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
const SESSION_STORAGE_KEY = 'kaboot_session';
const STATE_SYNC_INTERVAL = 5000;
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);
};
export const useGame = () => {
const [role, setRole] = useState<GameRole>('HOST');
@ -18,6 +45,7 @@ export const useGame = () => {
const [gamePin, setGamePin] = useState<string | null>(null);
const [currentCorrectShape, setCurrentCorrectShape] = useState<string | null>(null);
const [lastPointsEarned, setLastPointsEarned] = useState<number | null>(null);
const [lastAnswerCorrect, setLastAnswerCorrect] = useState<boolean | null>(null);
const [selectedOption, setSelectedOption] = useState<AnswerOption | null>(null);
const [currentPlayerScore, setCurrentPlayerScore] = useState(0);
const [currentStreak, setCurrentStreak] = useState(0);
@ -27,28 +55,341 @@ export const useGame = () => {
const [sourceQuizId, setSourceQuizId] = useState<string | null>(null);
const [gameConfig, setGameConfig] = useState<GameConfig>(DEFAULT_GAME_CONFIG);
const [firstCorrectPlayerId, setFirstCorrectPlayerId] = useState<string | null>(null);
const [hostSecret, setHostSecret] = useState<string | null>(null);
const [isReconnecting, setIsReconnecting] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const syncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const peerRef = useRef<Peer | null>(null);
const connectionsRef = useRef<Map<string, DataConnection>>(new Map());
const hostConnectionRef = useRef<DataConnection | null>(null);
// Refs for callbacks/async functions to access latest state
const timeLeftRef = useRef(0);
const playersRef = useRef<Player[]>([]);
const currentQuestionIndexRef = useRef(0);
const quizRef = useRef<Quiz | null>(null);
const gameConfigRef = useRef<GameConfig>(DEFAULT_GAME_CONFIG);
const gamePinRef = useRef<string | null>(null);
const hostSecretRef = useRef<string | null>(null);
const gameStateRef = useRef<GameState>('LANDING');
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]);
const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + "";
// -- HOST LOGIC --
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,
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<string | null> => {
try {
const response = await fetch(`${BACKEND_URL}/api/games`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pin,
hostPeerId: peerId,
quiz: quizData,
gameConfig: config,
}),
});
if (!response.ok) {
console.error('Failed to create game session');
return null;
}
const data = await response.json();
return data.hostSecret;
} catch (err) {
console.error('Error creating game session:', err);
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}`);
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();
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 pin = generateGamePin();
setGamePin(pin);
setupHostPeer(pin, async (peerId) => {
const secret = await createGameSession(pin, peerId, newQuiz, gameConfigRef.current);
if (!secret) {
setError("Failed to create game. Please try again.");
setGameState('LANDING');
return;
}
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);
const hostData = await fetchHostSession(session.pin, session.hostSecret);
if (!hostData) {
clearStoredSession();
setIsReconnecting(false);
return;
}
setRole('HOST');
setGamePin(session.pin);
setHostSecret(session.hostSecret);
setQuiz(hostData.quiz);
setGameConfig(hostData.gameConfig);
setCurrentQuestionIndex(hostData.currentQuestionIndex || 0);
const hostPlayer = (hostData.players || []).find((p: Player) => p.id === 'host');
if (hostPlayer) {
setCurrentPlayerId('host');
setCurrentPlayerName('Host');
setCurrentPlayerScore(hostPlayer.score);
setCurrentStreak(hostPlayer.streak);
setPlayers([hostPlayer]);
if (hostPlayer.lastAnswerCorrect !== null) {
setHasAnswered(true);
if (hostPlayer.pointsBreakdown) {
setLastPointsEarned(hostPlayer.pointsBreakdown.total);
}
}
} else {
setPlayers([]);
}
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);
const gameInfo = await fetchGameInfo(session.pin);
if (!gameInfo) {
setGameState('DISCONNECTED');
setIsReconnecting(false);
return;
}
if (gameInfo.gameState === 'PODIUM') {
clearStoredSession();
setIsReconnecting(false);
return;
}
const peer = new Peer();
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 session = getStoredSession();
if (session) {
if (session.role === 'HOST') {
reconnectAsHost(session);
} else {
reconnectAsClient(session);
}
}
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<ProcessedDocument> => {
const formData = new FormData();
@ -140,85 +481,112 @@ export const useGame = () => {
setGameState('LANDING');
};
// We use a ref to hold the current handleHostData function
// This prevents stale closures in the PeerJS event listeners
const handleHostDataRef = useRef<(conn: DataConnection, data: NetworkMessage) => void>(() => {});
const initializeHostGame = (newQuiz: Quiz, hostParticipates: boolean = true) => {
setQuiz(newQuiz);
const pin = generateGamePin();
setGamePin(pin);
const peer = new Peer(`kaboot-${pin}`);
peerRef.current = peer;
peer.on('open', (id) => {
if (hostParticipates) {
const hostPlayer: Player = {
id: 'host',
name: 'Host',
score: 0,
previousScore: 0,
streak: 0,
lastAnswerCorrect: 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');
});
peer.on('connection', (conn) => {
conn.on('data', (data: any) => {
// Delegate to the ref to ensure we always use the latest function closure
handleHostDataRef.current(conn, data);
});
});
peer.on('error', () => {
setError("Network error. Try a different topic or reload.");
setGameState('LANDING');
});
};
const handleHostData = (conn: DataConnection, data: NetworkMessage) => {
if (data.type === 'JOIN') {
setPlayers(prev => {
if (prev.find(p => p.id === conn.peer)) return prev;
const colorIndex = prev.length % PLAYER_COLORS.length;
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 }))
});
if (reconnectedPlayer) {
setPlayers(prev => prev.map(p => p.id === reconnectedPlayer.id ? { ...p, id: conn.peer } : p));
} else if (!playersRef.current.find(p => p.id === conn.peer)) {
const colorIndex = playersRef.current.length % PLAYER_COLORS.length;
const newPlayer: Player = {
id: conn.peer,
name: data.payload.name,
name: payload.name,
score: 0,
previousScore: 0,
streak: 0,
lastAnswerCorrect: null,
selectedShape: null,
pointsBreakdown: null,
isBot: false,
avatarSeed: Math.random(),
color: PLAYER_COLORS[colorIndex]
};
return [...prev, newPlayer];
setPlayers(prev => [...prev, newPlayer]);
}
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: playersRef.current,
gameState: currentState,
currentQuestionIndex: currentIndex,
totalQuestions: currentQuiz?.questions.length || 0,
timeLeft: timeLeftRef.current,
score: 0,
streak: 0,
hasAnswered: false,
lastAnswerCorrect: null,
selectedShape: null,
};
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
});
connectionsRef.current.set(conn.peer, conn);
conn.send({ type: 'WELCOME', payload: { playerId: conn.peer, quizTitle: 'Kaboot', players: [] } });
conn.send({ type: 'WELCOME', payload: welcomePayload });
}
if (data.type === 'ANSWER') {
const { playerId, isCorrect } = data.payload;
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) return;
if (!currentPlayer || currentPlayer.lastAnswerCorrect !== null) {
console.log('[HOST] Ignoring answer - player not found or already answered');
return;
}
const isFirstCorrect = isCorrect && firstCorrectPlayerId === null;
if (isFirstCorrect) {
@ -241,16 +609,16 @@ export const useGame = () => {
setPlayers(prev => prev.map(p => {
if (p.id !== playerId) return p;
return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, pointsBreakdown: breakdown };
return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, selectedShape, pointsBreakdown: breakdown };
}));
conn.send({ type: 'RESULT', payload: { isCorrect, scoreAdded: breakdown.total, newScore, breakdown } });
}
};
// Update the ref whenever handleHostData changes (which happens on render)
useEffect(() => {
handleHostDataRef.current = handleHostData;
handleClientDataRef.current = handleClientData;
});
const broadcast = (msg: NetworkMessage) => {
@ -263,7 +631,12 @@ export const useGame = () => {
startCountdown();
};
const startCountdown = () => {
const resumeGame = () => {
broadcast({ type: 'GAME_START', payload: {} });
startCountdown(true);
};
const startCountdown = (isResume: boolean = false) => {
setGameState('COUNTDOWN');
broadcast({ type: 'START_COUNTDOWN', payload: { duration: 3 } });
@ -276,19 +649,33 @@ export const useGame = () => {
setTimeLeft(count);
if (count <= 0) {
if (timerRef.current) clearInterval(timerRef.current);
startQuestion();
startQuestion(isResume);
}
}, 1000);
};
const startQuestion = () => {
const startQuestion = (isResume: boolean = false) => {
setGameState('QUESTION');
setHasAnswered(false);
setLastPointsEarned(null);
setSelectedOption(null);
setTimeLeft(QUESTION_TIME_MS);
setFirstCorrectPlayerId(null);
setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null, pointsBreakdown: 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;
@ -356,35 +743,224 @@ export const useGame = () => {
} else {
setGameState('PODIUM');
broadcast({ type: 'GAME_OVER', payload: { players } });
if (gamePinRef.current && hostSecretRef.current) {
deleteGameSession(gamePinRef.current, hostSecretRef.current);
}
clearStoredSession();
}
};
// -- CLIENT LOGIC --
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 = (pin: string, name: string) => {
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();
peerRef.current = peer;
peer.on('open', (id) => {
setCurrentPlayerId(id);
const conn = peer.connect(`kaboot-${pin}`);
hostConnectionRef.current = conn;
conn.on('open', () => {
conn.send({ type: 'JOIN', payload: { name } });
setGameState('LOBBY');
});
conn.on('data', (data: any) => handleClientData(data));
conn.on('close', () => { setError("Disconnected"); setGameState('LANDING'); });
setTimeout(() => { if (!conn.open) setError("Check PIN"); }, 5000);
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();
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();
}
setGameState('LANDING');
setError(null);
};
const handleClientData = (data: NetworkMessage) => {
if (data.type === 'WELCOME') setQuiz({ title: data.payload.quizTitle, questions: [] });
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.selectedShape && payload.options) {
const matchedOption = payload.options.find(opt => opt.shape === payload.selectedShape);
if (matchedOption) {
setSelectedOption(matchedOption);
}
}
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');
@ -399,6 +975,7 @@ export const useGame = () => {
setGameState('QUESTION');
setHasAnswered(false);
setLastPointsEarned(null);
setLastAnswerCorrect(null);
setSelectedOption(null);
setCurrentQuestionIndex(data.payload.currentQuestionIndex);
setTimeLeft(data.payload.timeLimit * 1000);
@ -424,6 +1001,7 @@ export const useGame = () => {
if (data.type === 'RESULT') {
setLastPointsEarned(data.payload.scoreAdded);
setLastAnswerCorrect(data.payload.isCorrect);
setCurrentPlayerScore(data.payload.newScore);
if (data.payload.isCorrect) {
setCurrentStreak(prev => prev + 1);
@ -437,8 +1015,16 @@ export const useGame = () => {
}
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);
setGameState('REVEAL');
if (gameStateRef.current !== 'WAITING_TO_REJOIN') {
setGameState('REVEAL');
}
}
if (data.type === 'SHOW_SCOREBOARD') {
@ -448,6 +1034,7 @@ export const useGame = () => {
if (data.type === 'GAME_OVER') {
setGameState('PODIUM');
setPlayers(data.payload.players);
clearStoredSession();
}
};
@ -484,39 +1071,45 @@ export const useGame = () => {
});
setLastPointsEarned(breakdown.total);
setLastAnswerCorrect(isCorrect);
const newScore = Math.max(0, (hostPlayer?.score || 0) + breakdown.total);
setCurrentPlayerScore(newScore);
setCurrentStreak(newStreak);
setPlayers(prev => prev.map(p => {
if (p.id !== 'host') return p;
return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, pointsBreakdown: breakdown };
return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, selectedShape: option.shape, pointsBreakdown: breakdown };
}));
} else {
const option = arg as AnswerOption;
setSelectedOption(option);
const isCorrect = option.shape === currentCorrectShape;
hostConnectionRef.current?.send({ type: 'ANSWER', payload: { playerId: peerRef.current?.id, isCorrect } });
console.log('[CLIENT] Answering:', { selectedShape: option.shape, currentCorrectShape, 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();
window.location.reload();
};
useEffect(() => {
if (role === 'HOST' && (gameState === 'SCOREBOARD' || gameState === 'PODIUM')) {
broadcast({ type: gameState === 'SCOREBOARD' ? 'SHOW_SCOREBOARD' : 'GAME_OVER', payload: { players } });
}
}, [gameState, players, role]);
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current);
if (peerRef.current) peerRef.current.destroy();
};
}, []);
return {
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig,
pendingQuizToSave, dismissSavePrompt, sourceQuizId,
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, lastAnswerCorrect, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig,
pendingQuizToSave, dismissSavePrompt, sourceQuizId, isReconnecting, currentPlayerName,
startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard,
updateQuizFromEditor, startGameFromEditor, backFromEditor
updateQuizFromEditor, startGameFromEditor, backFromEditor, endGame, attemptReconnect, goHomeFromDisconnected, resumeGame
};
};
};