All green. Final verification complete — writing the report.
**Final pass report — phase 06 `realtime_websocket_protocol`**
- Verified: all 6 task files present in `complete/`; this was the final verification pass (no implementation work outstanding)
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors
- `uv run undercooked-levels validate` → content OK; `uv run pytest` (full suite) → **1751 passed, 0 failed, coverage 97.30% (>90% floor)**; slow sim stage (`-m slow tests/sim --no-cov`) → 2 passed; repo hygiene clean
- App launches: `/healthz` 200, `/` 200, `/api/levels`, `/api/config`, `POST /api/rooms` (201), `/api/stats` all verified live on an ephemeral port (server cleaned up)
- Criteria: scripted client room→hello→ready→start→countdown→≥20 snaps→round — PASS (`test_ws_round_two_clients.py`); identical `round` payloads both clients — PASS (`round_a == round_b`); version mismatch → `err protocol_mismatch` + close `4400` — PASS; 64 KB refused / 4 KB boundary, 500-burst → `rate_limited` → `4404` with bounded queue, 2000 malformed + 200 handshakes within memory bound — PASS; snap ≤ 1.5 KB (4p/4o/3h) and 64×64 ≤ 4 KB, downlink ≤ 60 KB/s over 100 ticks — PASS; `docs/PROTOCOL.md` model-sync test-enforced in both directions + full-round sequence diagram present — PASS
- Docs: README.md had 3 stale spots — status section (claimed 01–05 complete, 06 pending → now 01–06 complete, 07 pending), "until phases 06/07 wire the transport" wording, and the `PROTOCOL.md` layout note ("phase 06 completes it"). All fixed. AGENTS.md verified consistent — no changes needed
- No defects found in completed phases; no live infrastructure touched
Next pending phase: `07_canvas_client_and_hud`.
606 lines
40 KiB
Markdown
606 lines
40 KiB
Markdown
# Undercooked
|
||
|
||
A cooperative, **in-browser**, Overcooked-style kitchen game: 1–4 players share one
|
||
kitchen over WebSockets, cook orders against the clock, and climb a campaign of
|
||
levels that get progressively harder. Lobbies are **anonymous** — no accounts, no
|
||
email, no password: you create a room, share the 5-character code, play.
|
||
|
||
> Working title. "Overcooked" is a trademark of Ghost Town Games/Studio MDG; this
|
||
> project is an independent homage with its own art, code, and content.
|
||
|
||
## Status
|
||
|
||
Phases **01** (runnable service scaffold), **02** (level content, schema and
|
||
validation), **03** (the deterministic simulation kernel: fixed-step tick,
|
||
movement and collision, the action button, the hands, the replay goldens),
|
||
**04** (the kitchen pipeline: station state machines, the ticket rail,
|
||
serve scoring, freshness and the dirty loop, the round verdict with stars)
|
||
and **05** (anonymous lobbies and room lifecycle: the `RoomSupervisor`,
|
||
the guarded `Room` FSM, the in-memory session store, the lifecycle
|
||
integration suite) and **06** (the realtime WebSocket protocol: the
|
||
`/ws/{code}` transport, the versioned JSON wire contract with inbound
|
||
guardrails, input ingestion with sequence discipline, the quantized
|
||
snapshot stream, heartbeats and seat grace — `docs/PROTOCOL.md`)
|
||
are **complete**; the next pending phase is
|
||
`07_canvas_client_and_hud`. What
|
||
runs today:
|
||
|
||
- **The service.** The Python toolchain is locked (`uv.lock`), configuration is a
|
||
single typed, validated `Settings` object (`DEBUGPY` support included), and
|
||
`uv run undercooked` starts a FastAPI server that serves the client shell at `/`,
|
||
the browser assets from `app/static/`, and `GET /healthz` — which answers
|
||
`{"status":"ok","version":"0.1.0","rooms":<live room count>}` from the room
|
||
supervisor (phase 05) — with the security headers (CSP `default-src 'self'`) on
|
||
every response.
|
||
- **The content.** The recipe book and the first two campaign levels live in
|
||
`levels/`; `sim/level.py` parses them into typed models, `sim/validation.py` runs
|
||
PLAN §8's named rules, and `create_app` loads + validates that bundle at startup and
|
||
**refuses to boot** broken content — the named-rule report goes to stderr and
|
||
`undercooked` exits `2` instead of serving a half-working kitchen. `GET /api/levels`,
|
||
`GET /api/levels/{id}` and `GET /api/config` answer from it, and
|
||
`undercooked-levels` gives authors the same gate from the command line
|
||
(`docs/LEVELS.md` is the schema reference).
|
||
- **The tick.** `sim/state_build.py` builds a round from level + seats + seed and
|
||
`sim/step.py` advances it in whole `0.05` s substeps (20 Hz, A4) with a splitmix64 RNG
|
||
seeded from the round seed — pure (no clock, no `random`, nothing mutated), replayable
|
||
from a seed plus an input script, and 100 % covered. The clock, the input contract and
|
||
the round's end condition are real. `docs/SIMULATION.md`
|
||
is the contract, `tests/sim/test_rng.py` locks the generator's published vectors.
|
||
- **Walking the kitchen.** `sim/movement.py` + `sim/collision.py` are stage 3 of the tick:
|
||
intent is clamped server-side (`move=(50,0)` walks at 3.5 tiles/s, not 175), accelerated
|
||
at 30 tiles/s² and integrated one axis at a time against an axis-aligned sweep, so a cook
|
||
stops flush against a wall instead of inside it, slides along it for free, cannot tunnel a
|
||
1-tile wall even at the coarsest legal `dt`, and cannot clip a diagonal corner. Walls,
|
||
counters, void, off-map and the solid fixtures (`spawn_shelf`, `chop_board`, `stove`,
|
||
`prep`) block; `serve`, `sink` and `trash` do not. Cooks soft-push each other apart
|
||
symmetrically and positionally — a bump never injects velocity — and only a genuine
|
||
terrain deadlock drops an input, the later-received one, exactly like the kernel's
|
||
arbitration. Stun (`0.6` s) pins velocity and position and reports one `stunned` event on
|
||
entry; facing follows the last non-zero intent, so an action press while standing still
|
||
does not spin the cook. Proven by hand-computed geometry in `tests/sim/test_movement.py`,
|
||
by a 2000-substep four-cook random walk from the sim's own RNG that re-checks containment,
|
||
non-overlap and hash equality after *every* substep
|
||
(`tests/sim/test_movement_stress.py`), and by the same walk on the shipped levels through
|
||
the whole `step()` (`tests/sim/test_movement_kernel.py`).
|
||
- **The action button.** `sim/interact.py` is the second half of stage 2 of the tick:
|
||
the one `action` flag per frame becomes one press per rising edge, so however long the
|
||
button is held there is one effect — and one named refusal when there is nothing to do
|
||
(`DENIED` with `out_of_reach`, `hands_full`, `station_busy`, `contested_lost`, … in the
|
||
payload, which the client turns into a nudge; a silent no-op is the failure mode PLAN §5
|
||
bans by name). A press resolves a reach zone — the tile in front, then the two
|
||
diagonal-front tiles (a 0.6-tile forgiveness beyond the 1-tile reach), then the tile
|
||
underfoot — and a ladder picks the verb: a full hand sets its item down on the fixture
|
||
(the spawn shelf is the escape hatch), a free hand picks from a ready fixture or shelf
|
||
stock, a stunned cook is refused before the target is even read, and a fixture owns
|
||
what is on it (an onion on a chop board is *to be chopped*, not grabbable). Two cooks
|
||
pressing the same item in the same substep are arbitrated by arrival, `seq`, then seat
|
||
index — the loser is told `contested_lost`, never left wondering. The work verbs (chop,
|
||
cook, plate, wash) are spent by `sim/stations.py` (phase 04 task 01's pipeline —
|
||
a set-down on a work surface starts the work, a release freezes the progress, a
|
||
dirty fixture is washed in place); `SERVE` is spent by `sim/scoring.py`
|
||
(phase 04 task 03 — see "Serve scoring" below).
|
||
The long-press trash is real — see "The hands" below; `docs/SIMULATION.md`
|
||
§"The action button" and §"The station pipeline" are the contract. Proven by
|
||
`tests/sim/test_targeting.py` (hand-computed reach geometry, the press edge through the
|
||
whole `step()`) and `tests/sim/test_verbs.py` (a table that covers every `Verb` and every
|
||
`DeniedReason`, plus the gate's contested arbitration).
|
||
- **The hands.** `sim/carry.py` is what the verbs *do*: one carried item per cook (a
|
||
second pick is `hands_full`), no floor drops in v1 (a press at open floor is the named
|
||
`nothing_here`), and a set-down is a *placed object* — `RELEASE` / `PLACE_ON_STATION` /
|
||
`PLATE_DISH` put the item on the fixture and stamp it with a mark, so a teammate's pick
|
||
of a set-down within 20 ticks is a `HANDED_OFF` with both ids (the sanctioned pass) and
|
||
an unmarked pipeline input stays the work press's. The trash is long-press and always
|
||
available: holding the button over the can for 8 substeps destroys the carried item
|
||
(`TRASHED`, the raw `−2` score nudge in the payload for phase 04's clamped scoring,
|
||
`counters["trashed"]` up by one); a short press and a walk-away do nothing. A 100-tick
|
||
fuzz of random simultaneous presses re-checks the conservation law
|
||
(`hands + stations + trashed == initial`) after every tick, and a scripted two-player
|
||
pass-and-contest reaches its committed golden hash. `docs/SIMULATION.md` §"The hand" is
|
||
the contract (phase 09's co-op E2E asserts exactly this tie-break). Proven by
|
||
`tests/sim/test_carry.py` and `tests/sim/test_replay_carry.py`.
|
||
- **The ticket rail.** `sim/orders.py` is stage 5 of the tick: the level's `orders`
|
||
spec is copied onto the state in tick units at round build (a replay stays
|
||
self-contained), the first order arrives 1.5 s in, the recipe is a seeded draw from
|
||
the level's pool (the order's value is the recipe's `base_score`), the concurrency
|
||
cap holds, a due tick a full rail cannot serve retries 1 s later *without consuming
|
||
the interval draw* (a full rail would otherwise silently warp the spawn cadence),
|
||
and an order's clock — derived, never accumulated — expires on exactly the tick it
|
||
reaches 0: removed, `counters["missed"]` up by one, one `ORDER_MISSED` naming the id
|
||
and recipe (the penalty score deduction and the streak reset are task 03's
|
||
scoring, the single owner of score and streak, injected as the stage's
|
||
`on_miss` callback). Proven by 200 simulated seconds of
|
||
interval bounds and cap under idle and busy rails, same-seed/different-seed
|
||
reproducibility, and twenty randomized spec fuzz runs
|
||
(`tests/sim/test_orders.py`, `tests/sim/test_orders_fuzz.py`); the contract is
|
||
`docs/SIMULATION.md` §"Orders — the ticket rail".
|
||
- **Serve scoring.** `sim/scoring.py` is the single owner of the round's
|
||
`score` and `streak`, from both sides: a `SERVE` press at the window
|
||
validates the carried dish (burnt → `burnt`, not a plated dish →
|
||
`no_target`, an ingredient the declared recipe does not require →
|
||
`incomplete`, no live order for the recipe → `wrong_recipe`, an empty
|
||
rail → `no_orders` — each a named `DENIED` with the dish left in the
|
||
hand), and a matched dish is scored `round(base × quality ×
|
||
streak_mult × time_bonus)` — the streak counted *after* the increment and
|
||
capped at `1.5`, the bonus 1.5 with the full deadline unspent down to 1.0
|
||
at the edge — with the order off the rail and the cook's `score`/
|
||
`dishes_served` credited (per-player contribution, display only; a prop
|
||
is dirty from its *use* — the station pipeline's stock bookkeeping —
|
||
not from the serve). A deadline's expiry is the scoring half of the
|
||
rail's miss: `score − order_penalty` floored at 0 and the streak reset,
|
||
injected into `step_orders` as the `on_miss` callback so `orders.py` and
|
||
`scoring.py` never import each other. Pinned by five hand-computed
|
||
serves, the rejection paths, FIFO and the caps
|
||
(`tests/sim/test_scoring.py`) and 500 seeded random serves
|
||
(`tests/sim/test_scoring_property.py`); the contract is
|
||
`docs/SIMULATION.md` §"Serve scoring".
|
||
- **Freshness — the clocks that create pressure.** `sim/freshness.py`
|
||
runs as substep stage 4b (after the station pipelines, before the
|
||
orders) and decays the quality: a dish on a ready stove burns exactly
|
||
`BURN_AFTER_S` (8.0 s) after the ready flip — tick-exact, the 160th
|
||
substep — with the station going `DIRTY` (the burnt pot is stuck) and
|
||
one `BURNT` event; a raw or chopped item left on a board rots linearly
|
||
over `ROT_S` (20.0 s); a carried prepared item decays at half the
|
||
board's rate (a hand is not a fridge); a new raw set-down on a board
|
||
that holds a burnt item destroys it (a named `WASTED`,
|
||
`counters["wasted"]`) while a usable raw item is never silently
|
||
overwritten (the named `station_busy`); a burnt dish is refused by the
|
||
serve window, never scored. The dirty loop's stock — a use moves a prop
|
||
`…_clean → …_dirty`, a wash (the sink, or the in-place wash of a stuck
|
||
prop) moves it back — holds `…_clean + …_dirty == the level's limit`
|
||
every tick, and the no-dead-end guarantee (a worst-case cook who serves
|
||
everything and never washes until forced never gets stuck: one wash
|
||
restores the stock) is proven on a 2 000-tick run
|
||
(`tests/sim/test_freshness.py`, `tests/sim/test_no_deadlock.py`); the
|
||
contract is `docs/SIMULATION.md` §"Freshness".
|
||
- **Round end — the verdict, the settle, the stars.** `sim/round_state.py`
|
||
runs as substep stage 7 and owns the round's clock and its verdict (which
|
||
turns on `score` against `target_score` and nothing else): the round wins
|
||
the moment the score reaches the target (the clock holds at that
|
||
substep's countdown), or on the buzzer — `WON` if the score made it,
|
||
`LOST` if it did not — with exactly one `ROUND_END` event. A decided
|
||
round then **settles**: for `SETTLE_S` (1.0 s, the room's, phase 05), the
|
||
kernel freezes every gameplay stage — inputs ignored, no movement,
|
||
stations, freshness, orders or hazards — and only the frozen clock and
|
||
the tick keep running, so the verdict substep's events land and the
|
||
result is a function of the state at the verdict alone. `stars(state)`
|
||
grades a win from the frozen clock — 0 when lost, 1 for winning, 2 with
|
||
≥ 25 % of the clock left, 3 with ≥ 50 % — and
|
||
`result_payload(state)` is the plain-JSON body of the `round` message the
|
||
protocol phase sends (PLAN §7): `result`, `score`, `target`, `stars`,
|
||
`time_left`, the `served`/`missed`/`wasted` counters, `longest_streak`
|
||
(owned by `sim/scoring.py`: a serve raises the mark, a miss never resets
|
||
it) and the per-seat `players[]` (`id`, `name`, `score`,
|
||
`dishes_served`, `missed_contrib` — the round's missed count spread
|
||
evenly, a display-only estimate: the sim does not attribute an expired
|
||
order to a cook). Pinned by `tests/sim/test_round_state.py` (verdict on
|
||
both sides of the epsilon, the settle freeze, the star boundaries one
|
||
tick below and exactly at 25 %/50 %, the `ABORTED` seam, the
|
||
`longest_streak` owner/reader) and `tests/sim/test_result_payload.py`
|
||
(a recursive JSON-safety walk with zero leaks, the exact key sets,
|
||
byte-stability across two identical runs and across the settle window);
|
||
the contract is `docs/SIMULATION.md` §"Round end — the verdict, the
|
||
settle, the stars".
|
||
- **The room supervisor (phase 05).** The server-side lobby shell is
|
||
live: `server/rooms.py` mints 5-character Crockford base32 codes (collision-
|
||
checked, never reused), enforces `max_rooms` / `max_players` capacity, keeps a
|
||
one-token-one-room index (a token joining a second room is evicted from the
|
||
first; a *disconnected* seat is kept for the rejoin path), and runs the 5 s GC
|
||
(empty rooms past a 30 s grace, idle lobbies past `lobby_idle_timeout_s`, stale
|
||
tokens). Everything runs on an injected clock (`server/clock.py`), so the tests
|
||
drive the garbage collector with a `FakeClock` and never sleep. The lifecycle is
|
||
testable over HTTP, and it now also rides the phase-06 WebSocket
|
||
transport (`/ws/{code}`; below):
|
||
`POST /api/rooms` (201, the session from the `X-Undercooked-Token`
|
||
header or the body's `token` — or a server-minted token, returned
|
||
once),
|
||
`GET /api/rooms/{code}`, `POST /api/rooms/{code}/join` and
|
||
`GET /api/session` — seats never leak
|
||
other players' tokens, a malformed code 404s exactly like an unknown one
|
||
(US-02 criterion 4), and a full room or a full process is a 503
|
||
`{"code": "room_full"}` (US-01 criterion 7). The `Room` itself is the guarded
|
||
FSM behind those routes — five states, a table of fourteen legal
|
||
transitions, one 20 Hz deadline-scheduled tick task per room and crash
|
||
isolation that closes only the room that crashed (contract: `docs/ROOMS.md`),
|
||
and the ready-up/host flow rides on it. The in-memory **session store**
|
||
(`server/sessions.py`) is live on top: per token it remembers the campaign
|
||
progression — `unlocked_level` (a `WON` round raises it to `order + 1`,
|
||
never decreases it), `best[level_id]` (a `BestRun` of `score`/`stars`/
|
||
`completed_at`, replaced only when the score is higher), the
|
||
`rounds_played` / `dishes_served` counters — LRU-capped at 10 000 (evicting
|
||
the stalest, never the session being created) and wiped on restart (A6);
|
||
`Room.set_level` asks the store's `is_unlocked`, `round_over` records the
|
||
result into every seated player's session, and `GET /api/session` answers
|
||
`to_public` (a fresh token reads `unlocked: 1` with an empty `best`; 400
|
||
`no_token` without the header) (contract: `docs/ROOMS.md` "Progression").
|
||
The **lifecycle integration suite** (phase 05 task 05) drives the whole
|
||
lifecycle over HTTP plus direct `tick_once`: create → 3 joins → ready-up
|
||
→ the host's start → the countdown → a scripted full-pipeline `WON` → the
|
||
`ROUND_END` payload → `next_level` → back to `LOBBY` with the same seats
|
||
and colours; plus a solo win with the unlock advanced, the 5th join's and
|
||
the process-capacity 503 `room_full`, malformed ≡ unknown codes (US-02
|
||
criterion 4), the seat-grace and lobby-idle expiries through the GC, and
|
||
one room's crash closing only itself (the test-only `_test_step_override`
|
||
seam on `Room`; contract: `docs/ROOMS.md` "Failure and expiry" and
|
||
"The lifecycle walkthrough"). The client buttons in the page still
|
||
answer "Not implemented until phase 07." until the phase-07 client
|
||
wiring replaces them.
|
||
- **The WebSocket transport (phase 06, task 02).** The room core is on
|
||
the wire: `GET /api/stats` answers the ops census (`rooms` / `players`
|
||
— the occupied seats / `sockets` — live WebSocket connections /
|
||
`tick_hz`), and `WS /ws/{code}` is the game socket — an origin check
|
||
*before* the accept (a disallowed `Origin` is an HTTP `403`, never a
|
||
close frame that looks like a network error), a `hello` within
|
||
`hello_timeout_s` (else close `4400`), a `p` gate (`protocol_mismatch`
|
||
+ `4400` before any seat is taken), the supervisor's seat/spectate
|
||
(`room_gone` + `4401` for an unknown code, `room_full` + `4402` for a
|
||
fresh token on a full room), the `welcome` + the current `lobby`, and
|
||
the two per-connection tasks (a non-blocking `pump_out` over a capped
|
||
send queue — the *oldest snapshot* drops first, `lobby` / `round` /
|
||
`err` never — and the `read_in` guardrails: the `max_message_bytes`
|
||
cap → `too_big`, the `max_inputs_per_s` token bucket →
|
||
`rate_limited`, the shape check, the per-state whitelist →
|
||
`wrong_state`; 3 consecutive rate/oversize violations or 5 shape
|
||
violations close `4404`). One socket per token: a second `hello` with
|
||
the same token evicts the first (`evicted` + `4403`, marked dead
|
||
before the close so no ghost frames) — and an evicted socket never
|
||
marks the seat disconnected, so the crown stays put. A *genuine*
|
||
disconnect reaches the room exactly once (`mark_disconnected`),
|
||
releases the registry slot, and leaves no error in the log — an
|
||
abrupt transport close (no close handshake) included. The contract in
|
||
prose is `docs/PROTOCOL.md` (message tables kept equal to the models
|
||
by `tests/unit/test_protocol_doc_sync.py`, close/error codes, the
|
||
limits, and a manual probe that reproduces the handshake without a
|
||
browser); the guardrails are driven by real sockets in
|
||
`tests/integration/test_ws_handshake.py` and the queue policy by
|
||
`tests/unit/test_send_queue_policy.py`.
|
||
- **Input ingestion (phase 06, task 03).** The `input` message is on the
|
||
wire: the `seq` discipline (equal → dropped as an idempotent resend,
|
||
backwards → `err` + a `resync` event the client hard-snapshots to,
|
||
gaps ≤ 3 tolerated UDP-style, larger gaps accepted and counted in the
|
||
room stats), the anti-cheat clamp (`move` clamped to unit length and
|
||
quantized to 3 decimals server-side — a client sending `(1000, 1000)`
|
||
walks at `MAX_SPEED` like everyone else, and the position never
|
||
arrives because the message has no position field and `extra="forbid"`
|
||
rejects one), `face` derived from `move` when absent, `act` coerced,
|
||
and `received_at_tick` stamped by the server, never the client. The
|
||
room's per-tick input queue caps at `4 x len(seats)` (overflow drops
|
||
the sender's oldest frame, `inputs_dropped` bumped), a `DISCONNECTED`
|
||
seat's frames are silently ignored, a spectator's get `not_seated`,
|
||
and `Room.stats()` carries the input counters. A wall-clock tick too
|
||
short to fill one substep keeps the queue, so a frame is never
|
||
dropped without being applied. The rules are in `docs/PROTOCOL.md`
|
||
("Input rules") and driven by real sockets in
|
||
`tests/integration/test_ws_input.py` (the hostile-move bound included).
|
||
- **The snapshot stream (phase 06, task 04).** `PLAYING` now broadcasts:
|
||
quantized snapshots (an explicit field map — no `pickle`/`asdict`; the
|
||
grid, station ids, spawn coordinates and the round seed never leave
|
||
the server) at `SNAPSHOT_HZ` for players and half that for
|
||
spectators, encoded once per (frame, audience) and fanned out across
|
||
every connection of it. The stream runs in delta mode (only the
|
||
changed entities, plus a `gone` tombstone for each vanished one; a
|
||
typical idle-kitchen delta is ≥ 40% smaller than a full), with a
|
||
full frame on a fresh connection's first frame, on a dropped
|
||
snapshot's next frame, and on the full cadence (every 20 encoded
|
||
frames), so a client can always resync cheaply. The boundary's
|
||
events ride the frame (the stable `k` names, an explicit short-key
|
||
payload map, capped at 24 per frame with `events_dropped` on
|
||
overflow); outside `PLAYING` they flush as the standalone `ev`
|
||
message, and the `countdown` / `round` state frames reach every
|
||
connection. `UNDERCOOKED_SIM_LATENCY_MS` applies the server-side send
|
||
delay the netcode tests use to simulate RTT. The shape, the delta
|
||
rules and a captured frame are `docs/PROTOCOL.md` ("The `snap`
|
||
frame"), the no-leak/quantization guard is
|
||
`tests/unit/test_encoding_no_leak.py`, and the budgets (a 4-player /
|
||
4-order frame ≤ 1.5 KB, a 64×64 level ≤ 4 KB, the 100-tick downlink
|
||
≤ 60 KB/s) are measured over live sockets in
|
||
`tests/integration/test_encoding.py`.
|
||
- **Disconnects, the ghost, and the rejoin (phase 06, task 05).** A
|
||
dropped socket (an abrupt close, a heartbeat timeout, or the
|
||
test-only `kill_socket` seam) keeps the seat for
|
||
`UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` (default 45 s): the other
|
||
players see one `ghost_on` event and a `connected: false` seat, the
|
||
sim's cook switches to `disconnected`, and after 3 s the cook's
|
||
carried item is released — onto the station on the cook's tile (a
|
||
marked set-down, grabbable), or into the `lost_items` counter when
|
||
no station is under the tile (an item is never deleted silently). A
|
||
`hello` with the same token inside the window rejoins the same seat
|
||
(`welcome.rejoined: true`, one `ghost_off`, the snapshot stream
|
||
resumes within one tick); past it the seat is abandoned (one
|
||
`ghost_off`, the spectate/join rules apply, an emptied room closes
|
||
through the 30 s seat grace / the GC). The client keeps the socket
|
||
honest with a `ping` at ≥ 5 Hz; 12 s of inbound silence is a dead
|
||
client (the server closes `1000` — the room sees the same drop as an
|
||
abrupt close), and a closing room closes its sockets with
|
||
`err room_gone` + close `4401`, so a client can tell "the room
|
||
died" apart from "you left" (`1000`). The grace, the park, the
|
||
ghost events and the close-code decision table are
|
||
`docs/PROTOCOL.md` ("Heartbeats, drops, and seat grace"); the
|
||
park's rules are `sim/ghost.py` (driven in
|
||
`tests/unit/test_ghost_parking.py`), the drop/rejoin/seam paths in
|
||
`tests/integration/test_ws_disconnect.py`.
|
||
- **The protocol, tested (phase 06, task 06).** The wire contract's
|
||
final proof is three real-socket suites in `tests/integration/`:
|
||
`test_ws_round_two_clients.py` (two scripted clients play a complete
|
||
round — the countdown, the `snap` stream, the scripted soup, the
|
||
shared `served` event and the byte-identical `round` frames, a
|
||
mid-round spectator, the `to_lobby` return), `test_ws_abuse.py`
|
||
(2000 malformed frames, the 500-message burst, the 64 KB refusal /
|
||
4 KB boundary, the host-check and emote-throttle flood, 200
|
||
concurrent handshakes) and `test_ws_budgets.py` (`slow`: the tick
|
||
cost over 400 real ticks, the `snap` size, the downlink rate, the
|
||
input→snapshot echo). The `live_server` / `ws_client` fixtures
|
||
(`tests/integration/conftest.py`) drive them — a real `uvicorn`
|
||
server on an ephemeral loopback port and a scripted client with a
|
||
merged snapshot view (`expect` / `collect` / `send_model`). The
|
||
contract, the test seams and the budget table with the measured
|
||
values are `docs/PROTOCOL.md` ("Testing the protocol").
|
||
- **The rules are tests, not promises.** The no-external-asset rule is
|
||
`tests/unit/test_no_external_assets.py`, "no level or recipe id in Python" is
|
||
`tests/unit/test_content_not_hardcoded.py`, the documentation set is guarded by
|
||
`tests/unit/test_docs_index.py`, and `bash .agents/validate.sh` is the gate the
|
||
phase pipeline runs after every task.
|
||
|
||
What does not exist yet: the hazards (phase 10) and the
|
||
in-browser
|
||
playable Canvas renderer (07 — the phase-06 WebSocket transport, including
|
||
the snapshot stream, is live but the client still answers "Not
|
||
implemented until phase 07."). Execute phases with the
|
||
`phased-execution` skill
|
||
(`auto-phase.sh`); it always takes the lowest-numbered directory still in
|
||
`.agents/phases/todo/`.
|
||
|
||
## How to play
|
||
|
||
The intended loop, once the room flow ships: open the site, **Create lobby**, read out
|
||
the 5-character code, everyone else **Joins** with it, the host picks a level, the room
|
||
readies up, and the kitchen starts. Each cook has one movement control and **one action
|
||
button** that picks up, uses and puts down whatever is in reach — one carried item at a
|
||
time. Orders appear in the HUD with a shrinking deadline; assemble the dish and serve it
|
||
at the window before the timer runs out. Levels escalate: more recipes, fewer stations,
|
||
hostile floors. Nothing identifies you: the browser
|
||
keeps only a random per-tab token and the last code you typed, both in `sessionStorage`.
|
||
The campaign does remember per token — unlocks and best scores live server-side,
|
||
in memory, and a server restart clears all progression (A6: no database).
|
||
|
||
What actually works today is the shell of that plus the server-side room
|
||
shell and the phase-06 WebSocket transport:
|
||
the page loads, normalises a typed code to five uppercase alphanumerics (codes
|
||
themselves are Crockford base32, minted by the room supervisor — `server/rooms.py`,
|
||
phase 05, which is live and testable over `POST /api/rooms` / `GET /api/rooms/{code}` /
|
||
`POST /api/rooms/{code}/join` / `GET /api/session`), a `WS /ws/{code}`
|
||
client can already create a room over HTTP, handshake (`hello` → `welcome`
|
||
+ `lobby`), ready up and keep alive — the full contract, including the
|
||
guardrails and the close codes, is `docs/PROTOCOL.md` — but the lobby
|
||
buttons in the page still answer "Not implemented
|
||
until phase 07." until the phase-07 client
|
||
wiring replaces them. The ready-up, the 20 Hz tick ownership, the session store
|
||
(the unlocks and best scores, in memory — a server restart clears all
|
||
progression) and the lifecycle integration suite are live. Movement keys and
|
||
touch controls are defined by the phase-07/08 client and documented in
|
||
`docs/CLIENT.md` once that phase writes it.
|
||
|
||
## Quickstart (development)
|
||
|
||
Requires [`uv`](https://docs.astral.sh/uv/), Python ≥ 3.12, and `podman`/`docker`
|
||
for container builds. **There is no database** — all live game state is in memory.
|
||
|
||
```bash
|
||
uv sync --extra dev
|
||
uv run undercooked # http://127.0.0.1:8000 (shell page + /healthz)
|
||
uv run undercooked --host 0.0.0.0 --port 8000 # e.g. to reach it from a phone
|
||
uv run undercooked --reload # dev reload
|
||
uv run undercooked --levels-dir levels # point at another content dir
|
||
curl -fsS http://127.0.0.1:8000/api/levels # the campaign cards the lobby will show
|
||
curl -fsS http://127.0.0.1:8000/api/config # client-safe settings + protocol version
|
||
curl -fsS -X POST http://127.0.0.1:8000/api/rooms # create a room: 5-char code + your token (201)
|
||
curl -fsS http://127.0.0.1:8000/api/rooms/<CODE> # inspect it: the seat list, no tokens
|
||
curl -fsS -H 'X-Undercooked-Token: <TOKEN>' http://127.0.0.1:8000/api/session # your session: unlocked, best (in memory; a restart clears it)
|
||
```
|
||
|
||
`python -m undercooked` is the same command (the container uses it). A flag
|
||
always beats the environment, which beats the built-in default. There is no
|
||
`/docs` or `/openapi.json`: FastAPI renders those pages from a public CDN, which
|
||
this project forbids. The validation the server runs at startup (every level
|
||
file loads, and no rule of PLAN §8 is broken) is a command too —
|
||
`undercooked-levels`, see **Authoring content** below.
|
||
|
||
Debug the server with a real debugger attached (dev only - the listener binds
|
||
`0.0.0.0`, so never enable it on a public host):
|
||
|
||
```bash
|
||
DEBUGPY=1 uv run undercooked # opens a debugpy listener on :5678, then serves as usual
|
||
```
|
||
|
||
Authoring content:
|
||
|
||
```bash
|
||
uv run undercooked-levels list # the campaign, in campaign order
|
||
uv run undercooked-levels show 01_first_kitchen --grid # resolved level + reachability overlay
|
||
uv run undercooked-levels validate # the content gate; --json for scripts
|
||
uv run undercooked-levels new 03_riverboat # scaffold a level from the recipe book
|
||
uv run undercooked-levels axes # escalation-axis table (phase 11's curve)
|
||
```
|
||
|
||
`undercooked-levels` is a thin shell over the loader and the PLAN §8 rules
|
||
`create_app` runs at startup, so a green `validate` is a server that boots. It
|
||
finds content through the settings (`--dir` > `UNDERCOOKED_LEVELS_DIR` >
|
||
`levels`, plus `--recipes` > `UNDERCOOKED_RECIPES_PATH` > `<dir>/recipes.yaml`)
|
||
and never from the current directory, so it works from any path. Exit codes:
|
||
`0` ok, `1` a level breaks a rule, `2` nothing usable to work with (load
|
||
failure, unknown id, refused overwrite, usage error). The schema, every field,
|
||
the overlay trick and a "which rule did I just hit" table live in
|
||
[docs/LEVELS.md](docs/LEVELS.md); `tests/unit/test_content_not_hardcoded.py`
|
||
is what keeps levels and recipes out of the Python.
|
||
|
||
Run the checks:
|
||
|
||
```bash
|
||
uv run ruff check .
|
||
uv run pyright
|
||
uv run pytest # everything, coverage floor included
|
||
uv run pytest -m "not e2e" # the quick loop: no browser launches
|
||
uv run playwright install chromium # once, before the first e2e run
|
||
uv run pytest -m e2e # browser suites only
|
||
uv run pytest -m slow tests/sim -q --no-cov # the gate's slow stage: goldens + perf budgets
|
||
bash .agents/validate.sh # the pipeline gate: full suite + ruff + pyright
|
||
```
|
||
|
||
The replay goldens behind `tests/sim/test_replay.py` are checked the same way a
|
||
level is validated: `uv run python -m undercooked.sim.replay check
|
||
<tests/fixtures/replays/<name>.json>` runs the committed input script and
|
||
gates it on `tests/goldens/<name>.golden.json` (exit `0` match, `1` divergence
|
||
with the first divergent tick and the changed fields, `2` missing script/level/
|
||
golden). `record` writes a golden — the manual re-bless, legitimate only after a
|
||
deliberate sim-contract change recorded in the phase task; the contract is
|
||
[docs/SIMULATION.md](docs/SIMULATION.md) ("Replaying and re-blessing goldens").
|
||
|
||
`pytest` with no `-m` runs the browser suites too, so chromium has to be
|
||
installed for the pipeline gate. Those suites start the app themselves on a free
|
||
loopback port (`live_server` in `tests/conftest.py`) — nothing needs to be
|
||
running first, and no test ever listens on a routable interface.
|
||
|
||
`bash .agents/validate.sh` is the gate a phase task must pass before it moves to
|
||
`.agents/phases/complete/`. It stops at the first failure, in this order:
|
||
`ruff check .`, `pyright`, `undercooked-levels validate` (unconditional: the same
|
||
loader and PLAN §8 rules `create_app` runs at startup, so a campaign that cannot
|
||
validate cannot commit), the **full** `pytest` suite with the 90 % coverage floor,
|
||
the slow sim stage (`pytest -m slow tests/sim`: the replay goldens and the perf
|
||
budgets — the random-walk smoke and the full-pipeline run — named on their
|
||
own so a quick loop that skips them cannot let them slip through the gate),
|
||
then repo-hygiene checks (nothing tracked that `.gitignore`
|
||
excludes, no log drops or key material committed). Run it from the repo root or
|
||
from `.agents/`.
|
||
|
||
## Container
|
||
|
||
```bash
|
||
podman build -t undercooked -f Containerfile .
|
||
podman run --rm -p 8000:8000 undercooked
|
||
# or
|
||
podman compose up -d
|
||
```
|
||
|
||
## Configuration
|
||
|
||
All settings come from the environment, parsed once into
|
||
`undercooked.config.Settings` — the only module that reads the environment, so
|
||
nothing else in the codebase touches `os.environ`. Precedence: `undercooked` CLI
|
||
flags, then exported environment variables, then a local `.env` (found from the
|
||
current directory upwards; never commit it), then the built-in defaults. A blank value counts as
|
||
unset. Invalid values (`UNDERCOOKED_TICK_HZ=99`) raise `ConfigError` at startup
|
||
instead of half-configuring the server, and unusable content is refused the same
|
||
way — an unreadable `UNDERCOOKED_LEVELS_DIR` or a level that breaks a PLAN §8
|
||
rule prints the named-rule report and exits `2` (under `--reload` the reload
|
||
child reports and restarts instead; docs/LEVELS.md). `.env.example` and the table below list
|
||
every variable with its default; tests keep both in sync with `config.py`.
|
||
|
||
| Variable | Default | Meaning |
|
||
|---|---|---|
|
||
| `UNDERCOOKED_HOST` | `127.0.0.1` | Bind address |
|
||
| `UNDERCOOKED_PORT` | `8000` | Bind port |
|
||
| `UNDERCOOKED_TICK_HZ` | `20` | Server simulation rate (5-60) |
|
||
| `UNDERCOOKED_SNAPSHOT_HZ` | `20` | Snapshot broadcast rate to players (<= tick rate) |
|
||
| `UNDERCOOKED_MAX_PLAYERS` | `4` | Players per room (1-4) |
|
||
| `UNDERCOOKED_MAX_ROOMS` | `200` | Concurrent rooms before new rooms are refused |
|
||
| `UNDERCOOKED_LEVELS_DIR` | `levels` | Directory of level YAML files (`~` is expanded) |
|
||
| `UNDERCOOKED_RECIPES_PATH` | *(derived)* | Recipe book file; empty = `recipes.yaml` inside `UNDERCOOKED_LEVELS_DIR` (here: `levels/recipes.yaml`) |
|
||
| `UNDERCOOKED_STATIC_DIR` | *(derived)* | Directory served at `/static`; empty = `app/static/` beside the source (containers set it) |
|
||
| `UNDERCOOKED_LOBBY_IDLE_TIMEOUT_S` | `900` | Close empty/idle lobbies |
|
||
| `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` | `45` | Drop a disconnected player's seat after this |
|
||
| `UNDERCOOKED_MAX_MESSAGE_BYTES` | `4096` | Hard cap on inbound WS frame size |
|
||
| `UNDERCOOKED_MAX_INPUTS_PER_S` | `40` | Per-connection inbound message rate limit |
|
||
| `UNDERCOOKED_SIM_LATENCY_MS` | `0` | Injected server-side downlink latency (netcode tests) |
|
||
| `UNDERCOOKED_HELLO_TIMEOUT_S` | `5` | A WebSocket that sends no `hello` within this many seconds is closed (code 4400) |
|
||
| `UNDERCOOKED_TEST_MODE` | `0` | `1` = register the test-only seam route (`POST /api/test/kill_socket/...`); never set in production |
|
||
| `UNDERCOOKED_ALLOWED_ORIGINS` | *(empty)* | Comma-separated `Origin`s accepted on the WebSocket handshake; empty = same-host only |
|
||
| `DEBUGPY` | `0` | `1` = open a debugpy listener on `DEBUGPY_PORT` at startup |
|
||
| `DEBUGPY_PORT` | `5678` | debugpy listen port |
|
||
|
||
## Layout
|
||
|
||
```
|
||
src/undercooked/ server: FastAPI app, rooms, WebSocket protocol, simulation
|
||
src/undercooked/__main__.py the `undercooked` command (CLI > env > default)
|
||
src/undercooked/config.py typed env settings (the only `os.environ` reader)
|
||
src/undercooked/debug.py DEBUGPY-gated debugger bootstrap
|
||
src/undercooked/sim/ pure simulation kernel: entities (`model`), grid geometry
|
||
(`grid`), content schema + loader (`recipe`, `level`,
|
||
`content` — the YAML contract is docs/LEVELS.md), the
|
||
level validator (`validation` — PLAN §8 named rules;
|
||
rule table in docs/LEVELS.md), the fixed-step tick (`step`,
|
||
`state_build`, `rng`, and the subsystem hooks `movement`
|
||
(+ `collision`, its pure geometry), `interact` (the action
|
||
button — reach zone, verb ladder, named denials, contested
|
||
arbitration, the long-press trash), `carry` (the hands — one
|
||
item per hand, the marked set-down and counter hand-off,
|
||
the always-available trash), `stations`, `freshness`,
|
||
`scoring`, `orders`, `hazards`, `round_state` —
|
||
contract in docs/SIMULATION.md), and the A5 purity guard
|
||
(`purity`, test-enforced)
|
||
src/undercooked/sim/replay.py the replay harness: the input-script format, the golden
|
||
hashes, `run_script`/`compare`, and the record/check CLI
|
||
(docs/SIMULATION.md "Replaying and re-blessing goldens")
|
||
src/undercooked/tools/levels.py the `undercooked-levels` author CLI — validate, list,
|
||
show [--grid], new, axes; same loader, same rules, no
|
||
hard-coded content list (docs/LEVELS.md "Authoring workflow")
|
||
src/undercooked/server/app.py app factory, `/healthz`, `/`, `/static`, mounts the
|
||
content API and the room API; installs the supervisor
|
||
src/undercooked/server/clock.py injectable time: `Clock`, `SystemClock`, `FakeClock`
|
||
(the only server module that imports `time`; a grep
|
||
test keeps the wall clock out of the rest of `server/`)
|
||
src/undercooked/server/codes.py Crockford room codes: the alphabet, collision-checked
|
||
minting (`new_code`), input normalisation (confusables,
|
||
malformed ≡ unknown, US-02 criterion 4)
|
||
src/undercooked/server/room.py `Room`: code, seats (the rejoin path keeps
|
||
disconnected seats), `RoomState`, one owned tick task
|
||
src/undercooked/server/rooms.py `RoomSupervisor`: the registry, capacity, the
|
||
one-token-one-room index, the 5 s GC, `start`/`shutdown`
|
||
src/undercooked/server/sessions.py `SessionRecord` + `BestRun` + `mint_token`
|
||
+ the bounded LRU `SessionStore` (`record_result`'s win-only
|
||
unlock bump, best-score replacement, `is_unlocked` /
|
||
`next_level_id`, `to_public` — in memory; a process
|
||
restart clears all progression)
|
||
src/undercooked/server/routes_rooms.py `POST /api/rooms`, `GET /api/rooms/{code}`, `POST
|
||
/api/rooms/{code}/join`, `GET /api/session` — the lifecycle
|
||
over HTTP before the phase-06 WebSocket transport (session
|
||
from the `X-Undercooked-Token` header or the body's
|
||
`token`, minted otherwise; tokens returned once; seats never
|
||
leak tokens)
|
||
src/undercooked/server/routes_content.py `/api/levels`, `/api/levels/{id}`, `/api/config`,
|
||
plus the startup gate: broken content refuses startup
|
||
src/undercooked/server/headers.py the security-header middleware
|
||
.env.example every configuration variable, with its default
|
||
levels/ data-driven content (YAML): the recipe book (A8) and the
|
||
first two campaign levels (01_first_kitchen, 02_double_shift)
|
||
app/static/ browser client: the ES-module shell today; Canvas 2D renderer
|
||
and this repo's SVG art arrive with phase 07
|
||
tests/ unit/, integration/, e2e/ (one Playwright suite per story) and
|
||
sim/ (the pure kernel: grid, entities, purity guard, validation,
|
||
the seeded RNG and the tick; plus the replay harness — committed
|
||
goldens in goldens/, scripts in fixtures/replays/ — and the
|
||
`slow` perf budgets (the random-walk smoke and the
|
||
full-pipeline run));
|
||
fixtures/levels/ is the content dir tests see (a recipe book and no
|
||
levels; `_bad/` holds the deliberately-broken campaign the `_`
|
||
discovery rule ignores); conftest.py
|
||
docs/ DESIGN.md, LEVELS.md (the level & recipe YAML contract),
|
||
SIMULATION.md (the tick contract), ROOMS.md (rooms &
|
||
lobby lifecycle), PROTOCOL.md (the v1 wire contract —
|
||
message tables, error/close codes, limits, the full-round
|
||
sequence, the test seams and the budget table), TESTING.md
|
||
(how this project
|
||
is verified), DEPLOYMENT.md; CLIENT lands with
|
||
phase 07 (the index is PLAN.md §13)
|
||
.agents/ PLAN.md, user_stories/, phases/, validate.sh — the phased-execution plan
|
||
```
|
||
|
||
**No external CDNs.** Every byte of JS, CSS, font, and image is served by this app
|
||
from `app/static/`. `tests/unit/test_no_external_assets.py` enforces it, and the
|
||
e2e suite fails if loading the page ever issues a request off-origin.
|
||
|
||
## Contributing
|
||
|
||
Read [AGENTS.md](AGENTS.md) first: it points at `.agents/PLAN.md` (LOCKED decisions)
|
||
and the phase protocol. Code changes land by executing phase task files, not by
|
||
editing ad hoc.
|