A player at a quiz night scans the code on their table, types a name, and from then on their phone shows exactly one of six screens:
type Phase = 'waiting' | 'reveal' | 'selection' | 'submitted' | 'result' | 'ended'
Waiting for the host, a three second get-ready screen with the question but no options, the answer window, a confirmation that their tap is in, the standings after the question closes, and the final table. The live leaderboard page shows what the last two look like on the room's big screen while this is happening in everyone's hands.
What I want to write about is not the six screens. It is that this component, which is the largest in our codebase, cannot decide to change screens. Five of the six transitions are caused by a message from the server, and the sixth is a timer whose end time the server picked.
The event switch is the whole controller
switch (event.type) {
case 'question_launched':
if (event.payload.questionIndex < questionIndexRef.current) break
if (event.payload.question.id === questionRef.current?.id) break
startQuestion(event.payload.question, event.payload.questionIndex)
break
case 'question_result': {
const result = event.payload
resultsRef.current.set(result.questionIndex, result)
if (phaseRef.current === 'result' && result.questionIndex === questionIndexRef.current) {
setMyResult(result)
}
break
}
case 'question_closed': {
const { questionIndex: idx, correctAnswer: correct, leaderboard: board, isLast } = event.payload
if (idx < questionIndexRef.current) break
setLeaderboard(board)
setIsLastQuestion(isLast)
showResult(idx, correct, resultsRef.current.get(idx) ?? null)
break
}
case 'session_ended':
endGame(event.payload.leaderboard)
break
case 'state_snapshot':
applySnapshot(event.payload)
break
}
Four events and a snapshot. Notice what the first three lines of two of those cases are doing, because that pattern is the actual subject here:
if (event.payload.questionIndex < questionIndexRef.current) break
if (idx < questionIndexRef.current) break
An event about a question older than the one this phone is on is dropped. Not queued, not applied, dropped. This is a phone on pub WiFi behind a hundred other phones, and a late or duplicated delivery is routine. Without those two lines, a question_closed for question 4 arriving after question 5 has launched would pull the player back to an old answer reveal while the room has moved on, and the only way out would be the next event.
Monotonic question index, enforced at the edge. Every piece of state in the component hangs off it.
The one local transition is clamped by the server's clock
The get-ready phase is the exception. The server sends question_launched once, and the phone schedules its own move from reveal to selection three seconds later:
const untilOptions = Math.min(
QUESTION_REVEAL_MS,
new Date(question.launchedAt).getTime() + QUESTION_REVEAL_MS - correctedNow(),
)
if (untilOptions > 50) {
setPhase('reveal')
revealTimerRef.current = setTimeout(() => {
revealTimerRef.current = null
setPhase('selection')
startSelectionTimer(question)
}, untilOptions)
} else {
setPhase('selection')
startSelectionTimer(question)
}
The timer is not three seconds. It is however much of the three seconds is left according to launchedAt, which came from the server, read against a clock that has been corrected for this device's offset. A phone that joins two seconds late gets a one second lead-in. A phone that joins after the lead-in has expired skips it entirely, which is what the > 50 is for: scheduling a setTimeout for 4 milliseconds is a frame of flicker for no reason.
That is why every phone in the room flips to the options at the same moment, and why the countdown they all see agrees. Nobody's answer window is longer because their phone was slower to receive the launch.
Refs shadowing state, and why
Every piece of state that an event handler needs to read is also held in a ref:
const phaseRef = useRef<Phase>('waiting')
const questionRef = useRef<QuestionData | null>(null)
const questionIndexRef = useRef(-1)
const submittingRef = useRef(false)
const setPhase = useCallback((next: Phase) => {
phaseRef.current = next
setPhaseState(next)
}, [])
The handler is called from a WebSocket message callback, not from a render. React state inside a callback is whatever it was when the callback was created, which for a guard like "is this event older than the question I am on" is the wrong answer often enough to be a bug you cannot reproduce. The refs are the synchronous copy, state is the rendered copy, and setPhase exists so the two cannot diverge by accident.
I do not love it. It is two sources of truth with a convention holding them together. The alternative, threading every guard through a reducer so the handler only ever dispatches, is a real option and a bigger change than it looks, because several of the guards need to read two pieces of state at once.
The result that arrives before the screen that shows it
One ordering detail that produced a genuinely confusing bug. The server sends question_result (how this player did) slightly before question_closed (the correct answer, and the new standings). The player is still on the submitted screen when the result lands, so there is nowhere to put it yet.
/** question_result arrives just before question_closed, so park it by question. */
const resultsRef = useRef(new Map<number, PlayerQuestionState>())
A Map keyed by question index. question_closed then reads its own index out of it:
showResult(idx, correct, resultsRef.current.get(idx) ?? null)
If it is missing, the result screen renders without the personal line and the player still sees the answer and the standings. An early message is stored rather than discarded, and a missing one degrades instead of blocking. Both halves of that matter on pub WiFi.
Submitting, when the question might already be closed
The tap handler has to cope with the question closing while the request is in flight, so it re-checks after the await:
const markSubmitted = (noAnswer: boolean) => {
// question_closed may have overtaken the response; never go back.
if (questionRef.current?.id !== question.id || phaseRef.current !== 'selection') return
clearTimers()
setSubmittedAnswer(noAnswer ? null : answer)
setNoAnswerSubmitted(noAnswer)
setPhase('submitted')
}
And a duplicate is treated as a success:
if (result.success || result.error === 'duplicate_answer') {
// A duplicate means an earlier attempt (or a teammate's phone) got
// there first: as far as this player is concerned, it is in.
markSubmitted(false)
}
That one is a product decision wearing error handling's clothes. In team mode a table shares a score, so two people at the same table tapping at once is not a conflict to report, it is the table answering. Showing one of them "duplicate answer" would be technically accurate and completely useless. The team scoring page describes what the two modes do differently; this is the one place in the client where the difference leaks into error handling.
When the socket is gone
The connection has its own state, separate from the phase, and the component only overlays it when there is something a player can do about it:
const isReconnecting = phase !== 'ended' && (status === 'reconnecting' || status === 'polling')
After twenty seconds of failed reconnects the hook stops trying the socket as its primary channel and polls for a snapshot every five seconds with jitter, and state_snapshot puts the phone back in the right phase, including straight to submitted if the server already has an answer from this player. A phone that was locked in a pocket for two questions does not replay them. It arrives at the present.
The final screen is a phase === 'ended' branch, and once that is set the event handler returns early on everything, so nothing can move a finished quiz. If you want the host's side of the same conversation, the question timer page covers who controls the clock, and the joining flow is how a phone gets into this component in the first place.













