Files
undercooked/README.md
T
ducoterra f51ba919af phase: 05_anonymous_lobbies_and_room_lifecycle
All green. Final report:

**Phase 05 final verification pass — complete, 100% green.**

**Verified (all 5 tasks already in `complete/`):**
- `RoomSupervisor` import, `POST /api/rooms` → 5-char Crockford code (`VBE8Y`), `GET /api/rooms/{code}` → seat list, join/session/404 flows — all live over HTTP
- `docs/ROOMS.md` present with FSM diagram, crash-isolation and "What is lost on restart" sections
- Phase tests all pass: lifecycle 4-seat `WON`→`LOBBY` (7 tests), crash isolation, GC/idle via `FakeClock` (no sleeps), FSM, tick cadence, supervisor, sessions

**Results:**
- `uv run pytest` → **1506 passed, 0 failed**; coverage **98.89%** (>90% floor; `room.py` 99%, `rooms.py` 97%, `sessions.py` 99% — all ≥95% per-module gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors; `uv run undercooked-levels validate` → content OK; server boots, `/healthz` 200, client at `/` 200
- e2e US-01 suite re-run after fixes: 10 passed

**Documentation — inconsistencies found and fixed:**
- `README.md`: status now lists phases 01–05 complete, next pending `06_realtime_websocket_protocol`; dropped "(phase 05, in progress)"; 3 stale "Not implemented until phase 05." references corrected
- `app/static/js/app.js` + `tests/e2e/test_us01_landing_surface.py`: placeholder string updated in lockstep to "Not implemented until phase 07." (phase 07 task 02 wires the buttons), comments updated to reflect phase-05 completion
- `AGENTS.md`: reviewed in full — §6 already documents all phase-05 modules accurately; no changes needed

**Decisions:** no code behavior changes beyond the user-visible placeholder string; no live-infrastructure touch (only a local loopback server, stopped).

**Next pending phase:** `06_realtime_websocket_protocol`.
2026-10-08 05:17:21 -04:00

478 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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) are **complete**; the next pending phase is
`06_realtime_websocket_protocol`. 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 before the phase-06 WebSocket transport exists:
`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 still answer
"Not implemented until phase 07." until phases 06/07 wire the transport.
- **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), the
WebSocket
protocol (06 — the lobby buttons answer "Not implemented until phase 07.") 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 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:
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`), and the lobby buttons still answer "Not implemented
until phase 07." until the phase-06 WebSocket transport and the phase-07 client
wiring replace 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_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), TESTING.md (how this project
is verified), DEPLOYMENT.md; PROTOCOL / CLIENT land with their
own phases (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.