# 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) and **04** (the kitchen pipeline: station state machines, the ticket rail, serve scoring, freshness and the dirty loop, the round verdict with stars) are **complete**; the next pending phase is `05_anonymous_lobbies_and_room_lifecycle`. 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":0}` (`rooms` stays `0` until phase 05 owns a supervisor) — 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 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), rooms and the WebSocket protocol (05-06 — the lobby buttons answer "Not implemented until phase 05.") and the playable Canvas renderer (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 is saved between rounds and nothing identifies you: the browser keeps only a random per-tab token and the last code you typed, both in `sessionStorage`. What actually works today is the shell of that: the page loads, normalises a typed code to five uppercase alphanumerics (codes themselves are Crockford base32, and only get issued once phase 05 runs the room supervisor), and answers "Not implemented until phase 05." 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 ``` `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` > `