Fix client answer validation bug and improve scoreboard UX

Bug Fix:
- Fix stale closure bug in client answer validation where correct answers
  were sometimes marked incorrect. The issue occurred when players answered
  quickly after a question started - React's async state updates meant
  currentCorrectShape could hold the previous question's value. Added
  currentCorrectShapeRef to ensure the latest value is always used.

Scoreboard Improvements:
- Unified desktop/mobile layout: avatar, name, and points on first line;
  progress bar on second line; bonus pills on third line
- Removed 5-player limit to show all players
- Added vertical scrolling when player list exceeds viewport
- Fixed layout to prevent content overflow issues
This commit is contained in:
Joey Yakimowich-Payne 2026-01-14 22:21:10 -07:00
commit 62281e1124
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
2 changed files with 66 additions and 31 deletions

View file

@ -93,39 +93,29 @@ const PlayerRow: React.FC<PlayerRowProps> = ({ player, index, maxScore }) => {
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ delay: baseDelay, duration: 0.4 }} transition={{ delay: baseDelay, duration: 0.4 }}
className="flex flex-col md:flex-row md:items-center gap-2 md:gap-4 py-3" className="flex flex-col gap-2 py-3"
> >
<div className="flex items-center justify-between md:justify-start gap-3 w-full md:w-48 md:shrink-0"> <div className="flex items-center justify-between gap-3 w-full">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<PlayerAvatar seed={player.avatarSeed} size={32} /> <PlayerAvatar seed={player.avatarSeed} size={32} />
<span className="font-black text-lg font-display truncate">{player.displayName}</span> <span className="font-black text-lg font-display truncate">{player.displayName}</span>
</div> </div>
<div className="md:hidden"> <span className="font-black text-2xl font-display">
<span className="font-black text-2xl font-display"> <AnimatedNumber value={getDisplayScore()} />
<AnimatedNumber value={getDisplayScore()} /> </span>
</span>
</div>
</div> </div>
<div className="w-full md:flex-1 flex items-center gap-4"> <div className="w-full h-10 md:h-12 bg-gray-100 rounded-full overflow-hidden relative">
<div className="flex-1 h-12 bg-gray-100 rounded-full overflow-hidden relative"> <motion.div
<motion.div className="h-full rounded-full"
className="h-full rounded-full" style={{ backgroundColor: player.color }}
style={{ backgroundColor: player.color }} initial={{ width: 0 }}
initial={{ width: 0 }} animate={{ width: `${Math.max(barWidth, 2)}%` }}
animate={{ width: `${Math.max(barWidth, 2)}%` }} transition={{ duration: 0.6, delay: baseDelay + 0.1 }}
transition={{ duration: 0.6, delay: baseDelay + 0.1 }} />
/>
</div>
<div className="hidden md:block w-20 text-right">
<span className="font-black text-2xl font-display">
<AnimatedNumber value={getDisplayScore()} />
</span>
</div>
</div> </div>
<div className="flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto md:min-w-[280px] justify-start"> <div className="flex flex-wrap items-center gap-2 w-full justify-start">
<AnimatePresence> <AnimatePresence>
{breakdown === null && ( {breakdown === null && (
<motion.span <motion.span
@ -200,16 +190,16 @@ export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost,
...p, ...p,
displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name
})); }));
const sortedPlayers = [...playersWithDisplayName].sort((a, b) => b.score - a.score).slice(0, 5); const sortedPlayers = [...playersWithDisplayName].sort((a, b) => b.score - a.score);
const maxScore = Math.max(...sortedPlayers.map(p => p.score), 1); const maxScore = Math.max(...sortedPlayers.map(p => p.score), 1);
return ( return (
<div className="flex flex-col h-screen p-4 md:p-8"> <div className="flex flex-col h-screen p-4 md:p-8 overflow-hidden">
<header className="text-center mb-4 md:mb-8"> <header className="text-center mb-4 md:mb-8 shrink-0">
<h1 className="text-3xl md:text-5xl font-black text-white font-display drop-shadow-md">Scoreboard</h1> <h1 className="text-3xl md:text-5xl font-black text-white font-display drop-shadow-md">Scoreboard</h1>
</header> </header>
<div className="flex-1 bg-white rounded-2xl md:rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.2)] p-4 md:p-12 text-gray-900 max-w-5xl w-full mx-auto relative z-10 border-4 md:border-8 border-white/50 overflow-hidden"> <div className="flex-1 min-h-0 bg-white rounded-2xl md:rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.2)] p-4 md:p-12 text-gray-900 max-w-5xl w-full mx-auto relative z-10 border-4 md:border-8 border-white/50 overflow-y-auto">
<div className="space-y-2"> <div className="space-y-2">
{sortedPlayers.map((player, index) => ( {sortedPlayers.map((player, index) => (
<PlayerRow key={player.id} player={player} index={index} maxScore={maxScore} /> <PlayerRow key={player.id} player={player} index={index} maxScore={maxScore} />
@ -217,7 +207,7 @@ export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost,
</div> </div>
</div> </div>
<div className="mt-4 md:mt-8 flex justify-center md:justify-end max-w-5xl w-full mx-auto"> <div className="mt-4 md:mt-8 flex justify-center md:justify-end max-w-5xl w-full mx-auto shrink-0">
{isHost ? ( {isHost ? (
<button <button
onClick={onNext} onClick={onNext}

View file

@ -74,6 +74,7 @@ export const useGame = () => {
const hostSecretRef = useRef<string | null>(null); const hostSecretRef = useRef<string | null>(null);
const gameStateRef = useRef<GameState>("LANDING"); const gameStateRef = useRef<GameState>("LANDING");
const firstCorrectPlayerIdRef = useRef<string | null>(null); const firstCorrectPlayerIdRef = useRef<string | null>(null);
const currentCorrectShapeRef = useRef<string | null>(null);
useEffect(() => { timeLeftRef.current = timeLeft; }, [timeLeft]); useEffect(() => { timeLeftRef.current = timeLeft; }, [timeLeft]);
useEffect(() => { playersRef.current = players; }, [players]); useEffect(() => { playersRef.current = players; }, [players]);
@ -84,6 +85,7 @@ export const useGame = () => {
useEffect(() => { hostSecretRef.current = hostSecret; }, [hostSecret]); useEffect(() => { hostSecretRef.current = hostSecret; }, [hostSecret]);
useEffect(() => { gameStateRef.current = gameState; }, [gameState]); useEffect(() => { gameStateRef.current = gameState; }, [gameState]);
useEffect(() => { firstCorrectPlayerIdRef.current = firstCorrectPlayerId; }, [firstCorrectPlayerId]); useEffect(() => { firstCorrectPlayerIdRef.current = firstCorrectPlayerId; }, [firstCorrectPlayerId]);
useEffect(() => { currentCorrectShapeRef.current = currentCorrectShape; }, [currentCorrectShape]);
const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + ""; const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + "";
@ -748,6 +750,48 @@ export const useGame = () => {
const endQuestion = () => { const endQuestion = () => {
if (timerRef.current) clearInterval(timerRef.current); 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,
});
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'); setGameState('REVEAL');
broadcast({ type: 'TIME_UP', payload: {} }); broadcast({ type: 'TIME_UP', payload: {} });
}; };
@ -1116,8 +1160,9 @@ export const useGame = () => {
} else { } else {
const option = arg as AnswerOption; const option = arg as AnswerOption;
setSelectedOption(option); setSelectedOption(option);
const isCorrect = option.shape === currentCorrectShape; // Use ref to avoid stale closure - currentCorrectShape state may not be updated yet
console.log('[CLIENT] Answering:', { selectedShape: option.shape, currentCorrectShape, isCorrect }); 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 } }); hostConnectionRef.current?.send({ type: 'ANSWER', payload: { playerId: peerRef.current?.id, isCorrect, selectedShape: option.shape } });
} }
}; };