Skip to content
cd ../projects

Daily Double

Solo developer · AI-generated trivia game

Daily Double

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

Next.jsTypeScriptClaude API (Sonnet 5 + Haiku 4.5)Firebase AuthFirestoreAWS AmplifyTailwind CSS

// 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.

  1. Pick. The active picker taps an unanswered clue.
  2. Countdown. A synchronized 3·2·1 plays on every screen — clue text withheld until it ends.
  3. Answer. The clue appears and a 10-second timer starts, identical on all screens.
  4. Reveal. Each answer is shown with a ruling; every correct answer earns the clue's value.
  5. 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.

Data flow — read path vs. write path

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.

Phase transitions
One clue, end to end

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

RouteByDoes
POST/api/live/createhostCreates a game, assigns an unguessable code, returns it.
POST/api/live/joinplayerValidates code & 3-player cap, adds caller to playerUids.
POST/api/live/starthostLobby → picking; sets first picker.
POST/api/live/pickpickerWrites public clue text + deadlines, phase → countdown.
POST/api/live/submitplayerRecords a submission iff server clock < answerEndsAt.
POST/api/live/resolveanyJudges 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 submitsThe 10s timer resolves without them — a missing submission is just a non-score, no one is blocked.
Drops mid-answerTheir 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.
ReconnectsThe 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 pickThe only case that can stall everyone. A pick deadline (or host “skip”) reassigns the pick so the game can't hang.
Never comes backRounds 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 gamesA 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.

00

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.

01

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.

02

Completion & polish

Winner screen, rematch, presence indicators, picker-timeout skip, shareable result.

ships: Feels finished, not a prototype.

03

Depth (optional)

Spectators, reactions, per-round “fastest correct” flair, live Daily Double wagers, bigger lobbies.

ships: Replay value and personality.

11 // risks & open questions

Correctness

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.

Security

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.

UX

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.

Latency

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.

Cost

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.

Open

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