
01 // problem
Wanted a genuinely fresh trivia experience instead of a static question bank — but AI-generated content on a public game is only as good as its incentive structure. Nothing stops a player from replaying a board after learning the answers, or from just asserting a high score.
02 // approach
Claude writes a full two-round board every day — 60 clues via structured JSON, real Daily Double placement, and a Final — using a two-model split to control cost: Sonnet 5 for the writing, where quality matters, and Haiku 4.5 for the high-frequency answer judging (with a Sonnet fallback if it errors). Beyond the daily board, 9,300+ real episodes scraped from the J! Archive are searchable by category or date and fully playable — solo, in real-time multiplayer (up to three players, Firestore-synced, everyone on the same clock), or in a classroom “host mode” that runs a game for a room off one screen with teams and manual scoring. The throughline is integrity: the server, not the client, owns what's been played. Every judged clue is recorded per account, so re-opening it after clearing local storage returns the original verdict; Daily Double wagers are clamped to a player's real score; leaderboard totals are computed server-side; and generation spend is bounded by per-IP, per-account, and global daily caps.
03 // outcome
Deployed on AWS Amplify with the same Next.js SSR + Firestore stack as this site, and grown from a single daily board into a full platform: thousands of playable real episodes, ranked Elo multiplayer with weekly leaderboards, custom boards, and a classroom host mode — backed by unit tests, a pre-deploy model smoke test, and CI to guard the AI integration.
// stack
// architecture deep-dive
Live Multiplayer: three players, one board, a 10-second clock
A realtime mode for the daily game — synchronized rounds where everyone answers the same clue at once, adjudicated server-side, with no new infrastructure to run. Below is how it's built: the sync model, the data shapes, and the failure cases that actually decide whether realtime holds up.
the one-paragraph version
Players join by short code. When it's your turn you pick a clue; a 3-second countdown plays, the clue appears, and all players get exactly 10 seconds to type an answer. At the buzzer every correct answer scores — it's not a speed race, so latency never decides a winner. Realtime sync rides on Firestore's own onSnapshot listeners (browser-direct, so Amplify's lack of WebSocket support is a non-issue), while every state change is written server-side through the Admin SDK, keeping answers off the client.
Transport
Firestore onSnapshot, browser→DB direct. No socket server, no new service.
Mechanic
Fixed 10-second window for all. Every correct answer scores; no lockout, no race.
Authority
All writes via Admin SDK routes. Clients only read. Answers never leave the server.
Isolation
Its own liveGames collection & judging path — never touches the daily leaderboard.
01 // the game, as played
The mechanic is deliberately not a buzzer race. The hardest problem in realtime trivia is “who was first” — latency means the player on faster wifi wins even when they typed slower, and AI judging (a 1–3s Claude call) can't sit in the critical path of a race. A fixed answer window sidesteps all of it: one server-driven clock for everyone, judging after the window closes.
- Pick. The active picker taps an unanswered clue.
- Countdown. A synchronized 3·2·1 plays on every screen — clue text withheld until it ends.
- Answer. The clue appears and a 10-second timer starts, identical on all screens.
- Reveal. Each answer is shown with a ruling; every correct answer earns the clue's value.
- Advance. Scoreboard updates, the pick passes on. Highest total wins.
02 // system architecture
The key realization: the realtime backend already exists. Firestore's client SDK opens a persistent listener straight from the browser to the database — it never touches Amplify's SSR compute, which is exactly why Amplify's inability to hold WebSocket connections doesn't matter. Reads flow browser-direct; writes flow through stateless API routes using the Admin SDK.
Because state lives entirely in one document, two properties fall out free: a reconnecting client re-subscribes and is instantly back in sync, and there's no in-memory server state to lose on a cold start — the document is the game.
03 // data model
One document per game holds the shared state; a subcollection holds per-clue submissions. Note what's deliberately absent from the client-readable doc: the clue's answer.
liveGames/{gameId}
// gameId = short unguessable join code, e.g. "K7QP2M" { status: "lobby" | "in_progress" | "finished", players: [{ uid, name, joinedAt, lastSeen }], playerUids: ["…"], // flat array — drives security rules scores: { "uid": 0 }, phase: "picking" | "countdown" | "answering" | "reveal", pickerUid: "…", answeredClueIds: ["…"], // greys out the board // current clue — NO answer field, ever currentClue: { clueId, categoryTitle, value, clueText, countdownEndsAt: <ms>, // server-stamped, absolute answerEndsAt: <ms>, // countdownEndsAt + 10_000 resolved: false // idempotency latch } }
liveGames/{gameId}/submissions/{clueId}_{uid}
{
uid, clueId, answer,
submittedAt: <server ts>, // server decides "in time," not the client
// written back by the resolver after judging:
outcome: "correct" | "wrong",
correctAnswer, comment
}04 // staying out of the daily game's way
The one place a naive build would corrupt real data. The single-player judge writes every ruling to users/{uid}/answeredClues/{date}_{clueId} — the record that marks your daily board as played and feeds the real leaderboard. If live mode reused that path, playing a live game would mark your personal daily run as complete and poison your standing.
So live mode gets a clean split: a dedicated board (reuse any existing board by boardId — no generation latency at game start) and a call to judgeAnswer() without recordAnsweredClue(). The two games never see each other's data.
05 // state machine & the secrecy model
Every transition is an Admin-SDK write; clients only observe the resulting phase. Crucially, the answer is never in a client-readable place: when the picker picks, the server reads the full board (server-only) and writes just the public clue text. Answers are compared server-side at resolve time; only the ruling is written back.
06 // security rules — a new surface
The first time the app needs Firestore client-read rules at all — today everything goes through the Admin SDK, which bypasses rules. Live games change that. The model stays strict: reads allowed only to members (uid in playerUids), all client writes denied. Joining is bootstrapped by a join route that adds you server-side; the unguessable code gates discovery, membership enforces the rest.
firestore.rules (sketch)
match /liveGames/{gameId} {
allow read: if request.auth != null
&& request.auth.uid in resource.data.playerUids;
allow write: if false; // Admin SDK only
match /submissions/{subId} {
allow read: if isMember(gameId);
allow write: if false;
}
}07 // API surface & the double-resolve guard
| Route | By | Does |
|---|---|---|
| POST/api/live/create | host | Creates a game, assigns an unguessable code, returns it. |
| POST/api/live/join | player | Validates code & 3-player cap, adds caller to playerUids. |
| POST/api/live/start | host | Lobby → picking; sets first picker. |
| POST/api/live/pick | picker | Writes public clue text + deadlines, phase → countdown. |
| POST/api/live/submit | player | Records a submission iff server clock < answerEndsAt. |
| POST/api/live/resolve | any | Judges all submissions, updates scores, phase → reveal. Idempotent. |
The resolve guard matters. Any client fires resolve() when its timer hits zero, so three may call it near-simultaneously — without protection that triple-scores the round. The resolver runs in a Firestore transaction that checks and flips currentClue.resolved: the first call judges and scores, the rest no-op.
08 // keeping the clock honest
All timing anchors to absolute server timestamps written once at pick time — never to per-client durations, which drift the moment a tab lags. Each client renders remaining = answerEndsAt − now, applying a one-time server-time offset estimated on join. Submission validity is judged by the server's clock, so the countdown is a UI affordance — the authority is server-side regardless of what any timer shows.
09 // failure modes & edge cases
| A player never submits | The 10s timer resolves without them — a missing submission is just a non-score, no one is blocked. |
| Drops mid-answer | Their submit (an API call, not a direct write) simply fails, so they score nothing that clue. On reconnect they land on whatever clue is live now. |
| Reconnects | The whole game is one document, so the listener re-subscribes and immediately has the current phase, clue, scores and turn — no replay or catch-up logic. Same principle as the single-player server-hydration. |
| Drops while it's their turn to pick | The only case that can stall everyone. A pick deadline (or host “skip”) reassigns the pick so the game can't hang. |
| Never comes back | Rounds keep resolving (non-scores), their score freezes. A lastSeen heartbeat shows a greyed “dropped” badge — informational only; the game never gates on presence, since you can't tell “slow” from “gone” fast enough. |
| Simultaneous resolve() | Transaction + claim latch — the first caller scores the round, the rest no-op. |
| Abandoned games | A Firestore TTL policy on createdAt reaps stale docs; no manual cleanup. |
the honest sharp edge
Any client fires resolve() at the buzzer, which is fine as long as one player is still connected — and every connected client fires it, so redundant calls are harmless (the latch means only the first scores). But the pathological case — the picker picks, then every remaining tab freezes at once — could leave a round unresolved with no one to trigger it. Closing that fully needs a server-side backstop: a scheduled sweeper (a small Lambda, like the board pre-generator already running) that resolves any round past its deadline. With real players it effectively never happens, so it's a later hardening item rather than a pretense that the client-triggered version is airtight.
10 // build phases
Ordered so there's something playable as early as possible; each phase is independently shippable.
Foundation & lobby
Security rules, the liveGames model, and create / join / start routes. Bare lobby UI: start a game, share the code, watch players appear.
ships: Three people can sit in a shared lobby.
The core round loop
Pick → synced countdown → 10s window → resolve/judge → reveal → live scoreboard. The heart of the feature.
ships: A full game is playable end to end.
Completion & polish
Winner screen, rematch, presence indicators, picker-timeout skip, shareable result.
ships: Feels finished, not a prototype.
Depth (optional)
Spectators, reactions, per-round “fastest correct” flair, live Daily Double wagers, bigger lobbies.
ships: Replay value and personality.
11 // risks & open questions
Double-scoring on resolve
Multiple clients racing resolve(). Mitigated by the transaction latch — but the bug most likely to corrupt scores if implemented loosely, so it needs a real test.
First client-read rules in the app
An over-permissive rule could expose game docs. The “members read, nobody writes” model is deliberately minimal; answers stay out of the doc entirely by design.
Clock drift across devices
A skewed client clock could show a timer a second off. The offset estimate handles the common case; server-authoritative submission means it can never cause an unfair result.
Amplify cold starts on pick/resolve
A cold route could add a beat. The 3-second countdown hides pick latency; resolve latency lands during reveal, where a brief “judging…” state is fine.
Firestore reads/writes
A few writes per round and three listeners per game — trivially inside the free tier. No new infrastructure means no new bill.
Decisions still to make
Which board a live game draws from; whether wrong answers ever cost points; whether the picker rotates or “last correct picks.” All config, not architecture.
// gallery