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
30 KiB
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)
- 4 players on 4 devices complete level 1 of a lobby in one sitting, no page reloads.
- Creating a lobby, sharing the code, and joining takes < 20 seconds end to end.
- A player who closes their laptop and returns mid-round rejoins and keeps playing.
- All 10 campaign levels load, validate as solvable, and are measurably harder than their predecessor by the difficulty metrics in §8.
- 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
- 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.
- Anonymous is a feature, not a fallback. Losing progress on server restart is acceptable; therefore no database is required (user-approved, §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.
- Art is ours. Simple vector shapes/sprites generated in-repo; no third-party asset packs, no CDN fonts.
- Trust model: players are guests on our server, potentially hostile. The server validates everything; nothing client-reported is authoritative.
- 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)
- Each connected client sends at most one
inputper frame:{seq, move:{x,y}, act, face}— only on change, plus a keep-alive at ≥ 5 Hz. protocol.pyvalidates shape, size, rate, and legality for the room's state; staleseqis dropped; illegal moves are clamped, never trusted.room.tick()applies queued inputs to the sim, thensim.step(state, dt)advances physics, station timers, order spawning, hazards, and win/loss checks.- The sim returns
(state, events);eventsare transient FX/log lines (chopped,burnt,served,order_missed,fire_started,score, …). - The room builds a snapshot (compacted: entities quantized to 1/64 tile,
station arrays, order list, score/timer) and pushes
snapframes to players atSNAPSHOT_HZand to spectators at half that; events ride along with the frame. - 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)
{ "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-stationcooldownItem—kind(tomato|onion|lettuce|dough|patty|rice|fish|…),form(raw|chopped|cooking|cooked|burnt|plated|dirty),quality(1.0 → 0.0 as it burns),ttlPlayer— id, pos, vel, facing,carrying(Item|None),actionstate,state(idle|moving|using|stunned),stun,score_share,connectedOrder— id, recipe id,deadline,time_left,valueHazard—fire|spill|moving_obstacle, pos,life,path/periodfor moversKitchenState— 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:
- carrying → drop/transfer onto target station (throwing is out of scope v1),
- station in reach (1 tile in front, then radius 0.6): pick item, start work, place item, plate, serve, wash, trash (long-press),
- 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
poolwithconcurrentandspawn_intervalbounds. - 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_scorereached →result=wonimmediately (withstarsfrom remaining time);time_left == 0→wonifscore ≥ targetelselost.- 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 inSPECTATINGuntilLOBBY.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).
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)
- Recipe complexity — 1 → 4 ingredients, multi-station chains.
- Order pressure — concurrent 1 → 4,
spawn_interval16 s → 6 s,deadline60 → 30 s. - Time budget —
time_limit150 → 75 s against risingtarget_score. - Station scarcity — 1 shared stove for 4 players, limited plates/pots (dirty loop).
- Layout hostility — open room → two halves → narrow bridges → void gaps.
- Hazards — none → spills → fires → moving obstacles → order-spam "rush" waves.
- 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:
- Static:
ruff check(lint incl.ASYNC,S,PERF),pyright. - Pure-sim unit tests: movement/collision, station state machines, recipe/serve resolution, scoring, hazards, validation rules — table-driven, no I/O.
- 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.
- 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.
- Content gate:
undercooked-levels validateover every YAML (fixture + shipped). - 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).
- Netcode tests: server-side latency injection (
UNDERCOOKED_SIM_LATENCY_MS=150), socket-kill mid-round + rejoin, prediction error bound assertions. - 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. - Doc & a11y pass: README/AGENTS reviewed project-wide; axe-core-free checks
(contrast, focus order,
aria-liveresult 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, CSPdefault-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 viatextContent).
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-executionskill (/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 its00_phase.mdCompletion Criteria are all checked. - New work is never implemented directly: it is captured as a phase via the
phase-authoringskill 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.