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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Server authority; answer keys never cross the wire. Any key reaching a client is readable in the network tab — the game becomes cheatable.
Vanilla TypeScript, no UI framework. Clients render server state and emit input; a reconciler earns nothing here.
Platform-first repo; games are modules. "If code wouldn't serve a hypothetical second game, it's in the wrong folder."
Files over a database. JSON at laptop scale, behind a GameStateStore interface so SQLite later is an implementation, not a rewrite.
Outbound-only WebSockets. Clients dial out, keyed by a room code; anything needing reachable clients "dies on firewalls."
RxJS bus behind a worker-ready interface. Message-passing only, so the future Web Worker move is a swap, not a refactor.
gh CLI; build our own MCP server instead. "Consuming someone else's MCP server teaches nothing about designing one."
A minimal GameModule seam, proven later by a stub. "Over-generalizing before a second game exists is how you build the wrong abstraction."
Mermaid diagrams in markdown. Diagrams diff as text and update in the same PR as the architecture change.
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."
Generic round protocol. Written mid-build when trivia types leaked into the wire; modules now own round payloads, opaque to the platform.
Demo VPS with layered access control. The privileged action to gate isn't the host page — it's createRoom over the socket.
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.
- 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."
#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.
#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.
#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.
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:
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.
"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.
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.
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
ADR-0011 — generic round protocol, written mid-buildsrc/games/*now has nothing to hide."
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.
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."
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.
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.
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
View as table
| PR | #30 | #33 | #38 | #43 | #47 | #59 | #63 |
|---|---|---|---|---|---|---|---|
| tests | 54 | 66 | 79 | 85 | 124 | 162 | 164 |
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.
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.
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).
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 (
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. env) would parameter-expand the values, and a bcrypt hash is full of$sequences — underset -uthat aborts outright, and otherwise it silently mangles the hash. So read KEY=VALUE literally."
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.