Case study · A private repo, shown by its process · July 2026

Spec first, ship fast: building Pizza with Claude Code

Pizza is a multiplayer, AI-hosted party-game platform — one shared screen, every player's phone is the controller. This is the build log of its v0.1: fourteen days from an empty repo to a deployed, live-demoed MVP, built pair-style with Claude Code on a foundation of thirteen architecture decision records, a sixteen-issue ordered backlog, and a test suite that grew from 54 to 166 along the way.

14
days to MVP
13
ADRs · 9 pre-code
30
pull requests
166
tests · node:test
8.4k
lines of strict TS
1
live demo box

This page is set in Pizza's own design tokens (docs/ui-spec.md) — the case study wears the product.

The product

A game platform that happens to start with trivia

The Jackbox model: a host opens a room on the big screen, a QR code and a four-letter room code appear, and everyone joins from their own phone — no app, no accounts, no install. The server is a Node WebSocket relay and the single source of truth; clients render state and emit input, nothing more.

The deliberate twist is in the name of the repo: Pizza is the platform, trivia is a module. The platform core never knows which game it is serving — game rounds cross the wire as opaque payloads owned by the module. And although the product is "AI-hosted," v0.1 contains zero AI on purpose: the roadmap introduces one concern at a time — a deterministic game loop first, Claude calling in via a custom MCP server at v0.3, the app calling out for AI grading at v0.4.

PIZZA trivialive
Scan to join — or enter the code
PVPE
AdaBexPrajno
3 players in
Start game
PIZZA trivialive
Round 1 116
What is the capital of France?
AParis
BBerlin
CMadrid
DRome
PIZZA trivialive
Round 1 78
What is the capital of France?
AParis
BBerlin
CMadrid
DRome
PIZZA trivialive
ROUND 1 · THE ANSWER IS
AParis
BBerlin
CMadrid
DRome
2 correct  1 missed
PIZZA trivialive
FINAL RESULTS
🏆 Prajno wins
1Prajno 🥇2
2Bex1
3Ada0
PIZZA trivia
Join the game
Get the code from the big screen
Room code
PVPE
Your name
Prajno
Join
Round 11:43
What is the capital of France?
AParis
BBerlin
CMadrid
DRome
Pick an answer
Round 11:18
What is the capital of France?
AParis
BBerlin
CMadrid
DRome
Lock it in
Correct!
+1 point
AAnswer: Paris
Next round starting…
🏆
You came 1st
2 points · you won! 🎉
Full standings are on the big screen.
Thanks for playing!

Not a video — a recreation of the real v0.1 screens in the product's own CSS. Every state was verified against a live game played while producing this page: the simulated-player bots Ada and Bex joined room PVPE over real WebSockets, and lost.

The process

Fourteen days, in the order it actually happened

The sequence below is reconstructed from the merge history — and the order is the story. Architecture was decided and reviewed as documents before any code existed; every feature landed as a PR closing a pre-specified issue; reviews produced tracked issues rather than vibes; and shipping produced new decisions, recorded as new ADRs.

Jul 09 · day 1

Eight ADRs before a line of code

The repo's second commit is ADR-0001…0008: server authority & answer secrecy, no UI framework, platform-first repo, files over a database, outbound-only connections, a worker-ready bus, gh CLI over a GitHub MCP server, and the platform/game seam.

Jul 10 · day 2

The paper foundation, then the first code

CLAUDE.md, PRD, roadmap, and Mermaid diagrams land (ADR-0009); then the scaffold — a runnable empty dev setup; then ADR-0010 (testing strategy) before the first feature. Shared wire contracts follow, plus a written convention: npm run verify green before any PR.

Jul 11 · day 3

Server core

Question pool with a validating loader, WebSocket relay bootstrap with typed routing, the GameStateStore interface, and room lifecycle — create, join, rejoin, roster, host-disconnect grace.

Jul 12 · day 4

The game exists

The GameModule seam and round engine (with WS tests asserting no answerIndex on the wire from day one), round-end reveal + leaderboard, and a file-backed store with atomic writes.

Jul 13 · day 5

Review waves

A multi-agent architecture review lands as two PRs: a same-day hardening pass (two latent bugs, including a process-killing timer exception) and a four-commit batch — tests → flake fix → god-object decomposition → read validation.

Jul 14 · day 6

The seam repair

Building the client bus exposed a trivia-shaped wire protocol — a latent seam violation. ADR-0011 lands with the fix in the same PR: generic round envelopes, modules own record / content / action / reveal, verified by grep.

Jul 15–16 · days 7–8

Design before build, then the clients

WS transport with reconnect; then the UI spec + standalone HTML mockups merge at 20:02 — and the host screen implementation follows at 22:56, the player phone just after midnight. Design beat code to main by three hours.

Jul 16–17 · days 8–9

Solo-testable end to end

One port serves client + WebSocket (a phone needs only a URL); Tailscale Funnel docs; and the simulated-player bot — the E2E harness that plays full games and fails if a correct answer ever leaks early.

Jul 20–21 · days 12–13

Ship it

VPS demo deploy: Caddy auto-HTTPS, Basic Auth on the host page, a HOST_SECRET token gating createRoom over the socket (ADR-0012). A pre-merge adversarial review found seven issues, including a blocker that would have failed every deploy.

Jul 23 · two weeks to the day

Operate & steer

Milestones reordered by ADR-0013 (platform breadth before AI grading); the reveal-pacing bug found in live play gets fixed; CI runs the same verify gate as local; npm run smoke + /healthz; and Deploy / Ops / Monitor become GitHub Actions.

Decisions first

An immutable decision ledger

Nine ADRs predate all code; ten predate all feature code. Each is four sections — Status, Context, Decision, Consequences — and none has ever been edited to change a decision. A changed mind gets a new numbered record that names exactly what it extends or reverses, so the ledger reads chronologically: day-one bets, a mid-build course correction, what shipping changed, and a priority shift — with every earlier rationale preserved verbatim, including the reversed ones.

ADR-0001

Server authority; answer keys never cross the wire. Any key reaching a client is readable in the network tab — the game becomes cheatable.

ADR-0002

Vanilla TypeScript, no UI framework. Clients render server state and emit input; a reconciler earns nothing here.

ADR-0003

Platform-first repo; games are modules. "If code wouldn't serve a hypothetical second game, it's in the wrong folder."

ADR-0004

Files over a database. JSON at laptop scale, behind a GameStateStore interface so SQLite later is an implementation, not a rewrite.

ADR-0005

Outbound-only WebSockets. Clients dial out, keyed by a room code; anything needing reachable clients "dies on firewalls."

ADR-0006

RxJS bus behind a worker-ready interface. Message-passing only, so the future Web Worker move is a swap, not a refactor.

ADR-0007

gh CLI; build our own MCP server instead. "Consuming someone else's MCP server teaches nothing about designing one."

ADR-0008

A minimal GameModule seam, proven later by a stub. "Over-generalizing before a second game exists is how you build the wrong abstraction."

ADR-0009

Mermaid diagrams in markdown. Diagrams diff as text and update in the same PR as the architecture change.

ADR-0010

Test invariants and logic first; a headless bot for E2E. Browser E2E over a realtime WS loop is the flakiest layer — "not where the risk lives."

ADR-0011

Generic round protocol. Written mid-build when trivia types leaked into the wire; modules now own round payloads, opaque to the platform.

ADR-0012

Demo VPS with layered access control. The privileged action to gate isn't the host page — it's createRoom over the socket.

ADR-0013

Reorder milestones: platform before AI. A pure scheduling ADR that openly reverses ADR-0008's timing — "the one reversal of stated reasoning, made deliberately."

"The immutable ADRs keep their original numbers on purpose. Per the ADR-immutability rule, they are not edited; this ADR is the single source of truth for the remap."

ADR-0013 — changing the plan without rewriting history

The unit of work

Issues you can hand to an agent

Every feature is one GitHub issue with the same five-part shape — and the shape is what makes AI-paired development controllable: Context cites the ADRs, Goal is one sentence, Acceptance criteria are checkable (often literally executable), Out of scope names where each deferral lands, and Notes for Claude speaks directly to the pair programmer.

#8 Round engine + GameModule seam: serve wire payload, collect, MC-score, advance featarea: servermilestone: v0.1
Acceptance criteria (abridged)
  • An automated test asserts no answer field appears on any client-bound payload produced by the loop (ADR-0001) — promote the informal "check" to a real test.
  • A WS-level integration test drives a full round over a real socket.
Out of scope
Reveal / leaderboard broadcast — that's #9. Free-text scoring — v0.4.
Notes for Claude
"This is the biggest issue in the batch — if it gets unwieldy, split the GameModule interface into its own tiny PR first, then the loop."
Bugs become issues

#52 opens: "Found during #15's live end-to-end run." The reveal rendered for one frame before the leaderboard — "the quiz-show money moment never lands." Filed with a fix sketch and a written justification for deferring; fixed two weeks in.

Ideas get parked, not built

#29 (host accounts) is stamped "Status: post-MVP — captured, not scheduled for v0.1." Accounts are a PRD non-goal, so step 1 would be a superseding ADR. #54 and #55 are fully specified future features left deliberately open.

Honest division of labor

#21's CI checklist tags one item [Manual, Praj] — enabling branch protection is a repo setting, "not doable from code." The human work is in the spec too.

Architecture

Diagrams that live in the repo

Per ADR-0009, every diagram is Mermaid in markdown — one per file, reviewed in the same PR as the change it depicts. These four are pre-rendered from the repo's own sources.

System overview diagram: clients, the relay server, AI tooling, and JSON data files, with the question pool as the shared seam
docs/diagrams/system-overview.md — the whole system at a glance. The AI paths exist in the architecture from day one but carry no v0.1 traffic.
Answer-secrecy trust boundary diagram: the answer key stays inside the server; blocked edges show it never crossing to host or phone
docs/diagrams/answer-secrecy-boundary.md — the core invariant made visible: the answer key structurally never crosses the trust boundary (ADR-0001).
Room join sequence diagram: host creates a room, phone dials out to join, lobby view carries no answer keys, host starts the game
docs/diagrams/room-join-sequence.md — the join handshake. Phones always dial out (ADR-0005), and even the lobby view carries no keys: secrecy starts at join, not at round start.
Demo deployment diagram: Caddy with auto-HTTPS and Basic Auth in front of the one-port Node app on an always-on VPS
docs/diagrams/demo-deployment.md — the live demo in production shape: three numbered access controls compose so a bare visitor can neither host nor play (ADR-0012).

The invariant

Cheating is a compile error

The headline engineering idea: answer secrecy isn't a code-review guideline, it's structural. The server-only QuestionRecord carries the key; the client-bound QuestionWire declares the key's field as never, so a record is not even assignable to the wire type — and toWire() is the only sanctioned projection between them.

// src/games/trivia/question.ts — the wire type cannot carry the key
export interface QuestionWire {
  readonly id: QuestionId;
  readonly category: string;
  readonly difficulty: Difficulty;
  readonly prompt: string;
  readonly options: readonly string[];
  readonly answerIndex?: never;
}

export function toWire(record: QuestionRecord): QuestionWire {
  const { id, category, difficulty, prompt, options } = record;
  return { id, category, difficulty, prompt, options };
}

And the guard guards itself. The test suite uses @ts-expect-error as a compile-time regression test: if the never protection ever weakens, these directives become unused and npm run typecheck fails.

// src/games/trivia/question.test.ts — a test that runs in the type system
test('a QuestionWire cannot carry the answer key (compile-time)', () => {
  // @ts-expect-error — a full record (with answerIndex) is not assignable to a wire.
  const fromRecord: QuestionWire = record;
  // @ts-expect-error — answerIndex cannot be added to a wire object.
  const smuggled: QuestionWire = { ...toWire(record), answerIndex: 0 };
  assert.ok(fromRecord && smuggled);
});

The same invariant is then enforced at four independent layers — coloured here like the game's own answer identities:

A · Compile time

The never-typed field plus @ts-expect-error regression tests. Branded nominal ids (Brand<T, B> via a unique symbol) keep a RoomCode from ever standing in for a PlayerId — zero runtime cost.

B · Unit & module tests

"toWire strips the answer key" and "contentFor strips the answer key (ADR-0001)" — the projection functions are tested directly, at both the trivia edge and the module seam.

C · Wire integration

WS tests drive a full 3-question game over real sockets and assert Object.hasOwn(wire, 'answerIndex') === false on live frames — the reveal exists in exactly one message, roundResult.

D · The bot, in production

The simulated-player bot deep-scans every pre-reveal frame for four spellings of the key and exits non-zero on a leak — locally in npm run smoke, and against the live box after every deploy.

The seam that got repaired in public

The platform/game boundary got its own stress test on day 6. Building the client bus exposed that the wire protocol was trivia-shaped and the "game-agnostic" round engine was reading answerIndex directly — a latent violation of the seam ADR. The response is the pattern worth showcasing: stop, write ADR-0011, and land the repair with the decision in one PR — generic envelopes, module-owned payloads, trivia types relocated out of shared code, and the diff verified by grep: "no game type or wire field survives in src/shared | server | platform | client."

// src/shared/messages.ts — the platform relays payloads it cannot read
export type ServerMessage<Content = unknown, Reveal = unknown> =
  | { readonly type: 'roundStarted'; readonly roundIndex: number;
      readonly content: Content; readonly deadline: number }
  | { readonly type: 'roundResult';  readonly roundIndex: number;
      readonly results: readonly PlayerRoundResult[]; readonly reveal: Reveal }
  // … the reveal exists in exactly one message, by construction

"Left in place, every client (#12–#14) would be built against a trivia-shaped protocol, cementing the leak exactly where it is most expensive to undo. […] The lint rule that forbids the platform from importing src/games/* now has nothing to hide."

ADR-0011 — generic round protocol, written mid-build

The proof that the platform is genuinely generic is also a test: the room lifecycle runs against a StubGameModule with deliberately non-trivia payload types.

Design

The mockups merged three hours before the code

Before either client screen was built, issue #46 produced a UI spec and two standalone HTML mockup galleries — checked into the repo, openable by double-click, covering every state including the edges: 7 host frames and 11 phone frames, join errors and reconnect banners included. The commit log is the receipt: spec and mockups merged at 20:02 on July 15; the host screen landed at 22:56; the player phone at 00:08. Because the mockups used the exact design tokens of the planned app, "the CSS ports directly" — a head start, not throwaway work.

--ground --brand --opt-a --opt-b --opt-c --opt-d --correct --wrong

The system is "quiz-show broadcast": a chosen plum-ink ground (not flat black), one magenta accent spent sparingly, and four colour + letter answer identities that match across host and phone — so "I picked the teal one, B" reads across a room. The letter is the primary channel, which makes the coding colourblind-safe; sans-vs-mono is the type system (mono for machine values, sans for prose); dark is primary, light fully designed.

"Semantic green/red appear only as a ring, badge, or check — never as a card fill — so they never collide with an option's identity colour. At reveal, the correct card keeps its own colour and gains a green ring + check; the others dim."

docs/ui-spec.md — the colour rule

Even secrecy shows up in the design layer: the host's mid-round view renders a payload that simply contains no key, so "the host structurally cannot show an answer mid-round." And each mockup ends with a "Decisions I made — and the open questions for you" panel; the spec's decision table records the product owner's ✓ on each call and one explicit ✗ (per-answer vote counts — not in the wire payload, not MVP).

Quality loop

Reviews that produce commits, tests that guard themselves

Quality ran as a loop, not a phase. A multi-agent architecture review on day 5 split its findings by cost: the cheap, high-value fixes shipped same-day as a hardening pass that closed two latent bugs — one of which (an uncaught exception inside a setTimeout tick) would have taken every room in the process down. The bigger findings became tracked issues, landing next as four independently-green commits ordered tests → flake fix → refactor → validation, "so the refactor lands on a hardened, non-flaky suite."

The gate can't drift

npm run verify = typecheck + lint + test + build, green before any PR. CI runs the same four npm scripts on every PR to main — same commands, so local and CI are one gate by construction.

Flakes are bugs

Sleep-as-synchronization was systematically replaced with awaited signals — tests wait for the actual roomClosed or gameOver frame, with timeouts. "Ran the suite 3× clean" is in the PR body.

Test the checker

The bot's answer-leak scanner is itself unit-tested — it provably catches a planted answerIndex — and its pass rule has a seatedPlayers > 0 backstop against green-while-nothing-ran.

Tests across the v0.1 pull requests

npm test count reported in each PR body · 164 at the CI gate, 166 by ship · node:test, zero test-framework dependencies
0 80 160 PR #30 · round engine — 54 tests PR #33 · hardening pass — 66 tests PR #38 · review batch — 79 tests PR #43 · generic protocol — 85 tests PR #47 · UI spec — 124 tests PR #59 · demo deploy — 162 tests PR #63 · CI gate — 164 tests 54 164 #30 #33 #38 #43 #47 #59 #63 pull request
View as table
PR#30#33#38#43#47#59#63
tests54667985124162164

The last ring of the loop is the smoke: npm run smoke boots the built artifact (dist/main.js), waits on /healthz, and has the bot play a full 3-player game with a mid-game reconnect — asserting the round loop and answer secrecy on the thing that actually ships, not just in-process tests.

Ship & operate

A one-person devops loop with no laptop required

The deploy story keeps the architecture honest: ADR-0012 frames the VPS as "a remote always-on laptop" — clients still dial out, state is still files (which finally gain a durable disk). The security insight is where to put the gate: not on loading the host page, but on createRoom over the WebSocket, which a scripted socket could otherwise hit. Basic Auth guards the page; a HOST_SECRET token — injected only into the authenticated page — guards the privileged action; guests need only a room code.

Deploy · a deliberate click

Manual-dispatch only — "never auto-deploys on merge." The ref input means deploying an older ref is the rollback. After rsync + build + restart, verify-live.sh has the bot play a full game against the live wss:// endpoint — a broken deploy fails the release on a product invariant, not just a ping.

Ops · least privilege

CI connects as a non-root deploy user: owns /opt/pizza, passwordless sudo for exactly one command (systemctl restart pizza), reads logs via group, cannot read the secrets file. The app itself runs in a hardened systemd sandbox (ProtectSystem=strict, nologin user).

Monitor · zero side effects

A 15-minute cron curls /healthz — no SSH, no secrets, and deliberately no bot, "so nothing accumulates in the state store." The same probe the smoke and the deploy verification already use.

Underneath it all is one zero-config property: the client always dials ws(s):// on the page's own origin — nothing hardcoded — which is why localhost, LAN, Tailscale Funnel, and the Caddy-fronted VPS all run the identical build.

"Sourcing (. env) would parameter-expand the values, and a bcrypt hash is full of $ sequences — under set -u that aborts outright, and otherwise it silently mangles the hash. So read KEY=VALUE literally."

deploy/setup.sh — the kind of comment a pre-merge adversarial review leaves behind; this exact bug was the blocker that "would have failed 100%" of documented deploys, caught before anyone ran it

The playbook

What made the AI pairing work

None of the above required heroics — it required a repeatable working agreement between a developer and an agent. These are the practices this repo actually demonstrates, each with its receipt.

Write the constitution before the code

Eight ADRs were the repo's second commit; CLAUDE.md turns them into standing orders ("YOU MUST NOT break these") every session inherits. The agent never has to guess the architecture — it's written down, with the why.

The repo is the memory

PRD, roadmap, ADRs, diagrams, specs — all in-repo, reviewable, and loadable on demand. Issues stay short because they reference ADRs for rationale. Any decision that would otherwise live in a chat gets written down.

One issue at a time, specified to be checkable

Context → Goal → Acceptance criteria → Out of scope → Notes for Claude. Criteria are often literally executable ("an automated test asserts…"). Underspecified? The rule is stop and ask, not guess.

Make invariants structural — then test them anyway

The answer key is unrepresentable on the wire type, the seam is lint-enforced, and both are still covered by unit, integration, and live-bot checks. Belt, suspenders, and a bot pulling on the trousers.

Green before PR; CI is the same command

npm run verify locally, the identical four scripts in CI — a visible command, deliberately not a hidden pre-commit hook. Every PR body reports the gate green with the test count.

Review adversarially, in waves

Multi-agent architecture reviews found real bugs (a process-killing exception; a deploy that would have failed 100%). Cheap fixes ship immediately; expensive ones become tracked issues. Findings land in PR bodies and issues — repo-as-memory again.

Bugs become issues, even mid-session

"Found during #15's live end-to-end run" is the first line of a bug report, not a silent hotfix. The grace-timer gap (#27) and the reveal pacing (#52) both got specs, tests, and their own PRs.

Park ideas with full specs; change plans with new ADRs

Host accounts, host-paced rounds, the module registry — fully specified, deliberately unbuilt. When priorities really changed, ADR-0013 reordered milestones without editing history, naming the one piece of reasoning it reverses.

Design lands before implementation

UI spec + every-state mockups merged three hours before the first client screen, in the app's exact tokens — so implementation was a port, not an improvisation, and product decisions (✓/✗) were on record first.

Prove it end to end with no humans in the room

The definition of done was "a full game, solo": a simulated-player bot that joins, plays, reconnects, and polices secrecy — reused as the local smoke, the manual checklist, and the post-deploy verification against production.

What's next

AI enters one concern at a time

v0.1 proved the loop with zero AI. The reordered roadmap (ADR-0013) now goes breadth-first: v0.2 — polish, reconnection robustness, and a deliberately thin second game module to prove the seam with a real consumer; v0.3 — Claude calls in: a custom MCP server over the question pool (search_questions, add_verified_question, get_game_stats) plus a generate→verify content pipeline; v0.4 — the app calls out: free-text questions graded live by Claude, where answers travel up and the key still never travels down. The parked issues — host accounts, host-paced rounds, the module registry, Docker — are already specified and waiting their turn.