cf.completefrontend
My progress
SYSTEM DESIGN · #78

Design a multiplayer trivia game

A live quiz where a host runs rounds and thousands of players answer on their phones at the same moment. The hard part is not the quiz screen: it is one synchronized game loop driven by the server, the same deadline for every player whatever their clock and latency, fair speed scoring, fan-out to 100,000 sockets, and players who reload or lose Wi-Fi in the middle of a question.

The question

Design a live trivia game like Kahoot!. A host starts a game on a big screen, players join from their phones with a PIN and a nickname, and then the host runs about 15 questions. Each question has a 20 second timer, faster correct answers score more, and a leaderboard shows between questions.

Most games have 30 players in a classroom, but we also run big live events with up to 100,000 players in one game. Walk me through the game loop, how everyone gets the same deadline, how answers are judged fairly, how players rejoin after a reload, and what the host and player screens show.

Real-life examples: Kahoot!, HQ Trivia (2017 to 2020), Wayground (formerly Quizizz), Jackbox Games party games, Slido quizzes, Mentimeter quizzes

Requirements: which trivia game are we building?

A live trivia game is a quiz that everybody plays at the same time. A host (a teacher, a presenter, or a studio show) controls the pace, and players answer on their own devices. After each question the game reveals the right answer, adds up points and shows who is winning.

Kahoot! is the classroom version. HQ Trivia was the TV-show version, with a live host on video and a cash prize; its record was about 2.38 million players at once, in March 2018, before the company shut down in 2020. Jackbox is the living-room version with a shared TV.

These products look alike but stress different parts of the system. A classroom game is 30 players on school Wi-Fi, while a live show is 100,000 players who all tap within the same few seconds. The first job in the interview is to find out which game we are building. Each answer below changes the design; the middle column is what this guide assumes.

QuestionAnswer we assumeWhat it changes
Who controls the pace: a host, or each player?A host. Everyone sees the same question at the same timeOne shared game clock on the server, not a per-player quiz
How many players in one game?Usually 5 to 50; big events up to 100,000Fan-out across many socket servers and aggregated answer counts
Does speed matter for the score?Yes: correct answers score up to 1,000 points, more for fasterFair timing becomes a scoring problem, not only a UX one
Is there a prize?Sometimes (brand events). Never money in this versionAnti-cheat effort is moderate: no bank-grade proof
Is the host on a separate screen?Yes: a laptop on a projector shows the question; phones show answer buttonsTwo different clients with different payloads
Can people join late or rejoin after a reload?Yes to bothSession tokens and a snapshot of the current state
Is there live video, like HQ Trivia?No, the host screen is the stage. Video is a follow-upNo need to sync questions with a video stream
Which devices and networks?Cheap Android phones on crowded venue Wi-Fi and 4GTiny payloads, few re-renders, reconnects that do not stampede

Neighbouring guides already cover some pieces, and we link to them rather than repeat them. The online judge guide estimates the server clock offset (how far a device clock is from the server clock) from a few round trips, and the countdown timer guide turns a deadline into a display that does not drift. The live leaderboard guide covers ranking UI and sorted sets, the poll widget guide covers vote counting with sharded counters, and the live comments guide covers high fan-out chat. This guide ties them together: the synchronized game loop.

Functional requirements

  • The host creates a game from a quiz and gets a short numeric PIN to show on screen.
  • Players join with the PIN and a nickname, wait in a lobby, and can rejoin after a reload without losing their score.
  • The host starts the game and moves through questions. Each question has 2 to 4 answers and a time limit (5 to 120 seconds, 20 by default).
  • Players answer once per question. The answer is locked when they tap; they cannot change it.
  • After the time limit (or when everyone has answered), the game reveals the right answer, the answer distribution, and each player's points.
  • Between questions the host screen shows the top 5. Each phone shows the player's own score and rank.
  • Late joiners can enter between questions and play the rest of the game.

Non-functional requirements

  • Same deadline for everyone: every phone shows the question closing within about 100 ms of the same real moment, whatever its clock or latency.
  • Fast phase changes: when the host taps Next, phones switch screens within 300 ms at the 95th percentile on 4G.
  • Fair judging: the server decides whether an answer was in time and how fast it was. A slow network costs at most a small, bounded number of points.
  • Scale: 100,000 sockets in one game, with up to 100,000 answers arriving in about 10 seconds (bursts near 20,000 per second).
  • Small and cheap: a phase message to players under 300 bytes; the player app under 100 KB of JavaScript compressed so it loads fast on a crowded network.
  • Resilient: a player who drops for 5 seconds comes back to the right screen, with the right time left and their answer still counted.
  • Accessible: answers work by color, shape, and text; an extended time option; a screen reader hears the question and time without a stream of noise.

Out of scope

  • The quiz editor (creating questions, uploading images): a normal form-and-CRUD problem.
  • Live video of a presenter: the live streaming guide covers low-latency video. We mention how to sync with it later.
  • Payments, prizes, and identity checks for cash games.
  • Chat between players: the live comments guide covers that kind of fan-out.

Think first

A classroom game has 30 players. A live event has 100,000. Which parts of the design would you keep the same for both, and which would you only build for the big one?

Check your understanding · Exercise 1

Which answers change the architecture, not only the UI? Select all that apply.

What makes a live trivia game hard

The first version everyone sketches is a quiz app: fetch the questions, show one, start a 20 second setInterval on the phone, post the answer, show the next one. It works for one player and breaks the moment a second player joins.

A live game is not 1,000 quizzes running side by side. It is one game that 1,000 screens are looking at. Five properties drive every decision in the rest of this guide.

  • One shared clock. The question opens and closes at the same real moment for everyone. A phone whose clock is 4 seconds fast, or whose messages arrive 400 ms late, must still show the same deadline. A local setInterval started on message arrival gives each player a different deadline.
  • The server is the referee. Points depend on correctness and speed, and players have a motive to cheat. So the right answer, the deadline, and the response time all live on the server. The client is a remote control and a display, nothing more.
  • Bursty fan-out. Traffic is not smooth. For 15 seconds nothing happens, then the host taps Next and one message must reach 100,000 sockets at once. Then 100,000 answers arrive in a 10 second burst, most of them in the first 3 seconds.
  • Unreliable networks in one room. A classroom or a conference hall puts hundreds of phones on one Wi-Fi access point. Packets drop, sockets close, phones sleep when the screen turns off. Reconnects are normal, not an edge case, and they arrive as a crowd.
  • Two very different screens. The host screen on a projector shows the question, images, a big timer, and a live chart of answers. A phone shows four colored buttons. Sending the host's payload to every phone wastes bandwidth and leaks information.

Put numbers on the burst to see why it matters. A 100,000 player game with 15 questions has 5 phase changes per question (question, answering, locked, reveal, scores), so 75 broadcasts, each reaching 100,000 sockets: 7.5 million socket writes in a 20 minute show. The answers are 1.5 million small writes, and roughly a third of each question's answers land in the first 2 seconds of the window: about 17,000 per second at peak.

Moment in a questionDirectionMessagesSize eachWhat can go wrong
Host taps NextServer to all players100,000about 150 bytesSlow broadcast: far players see the question late
Answering windowPlayers to serverup to 100,000 in 20 sabout 80 bytesBurst at the start; answers in flight at the deadline
Live answer countServer to host only4 to 10 per secondabout 100 bytesSending each answer to the host would flood it
Reveal and scoresServer to each player100,000 personal resultsabout 120 bytesPersonal messages cannot be one shared broadcast
Wi-Fi blip in the hallPlayers to serverthousands of reconnectsa snapshot eachA reconnect storm that hits the servers at once

The design that falls out of these properties has four pillars, and each gets its own chapter: a server-driven state machine that every screen renders; absolute deadlines in server time that each client converts with its clock offset; server-side judging with a clear rule for answers in flight; and a fan-out layer that broadcasts shared phases and sends small personal results. Everything else (the lobby, reconnects, leaderboards, accessibility) is built on those four.

Messages per second during one 40 second question in a 100,000 player game, drawn to one scale. At 0 seconds the host taps Next and one message goes to 100,000 phones, a spike of about 100,000. From 4 to 24 seconds answers arrive, peaking near 17,000 per second in the first seconds and falling to a trickle. At 24 seconds the window locks and the reveal sends 100,000 personal results. At 33 seconds the scores phase sends each phone its rank. In between, nothing happens.Messages per second, one question, 100,000 playersNextanswers: 17k per second at peaklock, revealscores100k per second for each broadcast0 s10 s20 s30 s40 s
Drawn to one scale. The load is not smooth: quiet gaps, then one message to everybody, then a burst of tiny messages from everybody. Every scale decision in this guide is about those spikes, not about the average.

Check your understanding · Exercise 2

A first draft starts a 20 second setInterval on each phone when the question message arrives, and the phone sends timeTaken with its answer. What goes wrong? Select all that apply.

The game loop is a state machine on the server

A game moves through a fixed set of phases: waiting in the lobby, showing a question, accepting answers, locked, revealing the answer, showing scores, then the next question or the end. At any moment the game is in exactly one phase, and only certain moves are allowed (you cannot reveal a question nobody has seen). That is the textbook shape of a state machine: a list of states, the events that move between them, and rules about which moves are legal.

The state machines lesson covers the idea in general. Here we decide where it runs, because that choice decides who can cheat, who can pause, and what happens when a laptop goes to sleep.

Decision Who owns the game state and decides when the phase changes?

Each client runs a timeline

At start, every phone downloads the quiz and a schedule: question 1 at 0 s, reveal at 25 s, and so on.

Each phone moves itself through the phases on its own timer.

Pros

  • Almost no server traffic after the start.
  • Works through short network outages.

Cons

  • The answers are on every phone from the start: anyone can read them in DevTools.
  • The host cannot pause, skip, or wait for a slow room.
  • Clocks and sleeping phones drift apart; screens disagree.

The host's browser is the game

The host page holds the state machine and the answers. The server relays its messages to players and players' answers back to it.

This is close to how party games with a shared TV often work.

Pros

  • Simple server: a dumb relay with rooms.
  • The host page can run rich logic and animations locally.

Cons

  • If the host laptop sleeps or its Wi-Fi drops, the whole game stops.
  • 100,000 answers cannot be funnelled into one browser tab.
  • Scoring happens on a client that a host could tamper with.

Server state machine, host sends commands

The server holds the phase, the answers, and the timers. The host sends intents such as next or skip.

The server checks that the move is legal, changes phase, and broadcasts the new phase to every screen.

Pros

  • One source of truth: every screen renders the same phase.
  • Answers stay secret until reveal; scoring cannot be forged.
  • The server can close the answer window itself, even if the host is offline.

Cons

  • Every phase change needs a round trip through the server.
  • The server must be stateful per game (or keep state in a fast store), which complicates scaling and deploys.

What we pick: Run the state machine on the server. The cost is one round trip per phase change (tens of ms), and in return we get a single referee that holds secrets, owns time, and survives a host laptop going to sleep. Clients become views: they render whatever phase the server says and send intents, never conclusions.

The server's game state machine. lobby goes to question when the host sends start. question goes to answering when the reading timer ends. answering goes to locked when the server timer reaches closesAt or everyone has answered. locked goes to reveal when judging is done. reveal goes to scores when the host sends next. scores goes back to question when the host sends next and questions are left, or to finished after the last question. In any phase the host can pause or end.host: starttimertimer or all inlobbyPIN, namesquestionreading timeansweringbuttons livelockedjudgingjudgedrevealright answerscorestop 5, rankfinishedpodiumhost: next, more lefthost: nexthost: next, last oneany phase: host can pause or end
Only two things move the machine: host commands and server timers. Players never change the phase; they only add answers. Every arrow bumps seq and is broadcast to every screen.

The phases and what triggers each move

PhaseHost screenPlayer phoneLeaves when
lobbyPIN, join link, nicknames appearing"You're in! See your name on screen?"Host sends start
questionQuestion text and image, a short reading countdown"Get ready" and the question numberServer timer: reading time ends (about 3 to 5 s)
answeringAnswers, big timer, answer countFour colored shape buttonsServer timer hits closesAt, or all players answered
locked"Time's up""Answer locked" or "Too late"Server finishes judging (under 1 s)
revealRight answer highlighted, bar chart of picksCorrect or wrong, points this roundHost sends next
scoresTop 5 with movementYour score and rankHost sends next; goes to question or finished
finishedPodiumFinal rankGame is archived after a few minutes

Two kinds of events move the machine: host commands (start, next, skip, pause, end) and server timers (reading time over, answering window closed). Player actions never change the phase directly; they only add answers. The one exception is a convenience rule: when every connected player has answered, the server may close the window early, and that is still the server's decision, counted on the server.

The client as a view of the phase

Each phase message carries a sequence number that grows by one at every transition, so a client can drop messages that arrive out of order or twice (which happens around reconnects). The client keeps only the latest phase and renders a screen for it. There is no client-side game logic beyond "is this newer than what I have".

ts
type Phase =
  | { kind: "lobby"; players: number }
  | { kind: "question"; index: number; total: number; answersAt: number }
  | { kind: "answering"; index: number; choices: number; opensAt: number; closesAt: number }
  | { kind: "locked"; index: number }
  | { kind: "reveal"; index: number; correct: number; you: RoundResult }
  | { kind: "scores"; index: number; you: { score: number; rank: number } }
  | { kind: "finished"; you: { score: number; rank: number } };

type GameView = { seq: number; phase: Phase; myAnswer?: number };

function onPhase(view: GameView, msg: { seq: number; phase: Phase }): GameView {
  if (msg.seq <= view.seq) return view; // stale or duplicate
  const sameQuestion = "index" in msg.phase && "index" in view.phase
    && msg.phase.index === view.phase.index;
  return { seq: msg.seq, phase: msg.phase, myAnswer: sameQuestion ? view.myAnswer : undefined };
}
The player's view of the game: a discriminated union and a reducer that only accepts newer phases

Notice what is missing. The answering phase has choices: 4 but no answer texts and no correct index: the player phone shows shapes, the host projector shows the words. The correct field appears only in reveal. The times are absolute server times (opensAt, closesAt), not "20 seconds from now": the next chapter explains why.

Check your understanding · Exercise 3

The host's laptop loses Wi-Fi during the answering phase of question 7. With a server-side state machine, what happens? Select all that apply.

Joining with a PIN, rejoining after a reload, and late joiners

Joining must take seconds on a phone with no account: type a short numeric PIN shown on the projector, pick a nickname, done. Kahoot! made this flow famous. Behind its simplicity are three questions: how does the PIN find the right game, how does the server know you after a reload, and what happens to someone who arrives in the middle of question 5?

The PIN: short, live, and rate limited

A 6 digit PIN has 1,000,000 values. If 20,000 games are live at once, a random guess hits some game 2% of the time. That is fine for a classroom quiz, as long as three rules hold: PINs exist only while a game is live, the join endpoint is rate limited per IP and per device (for example 10 attempts a minute), and the host can lock the lobby once everyone is in.

The PIN maps to a game id and to the region or cluster that runs it, so the join call can route the player to the right socket servers. When a game ends, its PIN waits through a cool-off period before reuse, so an old QR code cannot drop someone into a stranger's game.

Who are you after a reload?

Phones reload pages all the time: the user switches to the camera, the browser drops the tab to save memory, the screen locks for 10 minutes. When the page comes back, the player must get their seat, their nickname, and their score back, not a fresh "enter PIN" screen.

Decision How does the server recognise a returning player?

Match by nickname

The player types the PIN and the same nickname again; the server gives them the existing seat.

Pros

  • No storage on the client.

Cons

  • Anyone can type "Priya" and take Priya's seat and score.
  • Two people often pick the same name.

Opaque session token in storage

On join, the server returns a random playerToken (128 bits) and the player id.

The client keeps it in localStorage keyed by game id, and sends it when it opens the socket. The server maps token to seat.

Pros

  • Survives reloads and tab discards without an account.
  • Cannot be guessed; one token, one seat.
  • The token expires with the game.

Cons

  • A different device or a cleared browser means a new seat.
  • Readable by any script on the origin, so the page must stay free of XSS.

Require a signed-in account

Players log in; the account id is the seat.

Pros

  • Works across devices; good for graded school quizzes.

Cons

  • Kills the 10 second join. Most players in a hall will not create an account to play.

What we pick: Use an opaque session token, stored per game, as the default. Offer accounts as an option for graded or prize games where identity matters. The token is a capability for one seat in one game, so it is low value if stolen, and it expires within hours.

Why localStorage and not sessionStorage? sessionStorage survives a reload but belongs to one tab, and scanning the QR code a second time opens a new tab with empty storage. A cookie would also work, and an HttpOnly cookie is safer against XSS, but if the socket servers live on a different site from the page it becomes a third-party cookie, which Safari blocks and other browsers restrict. Passing the token in the first socket message, not in the URL, keeps it out of server logs.

Sequence diagram of joining and rejoining. The phone posts a mistyped PIN to the join API and gets 404 game_not_found. It posts PIN 482193 with nickname Priya and gets 200 with a token, which it saves, and the socket URL. It opens the socket and sends resume with the token; the gateway replies with a snapshot of the lobby. Later the page reloads during question 3. The phone reads the saved token, sends resume again, and the gateway replies with a snapshot: answering, 12 seconds left, same seat and score.PhoneJoin APIGatewayPOST 48219 (typo)404 game_not_foundPOST 482193, Priya200 token (saved), urlresume(token)snapshot: lobbyreload during q3resume(saved token)snapshot: 12 s left
The PIN and nickname are only needed once. After that the saved token is the player's identity, so a reload is one resume message and one snapshot, not a new seat.

Late joiners

Someone scans the QR code during question 5. The server gives them a seat with 0 points and a snapshot of the current phase. What they see depends on the phase.

In answering, one fair choice is to let them answer if at least a few seconds remain, scored against the same opensAt as everyone else, so they lose speed points for arriving late. Many products simply say "You'll join at the next question", which is simpler and avoids a player who joined with 1 second left. We take the simple rule: late joiners wait for the next question phase, and the leaderboard ranks them normally.

  • Duplicate nicknames: the server appends a number ("Sam 2") rather than rejecting, because typing on a phone is slow.
  • Rude nicknames: a server-side filter, plus a host button to kick a player. Kicking revokes the token so a reload cannot bring them back.
  • Capacity: the server rejects joins above the game's limit with a clear message, not a spinner.
  • Lobby list: the host screen shows names as they join, but at 5,000 players it shows a count and the latest 20 names. Rendering 5,000 name chips is both slow and unreadable.

Check your understanding · Exercise 4

Which choices make rejoining after a reload safe and smooth? Select all that apply.

Synchronized rounds: one deadline for every screen

The host taps Next and the server broadcasts "question 4 is open, 20 seconds". Ana in the front row is on fast Wi-Fi and gets the message 40 ms later. Ben is on a congested 4G cell and gets it 600 ms later. Chen's phone clock is 3 seconds fast because it has not synced with the network in weeks.

If each phone starts a 20 second countdown on arrival, Ben's timer ends 560 ms after Ana's. Nobody notices on screen, but it matters for fairness and for the moment the reveal appears: Ben's phone still shows half a second left when the projector says "Time's up".

Decision How does the server tell clients when the answering window closes?

A duration, started on arrival

Message: { seconds: 20 }. The phone counts down from the moment it receives it.

Pros

  • Simplest. Ignores clocks entirely.

Cons

  • Every player's deadline shifts by their delivery latency.
  • A message delayed by a Wi-Fi retry of 2 s gives that player 2 s extra on screen.

An absolute time, read with the device clock

Message: { closesAt: 1793000420000 } in epoch ms. The phone compares it with Date.now().

Pros

  • Latency no longer matters: everyone aims at the same instant.

Cons

  • Device clocks are often off by seconds, sometimes minutes. Chen's fast clock ends his window 3 s early.

An absolute server time, corrected by a clock offset

When it connects, the client sends a few timestamped pings and keeps the sample with the lowest round trip time: offset = serverTime + rtt / 2 - clientNow.

It shows time left as closesAt - (clientNow + offset).

Pros

  • Same deadline everywhere, within the error of the offset (at most half the round trip, typically tens of ms).
  • Late messages do no harm: a message that arrives late just shows less time left.

Cons

  • Needs a small sync step on connect and after wake-ups.
  • The estimate is only as good as the RTT symmetry assumption.

What we pick: Send absolute server times and correct them with a clock offset. The online judge guide builds the offset estimate (several pings, keep the sample with the lowest round trip time), and the countdown timer guide renders a deadline without drift. We reuse both here and add one trick of our own: schedule phases slightly in the future.

Schedule the open slightly in the future

Even with perfect offsets, a broadcast to 100,000 sockets takes time to fan out: the first socket gets it in 30 ms, the last in maybe 800 ms. If opensAt is "now", early receivers see 20.0 s and late ones see 19.2 s. The fix is a lead time: the server sends opensAt = now + 1000 ms, so every phone receives the message before the window opens, shows "Get ready", and flips to the buttons at the same instant.

The reading time of the question phase gives us that buffer for free. Its message already carries answersAt, the moment answering will open. The server broadcasts answering about a second before that moment, with opensAt equal to answersAt, so the fan-out delay hides inside the reading time.

ts
// One ping sample: t0 and t1 are performance.now() when the ping left and
// the pong arrived; serverTime is the server's epoch ms inside the pong.
let best = { rtt: Infinity, serverMs: 0, perfMs: 0 };

export function addSample(t0: number, t1: number, serverTime: number) {
  const rtt = t1 - t0;
  // Assume the server read its clock halfway through; keep the shortest trip.
  if (rtt < best.rtt) best = { rtt, serverMs: serverTime + rtt / 2, perfMs: t1 };
}

export function resetClock() { // on reconnect and after the page wakes up
  best = { rtt: Infinity, serverMs: 0, perfMs: 0 };
}

export function serverNow(): number {
  return best.serverMs + (performance.now() - best.perfMs);
}

export function msLeft(closesAt: number): number {
  return Math.max(0, closesAt - serverNow());
}
Turning server times into local moments, with the same estimate as the online judge guide. performance.now() is monotonic, so a wall clock change on the phone does not move the deadline

Pinning the offset to performance.now() instead of Date.now() matters on phones: if the OS adjusts the wall clock in the middle of a game (a network time sync), a Date.now() based countdown jumps, while the monotonic clock does not. After the page has been hidden or the phone slept, re-sync: some browsers pause performance.now() while the device sleeps, and a cheap ping on visibilitychange costs nothing.

Timeline of one answering window. The server sends the phase at T with opensAt T plus 1 second and closesAt T plus 21 seconds. Ana's message arrives after 40 ms and Ben's after 600 ms; both show Get ready until opensAt, then the same 20 second window. Chen's device clock is 3 seconds fast: without the offset his window closes 3 seconds early. Ben with a duration started on arrival and no lead time: his window ends 0.56 seconds after Ana's.opensAt = T + 1 sclosesAt = T + 21 sAna, 40 ms20 s, same deadlineBen, 600 ms20 s, same deadlineChen, +3 sno offset: closes 3 s earlyBen, naive20 s from arrival: 0.56 s latehatched: Get ready, the lead time before opensAt
The lead time absorbs delivery delay, and the offset absorbs clock error, so the top two rows end at exactly the same line. The bottom two rows are the naive versions: one trusts the device clock, the other trusts arrival time.

Pre-sending the next question without leaking the answer

The host projector needs the question text and image at the instant of question. Downloading a 300 KB image at that moment adds a visible delay on venue Wi-Fi, so the host client prefetches the next question's media during the scores phase. That is safe because the host is trusted and the media holds no answer. Players are the risk: anything sent to a phone before the reveal is readable by a curious player with DevTools.

  • Never send the correct answer early. The correct index travels only in reveal. Pre-sending it "encrypted" with the key at reveal time works, but the gain is tiny: the reveal message is already under 200 bytes.
  • Question text to phones is optional. Kahoot!-style phones show only shapes, so there is nothing to leak. Formats that show text on the phone (needed for accessibility and for remote players) send it in the question phase, which is fine: everybody sees it at the same time anyway.
  • Encrypt-ahead pays off for big payloads in huge games. If a show sends a 50 KB image to 100,000 phones, sending it encrypted 30 s early and broadcasting a 32 byte key at opensAt shows the image the instant the question opens, without giving anyone a head start. The client decrypts with crypto.subtle.decrypt. This is a niche optimization; mention it, do not lead with it.
  • Syncing with live video is a separate problem. In a show like HQ Trivia, a host reads the questions on live video, which reaches viewers several seconds late, and the in-app question has to line up with the host's voice. The live streaming guide covers stream latency; a common approach is to delay the data to match the video for each viewer rather than the other way round.

Check your understanding · Exercise 5

A phone's clock is 3 s fast and its messages arrive 400 ms late. The server sends opensAt 1 s in the future and closesAt 20 s after that. With a correct offset, what does the player see? Pick one.

Speed scoring, fairness, and leaderboards

Points reward both being right and being fast. Kahoot! publishes its formula: a correct answer scores round(1000 * (1 - (responseTime / timeLimit) / 2)). Answer instantly and you get close to 1,000 (Kahoot! gives the full 1,000 to correct answers under 0.5 s); answer at the last moment and you still get 500.

We adopt the same shape. Kahoot! still shows answer streaks, but its help center says streaks no longer add points, so we keep a streak only for display. The real question is what responseTime means, and who measures it.

Do the arithmetic first. With a 20 s limit, every 100 ms of response time costs 1000 * 0.1 / 20 / 2 = 2.5 points. A player whose answers take 300 ms longer to reach the server loses 7.5 points per question, or about 110 points over 15 questions. That sounds small, but near the top of a 10,000 player game, places can easily be separated by less than that.

With a 5 s limit the penalty quadruples to 10 points per 100 ms. Latency is a scoring bias, not just a UX detail.

Decision Which clock decides whether an answer is in time, and how fast it was?

Server receipt time, strict

responseTime = receivedAt - opensAt on the server. Anything received after closesAt is late.

Pros

  • Cannot be forged: the client never states a time.
  • One line of code, easy to explain.

Cons

  • Far players pay their upload latency on every answer.
  • A tap at 19.9 s from a 300 ms network is rejected as late, although the player saw time left on screen.

Client-reported tap time

The phone stamps the tap with serverNow() and sends it. The server trusts it.

Pros

  • Perfectly fair for honest players: network delay does not count.

Cons

  • Trivial to cheat: send tappedAt = opensAt + 1 and score 999 every time.
  • A fast device clock with a stale offset also shifts it.

Receipt time minus a capped, server-measured latency

The server measures each connection's round trip time with its own pings and keeps a rolling median.

responseTime = receivedAt - min(rtt / 2, 250) - opensAt. Answers are accepted until closesAt + 250 ms if their adjusted time is within the window.

Pros

  • Removes most of the latency bias for honest players.
  • The client cannot claim a time; it can only slow its pings, and the gain is capped at 250 ms.

Cons

  • More logic, and the cap is a policy choice.
  • Asymmetric links (slow upload, fast download) are only roughly corrected.

What we pick: Use the third rule: server receipt time minus a capped latency allowance, plus a grace period of the same size for answers in flight. A cheater who games their pings wins at most 250 ms, worth about 6 points on a 20 s question. For cash prizes, drop the allowance to 0 and accept the bias in exchange for simplicity you can defend.

Try the rules in the lab. Ana, Ben and Chen each tap 3 s after their buttons appear, and Dev taps 0.1 s before the end. Under the device clock, Chen's fast clock shifts his whole window 3 s early; under strict receipt, Dev's last-moment tap arrives late. With the capped rule, Ben stays within a few points of fair until his one-way latency is well past the 250 ms cap.

Ana: 925 pts, fair 925; Ben: 918 pts, fair 925; Chen: 924 pts, fair 925; Dev: late, fair 503

Fairness lab. Screen: closesAt + offset. Judge: Server receipt. Ana sees buttons from 1.00 s to 21.00 s server time, taps at 4.00 s, result 925 pts, fair score 925. Ben sees buttons from 1.00 s to 21.00 s server time, taps at 4.00 s, result 918 pts, fair score 925. Chen sees buttons from 1.00 s to 21.00 s server time, taps at 4.00 s, result 924 pts, fair score 925. Dev sees buttons from 1.00 s to 21.00 s server time, taps at 20.90 s, result late, fair score 503.buttons on screentaparrivesresultAna1.00 to 21.004.004.02925 ptsBen1.00 to 21.004.004.30918 ptsChen1.00 to 21.004.004.06924 ptsDev1.00 to 21.0020.9021.05lateserver window: 1 to 21 s; times are server seconds
Ana, Ben and Chen all react 3 s after their buttons appear, so a fair rule gives them about 925 points each. Dev taps at the last moment. Green is within 3 points of fair, hatched cells are off by more, red means cut off. Watch Chen's skew under the device clock, Dev under strict receipt, and Ben's latency under each judge.

The grace period and the lock

At closesAt some answers are still on the wire. If the server locks the question at exactly closesAt, a player who tapped at 19.95 s on screen sees "Too late". So the server keeps accepting until closesAt + grace, then moves to locked, then judges. The grace equals the latency cap (250 ms), and the reveal waits for it: a quarter of a second nobody notices on the projector.

ts
const CAP_MS = 250;

function judge(q: OpenQuestion, a: IncomingAnswer, conn: Conn): Verdict {
  if (q.answers.has(a.playerId)) return q.answers.get(a.playerId)!; // first answer wins
  const allowance = Math.min(conn.medianRttMs / 2, CAP_MS);
  const effectiveAt = a.receivedAt - allowance;
  if (effectiveAt > q.closesAt || a.receivedAt > q.closesAt + CAP_MS) {
    return { status: "late" };
  }
  const t = Math.max(0, effectiveAt - q.opensAt);
  const correct = a.choice === q.correct;
  const points = correct ? Math.round(1000 * (1 - t / q.limitMs / 2)) : 0;
  const verdict = { status: "accepted" as const, correct, points, t };
  q.answers.set(a.playerId, verdict);
  return verdict;
}
Server-side judging. The client sends only which choice it tapped and an id for retries

The verdict is stored, not sent. The phone gets an immediate acknowledgement (answer.ack with status: accepted) so it can show "Answer locked in", but whether it was correct and how many points it earned wait for reveal. Sending correct: true in the ack would leak the answer: a group of friends could have one person tap each shape and share which one came back correct.

Sequence diagram of submitting an answer. The player taps choice 2; the phone UI shows Answer sent at once and hands the tap to the outbox. The outbox sends answer for question 4, choice 2 to the gateway. The gateway accepts it, but the ack is lost when the Wi-Fi drops. After 1.5 seconds with no ack the outbox retries the same answer. The gateway replies ack duplicate: it kept the first answer. The outbox tells the UI Locked in. At reveal the gateway sends correct, 812 points.Phone UIOutboxGatewaytap: choice 2shows Answer sentanswer q4, choice 2ack lost (Wi-Fi drop)no ack in 1.5 s: retrysame answer, retryack: duplicate, keptLocked inreveal: correct, 812 pts
The UI never waits for the network to acknowledge a tap, and it never undoes one. Retries are safe because the server keeps the first answer per player and question, so a lost ack costs nothing but a second message.

Edge cases worth naming

  • Retries: the phone retries an answer whose ack did not come back within 1.5 s. The server's "first answer wins" rule makes retries safe. If the first copy arrived, the retry is ignored; if it was lost, the retry's own receipt time counts, so a lost packet costs speed points, never a double score.
  • Double taps: the button disables on the first tap, and the server ignores a second answer anyway.
  • Answer while disconnected: the phone queues the tap and sends it on reconnect. The server judges it on receipt like any other answer, so it counts only if it arrives before closesAt plus the grace period; otherwise "Too late". The phone does not send a local tap time, for the cheating reason above.
  • Everyone answered early: the server closes the window as soon as all connected players answered. It counts only players connected in the last few seconds, or one player with a dead socket would hold up the room.
  • Ties: two players with equal points are ordered by total response time, then by join time. Write the rule down; players will ask.

Check your understanding · Exercise 6

Your server uses "receipt time minus min(RTT/2, 250 ms)". Which statements are true? Select all that apply.

Leaderboards between rounds

Scores feed the leaderboard. In the scores phase after each reveal, the host screen shows the top 5 with arrows for who moved up, and every phone shows something like "You're 14th with 4,410 points". The top 5 is easy: one sorted list, five rows. The personal rank is the expensive part at scale, and it is also what keeps the 99,995 players outside the top 5 interested.

The live leaderboard guide covers the ranking UI, ties, and sorted-set mechanics in depth, so we do not repeat them. Here we only decide what rank to show each player between rounds.

Decision What rank does each phone show in a 100,000 player game?

Exact rank for everyone

After each question, compute every player's exact position and send it.

Pros

  • Precise; players like exact numbers.

Cons

  • 100,000 rank lookups per question in a short window.
  • "61,204th" is not motivating, and it changes noisily.

Exact near the top, buckets below

Exact ranks for the top 1,000; everyone else sees a percentile band ("top 30%") and the points to the next band.

Pros

  • One histogram of scores answers every bucket question.
  • Gives a goal: "240 points to the top 20%".

Cons

  • Two kinds of messages; players near the boundary see the format switch.

Only the player's own score

No rank at all on the phone; the host shows the top 5.

Pros

  • Cheapest by far.

Cons

  • Players lose the sense of progress, which is most of the fun.

What we pick: Exact ranks for small games (under 5,000 players the lookups are trivial), and the hybrid for large ones. A score histogram with 100 buckets, built once per question, answers every player's band, and the batched lookup only runs for the top 1,000.

The scores phase. Host screen after question 6 shows the top 5: Priya 5,780, Sam 5,712, Leo 5,680, Mei 5,501, Ana 5,455, with Priya highlighted as the leader. A player's phone in a 100,000 player game shows score 4,410, plus 812 this round, the band top 30 percent, and 240 points to the top 20 percent.Host: after question 61. Priya 5,7802. Sam 5,7123. Leo 5,6804. Mei 5,5015. Ana 5,455Player phoneScore 4,410+812 this roundtop 30%240 pts to top 20%small game: 14th
Five rows for the room, one card per phone. In a small game the card shows an exact rank; in a huge one a band and a near goal are cheaper to compute and more motivating than 61,204th.

Anti-cheat basics

Most cheating in a classroom quiz is mischief, not fraud: bots flooding a lobby with 500 fake names, someone joining as a rude nickname, a friend group sharing answers in a chat. The design already handles the dangerous part, because the phone never has the correct answer before reveal and the server measures time. What is left is a short list of cheap defenses.

ThreatDefenseWhere
Reading answers from the page or networkCorrect index sent only in reveal; acks say "accepted", not "correct"Protocol
Forged speedServer receipt time, capped latency creditGateway judging
PIN guessingJoin rate limit per IP and device, PINs recycled only after a cool-offJoin API
Bot floods in the lobbyJoin rate limits, host lock, kick revokes the token, a challenge (such as a CAPTCHA) only when joins spikeJoin API and host UI
Message spam on the socketPer-socket limit (for example 20 messages a second), one answer per questionGateway
Rude nicknamesServer-side filter, optional generated nicknames, host kickJoin API and host UI
Friends sharing answersCannot be stopped in a shared room. For remote prize games, shuffle choice order per player and flag accounts with identical answer patternsProduct policy

Check your understanding · Exercise 7

A player opens DevTools during answering. What can they learn with this design? Pick one.

Fan-out at scale: 100,000 phones, one question

A classroom game fits on one server with room to spare. A 100,000 player event does not, and the shape of its traffic is unusual: long quiet gaps, then one message to everybody, then a flood of tiny messages from everybody. This chapter is mostly server work, but a frontend engineer must be able to sketch it, because it decides what the client protocol looks like and how reconnects behave.

Decision Which transport carries phases down and answers up?

Polling

Phones ask GET /game/state every second; answers go by POST.

Pros

  • Works through every proxy; trivially cacheable at the edge.

Cons

  • A 1 s poll means up to 1 s of extra delay on every phase change.
  • 100,000 polls a second when nothing is happening.

Server-Sent Events plus POST

An EventSource stream pushes phases; answers go by fetch POST.

Pros

  • Plain HTTP; automatic reconnect with Last-Event-ID.
  • Easy to put behind a CDN or HTTP load balancer.

Cons

  • Every answer pays HTTP request overhead (headers often larger than the 80 byte answer).
  • Two channels to keep in step; RTT pings need a separate path.

WebSocket

One long-lived socket per phone carries phases, answers, acks, and pings in both directions.

Pros

  • Lowest overhead per message (a few bytes of framing).
  • One ordered channel, so phase and ack order is easy to reason about.
  • Ping and pong on the same path give the server real RTT numbers for fairness.

Cons

  • Stateful connections: deploys, load balancing, and reconnects need care.
  • Some corporate proxies break it, so keep a fallback.

What we pick: WebSocket, with an SSE plus POST fallback for networks that block it. The choosing realtime lesson walks through this trade-off in general; for a game the bidirectional, low-overhead channel and the server-measured RTT tip it clearly. Libraries such as Socket.IO ship a similar fallback (HTTP long polling) out of the box.

Gateways, a game coordinator, and pub/sub

Split the work in two. Gateways are socket servers, and each holds a share of the connections. We plan for about 25,000 per node, so 100,000 players need 4 nodes, and we run 8 for headroom and rolling deploys. That is a conservative planning number: a tuned server can hold far more idle sockets, but a broadcast burst needs CPU to spare.

The game coordinator owns one game's state machine and timers: exactly one coordinator per game, chosen by hashing the game id. It publishes each phase change once to a pub/sub channel such as game:4821, and every gateway with players in that game subscribes and writes the message to its local sockets. One publish becomes 8 deliveries becomes 100,000 socket writes, in parallel.

Fan-out for one 100,000 player game. The host app sends next to the game coordinator and receives answer counts 4 times a second. The coordinator publishes each phase once to the pub/sub channel game:4821. Eight gateways subscribe; three are drawn, each holding 12,500 phone sockets. Each gateway writes the phase to its own phones in parallel. Going up, each gateway sends answer counts and verdicts to the coordinator every 250 ms.Coordinatorgame 4821 stateHost appprojectornextcountsPub/subgame:4821, 8 subs1 publishup: counts, verdictsevery 250 msGateway 112,500 sockets12,500 phonesGateway 212,500 sockets12,500 phonesGateway 812,500 sockets12,500 phones...
One publish becomes 8 deliveries becomes 100,000 socket writes, spread across machines. Answers travel the other way as 8 small batches, not 100,000 events, so the coordinator and the host never see the flood.

Answers up: judge near the edge, count in shards

Sending 100,000 answers to one coordinator in a burst of 17,000 a second is possible, but there is a simpler shape. When the window opens, the coordinator gives each gateway the question's opensAt, closesAt, and correct index (servers are trusted). Each gateway judges its own players' answers with the same judge function, keeps a small sharded counter (one count per choice, per gateway), and every 250 ms sends the coordinator a batch: the counts plus the verdicts.

The coordinator sums 8 small counters instead of 100,000 events, the same sharded counter idea the poll widget guide uses for vote totals. It still applies "first answer wins" when it merges verdicts, because a player who reconnects may land on a different gateway and send the answer again.

MessageWho gets itHow it is sentWhy
Phase change (question, answering, locked)EveryoneOne publish, fan-out by gatewaysIdentical for all, so build it once
Live answer countHost onlyCoordinator to host, 4 times a secondPlayers do not need it; it would leak the trend
reveal with your resultEach playerGateway adds the player's own verdict to the shared partShared part built once; personal part is about 40 bytes
scores with your rankEach playerGateway looks up ranks in one batched call to the score store100,000 single lookups would be a stampede

Ranks are the expensive part. A score store (a sorted set, as in the live leaderboard guide) holds every player's total, and for the scores phase each gateway asks it once for the ranks of all its players in a single batched call. In large games most phones get a percentile band instead of an exact rank, as decided in the scoring chapter, which makes this call cheaper still.

Reconnect storms

The venue's Wi-Fi controller restarts. 3,000 phones lose their sockets in the same second, and if each retries immediately, a gateway gets 3,000 handshakes plus 3,000 snapshot requests in 100 ms, often right at a phase change.

The client side of the fix is in the retries and backoff lesson: exponential backoff with full jitter (first retry at a random time between 0 and 1 s, then 0 to 2 s, then 0 to 4 s, capped at 10 s). The server side: snapshots come from the gateway's in-memory copy of the current phase, never from the database, and a gateway that is overloaded answers with a retryAfter hint instead of accepting connections it cannot serve.

Check your understanding · Exercise 8

100,000 players, 8 gateways. Which choices keep the reveal step fast? Select all that apply.

The host screen and the player screen

The two clients look like one product but have opposite needs. The host screen runs on a laptop connected to a projector: big type readable from the back of a hall, the question and image, a large countdown, a live bar of how many have answered, music, and the podium at the end. The player screen runs on a 4 year old Android phone held in one hand: four big buttons, a status line, and its own score. One is a stage, the other is a remote control.

Wireframe of the answering phase. Host screen on the projector: Question 4 of 15, the question Which planet has the most moons?, a timer showing 14, an image, an answered count of 61 of 80, and four answers with letters, colors and shapes: A Jupiter red triangle, B Saturn blue diamond, C Uranus yellow circle, D Neptune green square. Player phone: Question 4, four large buttons A triangle, B diamond, C circle, D square, the status Answer sent, and the score 2,410 points, 14th.Host screen (projector)Question 4 of 15Which planet has the most moons?14image61 / 80A Jupiterred triangleB Saturnblue diamondC Uranusyellow circleD Neptunegreen squarebig type, readable from the back rowPlayer phoneQuestion 4AtriangleBdiamondCcircleDsquareAnswer sent2,410 pts, 14th
The projector is the stage and carries all the words; the phone is a remote control with four big targets. Letters and shapes link the two screens, so color is never the only cue.

Decision One web app with a role switch, or two apps?

One app, role from the URL

/play and /host are routes in the same bundle; components check the role.

Pros

  • One codebase and one deploy.
  • Shared components without a package boundary.

Cons

  • The phone downloads host code (charts, media, audio) it never runs, unless splitting is perfect.
  • Host-only data shapes leak into the player's state types.

Two entry points over one protocol package

A shared package holds the message types, the clock sync, the socket client, and the design tokens.

The player app and the host app are separate bundles with separate budgets.

Pros

  • The player bundle can stay under 100 KB compressed.
  • Each app's state holds only what it needs.
  • The protocol types keep both in step at compile time.

Cons

  • Two build targets to maintain.
  • Shared UI pieces need a package boundary.

What we pick: Two entry points sharing a protocol package. The phone is the constrained client and the one that must load on bad Wi-Fi in a hall, so it deserves its own budget. The host app can afford richer media because it runs on a laptop on a cable or good Wi-Fi.

Payloads: what each screen gets

PhaseHost receivesPlayer receives
questionText, image URL, answer texts, time limit (about 1 KB)Index, total, answersAt (about 80 bytes)
answeringopensAt, closesAt, then answer counts 4 times a secondopensAt, closesAt, number of choices (about 90 bytes)
revealCorrect index, distribution per choiceCorrect index, your verdict and points (about 120 bytes)
scoresTop 5 with names, scores, and movementYour total and rank (about 60 bytes)

Keeping the player payload to shapes and indices does three things. It keeps every broadcast small (100,000 copies of 90 bytes is 9 MB; of 1 KB it would be 100 MB). It forces players to look up at the shared screen, which is the social point of the game. And it means there is nothing to inspect: no answer text, no correct index, no distribution until the reveal.

Players who need the text on their phone (low vision, a seat behind a pillar, remote players) can switch on a "show question on my device" mode, covered in the accessibility chapter.

Host screen details that matter

  • Live answer count updates 4 times a second from the coordinator, not once per answer. The bar animates with a CSS transform so the projector laptop does not re-layout 4 times a second.
  • Audio needs a user gesture before it can play. The host's Start click is that gesture: create and resume the AudioContext there, and keep it for the whole game.
  • Keep the screens awake: the Screen Wake Lock API (navigator.wakeLock.request("screen")) stops the projector laptop and the phones from dimming mid-game. Re-acquire it on visibilitychange, because the lock is released when the page is hidden.
  • Fullscreen via element.requestFullscreen() from the Start click, with the timer still readable if the host stays windowed.
  • Pause and skip are host commands like any other: the server stops the timer, records how much time was left, and broadcasts a new closesAt on resume.

Think first

A teacher runs the game from a laptop but wants to control it from their phone while walking around the room. How does the design change?

Check your understanding · Exercise 9

Why does the player phone receive only shapes and indices during answering? Select all that apply.

Reconnecting mid-game: resume from a snapshot

Ben's phone drops off Wi-Fi at 12 s into question 6 and switches to 4G. The socket is dead, but the browser may not notice for a long time: a TCP connection can look open for tens of seconds after the network under it vanished. When it reconnects 4 seconds later, the game has moved on: the window may have closed, the reveal may be showing. Ben must land on the right screen, with the right time left, with his answer (if he tapped one) counted.

Noticing quickly that the socket is dead

  • Application heartbeat: browsers do not expose WebSocket ping frames to JavaScript, so the client sends a tiny ping message every 5 s and expects a pong within 3 s. Two misses and it reconnects. The same pings feed the RTT used for fairness.
  • Browser hints: the offline event, and visibilitychange back to visible, trigger an immediate check instead of waiting for the next heartbeat.
  • Page lifecycle: close the socket on pagehide and reconnect on pageshow. In some browsers an open socket keeps a page out of the back/forward cache, and in every browser a page restored from that cache has a stale view of the game.
State machine of the player's connection. connected sends a ping every 5 seconds. If no pong arrives in 3 seconds it goes to suspect; a pong brings it back to connected. A second miss goes to reconnecting, which retries with exponential backoff and full jitter. When the socket opens it goes to resuming and sends resume with the token. The snapshot brings it back to connected. From reconnecting, a kicked or game over error goes to closed. The offline event or the page becoming visible again triggers a check right away.no pong 3 s2nd missconnectedping every 5 ssuspect1 pong missedreconnectingbackoff + jitterpongresumingwait for snapshotclosedkicked, game oversocket openkickedsnapshotoffline event or tab visible again:check now, do not wait
The browser may not notice a dead socket for a long time, so the app notices for it with its own pings. resuming is a separate state because the screen must not accept taps until the snapshot says which phase it is in.

Decision How does a reconnecting client catch up?

Replay missed events

The client sends its last seq. The server replays every event since then, in order.

Pros

  • Exact history; the client reducer just runs the events.

Cons

  • The server must buffer events per game; a long outage means a long replay.
  • Replaying a question and an answering that already ended makes the UI flash through stale screens.

Send a snapshot of now

The client sends its token and last seq. The server answers with the current phase (with its absolute times), the player's answer for this question if any, and the player's score and rank.

Pros

  • One message, whatever the gap: 2 s or 2 minutes.
  • The UI jumps straight to the right screen.
  • The gateway can build it from memory.

Cons

  • History is lost (but the phone does not need history).

What we pick: Snapshot. The game is a state machine, and the only thing a view needs is the current state. Because times in the phase are absolute server times, the snapshot does not go stale on the way: a phone that receives it 300 ms late simply shows 300 ms less time left.

ts
type Snapshot = {
  type: "snapshot";
  seq: number;
  serverTime: number;           // also refreshes the clock offset
  phase: Phase;                 // includes opensAt / closesAt when answering
  me: {
    playerId: string;
    nickname: string;
    score: number;
    rank: number | null;
    answer?: { index: number; choice: number; status: "accepted" | "late" };
  };
};
The snapshot a gateway sends after resume. It is just the current phase plus what is personal

The client treats a snapshot like a phase message that always wins (it replaces the view even if seq matches, because the personal part may be new). If the phone had a tap queued while offline, it sends it right after the snapshot if the snapshot still says answering for the same question; the server's grace rule decides. If the snapshot already shows reveal, the queued tap is dropped and the phone shows "You were offline when time ran out" instead of a generic "Wrong".

Lobby. Step the server, toggle the player's connection, and tap an answer.

Phase lab. Server phase lobby, question 0, seq 1. Host screen: PIN 482193, 80 players joined. Player online, seeing lobby at seq 1: You're in!, see your name?. Score 0.lobbyquestionansweringlockedrevealscoresHost screenPIN 48219380 players joinedseq 1Phone: onlineYou're in!see your name?seq 1
The top row is the server's machine; the host always mirrors it. The phone mirrors it only while connected. Disconnect during answering, tap, then reconnect before or after the window closes: the snapshot jumps the phone to the current phase, and the queued tap counts only if the window is still open.

Try it in the lab: step the server through a question, disconnect the player during answering, tap an answer while offline, then advance the server and reconnect at different moments. The player's screen never shows a phase the server did not reach, and the queued answer only counts when the reconnect lands inside the window.

Check your understanding · Exercise 10

A player reconnects 6 s after dropping. In that time the server moved from answering to locked to reveal. What should the phone do? Select all that apply.

Architecture, data model, and interface

With the big decisions made, draw the client. Both apps share the same skeleton; the player app is the one we detail, because it runs on the weakest device and the worst network. The rule behind every boundary: network code knows nothing about React, and components know nothing about sockets. In between sits one small store that holds the server's latest word.

Architecture of the player app. Inside the browser: Countdown, Phase screens and Join page on the top row; Clock sync, Game store and Answer outbox on the middle row; one Socket client across the bottom. Clock sync gives serverNow to the Countdown. The Game store feeds the Phase screens by subscription. A tap goes from the Phase screens to the Answer outbox, which sends the answer through the Socket client. The Socket client delivers phases and snapshots to the Game store and pongs to Clock sync. The Join page calls the Join API; the Socket client talks to a Gateway over a WebSocket.Player app (browser)CountdownmsLeft(closesAt)Phase screensone per phaseJoin pagePIN, nicknameClock syncoffset, serverNowGame storeseq, phase, scoreAnswer outboxack, retrySocket clientheartbeat, backoff, resume, fallbackJoin APIPOST /joinGatewaywssserverNowsubscribetappongphase, snapshotanswer
Network code sits at the bottom and knows nothing about React; screens sit at the top and know nothing about sockets. The store in the middle holds only the server's latest word, and the outbox keeps a tap alive across a reconnect.
PieceResponsibilityWhy it is its own piece
Join pagePIN and nickname form, calls POST /join, stores the tokenPlain HTTP and HTML, works before any socket exists
Socket clientOpens, heartbeats, reconnects with jitter, sends resume, queues outgoing messagesAll connection state in one place; testable without a UI
Clock syncPing samples, offset, serverNow()Shared by the countdown, the answer queue, and the debug panel
Game storeLatest seq, phase, my answer, my score; applies phases and snapshotsOne reducer that accepts only newer state; components subscribe to slices
Answer outboxSends the tap, waits for ack, retries, keeps it across a reconnectAnswers must survive a dropped socket without double sending
Phase screensOne component per phase: Lobby, GetReady, Buttons, Locked, Result, Rank, FinalA switch on phase.kind; each screen is small and dumb
CountdownRenders msLeft(closesAt) once a second, aligned to the secondReuses the countdown timer guide's ticker; owns the live region timing
Wake lock and lifecycleWake lock, visibilitychange, pagehide, re-sync triggersBrowser lifecycle quirks in one module

Data flow for one question

Follow question 4 end to end. The host taps Next, and the host app sends next over its socket. The coordinator checks the seq, moves the machine to question, and publishes one message on the game's channel. Each gateway trims it to the player shape and writes it to its sockets.

On the phone, the socket client parses it and hands it to the store, the store accepts it because its seq is newer, and only the phase screen component re-renders. When the player taps, the outbox sends the answer up, the gateway judges and acks, and the store marks the answer as locked. On reveal the same path runs downward again, with the gateway adding the personal result.

Data flow for one question. Top row, left to right: the Host app sends next to the Coordinator, which publishes the phase to Pub/sub, which delivers it to the Gateway; the coordinator sends answer counts back to the host. The Gateway sends the phase down to the phone's Socket client as about 90 bytes of JSON and receives answers going up. Bottom row, right to left: the Socket client passes the message to the Game store, the store passes a slice to the Phase screen, and the screen renders for the Player. A tap goes from the Phase screen to the Answer outbox, which sends the answer through the Socket client.nextphasephaseHost appprojectorCoordinatorstate, timersPub/subgame:4821Gatewayjudges answerscountsphase down, answer upmessageslicerenderPlayertaps, readsPhase screenButtonsGame storeseq, phaseSocket clientparse, resumeAnswer outboxack, retrytapanswer
Downward, one phase change crosses five hops and ends in one small re-render. Upward, a tap goes through the outbox, so it survives a dropped socket. Nothing on the phone ever computes a phase on its own.

Two details keep this cheap on a slow phone. First, the store is outside React (a tiny external store read with useSyncExternalStore), so a heartbeat pong or an RTT update does not re-render anything. Second, the countdown subscribes to time, not to the store: it re-renders once a second, while the phase screen re-renders only on the 5 phase changes and the player's own tap and ack, about 7 times per question.

Think first

Why not put the game state in a server-state library like TanStack Query and refetch on each socket message?

Check your understanding · Exercise 11

Which boundaries in the player app are worth keeping? Select all that apply.

Data model: what lives where

The server holds the real game: the quiz with correct answers, every player, every answer, every score. The phone holds a thin, disposable view of it. The test for each field on the client is simple: if the phone forgot it right now, could the next snapshot restore it? If yes, it is a cache of the server and lives in memory; only the session token must survive a reload on its own.

DataWhere it livesLifetimeWhy there
Session token, player id, game idlocalStorage, keyed by game idUntil the game ends, or 6 hoursMust survive reloads and closed tabs
Current phase and seqGame store (memory)This page loadReplaced by every phase message or snapshot
My answer for this questionGame store and answer outboxUntil the next questionOutbox keeps it across a reconnect
Score, rankGame storeThis page loadComes in reveal, scores, and snapshots
Clock offset and RTT samplesClock module (memory)This connectionRe-measured on connect and wake
Nickname, theme, extended time settinglocalStorageAcross gamesConvenience; not secret
Correct answers, other players' answersServer onlyGame lifetime, then archivedSecret until reveal; not needed on the phone
Data model. On the server: Game with pin, seq and phase; Player with nickname, score and a token hash; Question with choices and the correct index; Answer with choice and points. A game has players and questions; an answer belongs to one player and one question, keyed by game, question and player. The correct index is never sent before reveal. On the phone: storage holds the session token; the Game store in memory holds seq, phase, my answer and score; the Clock holds the offset and round trip time.Server: the real gameGamepin, seq, phasePlayernickname, scoreQuestionchoices, correctAnswerchoice, pointsAnswer key: game, question, playercorrect: never sent before revealPhone: a viewstoragetokenGame storeseq, phase, scoreClockoffset, rtt
The server holds the real game, including the secrets. The phone keeps only what a snapshot can rebuild, plus the one thing it cannot: the token that proves which seat is yours.
ts
type Game = {
  id: string; pin: string; quizId: string; hostId: string;
  seq: number; phase: Phase; locked: boolean;
  questionIndex: number; createdAt: number;
};
type Question = {
  index: number; text: string; imageUrl?: string;
  choices: string[]; correct: number; limitMs: number;
};
type Player = {
  id: string; gameId: string; nickname: string; tokenHash: string;
  score: number; streak: number; joinedAt: number; kicked: boolean;
};
type Answer = {
  gameId: string; questionIndex: number; playerId: string;
  choice: number; receivedAt: number; responseMs: number;
  correct: boolean; points: number;
};
Server-side entities (simplified). The client never sees Question.correct before reveal, nor any other player's Answer

Notice tokenHash: the server stores a hash of the session token, as it would for a password reset link, so a leaked database dump does not let anyone take over seats in a live game. The Answer key is (gameId, questionIndex, playerId), which is what makes "first answer wins" a simple insert-if-absent and makes retries harmless.

Check your understanding · Exercise 12

Which data should the player phone persist in localStorage? Select all that apply.

Interface: the HTTP calls and the socket protocol

The protocol is small on purpose. Two HTTP calls get a player into a game; everything after that is socket messages with a type field. Plain JSON is fine for these sizes: a phase message is around 100 bytes, and per-message compression (permessage-deflate) costs server memory per connection that is not worth spending on tiny frames at 100,000 sockets.

http
POST /api/games/join
Content-Type: application/json

{ "pin": "482193", "nickname": "Priya" }

200 OK
{ "gameId": "g_4821", "playerId": "p_77f2", "token": "k9Q...32 bytes...",
  "nickname": "Priya", "socketUrl": "wss://rt-eu3.example.com/play" }

404 { "error": "game_not_found" }      // wrong PIN or game ended
409 { "error": "lobby_locked" }        // host locked the game
429 { "error": "rate_limited", "retryAfter": 30 }
Joining is plain HTTP, so it works before the socket and is easy to rate limit
ts
// client to server
type ClientMsg =
  | { type: "resume"; token: string; lastSeq: number }
  | { type: "answer"; questionIndex: number; choice: number }
  | { type: "ping"; id: number }
  | { type: "host.command"; command: "start" | "next" | "skip" | "pause" | "end"; atSeq: number };

// server to client
type ServerMsg =
  | Snapshot
  | { type: "phase"; seq: number; phase: Phase }
  | { type: "answer.ack"; questionIndex: number; status: "accepted" | "late" | "duplicate" }
  | { type: "pong"; id: number; serverTime: number }
  | { type: "host.counts"; questionIndex: number; answered: number; byChoice: number[] }
  | { type: "error"; code: "kicked" | "game_over" | "overloaded"; retryAfter?: number };
Socket messages. Every server message carries seq when it changes the game; acks and pongs do not

The socket messages are a union on type, shared as one TypeScript package by the gateway, the player app and the host app, so a renamed field fails the build instead of a live game. The client ignores any type it does not know, which lets the server add messages (an emoji reaction, a host announcement) before every phone has reloaded.

  • Auth on the socket: the first message must be resume with the token; the gateway closes the socket if it does not arrive within 5 s. The host's socket uses a signed-in session instead of a seat token.
  • Answers carry the question index, so an answer that lands after the next question opened cannot be counted for the wrong question.
  • Host commands carry atSeq, so two taps on Next from two host devices move the game once.
  • Errors are typed: kicked clears the stored token and shows a message, game_over shows the final screen, overloaded makes the client back off for retryAfter seconds.
  • Versioning: a v query parameter on the socket URL lets the gateway reject clients that are too old after a protocol change, with a "please reload" message.

Check your understanding · Exercise 13

Why does an answer message carry questionIndex even though the server knows the current question? Select all that apply.

Accessibility and performance on cheap phones

A timed, colorful, fast game is close to a worst case for accessibility. Time pressure hurts players who read slowly, use a switch device, or rely on a screen reader. Color-coded buttons fail color-blind players, and a countdown that announces every second makes a screen reader unusable.

None of these needs a separate app; each needs a deliberate choice. The visual accessibility and screen readers lessons cover the general techniques, and this chapter applies them to the game. The second half of the chapter is about the other constraint every player feels: cheap phones on crowded Wi-Fi.

Time limits and WCAG 2.2.1

WCAG success criterion 2.2.1, Timing Adjustable, asks that users can turn off, adjust, or extend time limits. It has a real-time exception, but only when the limit is a required part of a live event (the W3C example is an auction) and no alternative to the limit is possible. It also has an essential exception, for limits whose removal would invalidate the activity.

A live quiz can argue for one of these, but the argument is weak when alternatives clearly exist, and it misses the point: a student with dyslexia in a graded classroom quiz should not lose because of a 20 second timer. Offer real accommodations.

  • Extended time per player: the host (or the player, in non-competitive games) turns on 1.5x or 2x time. The server gives that player their own closesAt, scores them against their own limit, and holds the lock until the longest window ends. The host screen shows "waiting for 2 players".
  • Longer timers for the whole game: a host setting, cheap and fair for everyone.
  • A self-paced mode: the same quiz as an assignment with no shared clock, for anyone who cannot play live.
  • Pause: the host can pause any question; the server stores the time left and sends a new closesAt on resume.

Kahoot! is a useful reference here. Its accessibility settings let hosts show questions and answers on player devices, which it requires for screen reader support, and offer an unlimited time option only inside its accuracy mode, which drops speed from scoring. That pairing is the honest one: when a player's time limit changes, their speed score must change with it, which is why our extended time option scores each player against their own limit.

Color, shape, and text on every answer

Kahoot!'s buttons pair each color with a shape: a red triangle, a blue diamond, a yellow circle, a green square. That is the right instinct, because WCAG 1.4.1, Use of Color, says color must not be the only way to tell things apart, and red and green are exactly the pair many color-blind players confuse.

We go one step further and give each button a visible letter (A to D) and an accessible name such as "A, triangle" or, in text mode, "A, Paris". The projector shows the same letter and shape next to each answer text, so the mapping is explicit. Buttons fill a quarter of the screen each, far above the 24 by 24 CSS pixel minimum of WCAG 2.5.8, Target Size.

Screen readers: announce sparingly

  • Text on the device: a player using a screen reader cannot read the projector. "Show question on my device" mode sends the question and answer texts to that phone in the question phase (the same moment everyone sees them, so no unfairness).
  • Focus on phase change: when the answering screen appears, move focus to the question heading, so the reader starts there and the user can tab to the answers. Do not move focus again until the next phase.
  • One polite live region for the game status: "Question 4 of 15", "Answer locked", "Correct, 812 points", "You're 14th". Each is announced once.
  • Time announcements at milestones only: 10 seconds left and 5 seconds left, through the same polite region. Never every second. The visible countdown has aria-hidden digits plus a "time left" button that reads the current value on demand.
  • Respect reduced motion: with prefers-reduced-motion: reduce, the podium and leaderboard reorder without sliding, and the timer ring does not pulse.
tsx
const MILESTONES = [10, 5];

function Countdown({ closesAt, announce }: Props) {
  const secs = Math.ceil(useMsLeft(closesAt) / 1000);
  const spoken = useRef(new Set<number>());
  useEffect(() => {
    if (MILESTONES.includes(secs) && !spoken.current.has(secs)) {
      spoken.current.add(secs);
      announce(`${secs} seconds left`);
    }
  }, [secs, announce]);
  return (
    <div className="timer">
      <span aria-hidden="true">{secs}</span>
      <button type="button" onClick={() => announce(`${secs} seconds left`)}>Time left</button>
    </div>
  );
}
Announce the time only at milestones; the visible digits are hidden from the reader

Check your understanding · Exercise 14

Which choices make the answering screen usable with a screen reader? Select all that apply.

Performance on cheap phones and crowded Wi-Fi

Picture the worst realistic room: 400 students in a lecture hall, mostly on 3 to 5 year old Android phones, all on two Wi-Fi access points, with some on 4G in a basement. The game must load in a few seconds, react to a tap instantly, and keep working through packet loss. The runtime performance lesson covers main thread work in general; the levers that matter here are bundle size, render count, tap feedback, and network behaviour.

BudgetTargetHow
Player JavaScriptUnder 100 KB compressedSeparate entry point; no chart, media, or editor code; small UI library
Join page interactiveUnder 3 s on a mid-range phone over 4GServer-rendered HTML form that works before JavaScript loads
Tap to visual feedbackUnder 100 ms (good INP is 200 ms or less)Mark the button pressed and show "Answer sent" before the ack
Renders per questionAbout 7 screen renders (5 phases, tap, ack) plus 1 per second for the timerExternal store with slice subscriptions; timer isolated
Bytes per question to a phoneUnder 1 KBShapes and indices only; no images on phones
Reconnect to correct screenUnder 2 s after the network returnsHeartbeat detection, jittered retry, snapshot from gateway memory

Tap feedback deserves a word. The ack takes a round trip, often 100 to 400 ms on venue Wi-Fi, and if the button waits for it, the tap feels broken and players tap again. So the tap updates the UI at once (the chosen shape grows, others fade, the status reads "Answer sent"), the outbox sends the message, and the ack only upgrades the status to "Locked in". If the ack never comes, the outbox retries; the UI does not undo the tap, because the server's first-answer rule makes the retry safe.

A short vibration with navigator.vibrate adds feel on Chromium-based Android browsers. Safari and current Firefox do not support the Vibration API, so treat it as a bonus and never as the only feedback.

  • Cache the shell: a service worker caches the player app's shell, so the second game in the same classroom loads from disk. The join page still needs the network for the PIN check.
  • Fonts and images: system fonts on the phone; the shapes are inline SVG, not image files.
  • No layout work at 60 fps: the timer ring animates with a CSS transform and the digits use font-variant-numeric: tabular-nums, so a second ticking over does not shift the layout.
  • Small frames: plain JSON for 100 byte messages; batch the host's answer counts at 4 per second instead of one per answer.
  • Observability: each phone reports a few numbers per game (RTT median, clock offset, reconnect count, tap to ack time) and the gateways report broadcast fan-out time at p50 and p95. A bad venue shows up as a cluster of high RTT and reconnects from one game, which is the evidence you need before blaming the code.

Check your understanding · Exercise 15

Players in a hall say "my taps don't register" although the server shows their answers. What is the most likely cause and fix? Select all that apply.

Running it in the interview

Forty-five minutes is not enough to cover every chapter, so choose the spine and let the interviewer pull you into the branches. The spine is the game loop: a state machine on the server, phases broadcast with absolute server times, answers judged on the server. If you get those three right in the first 20 minutes, the rest are follow-ups you can answer from a solid base.

MinutesWhat to doWhat to draw
0 to 5Ask the scale and scoring questions; say "one shared clock, server is the referee"Nothing yet, or a list of requirements with numbers
5 to 12The state machine: phases, who triggers each move, client as a viewThe phase diagram with host commands and server timers
12 to 20Synchronized deadline and fair judging: offset, lead time, receipt time, graceA timeline with two players at different latencies
20 to 28Client architecture and the socket protocolSocket client, store, outbox, phase screens; message types
28 to 36Scale: gateways, pub/sub, sharded counts, personal results; reconnect stormsThe fan-out diagram
36 to 45Reconnect snapshot, accessibility, cheap phones; whatever the interviewer picksThe connection state machine if asked

Say the numbers out loud. "With a 20 second limit, each 100 ms of latency costs 2.5 points" shows you understand why fairness is a scoring problem. "100,000 copies of a 90 byte message is 9 MB per broadcast" shows why the player payload is tiny. Interviewers remember candidates who quantify.

Follow-up questions to expect

Think first

"We want a live presenter on video, like HQ Trivia. How do you keep the question in sync with the host's voice?"

Think first

"Players are in Europe and Australia in the same game. Is that fair?"

Think first

"The server running the game coordinator crashes in the middle of question 9. What happens?"

Check your understanding · Exercise 16

You have 5 minutes left and have not covered reconnects. What is the strongest short answer? Select all that apply.

Summary

  • The game is a state machine on the server. Host commands and server timers move it; clients render the phase they are told and send intents, never conclusions.
  • Send absolute server times (opensAt, closesAt), convert them with a clock offset pinned to performance.now(), and schedule openings slightly in the future so every phone flips at once.
  • The server judges: receipt time minus a capped, server-measured latency credit, a short grace period, and first answer wins. With a 20 s limit, 100 ms is worth 2.5 points.
  • Never put the correct answer on a phone before reveal. Acks say accepted, not correct.
  • Fan out shared phases once through pub/sub to socket gateways; personalize results at the gateway; count answers in shards.
  • Reconnect with heartbeat detection, full-jitter backoff, and a snapshot of now. A session token in storage brings back the seat.
  • Keep the player app tiny and dumb: shapes and indices, instant tap feedback, few renders, under 100 KB of JavaScript.
  • Accessibility is a design choice: extended time per player, color plus shape plus text, text on device, milestone-only time announcements.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next