Files
ducoterra f84c6ebaef Scaffold project and author 14-phase roadmap
Browser co-op kitchen game: FastAPI + native WebSockets, server-authoritative
20 Hz simulation, in-memory anonymous lobbies, data-driven YAML levels, Canvas 2D
vanilla ES-module client with locally authored SVG art and no external CDNs.

.agents/PLAN.md records the LOCKED decisions; .agents/user_stories/ holds the 12
flow contracts; .agents/phases/todo/ breaks them into 14 sequential, test-gated
phases (71 task files), each leaving the app launchable and the suite green.

LLM-Generated: true
2026-10-05 17:05:56 -04:00

487 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PLAN.md — Undercooked (master design, LOCKED)
**Type:** real-time multiplayer web game (in-browser, cooperative)
**Working title:** Undercooked — an independent Overcooked-style homage
**Status:** design locked, implementation begins at phase 01
---
## 1. Vision
One browser tab is enough to start playing: open the site, create a lobby, read the
5-character code out loud to your friends, hit *Start*, and cook together. No signup,
no download, no install, no email. Each player's phone or laptop is one cook; the
kitchen is shared and authoritative on the server. A campaign of ~10 hand-authored
levels ratchets the pressure — more recipes, fewer stations, hostile floors — and an
endless mode keeps the escalation going after the campaign ends.
**The one thing a user does on day one:** create/join a lobby by code and finish
level 1 with 2+ other people, seeing each other's cooks move with no perceptible lag.
### Success criteria (v1)
1. 4 players on 4 devices complete level 1 of a lobby in one sitting, no page reloads.
2. Creating a lobby, sharing the code, and joining takes < 20 seconds end to end.
3. A player who closes their laptop and returns mid-round rejoins and keeps playing.
4. All 10 campaign levels load, validate as solvable, and are measurably harder than
their predecessor by the difficulty metrics in §8.
5. Full test suite passes; the game survives a 200-room stress run on one node.
### Non-goals for v1
Accounts, auth, payments, persistent accounts/friend lists, matchmaking with
strangers, cross-node scaling, mobile-native apps, voice/chat (text-free emotes only),
level editor UI, gamepads, internationalization, ranked/online leaderboards.
---
## 2. Assumptions
1. **Sessions are short** (a round is 60–180 s) and **rooms are small** (≤ 4 players),
so a single asyncio process can hold thousands of rooms with a 20 Hz tick each.
2. **Anonymous is a feature, not a fallback.** Losing progress on server restart is
acceptable; therefore no database is required (user-approved, §3).
3. **Browser is the only client.** Desktop keyboard and touch are both first-class;
the client must degrade gracefully on a 360 px-wide phone.
4. **Art is ours.** Simple vector shapes/sprites generated in-repo; no third-party
asset packs, no CDN fonts.
5. **Trust model:** players are guests on our server, potentially hostile. The server
validates everything; nothing client-reported is authoritative.
6. **Python is fast enough** for a 20 Hz tick over ≤ 4 players + ≤ 60 entities
(≈ 0.5–2 ms CPU per room per tick). The perf budget in §10 protects this; if a room
misses budget, it drops to a lower tick rate rather than changing the stack.
---
## 3. Architectural Anchors (LOCKED DECISIONS)
| # | Component | Decision | Rationale | Status |
|---|---|---|---|---|
| A1 | Language / tooling | Python ≥ 3.12, `uv` for env + run | Project standard; `uv sync`/`uv run` everywhere | LOCKED |
| A2 | Web framework | FastAPI + uvicorn (`uvicorn[standard]`) | Async-native, first-class WebSocket support, Pydantic integration | LOCKED |
| A3 | Realtime transport | **Native WebSocket** (`/ws/{room_code}`), JSON frames | Lowest-latency simple option; no polling fallback needed for a game. Socket.IO rejected: extra abstraction, no benefit | LOCKED |
| A4 | Simulation authority | **Server-authoritative fixed-tick** sim at 20 Hz; snapshots out, inputs in; client interpolation + local prediction | Single source of truth (anti-cheat, no drift), reconnectable, testable headless | LOCKED |
| A5 | Sim purity | `src/undercooked/sim/` has **zero** I/O, asyncio, wall-clock, or unseeded `random`; `step(state, dt)` is a pure function | Enables headless determinism tests and frame-rate-independent logic | LOCKED |
| A6 | Persistence | **None.** Rooms live in the supervisor's memory; results/session scoreboards in memory. No Postgres, no SQLite, no ORM, no Alembic, no Redis | Anonymous, ephemeral by design; user explicitly chose this over the default Postgres rigour | LOCKED (user-approved deviation) |
| A7 | Cache / queue | None (single process, in-process asyncio tasks) | Multi-node fan-out is a v2 concern; adding Valkey now buys nothing | LOCKED |
| A8 | Content format | **Data-driven** levels + recipes as YAML in `levels/`, parsed into Pydantic models and validated at startup | No hard-coded content lists; new levels require no Python change | LOCKED |
| A9 | Client rendering | Canvas 2D, `requestAnimationFrame`, DPR-aware | Smooth motion between snapshots; DOM tiles stutter with many moving entities | LOCKED |
| A10 | Client code | Vanilla **ES modules**, no bundler, no framework | Zero build step, debuggable in the browser, satisfies the no-CDN rule trivially | LOCKED |
| A11 | Art | In-repo generated **SVG** tileset/sprites + CSS; system font stack | Portable, tiny, no external fonts/CDN | LOCKED |
| A12 | No external CDNs | Every asset served from `app/static/`; CSP `default-src 'self' ws: wss:`; enforced by a test | Project rule; also removes a privacy and availability risk | LOCKED |
| A13 | Test stack | pytest (+`pytest-asyncio`) for unit/integration/sim; **Playwright** for E2E, **one suite per user story**, multi-context for multiplayer | Stories are the delivery unit; two browser contexts = two real players | LOCKED |
| A14 | Protocol | Versioned JSON messages (`p` protocol int, `type` discriminator) documented in `docs/PROTOCOL.md`; server rejects unknown versions/major mismatch | Debuggable, testable, forward-compatible | LOCKED |
| A15 | Lobby codes | 5 chars, Crockford base32 (no `I L O U`), server-generated, collision-checked, ~33 M space | Shoutable out loud, quick to type on a phone | LOCKED |
| A16 | Player identity | `secrets.token_urlsafe(24)` token per browser tab in `sessionStorage`; display name optional, sanitized, non-unique | Anonymous, rejoinable, no PII | LOCKED |
| A17 | Config | Env vars (`UNDERCOOKED_*`) + `.env` via `python-dotenv`; typed `config.py`; no hard-coded paths/ports | Deployable, testable | LOCKED |
| A18 | Debugging | `debugpy` gated by `DEBUGPY=1` (non-blocking listen on `DEBUGPY_PORT`) | Real-time server is hard to debug by printing | LOCKED |
| A19 | Packaging | Multi-stage `Containerfile` (slim runtime, non-root) + `compose.yaml` (app only, no DB) | Single-artifact deploy | LOCKED |
| A20 | Quality gate | ruff + pyright + pytest with **>90%** coverage on new/changed code; full suite green before any phase completes | Project standard | LOCKED |
| A21 | Determinism harness | Input scripts + seed replay to a golden state hash (`tests/sim/test_replay_*.py`) | Proves A4/A5 and makes balance regressions cheap to catch | LOCKED |
| A22 | Lint/format targets | `pyproject.toml` `[tool.ruff]`, `[tool.pyright]`, `[tool.pytest.ini_options]` as scaffolded | One source of truth for checks | LOCKED |
Anything not in this table is fair game to choose *within* an existing anchor. Using a
new dependency, service, protocol, or framework requires explicit user permission,
recorded in the phase's `00_phase.md` and appended to this table by the user.
---
## 4. High-level architecture
```
browser (player) server (one asyncio process)
┌───────────────────────────────────┐ WS ┌──────────────────────────────────────┐
│ app/static/js │ │ server/app.py FastAPI app │
│ app.js screen state mach. │═══════▶ │ ├─ / index.html │
│ net/connection.js WS + backoff │ JSON │ ├─ /static/* ES modules, SVG │
│ net/predict.js local predict │◀═══════ │ ├─ /healthz /api/levels /api/rooms │
│ net/snapshots.js buffer+lerp │ frames │ └─ /ws/{code} ws endpoint │
│ input/keyboard.js|touch.js │ │ │ │
│ render/canvas.js layers, camera │ │ server/protocol.py msg models+valid. │
│ render/hud.js score/time/orders│ │ server/room.py Room + FSM │
│ ui/… lobby/result DOM│ │ │ 20 Hz tick task │
└───────────────────────────────────┘ │ server/rooms.py RoomSupervisor │
│ server/sessions.py in-memory runs │
│ │ │
│ sim/* pure simulation kernel │
│ levels/*.yaml → sim/level.py │
└──────────────────────────────────────┘
(no database, no external calls)
```
### Component responsibilities
| Component | Owns | Never does |
|---|---|---|
| `sim/` | game truth: state, `step()`, interactions, scoring, hazards | network, HTTP, time, randomness outside round seed |
| `sim/level.py` + `sim/validation.py` | YAML → typed `LevelDef`; reachability/solvability validation | simulation stepping |
| `server/room.py` | one match: player seats, FSM, tick loop, snapshot/event emission, rejoin seats | routing, HTTP, cross-room logic |
| `server/rooms.py` | room registry, code issuance/collision, capacity, GC of idle rooms | game rules |
| `server/protocol.py` | message models, per-state whitelists, inbound validation, framing caps | game rules |
| `server/sessions.py` | in-memory records: completed rounds, best scores per session token | durable storage |
| `server/app.py` | FastAPI factory, static files, headers/CSP, startup level validation | game rules |
| client `net/` | connection lifecycle, snapshot buffer, interpolation, prediction/reconciliation | deciding anything |
| client `render/` | drawing, camera, HUD, effects | mutating game state |
### Data flow (one tick, 20 Hz)
1. Each connected client sends at most one `input` per frame: `{seq, move:{x,y}, act, face}`
— only on change, plus a keep-alive at ≥ 5 Hz.
2. `protocol.py` validates shape, size, rate, and legality for the room's state;
stale `seq` is dropped; illegal moves are clamped, never trusted.
3. `room.tick()` applies queued inputs to the sim, then `sim.step(state, dt)` advances
physics, station timers, order spawning, hazards, and win/loss checks.
4. The sim returns `(state, events)`; `events` are transient FX/log lines
(`chopped`, `burnt`, `served`, `order_missed`, `fire_started`, `score`, …).
5. The room builds a **snapshot** (compacted: entities quantized to 1/64 tile,
station arrays, order list, score/timer) and pushes `snap` frames to players at
`SNAPSHOT_HZ` and to spectators at half that; events ride along with the frame.
6. Client buffers snapshots and renders at `now − 100 ms`, interpolating between the
two bracketing snapshots; own player is additionally dead-reckoned from local input
and reconciled when a snapshot contradicts it.
### Snapshot content (contract, compact)
```jsonc
{ "t": 4821, "p": [ { "i":"ab12", "x":12.34, "y":8.06, "f":2, "h":"tomato_chopped",
"s":"move", "pi":0 } ],
"st":[ {"k":"chop","x":3,"y":2,"g":"onion","q":6,"p":0.42,"s":1} ],
"o": [ {"id":17,"r":"soup","l":0.63,"pts":40} ],
"s": 120, "tgt": 140, "tl": 83.65, "hz": [ {"t":"fire","x":7,"y":5,"l":0.8} ],
"e": [ {"k":"served","x":9,"y":1,"i":17} ] }
```
Quantization: positions are floats rounded to 2 decimals on the wire, progress to 2
decimals — enough for smooth interpolation, small enough to keep frames ≈ 300–900 B.
---
## 5. Simulation model (pure)
### Entity/state types (`sim/model.py`)
- `Vec2`, `Facing` (`up/right/down/left`), `TileKind` (`FLOOR, COUNTER, WALL, VOID`)
- `StationKind` (`SPAWN_SHELF, CHOP_BOARD, STOVE, POT, PREP, SERVE, SINK, TRASH`)
- `Station` — position, kind, contents (`list[Item]`), `progress`, `state`
(`idle|working|ready|burning|dirty|blocked`), per-station `cooldown`
- `Item` — `kind` (`tomato|onion|lettuce|dough|patty|rice|fish|…`), `form`
(`raw|chopped|cooking|cooked|burnt|plated|dirty`), `quality` (1.0 → 0.0 as it burns),
`ttl`
- `Player` — id, pos, vel, facing, `carrying` (`Item|None`), `action` state, `state`
(`idle|moving|using|stunned`), `stun`, `score_share`, `connected`
- `Order` — id, recipe id, `deadline`, `time_left`, `value`
- `Hazard` — `fire|spill|moving_obstacle`, pos, `life`, `path`/`period` for movers
- `KitchenState` — level id, seed, tick, grid, stations, players, items, orders,
hazards, score, target, time_left, `result` (`None|won|lost`), counters, rng state
### Movement
Continuous positions in tile units, velocity from the input vector, radius 0.35 tile,
axis-separated AABB resolution against blocked tiles, other players (soft push, mass 1),
and carried items. Max speed 3.5 tiles/s, accel 30 tiles/s², no diagonal speed boost.
Void tiles are lethal to movement (blocked, not falling — v1 keeps it simple); a
`moving_obstacle` occupies tiles it passes over and stuns on contact (0.6 s).
### Interaction model
One action button. `interact(state, player)` resolves against, in priority order:
1. **carrying → drop/transfer onto target station** (throwing is out of scope v1),
2. **station in reach (1 tile in front, then radius 0.6):** pick item, start work,
place item, plate, serve, wash, trash (long-press),
3. **nothing** → short "empty" event (client plays a whiff).
Two-hand rules: one carried item per player. Chopping requires holding `act` for the
chop duration; taking a chopped item off the board requires the board to be `ready`.
Contested targets resolve by earliest server-received input, tie-broken by player
seat index; the loser gets `denied` (client shows a nudge) — never a silent no-op.
### Station pipelines
| Station | Input | Progress | Output |
|---|---|---|---|
| Spawn shelf | — | instant, infinite while orders need it | `raw` item |
| Chop board | `raw` | hold `act` ~0.9 s | `chopped` (rot `raw` in 20 s) |
| Stove + pot | `chopped` (soup) / `raw` (rice) | 4–8 s | `cooked` → `burnt` after 8 s idle |
| Pan/griddle | `raw` patty/fish | 5 s | `cooked` → `burnt`; can start `fire` |
| Prep/assemble | `cooked`/`chopped` + plate | per recipe | `plated` dish |
| Serve window | `plated` | instant match vs orders | score, quality bonus, streak |
| Sink | `dirty` plate/pot | 1.2 s | clean (dirty stock caps available plates/pots) |
| Trash | any item | instant | gone (small score nudge −2) |
### Orders, scoring, results
- Orders spawn from the level's `pool` with `concurrent` and `spawn_interval` bounds.
- Serving a correct dish: `base × quality × streak_mult × time_bonus`. Wrong dish:
rejection event, no score, order stays.
- Missed order (deadline 0): `−order_penalty`, streak reset, client shake.
- `target_score` reached → `result=won` immediately (with `stars` from remaining time);
`time_left == 0` → `won` if `score ≥ target` else `lost`.
- Per-player contribution is tracked for the result screen (display only, never
competitive gatekeeping).
---
## 6. Lobby, rooms, and state machines
### Room FSM (`server/room.py`)
```
LOBBY ──(host starts, ≥1 ready)──▶ COUNTDOWN(3s) ──▶ PLAYING
▲ │
│ result=won|lost|aborted
│ next-level / retry ▼
└──────────────(≤ 20 s auto, any host action)── ROUND_END
│ ROUND_END ──(idle > lobby_idle_timeout)──▶ CLOSED (GC, seats freed)
└ LOBBY ──(all seats empty > grace)────────▶ CLOSED
```
- `LOBBY`: joinable/leavable, ready flags, level pick (host; limited to unlocked),
chat-free emotes only.
- `COUNTDOWN`: 3…2…1 broadcast; late joins wait in `SPECTATING` until `LOBBY`.
- `PLAYING`: inputs accepted from seated players; joiners become spectators; dropouts
keep their seat (`connected=false`, sim keeps them idle 3 s then parks them).
- `ROUND_END`: results, unlocked-next-level state, retry/next/host-picked level.
- `CLOSED`: supervisor drops the room; tokens become invalid.
### Supervisor rules
- Codes: Crockford base32, 5 chars, retried on collision; never reused within the
process; the empty-lobby room is created on click, not on page load.
- Caps: `max_players` (4) per room, `max_rooms` (200) process-wide → `503 room_full`.
- GC task every 5 s: close idle lobbies, evict orphaned rooms, purge stale seats.
- Every room owns exactly one asyncio tick task; task cancellation is the only teardown.
- Crash isolation: a room's tick exception is caught, logged, the room is marked
`aborted`, players are told, and the rest of the server is unaffected.
### Session records (`server/sessions.py`, in memory, bounded)
`session_token → {display_name, unlocked_level, best[ level_id ] = {score, stars,
completed_at}, rounds_played}`. LRU-capped at 10 000 entries; wiped on process
restart (acceptable per A6).
---
## 7. Wire protocol (`docs/PROTOCOL.md`)
Protocol version `p: 1`. Server closes the socket with code `4400` on major mismatch.
| Direction | `type` | Payload | Accepted when |
|---|---|---|---|
| C→S | `hello` | `{token?, name?, p}` | connecting |
| C→S | `ready` | `{}` | `LOBBY` |
| C→S | `set_level` | `{level}` | `LOBBY`, host only, level unlocked |
| C→S | `start` | `{}` | `LOBBY`, host only |
| C→S | `input` | `{seq, move:{x,y}, act, face}` | `PLAYING` |
| C→S | `emote` | `{id}` | `LOBBY`, `PLAYING` |
| C→S | `to_lobby` / `next_level` | `{}` | `ROUND_END` |
| C→S | `ping` | `{t}` | always |
| S→C | `welcome` | `{you, room, state, level, seats}` | after `hello` |
| S→C | `lobby` | `{seats[], level, host, can_start}` | lobby-state changes |
| S→C | `countdown` | `{n}` | `COUNTDOWN` |
| S→C | `snap` | snapshot (§4) | `PLAYING` |
| S→C | `round` | `{result, score, target, stars, unlocked, players[]}` | `ROUND_END` |
| S→C | `ev` | `[{k, …}]` (FX/log) | any |
| S→C | `err` | `{code, msg, seq?}` | on rejection |
| S→C | `pong` | `{t, now}` | after `ping` |
Inbound guards: max `max_message_bytes`; JSON-only; unknown `type` → `err unknown_type`
(disconnect after 5); per-state whitelist; per-connection token bucket
`max_inputs_per_s`; name sanitization (strip control chars, trim, 16 chars); one socket
per token (second connection evicts the first).
---
## 8. Level content and the difficulty curve
`levels/recipes.yaml` defines ingredients and recipes; `levels/NN_*.yaml` defines each
level. Every list the game reads comes from these files — the code never enumerates
levels or recipes (validated by a test that adds a fixture level and expects it to
appear in `/api/levels`).
```yaml
id: 07_riverboat
name: Riverboat Galley
order: 7
time_limit: 100
target_score: 150
grid: # ASCII, one char per tile; '#'=wall '.'=floor …
- "##############"
- "#S..C..P....X#"
- "#..==..==....#"
- "#P1.2......ZZ#"
tiles: { "S": {kind: spawn_shelf, item: tomato}, "C": {kind: chop_board}, … }
spawns: [{x: 1, y: 9}, {x: 12, y: 9}]
orders:
pool: [soup, salad, fried_rice]
concurrent: 3
spawn_interval: [7, 11]
deadline: 42
hazards:
- {type: fire, station: stove_2, chance_per_s: 0.02, extinguish: sink}
- {type: spill, x: 6, y: 5, respawn_s: 25}
- {type: moving_obstacle, path: [[4,3],[10,3]], period_s: 9}
station_limits: {plates: 3, pots: 2}
escalation_notes: "splits the kitchen; fire pressure without an extinguish shortcut"
```
### Escalation axes (each campaign level must move ≥ 2 of them vs. its predecessor)
1. **Recipe complexity** — 1 → 4 ingredients, multi-station chains.
2. **Order pressure** — concurrent 1 → 4, `spawn_interval` 16 s → 6 s, `deadline` 60 → 30 s.
3. **Time budget** — `time_limit` 150 → 75 s against rising `target_score`.
4. **Station scarcity** — 1 shared stove for 4 players, limited plates/pots (dirty loop).
5. **Layout hostility** — open room → two halves → narrow bridges → void gaps.
6. **Hazards** — none → spills → fires → moving obstacles → order-spam "rush" waves.
7. **Distance** — ingredient ↔ cook ↔ serve travel 3 → 14 tiles.
Campaign: L1 tutorial (solo-friendly), L2 two orders, L3 chop+cook chain, L4 dirty-dish
loop, L5 split kitchen, L6 fire, L7 boat + movers, L8 bridge/void, L9 rush waves,
L10 gauntlet (all axes). Then `endless`: generated by sampling a difficulty scalar
`d = 10 + rounds` against the same axes over the authored layouts.
**Validation gate** (`undercooked-levels validate`, also a test over every shipped
level): ids unique; grid rectangular, spawns/stations on floor; every spawn, station,
and serve window mutually reachable (BFS on walkable tiles, movers excluded); every
recipe's ingredient reachable and every required station kind present; `time_limit > 0`;
`target_score ≤ theoretical_max_score × 0.6` (greedy plausibility: orders possible in
the time window × mean order value); hazards reference existing stations; escalation
axes actually increase between consecutive `order` values.
---
## 9. Validation / verification workflow (how we know it is right)
Layered, cheapest first — every phase runs the layers it touched, and the pipeline gate
runs all of them:
1. **Static**: `ruff check` (lint incl. `ASYNC`, `S`, `PERF`), `pyright`.
2. **Pure-sim unit tests**: movement/collision, station state machines, recipe/serve
resolution, scoring, hazards, validation rules — table-driven, no I/O.
3. **Determinism/replay**: seeded input script → N ticks → state hash compared to a
golden file; a second run after a refactor must match bit-for-bit.
4. **Room/protocol integration**: in-process FastAPI + real WebSocket clients —
create/join/ready/start/snap/round/rejoin, illegal-message rejection, rate limiting,
4-player full round, room GC.
5. **Content gate**: `undercooked-levels validate` over every YAML (fixture + shipped).
6. **E2E per user story** (Playwright): real browser, real sockets; multiplayer stories
drive **two browser contexts** against the same room and assert *both* observe the
same outcome (score, result, seats).
7. **Netcode tests**: server-side latency injection (`UNDERCOOKED_SIM_LATENCY_MS=150`),
socket-kill mid-round + rejoin, prediction error bound assertions.
8. **Scale/perf budget** (marked `slow`): 200 rooms × 4 clients ticking 60 s — mean tick
≤ 2 ms/room, RSS growth < 20 %, zero missed ticks > 50 ms.
9. **Doc & a11y pass**: README/AGENTS reviewed project-wide; axe-core-free checks
(contrast, focus order, `aria-live` result announcements) in the a11y story's suite.
A phase is *Complete* only when layers 1–5 relevant to its change pass and the
**entire** suite is green with >90% coverage on new/modified code.
---
## 10. Performance and reliability budgets
| Budget | Target | How measured |
|---|---|---|
| Server tick cost | mean ≤ 2 ms, p99 ≤ 8 ms (4 players) | `room.tick` timing histogram, stress test |
| Snapshot size | ≤ 1.5 KB JSON | encoded-size assert in integration test |
| Downlink rate | ≤ 60 KB/s per player | measured in integration test |
| Client frame time | ≤ 16 ms at 4 players on a mid-tier laptop | Playwright `performance.now()` sampling |
| Input → visible echo | ≤ 120 ms (incl. 100 ms interpolation) | netcode test |
| Room memory | ≤ 1 MB per active room | RSS delta in stress test |
| Room capacity | ≥ 200 concurrent rooms | stress test |
| Rejoin to playable | ≤ 3 s after page load | e2e test |
Degradation policy: if a tick exceeds budget, halve that room's effective tick rate for
5 s and emit `ev{kind:"tick_slow"}`; never drop players for lagging.
---
## 11. Security & privacy posture
- No PII collected; no cookies; no localStorage beyond the session token and last room
code; no third-party requests at runtime (test-enforced).
- WebSocket: origin check (`ALLOWED_ORIGINS`, default same-host), message-size and rate
limits, per-state whitelists, one socket per token, idle close.
- Room codes are unguessable-ish but are *capability secrets*: never logged with
contents, and every server-side check assumes the code holder is a legitimate player
and validates their *actions*, not their identity.
- Static serving: no directory listing, path traversal guarded by `StaticFiles`,
`X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, CSP
`default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self'`.
- No `eval`, no dynamic JS assembly from server strings, no user HTML rendering
(names are rendered via `textContent`).
---
## 12. Roadmap (phases; details in `.agents/phases/todo/`)
| # | Phase | Delivers (player-visible if shipped) |
|---|---|---|
| 01 | `runnable_service_scaffold` | `uv run undercooked` serves the shell page + `/healthz`; checks green |
| 02 | `level_content_and_validation` | level/recipe schema, loader, validator, 2 levels, `undercooked-levels validate` |
| 03 | `simulation_movement_and_interaction` | deterministic movement, reach, carry; replay hashes pinned |
| 04 | `kitchen_pipeline_and_scoring` | full cook chain, orders, scoring, win/lose; headless round sim |
| 05 | `anonymous_lobbies_and_room_lifecycle` | supervisor, codes, ready-up, round FSM, session progression |
| 06 | `realtime_websocket_protocol` | `/ws/{code}`, inputs in, snapshots out, `docs/PROTOCOL.md` |
| 07 | `canvas_client_and_hud` | playable-in-browser single player vs. clock, HUD, art |
| 08 | `client_prediction_and_touch` | smooth remote play, prediction/reconciliation, touch controls |
| 09 | `co_op_two_player_e2e` | two real browsers cooking together, contested-resource arbitration |
| 10 | `hazards_and_escalation_mechanics` | fire, spills, movers, rush waves + client FX |
| 11 | `campaign_levels_and_progression` | all 10 levels authored+validated, campaign flow, curve check |
| 12 | `reconnect_resilience_and_scale` | mid-round rejoin, seat grace, room GC, 200-room stress test |
| 13 | `endless_mode_and_scoreboard` | endless generator, session scoreboard, post-campaign loop |
| 14 | `accessibility_polish_and_hardening` | contrast/reduced-motion/screen-reader, perf+security pass, docs |
Every phase leaves the app launchable and the suite green; phases 01→14 are strictly
sequential (each depends on the previous phase's capability).
---
## 13. Risks and mitigations
| Risk | Impact | Mitigation |
|---|---|---|
| Python tick too slow at scale | laggy rooms | perf budget test in phase 12 from day one; sim purity keeps the hot loop cheap; halve-tick degradation |
| Client desync/rubber-banding feels bad | unplayable | server-authoritative + interpolation delay + reconciliation tests (phase 08) |
| Contested pickups feel unfair | co-op friction | deterministic arbitration + explicit `denied` feedback (phase 09) |
| Difficulty curve guesswork | too easy/hard | escalation-axis metrics + headless greedy solver bound (phase 11) |
| Level content explodes in complexity | unmaintainable | strict YAML schema + validator + content test that never hard-codes lists |
| Anonymous rooms abused (spam) | noisy lobbies | no chat in v1, emote whitelist, rate limits |
| Restart wipes runs | lost progress | accepted by design (A6); result screen encourages saving nothing; endless/scoreboard explicitly in-memory |
| Trademark confusion with "Overcooked" | legal | original art, names, and content; homage note in README |
### Documentation index (canonical)
These are the only docs the project promises. A phase creates a file the first time it needs
one; no phase references a doc that does not exist by the end of the last phase.
| Path | Contents | Created by |
|---|---|---|
| `README.md` | what it is, quickstart, how to play, controls, deploy pointer, demo-vs-production | phase 01, refined throughout |
| `AGENTS.md` | agent rules: commands, architecture, conventions, guardrails | phase 01, verified every phase |
| `BUDGETS.md` | measured performance/latency budgets vs §10 targets, dated | phase 08 |
| `docs/DESIGN.md` | architecture, module map, the decisions behind A1–A12 in prose | phase 01 (stub) → phase 09 |
| `docs/SIMULATION.md` | tick contract, movement/collision, pipeline rules, arbitration, hazard rules | phase 02/03 |
| `docs/LEVELS.md` | level + recipe YAML format, escalation axes, how to author and validate a level | phase 02, phase 11 |
| `docs/PROTOCOL.md` | wire messages, enums, error codes, close codes, budgets, versioning policy | phase 06 |
| `docs/CLIENT.md` | client module map, render loop, snapshot buffer, input, assets | phase 07 |
| `docs/DEPLOYMENT.md` | container build/run, compose, TLS/reverse proxy, ops runbook | phase 01 (stub) → phase 14 |
| `docs/TESTING.md` | test layout, how to run everything, artifact policy | phase 01 |
| `docs/ACCESSIBILITY.md` | what WCAG 2.1 AA guarantees, how each is tested, text-mode contract, known limits | phase 14 |
| `docs/SECURITY.md` | threat model + finding table with severity, status, and the evidence test per row | phase 14 |
| `tests/e2e/README.md` | multiplayer harness usage, debugging failing browser tests | phase 07 |
`tests/unit/test_docs_index.py` (phase 01) keeps this table and the `docs/` tree identical, so
neither can drift silently.
---
## 14. Execution
- Run the pipeline with the `phased-execution` skill (`/next-phase`, `/run-phase`, `/phase-status`);
task files in `.agents/phases/todo/NN_name/` are the unit of work, and each phase moves to
`.agents/phases/complete/` only when its `00_phase.md` Completion Criteria are all checked.
- New work is never implemented directly: it is captured as a phase via the `phase-authoring`
skill first, so this roadmap stays the record of what the project claims to do.
- Amending a LOCKED decision (§3) requires editing §3 itself in the same phase, with the reason
and the phase number recorded inline — no silent reversals.