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
487 lines
30 KiB
Markdown
487 lines
30 KiB
Markdown
# 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.
|