# PROTOCOL.md — the wire contract (v1) ## Purpose This document is the wire contract of the Undercooked server, in prose: what the client sends, what the server answers, when each is legal, and what happens when a frame is malformed, oversized, or out of its state. It is the specification both sides of the WebSocket are built against — the client (phases 07/08) reads it table by table, and the server's `protocol.py` is the code of record for it. Phase 06's tasks have landed their sections as the transport pieces did (input rules, the `snap` encoder, heartbeats and seat grace); where a section still awaits its task it says so explicitly. Read `docs/ROOMS.md` for the pre-socket HTTP lifecycle this contract rides on. The single source of the wire contract. The code of record is `src/undercooked/server/protocol.py`; `tests/unit/test_protocol_doc_sync.py` keeps every table in this document field-for-field equal to the models' `model_fields` (and the error/close code tables equal to the code constants), so neither can drift. **Change a wire field and this file moves in the same task — and `PROTOCOL_VERSION` is bumped (see "Versioning policy").** The transport is a native WebSocket (`/ws/{room_code}`, PLAN A3) carrying versioned JSON frames (PLAN A14). The HTTP surface (`/api/rooms`, `/api/session`, `/api/levels`) is the pre-socket lifecycle (`docs/ROOMS.md`); the socket is the live channel. ## Framing - One JSON object per frame, UTF-8, ≤ `max_message_bytes` (default 4096; `UNDERCOOKED_MAX_MESSAGE_BYTES`). A bigger frame is `too_big`. - Every frame has a `type` string — the discriminator. Client→server discriminators: `hello`, `ready`, `set_level`, `start`, `input`, `emote`, `to_lobby`, `next_level`, `ping`. Server→client: `welcome`, `lobby`, `countdown`, `snap`, `round`, `ev`, `err`, `pong`. - The protocol version `p` travels **in the client's `hello` only**. The server checks it there (outside `[MIN_SUPPORTED_VERSION, PROTOCOL_VERSION]` → `protocol_mismatch`, close `4400`) and the connection is then bound to the negotiated version. Every server→client frame *also* carries `p` so a client can see what it is talking to. - Unknown fields are **rejected, not ignored** (`extra="forbid"`). This is intentional: a client and a server that disagree on shape must fail loudly (`bad_field`), not silently misinterpret. It is also what makes "no position is ever client-sent" trivially true — an `input` with a `pos` field is a `bad_field`, full stop. - `hello` and `ping` are legal in every room state (the handshake — including a mid-round rejoin — and the keep-alive); everything else is legal only in the states the per-state whitelist (the "Accepted when" column below, and `protocol.py` `MESSAGE_WHITELIST`, the single source) allows. A message outside its state is `wrong_state`. - Per-connection rate limit: `max_inputs_per_s` (default 40; `UNDERCOOKED_MAX_INPUTS_PER_S`) via a token bucket — over it is `rate_limited` (task 02). One socket per token: a second `hello` with the same token evicts the first (`evicted`, close `4403`, task 02). ## Versioning policy - `p` is a single integer. The server accepts `[MIN_SUPPORTED_VERSION, PROTOCOL_VERSION]` (today `1..1`); outside it the handshake is refused with `protocol_mismatch` and close code `4400`. - **Field changes bump `PROTOCOL_VERSION`.** Because unknown fields are a rejection, *any* change to a message shape — added, removed, renamed, re-typed — is a breaking change: bump `PROTOCOL_VERSION` (and `MIN_SUPPORTED_VERSION` only if older clients must keep working), update this file, and update the client in the same task (AGENTS.md "Protocol changes need a version bump"). - **Patch-compatible** (no bump): new *optional, additive* server→client fields that a client may ignore, and new error-code *usages* of existing codes. The client ignores unknown server fields; the server rejects unknown client fields. - A `hello` carrying an unsupported `p` is refused before anything else happens on the socket — no seat is taken, no `welcome` is sent. ## Sequence: handshake and a full round ```text browser A (host) server (one room) browser B │ POST /api/rooms {name, token?} │ │───────────────────────────────────────▶ │ │ ◀── 201 {room_code, you:{token, seat, host}} ── │ │ │ │ WS /ws/{room_code} │ │───────────────────────────────────────▶ accept │ │ {"type":"hello","p":1,"token":…,"name":…} │ │───────────────────────────────────────▶ seat A (0, host) │ │ ◀── {"type":"welcome","you",…,"seats":[A]} ── │ │ ◀── {"type":"lobby",…} ── │ │ │ WS /ws/{room_code} │ ◀─────────────────── B connects │ ◀── {"type":"lobby",…} (re-broadcast) ────────────────────────▶ B │ │ {"type":"hello","p":1,"token":B} │ │ ◀── welcome + lobby │ {"type":"ready"} (B) │ │ ◀─────────────────── │ ◀── {"type":"lobby",… ready:true …} ─────────────────────────▶ B │ {"type":"start"} (A, host) │ │───────────────────────────────────────▶ COUNTDOWN (3 s) │ │ ◀── {"type":"countdown","n":3} 2 1 ────────────────────────▶ B │ PLAYING (20 Hz) │ │ {"type":"input","seq":1,"move","act","face"} │ │───────────────────────────────────────▶ tick, snapshot out │ │ ◀── {"type":"snap","raw":{…}} (SNAPSHOT_HZ) ────────────────▶ B │ ◀── {"type":"ev","events":[…]} ───────────────────────────────▶ B │ {"type":"ping","t":…} │ │───────────────────────────────────────▶ │ │ ◀── {"type":"pong","t":…,"now":…} ───────────────────────────▶ B │ verdict + 1 s settle│ │ ◀── {"type":"round","result","score",…} ─────────────────────▶ B │ {"type":"next_level"} / {"type":"to_lobby"} │ │───────────────────────────────────────▶ COUNTDOWN / LOBBY │ ``` ## Client → Server messages | `type` | Model | Payload | Accepted when | |---|---|---|---| | `hello` | `HelloMsg` | `{p, token?, name?}` | always (handshake / rejoin) | | `ready` | `ReadyMsg` | `{}` | `LOBBY` | | `set_level` | `SetLevelMsg` | `{level}` | `LOBBY` (host only; level unlocked) | | `start` | `StartMsg` | `{}` | `LOBBY` (host only) | | `input` | `InputMsg` | `{seq, move, act, face}` | `PLAYING` | | `emote` | `EmoteMsg` | `{id}` | `LOBBY`, `PLAYING` | | `to_lobby` | `ToLobbyMsg` | `{}` | `ROUND_END` | | `next_level` | `NextLevelMsg` | `{}` | `ROUND_END` | | `ping` | `PingMsg` | `{t}` | always | ### HelloMsg The handshake: identity (or anonymous → the server mints a token) and the protocol version. `name` is sanitized server-side (control/format characters stripped, whitespace collapsed, capped at 16) and is non-unique and non-authoritative. | Field | Type | Notes | |---|---|---| | `type` | `"hello"` | discriminator | | `p` | int | protocol version; outside `[1, 1]` → `protocol_mismatch`, close `4400` | | `token` | str \| null | 1–128 chars; the session token (PLAN A16), null = mint one | | `name` | str \| null | 0–64 chars pre-sanitize; the display name | ### ReadyMsg The ready-up (US-03): flips the caller's seat `ready` on (a toggle — sending again flips it off; the `lobby` frame carries the flag). | Field | Type | Notes | |---|---|---| | `type` | `"ready"` | discriminator | ### SetLevelMsg The host's level pick (PLAN §6 "limited to unlocked"). The id must be in the room's campaign (`bad_field` if not) and unlocked by the host's session (`level_locked` if not). | Field | Type | Notes | |---|---|---| | `type` | `"set_level"` | discriminator | | `level` | str | 1–64 chars; a campaign level id (e.g. `01_first_kitchen`) | ### StartMsg The host's *Start* (US-03). Preconditions (a selected level; a solo host, or ≥ 1 ready non-host) are the room's — a refusal is an `err` whose `msg` names the reason (`no_level` / `no_ready_players`). | Field | Type | Notes | |---|---|---| | `type` | `"start"` | discriminator | ### InputMsg One frame of intent, sent on change plus a keep-alive at ≥ 5 Hz (PLAN §4). The sim clamps the magnitude again (`movement.intent_direction()`); the wire rejection is the early, explicit half of that rule. `seq` semantics (drop/resync, gaps) are the "Input rules" section below. | Field | Type | Notes | |---|---|---| | `type` | `"input"` | discriminator | | `seq` | int | ≥ 0; the client's monotonically increasing counter | | `move` | object | `Vec2Msg` — an intent direction, magnitude ≤ 1 | | `act` | bool | the primary action (use/pick/drop) press this frame | | `face` | `"up"` \| `"right"` \| `"down"` \| `"left"` \| null | explicit facing; null = derive from `move` | ### EmoteMsg An emote (PLAN §7: no chat in v1, emote whitelist). `id` must be in `seats.EMOTES` — `wave`, `cheer`, `thumbs_up`, `thumbs_down`, `angry`, `star` — checked server-side (an unknown id is `bad_field` with the whitelist in the message), throttled to 2 per 1.0 s per connection, and re-broadcast to everyone as an `ev` event. | Field | Type | Notes | |---|---|---| | `type` | `"emote"` | discriminator | | `id` | str | 1–32 chars; one of the six whitelisted emotes | ### ToLobbyMsg Return to the lobby after a round (PLAN §6): ready flags reset, waiting spectators are promoted into free seats. | Field | Type | Notes | |---|---|---| | `type` | `"to_lobby"` | discriminator | ### NextLevelMsg Start the next campaign level after a round (PLAN §6 "next-level"). The level id is resolved server-side from the viewer's session (`Room.next_level_id`): after a win it is `order + 1`'s level, after a loss the level just played. A "retry" is a `next_level` whose resolution is the level just played — the message carries no payload by design. The host's *pick* in the lobby is `set_level`. | Field | Type | Notes | |---|---|---| | `type` | `"next_level"` | discriminator | ### PingMsg Keep-alive / RTT probe; answered with `pong`. | Field | Type | Notes | |---|---|---| | `type` | `"ping"` | discriminator | | `t` | int | ≥ 0; the client's timestamp, echoed back in `pong` | ## Server → Client messages | `type` | Model | When | |---|---|---| | `welcome` | `WelcomeMsg` | after `hello` | | `lobby` | `LobbyMsg` | on every lobby-state change (and once in `welcome`'s wake) | | `countdown` | `CountdownMsg` | `COUNTDOWN`, one per whole second (3, 2, 1) | | `snap` | `SnapMsg` | `PLAYING`, at `SNAPSHOT_HZ` (players) / half (spectators) | | `round` | `RoundMsg` | `ROUND_END` (after the 1 s settle) | | `ev` | `EvMsg` | any — the FX/log batch for the boundary | | `err` | `ErrMsg` | on rejection | | `pong` | `PongMsg` | after `ping` | ### WelcomeMsg The handshake answer: who you are in this room, which room, its state, the selected level, the seat list. | Field | Type | Notes | |---|---|---| | `type` | `"welcome"` | discriminator | | `p` | int | the server's protocol version | | `you` | object | `YouWire` — your own seat | | `room` | str | the room code | | `state` | str | the room state (`lobby`/`countdown`/`playing`/`round_end`/`closed`) | | `level` | str \| null | the selected level id, or null for none | | `seats` | object[] | `SeatWire` per occupied seat, in seat order | | `rejoined` | bool | `true` when this `hello` reclaims a *disconnected* seat (the rejoin inside the grace, task 05); a fresh join and a duplicate `hello` on a live socket are `false` | ### LobbyMsg The lobby state (the body `Room.lobby_payload` builds, phase 05): sent on every change and once right after `welcome`, so a joiner has the full picture. | Field | Type | Notes | |---|---|---| | `type` | `"lobby"` | discriminator | | `p` | int | the server's protocol version | | `code` | str | the room code | | `state` | str | the room state | | `level` | str \| null | the selected level id | | `host_seat` | int \| null | the crown holder's seat index, or null | | `can_start` | bool | whether a start pressed now would be accepted | | `seats` | object[] | `SeatWire` per occupied seat, in seat order | | `spectators` | object[] | `SpectatorWire` per watching token, join order | | `unlocked` | int \| null | the *viewer's* `unlocked_level` (null for an anonymous viewer) | | `round_no` | int | how many rounds this room has started | ### CountdownMsg One per whole second of the 3 s countdown: `n` = 3, then 2, then 1. | Field | Type | Notes | |---|---|---| | `type` | `"countdown"` | discriminator | | `p` | int | the server's protocol version | | `n` | int | whole seconds left (3 → 2 → 1) | ### SnapMsg A snapshot frame (PLAN §4): encoder-owned — the model wraps the compacted, quantized snapshot in `raw`. Note the two `p`s: **this** message's `p` is the protocol version; `raw`'s `p` field is its *players* array, nested one level down. The shape, the delta mode, the pacing and the size budgets are the "The `snap` frame" section below. | Field | Type | Notes | |---|---|---| | `type` | `"snap"` | discriminator | | `p` | int | the server's protocol version | | `raw` | object | the snapshot body — see "The `snap` frame" below | ### RoundMsg The verdict after the 1 s settle: `sim/round_state.py` `result_payload`'s exact keys, plus `unlocked`. `players` is per-seat contribution, display only (PLAN §5 — never competitive gatekeeping). | Field | Type | Notes | |---|---|---| | `type` | `"round"` | discriminator | | `p` | int | the server's protocol version | | `result` | `"won"` \| `"lost"` \| `"aborted"` \| null | the verdict (null before the verdict) | | `score` | int | the round's final score | | `target` | int | the level's `target_score` | | `stars` | int | 0–3, from the remaining clock share (PLAN §5) | | `time_left` | float | seconds, frozen at the verdict | | `counters` | object | `served` / `missed` / `wasted` counts (0 where never written) | | `longest_streak` | int | the round's high-water mark of consecutive serves | | `players` | object[] | `PlayerScoreWire` per seat, in seat order | | `level_id` | str | the level that was played | | `seed` | int | the round's seed (replayable) | | `ticks` | int | how many sim ticks the round ran | | `unlocked` | str \| null | the viewer session's next level id (null when none/unknown) | ### EvMsg A batch of transient events for the boundary — `chopped`, `burnt`, `served`, an emote, a `denied` nudge, an `err` note, … FX/log only, never game truth. The batch caps at 24 events per frame (`MAX_EVENTS_PER_FRAME`); the overflow counts in `Room.stats()["events_dropped"]`. The events' short-keyed payload map is the "Events onto frames" subsection of "The `snap` frame" below. | Field | Type | Notes | |---|---|---| | `type` | `"ev"` | discriminator | | `p` | int | the server's protocol version | | `events` | object[] | `EventWire` per event, in occurrence order | ### ErrMsg A rejection. `code` is a stable `ErrorCode` value (see "Error codes"); `msg` is human-readable; `seq` — when the rejection refers to an `input` frame — is that frame's `seq`, which is what the client resyncs off (task 03). | Field | Type | Notes | |---|---|---| | `type` | `"err"` | discriminator | | `p` | int | the server's protocol version | | `code` | str | one of the `ErrorCode` values | | `msg` | str | human-readable explanation | | `seq` | int \| null | the offending `input` frame's `seq`, when applicable | ### PongMsg The answer to a `ping`: `t` echoed, `now` is the server's current time in seconds (the room's injected `Clock`) — the RTT is `now - t` (plus the one-way path, which a client estimates from the delta). | Field | Type | Notes | |---|---|---| | `type` | `"pong"` | discriminator | | `p` | int | the server's protocol version | | `t` | int | the client's `ping` timestamp, echoed | | `now` | float | the server's current time, seconds | ## Wire objects ### Vec2Msg A movement intent (the `input` `move`). Both coordinates finite, magnitude `hypot(x, y)` ≤ 1 — an intent direction, not a speed: the sim clamps the same way (`movement.intent_direction()`), so a client sending `(50, 0)` is a `bad_field` on the wire and walks at `MAX_SPEED` at worst in the sim. | Field | Type | Notes | |---|---|---| | `x` | float | finite, with `y` magnitude ≤ 1 | | `y` | float | finite, with `x` magnitude ≤ 1 | ### YouWire The viewer's own seat (as `welcome`'s `you`): the token is the viewer's own, echoed; it never appears anywhere else. | Field | Type | Notes | |---|---|---| | `token` | str | the viewer's session token (echoed once) | | `seat` | int | the viewer's seat index | | `host` | bool | whether the viewer's seat holds the crown | ### SeatWire One seat as the lobby shows it — no token, ever (PLAN §11). | Field | Type | Notes | |---|---|---| | `index` | int | 0-based seat index | | `name` | str | the sanitized display name | | `color` | str | the palette colour (one of the four) | | `ready` | bool | the ready-up flag | | `host` | bool | the crown flag | | `connected` | bool | a dropped seat stays, `connected: false` | ### SpectatorWire A watching token (PLAN §6 "SPECTATING"): no seat, not in the sim. | Field | Type | Notes | |---|---|---| | `name` | str | the sanitized display name | | `color` | str | the palette colour | ### EventWire One transient event (`sim/model.py` `Event`, projected): the `EventKind` value by its short name, its position in tile units, and the payload (short keys — e.g. a `served` event carries the order id as `i`). | Field | Type | Notes | |---|---|---| | `kind` | str | the `EventKind` value (e.g. `served`, `burnt`, `emote`, `err`) | | `x` | float | tile units (0 for HUD-level events) | | `y` | float | tile units (0 for HUD-level events) | | `payload` | object | short-keyed extras (e.g. `{"i": 17}`) | ### PlayerScoreWire One seat's contribution for the result screen (display only). | Field | Type | Notes | |---|---|---| | `id` | str | the seat's sim player id | | `name` | str | the seat's display name | | `score` | int | this seat's contribution to `score` | | `dishes_served` | int | how many of the served dishes this seat made | | `missed_contrib` | int | how many missed orders count against this seat | ## The `snap` frame (encoder-owned) The `raw` body of a `snap` message, built by `server/encoding.py` (an explicit field map per type — no `pickle`, no `dataclasses.asdict`). The projection is quantized: positions to 2 decimals (`Vec2.quantized(2)`), progress/quality/life to 2 decimals, enums to short strings. A frame is a projection of one `KitchenState` — the same state always encodes to the same frame (a replay's frames are replayable too), and it is the single source of truth for the client's interpolation. **Nothing internal leaves the server.** A frame carries no `token` field, no `display_name`, no round `seed`, no `rng_state`, and no station internal `id` — a station's wire identity is its kind plus coordinates (`k` + `x`/`y`), and an event's payload reaches the wire only through the explicit short-key map below (a key that is not listed there never leaves). `tests/unit/test_encoding_no_leak.py` checks this recursively on full and delta frames alike; `snapshot_size(frame)` (the UTF-8 length of the frame's JSON) is the budget hook. #### The shape (full frame) | `raw` key | Type | Meaning | |---|---|---| | `t` | int | the sim tick number | | `s` | int | the score | | `tgt` | int | the target score | | `tl` | float | time left, seconds (2 decimals) | | `full` | bool | `true` in a full frame, `false` in a delta (below) | | `p` | object[] | players: `i` (the sim player id), `x`/`y` (tile units, 2 decimals), `f` (facing 0–3: up/right/down/left), `h` (the carried item's short form, e.g. `tomato_chopped`, or null), `s` (state: `idle`/`moving`/`stunned`/`working`), `pi` (seat index) | | `st` | object[] | stations: `k` (kind, a short string: `spawn_shelf`/`chop_board`/`stove`/`prep`/`serve`/`sink`), `x`/`y` (tile units), `g` (the first content's short form, e.g. `onion_raw`, or null), `q` (the first content's quality 0–1, 2 decimals; 1.0 for an empty station), `p` (progress 0–1, 2 decimals), `s` (state: `idle`/`working`/`ready`/`burning`/`dirty`/`blocked`) | | `o` | object[] | orders: `id` (int), `r` (recipe id), `l` (deadline share 0–1, 2 decimals — the order's `time_left` over its deadline window), `pts` (the order value) | | `hz` | object[] | hazards: `t` (kind: `fire`/`spill`/`moving_obstacle`/`rush`), `x`/`y` (tile units), `l` (life — seconds remaining, 2 decimals; 0.0 = permanent until removed) | | `e` | object[] | the events that rode this boundary (the compact event shape, below) | A real captured frame (level 1, the first `PLAYING` boundary, two seats — `tests/integration/test_encoding.py` produces the same shape and asserts its keysets): a new connection's first frame is always a full one. ```json { "t": 0, "s": 0, "tgt": 45, "tl": 150.0, "full": true, "p": [ {"i": "tok-a", "x": 2.5, "y": 7.5, "f": 2, "h": null, "s": "idle", "pi": 0}, {"i": "tok-b", "x": 9.5, "y": 7.5, "f": 2, "h": null, "s": "idle", "pi": 1} ], "st": [ {"k": "spawn_shelf", "x": 1.0, "y": 1.0, "g": "tomato_raw", "q": 1.0, "p": 0.0, "s": "idle"}, {"k": "chop_board", "x": 4.0, "y": 1.0, "g": null, "q": 1.0, "p": 0.0, "s": "idle"}, {"k": "stove", "x": 8.0, "y": 1.0, "g": null, "q": 1.0, "p": 0.0, "s": "idle"}, {"k": "prep", "x": 2.0, "y": 3.0, "g": null, "q": 1.0, "p": 0.0, "s": "idle"}, {"k": "serve", "x": 9.0, "y": 3.0, "g": null, "q": 1.0, "p": 0.0, "s": "idle"}, {"k": "sink", "x": 3.0, "y": 5.0, "g": null, "q": 1.0, "p": 0.0, "s": "idle"}, {"k": "spawn_shelf", "x": 8.0, "y": 5.0, "g": "onion_raw", "q": 1.0, "p": 0.0, "s": "idle"} ], "o": [], "hz": [], "e": [] } ``` #### Delta frames The stream runs in delta mode: a frame encoded against the *previous* frame (which may itself be a delta) is `"full": false` and carries its entities under `d` — `d.p`, `d.st`, `d.o`, `d.hz` — **only the changed entities** (new, moved, or state-changed since `prev`; an entity that moved back to its previous values is in, one unchanged is out; the diff is a whole entry, so a changed entry carries all its values), plus a `{"gone": true}` **tombstone** for each entity that vanished (an order served or missed, a hazard extinguished — a tombstone carries the entity's identity keys so the client can drop it). The scalars (`t`, `s`, `tgt`, `tl`) and the tick's `e` ride every frame, full or delta. A delta of an idle kitchen shrinks a typical frame by ≥ 40%; the client merges by **upserting on the identity key and dropping the tombstones** (the identity keys: `p` by `i`, `st` by `k`+`x`+`y`, `o` by `id`, `hz` by `t`+`x`+`y`) — applying a delta chain to the last full lands on the server's own re-encode (the decode-equivalence the test suite checks). The stream keeps the client resyncable: a fresh connection's first frame is a full one, a **dropped snapshot** (the send queue's drop policy) marks the connection `needs_full` so its next frame is a full one again, and the **full cadence** — a full frame every 20 encoded frames (`FULL_SNAPSHOT_EVERY_TICKS = 20`; the fulls land at passes 1, 21, 41, … per audience) — means a client can always resync cheaply. #### Pacing (encode-once, fan-out-many) The room paces an encode pass every `tick_hz / snapshot_hz` ticks for the player audience and every `2 ×` that for spectators (the default 20 Hz tick / 20 Hz snapshot: a player pass on every tick, a spectator pass on every second one). One pass encodes **once** and reuses the frame across every connection of that audience (encode-once, fan-out-many); a pass with no audience encodes nothing. `snap` frames arrive at `SNAPSHOT_HZ` for players and half that for spectators; the `countdown` and `round` state frames reach *every* connection in the room. `UNDERCOOKED_SIM_LATENCY_MS` (default 0) applies a server-side send delay — the pump sleeps that long per batch — so netcode tests simulate RTT without a proxy; with 150, an input→frame round trip is lower-bounded by 150 ms (the integration suite measures it). #### Events onto frames (`e`) and the `ev` batch An event rides the frame of the boundary it occurs in. The compact `e[]` entry (the snapshot's shape) is `{"k": , "x"/"y" (tile units, 0 for HUD-level events), …the payload's short keys flattened…}`; the standalone `ev` message (a non-`PLAYING` boundary — a lobby emote, a `resync` nudge, the round-end verdict's `round_end` event — plus every state change outside `PLAYING`) carries the same events in the `EventWire` shape (`kind`, `x`, `y`, `payload` with the short keys nested). Events are **capped at 24 per frame** (`MAX_EVENTS_PER_FRAME`); the overflow is not spread over the next frame — it is counted in `Room.stats()["events_dropped"]`. The payload keys reach the wire only through this explicit map (`EVENT_PAYLOAD_SHORT` in `encoding.py`) — anything unlisted (a station's internal id, the per-event `tick` — the frame's `t` is the tick) never leaves the server: | payload key | short | | payload key | short | |---|---|---|---|---| | `player_id` | `i` | | `item` | `it` | | `to` | `to` | | `form` | `fm` | | `order_id` | `oi` | | `score_delta` | `sd` | | `id` | `oi` | | `stun` | `stn` | | `points` | `pts` | | `emote` | `em` | | `quality` | `q` | | `result` | `res` | | `streak` | `stk` | | `score` | `sc` | | `delta` | `d` | | `target` | `tgt` | | `reason` | `r` | | `stars` | `stars` | | `verb` | `v` | | `seq` | `seq` | So a `served` event is `{"k": "served", "oi": 17, "pts": 40, …}` on the snapshot and `{"kind": "served", "payload": {"oi": 17, "pts": 40, …}}` in an `ev` batch; the `resync` nudge of the "Input rules" section is `{"k": "resync", "i": , "seq": }` attached (or the `EventWire` shape in an `ev` flush). #### Size budgets A 4-player / 4-order / 3-hazard frame is **≤ 1.5 KB**; a 64×64 level's frame is **≤ 4 KB** (the grid, the station ids and the spawn coordinates never leave the server — a large level encodes to the same handful of stations as a small one). The 100-tick downlink stays **≤ 60 KB/s** per player. The integration suite measures all three over live sockets. ## Error codes `err.code` values (the `ErrorCode` constants in `protocol.py`); the `tests/unit/test_protocol_doc_sync.py` guard keeps this table equal to the code. | Code | Meaning | |---|---| | `unknown_type` | the `type` discriminator is not a known client message type | | `bad_json` | the frame is not valid JSON | | `bad_field` | shape/field validation failed (wrong type, missing/extra field, non-finite or magnitude > 1 move, negative/non-int `seq`, non-bool `act`, unknown `face`, unknown emote id, out-of-range token/name/level length) | | `too_big` | the frame exceeded `max_message_bytes` (task 02) | | `rate_limited` | the per-connection token bucket is empty (task 02) | | `not_seated` | a verb from a token that holds no seat in this room (a spectator, or none) | | `not_host` | `start` / `set_level` from a non-host | | `level_locked` | `set_level` on a level not unlocked by the session | | `wrong_state` | a message not in `MESSAGE_WHITELIST` for the room's current state | | `room_full` | joining a full room (or the process is at `max_rooms`) — close `4402` | | `room_gone` | unknown code, or the room is closed/gone — close `4401` | | `protocol_mismatch` | `hello` `p` outside `[MIN_SUPPORTED_VERSION, PROTOCOL_VERSION]` — close `4400` | | `evicted` | a second socket for the same token replaced this one — close `4403` | | `internal` | a server-side failure (a tick crash aborts the room; the players are told, the rest of the server is unaffected) | ## Close codes WebSocket close codes (the `CloseCode` constants in `protocol.py`): | Code | Name | Meaning | |---|---|---| | `1000` | `normal` | clean close | | `4400` | `protocol_mismatch` | the `hello` `p` is outside the supported range (also: no `hello` within the 5 s handshake window — task 02) | | `4401` | `room_gone` | the code is unknown, or the room is closed/gone | | `4402` | `room_full` | the room (or the process) has no free seat | | `4403` | `evicted` | a second socket for the same token replaced this one | | `4404` | `abuse` | repeated oversize / rate violations (task 02) | ## Limits | Limit | Default | Env var | What it caps | |---|---|---|---| | `max_message_bytes` | 4096 | `UNDERCOOKED_MAX_MESSAGE_BYTES` | one inbound frame (→ `too_big`) | | `max_inputs_per_s` | 40 | `UNDERCOOKED_MAX_INPUTS_PER_S` | the per-connection inbound rate (→ `rate_limited`) | | `tick_hz` | 20 | `UNDERCOOKED_TICK_HZ` | the sim rate (5–60) | | `snapshot_hz` | 20 | `UNDERCOOKED_SNAPSHOT_HZ` | the `snap` rate to players (≤ `tick_hz`; spectators get half) | | `sim_latency_ms` | 0 | `UNDERCOOKED_SIM_LATENCY_MS` | the injected server-side send delay per pump batch (netcode RTT simulation; 0 = never sleeps) | | `max_players` | 4 | `UNDERCOOKED_MAX_PLAYERS` | seats per room (→ `room_full`) | | name length | 16 | — | after `sanitize_name` (raw `hello` `name` ≤ 64 pre-sanitize) | | token length | 128 | — | `hello` `token` | | emote throttle | 2 / 1.0 s | — | per connection | | handshake window | 5 s | — | no `hello` within it → close `4400` (task 02) | | heartbeat dead window | 12 s | — | no inbound traffic → the server closes `1000` and runs the disconnect teardown (task 05) | | seat grace (guest idle) | 45 s | `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` | a disconnected seat is kept, then `SeatAbandoned` (task 05) | ## Manual probe (websocat equivalent) The handshake, the happy path and the refusal closes can be reproduced without a browser — and without `websocat` (an external tool the project does not ship) — with `curl` (the pre-socket lifecycle) plus any WebSocket client; the project's own `websockets` dependency is the docstring's probe. Against a running `uv run undercooked` (`http://127.0.0.1:8000`): ```bash uv run undercooked # in a second shell: mint a room, then read its census # (the `sockets` counter is the live `/ws/{code}` census) curl -s -X POST http://127.0.0.1:8000/api/rooms -H 'content-type: application/json' -d '{}' curl -s http://127.0.0.1:8000/api/stats ``` Then the socket half (replace `CODE` with the `room_code` curl printed; `token` may be omitted — the server mints one and echoes it in the `welcome`): ```python import json import websockets async def main() -> None: ws = await websockets.connect("ws://127.0.0.1:8000/ws/CODE") await ws.send(json.dumps({"type": "hello", "p": 1, "token": "tok-probe"})) print(json.loads(await ws.recv())) # the `welcome` print(json.loads(await ws.recv())) # the current `lobby` await ws.send(json.dumps({"type": "ready"})) print(json.loads(await ws.recv())) # the `lobby` re-broadcast, `ready: true` await ws.send(json.dumps({"type": "ping", "t": 1})) print(json.loads(await ws.recv())) # the `pong` await ws.close() import asyncio asyncio.run(main()) ``` The refusal closes, observed from the client's close code: | What you do | The `err` you get | The close code | |---|---|---| | connect `/ws/ZZZZZ` (no such room) | `room_gone` | `4401` | | `hello` with `p: 99` | `protocol_mismatch` | `4400` | | connect, then send nothing for `hello_timeout_s` | — | `4400` | | join a full room with a fresh token | `room_full` | `4402` | | a second socket, same token | `evicted` (the first socket) | `4403` | | 3 consecutive oversized / rate-limited frames | `too_big` / `rate_limited` | `4404` | | an `Origin` outside `allowed_origins` (non-empty) | — (refused before the accept) | HTTP `403` | An *abrupt* drop (the client's transport vanishes without a close handshake — e.g. `ws.transport.close()` in the probe client) is not an error: the room sees exactly one disconnect, keeps the seat for the rejoin path, and the `sockets` census in `/api/stats` drops by one. ## Input rules `input` is the only client message that reaches the simulation. A frame passes four layers before its intent is in the per-tick queue — the per-state whitelist (`input` is `PLAYING`-only), the seat check, the `seq` discipline, and the clamp — and the room's input counters (`stats()`) record what each layer did. The wire's `Vec2Msg` rejection (magnitude `> 1` → `bad_field`) is the early, explicit half of the clamp; the layers below are the ones a frame that survives it still traverses. **The `seq` discipline** (per connection, against `last_seq`, which starts at `0` — the client's first frame must therefore be `seq ≥ 1`): - `seq == last_seq` — an **idempotent resend**: silently dropped (no error, no stat; the frame's effect already landed, and a client that retransmits over a flaky connection is not at fault). - `seq < last_seq` — a **replay**: `err bad_field` *carrying the frame's `seq`*, plus a `resync` event (below). Counted in `stale_seqs`; `last_seq` is never lowered — a late duplicate of an earlier frame cannot un-accept what came after. - `seq > last_seq` — **accepted**, and `last_seq` advances to it. A jump of **≤ 3** ahead is tolerated silently (UDP-ish loss: a snapshot boundary can swallow up to three ticks' worth of inputs without the round noticing); a larger jump is accepted **and counted** in `input_gaps` (a reconnect that re-sent from a stale counter is a condition worth seeing in the room's stats, not a refusal — the `resync` path is what corrects a client that goes backwards). **The `resync` contract.** The `resync` event — `{"k": "resync", "i": , "seq": }` when it rides a `snap`'s `e` (a `PLAYING` boundary), the `EventWire` shape in an `ev` flush otherwise — is the server's way of saying "your counter has left mine": on it, the client **hard-snapshots** — it discards its prediction buffer, adopts the latest `snap` as ground truth, and resumes `seq` from the `err`'s `seq` + 1. The `err` frame's `seq` field is what the client resyncs *off*; the event is what tells it *to*. **Clamping and quantization** (`to_player_input`, the wire layer's second half of the magnitude rule). An accepted frame's `move` is clamped to magnitude `≤ 1` (a `(1000, 1000)` intent becomes a unit diagonal — the sim's `movement.intent_direction()` is the backstop behind this one) and quantized to 3 decimals (a replay must reproduce a frame's effect; past 3 decimals a float is client noise, not intent). `face` must be a known value (`"up"/"right"/"down"/"left"` — an unknown one is `bad_field` at the wire); when it is `null` it is **derived from `move`** — the dominant axis wins (a perfect 45° press resolves to the horizontal axis, the sim's `update_facing` rule); a zero `move` derives `null` ("hold"). `act` is coerced to `bool`. `received_at_tick` is the **server's own tick** — the contested arbitration's ordering (arrival, then `seq`, then seat) never depends on asyncio scheduling or a client's clock. A frame whose `move` magnitude exceeded 1.0 is counted in `clamped_moves`. **No position is ever client-sent.** There is no position field in the protocol, and `extra="forbid"` makes an `input` carrying one a `bad_field`, full stop (teleport is structurally impossible — the sim integrates velocity from intents, so even an accepted `move` is capped at `MAX_SPEED` by `movement.desired_velocity`, whatever it is). **The per-tick queue and its stats.** The room's input queue is per-tick (a tick consumes it, then it is empty) with a capacity of `4 × seats` (16 at a full room). At capacity, a new frame drops the *oldest* entry **for that player** (the newest intent wins) and is counted in `inputs_dropped`; a full queue that holds none of the player's entries drops the *new* frame instead (one spammer cannot clear another player's tick). The queue never grows unbounded. A `DISCONNECTED` seat's actions are ignored (the frame's socket is not the one that dropped, and the sim parks the cook anyway); a spectator's are `err not_seated`. `stats()` — `inputs_received` (frames queued), `inputs_dropped`, `stale_seqs`, `clamped_moves`, `input_gaps` — is the room's fixed-shape census of this discipline. ## Heartbeats, drops, and seat grace (task 05) #### Heartbeats The client keeps the socket honest with a `ping` at ≥ 5 Hz (any inbound frame refreshes the server's `last_seen`; the `ping` is the documented keep-alive, answered `pong{t, now}`). The server watches the silence: no inbound traffic for **`HEARTBEAT_DEAD_S` = 12 s** (a constant, not a setting — 12 s of silence is dead on any honest client) and the server closes the socket with **`1000`** and runs the standard disconnect teardown. The room sees exactly the same drop as an abrupt close (one `mark_disconnected`, one `GHOST_ON`) — this is what catches a *half-open TCP*, where the read side would wait forever. #### A drop: `on_disconnect` A closed or dead socket (an abrupt close, the heartbeat timeout, or the test seam) marks the seat `connected = false` — **kept** for the rejoin path — emits one `ghost_on` event (at the cook's position, payload `i` = the player id), switches the sim's cook (`connected = false`, state `disconnected`), and applies the host-crown rule (a dropped host is a host *leave*). **The park (after 3 s).** After **`PARK_AFTER_S` = 3.0 s** of disconnection the cook's carried item is released — once, idempotently (a double call cannot double-release): onto the station on the cook's tile (a marked set-down, grabbable, `dropped` event; the hand-off window applies, so a released item can be passed), or into `counters["lost_items"]` (a `ghost_park` event) when no station sits under the tile. An item is **never deleted silently**: the station or the counter is its tombstone. A cook that carried nothing parks with no release at all. **`SeatAbandoned` (after the grace).** A seat disconnected for longer than `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` (default 45 s) is emptied by the room's own tick in every state: one `ghost_off`, the seat out (a host transfer, `empty_since` for the close rules), and a *forced* release of the cook's carried item (the seat is going away; the release must not wait for the park deadline when the grace is shorter). The cook's sim player stays (parked). When the room empties, it closes through the existing rules (the 30 s seat grace in `LOBBY`/`ROUND_END`; the supervisor's GC in any state, a 5 s pass) — its sockets get `err room_gone` + close `4401`. #### The rejoin - **Inside the grace** — a `hello` with the same token reclaims the seat: `welcome.rejoined = true`, `connected = true`, one `ghost_off`, the sim's cook restored (`connected = true`, state `idle` — a still-carried item is kept; an already-parked cook comes back empty-handed), and the snapshot stream resumes on the next player pass (within one tick of `SNAPSHOT_HZ`; a rejoining socket's first frame is a full one, its `needs_full` resync). - **After the grace** — the seat is gone: the spectate/join rules apply (`PLAYING` → a spectator; `LOBBY` → a free seat). A room that closed in the meantime answers the `hello` with `err room_gone` + close `4401` (a stale token) before any seat is taken. #### Ghost events | `k` | When | Payload (short keys) | |---|---|---| | `ghost_on` | a socket dropped (once per drop) | `i`; at the cook's position | | `ghost_off` | a rejoin inside the grace, or a `SeatAbandoned` (once each) | `i`; at the cook's position | | `ghost_park` | a park release with no station under the tile | `i`, `it`, `fm`; `counters["lost_items"]` + 1 | A park release onto a station is the plain `dropped` event, with the set-down mark. The client's ghost label renders off the `disconnected` player state / the seat's `connected` flag plus these events. #### The close-code decision (what the client can tell apart) | Situation | Code | The client does | |---|---|---| | a clean close — or the server closing a dead client (the heartbeat) | `1000` | treat as a *drop*: the seat is kept for the grace; reconnect with backoff (any inbound frame resets the 12 s clock) | | a `hello` `p` unsupported, or no `hello` in time | `4400` | fix the client; do not auto-retry | | an unknown code, or the room closed/gone (including a stale-token rejoin after the grace) | `4401` (+ `err room_gone`) | **the room died**: no auto-retry into that code | | a full room (a new token) | `4402` | tell the player; try another room | | evicted by a second socket for the token | `4403` | the token's seat is live on the other socket; stop this one | | repeated oversize/rate/shape violations | `4404` | the connection was abusive; a fresh socket resets the bucket | #### The test-only seam `POST /api/test/kill_socket/{code}/{token}` — registered **only when `UNDERCOOKED_TEST_MODE=1`** (never set in production): force-closes the token's connection server-side (a hard drop for the client; the room sees the standard `on_disconnect`), so e2e tests can simulate a dropped socket. `404 {"code": "no_connection"}` when the room or the token holds no live socket. ## Testing the protocol (task 06) The contract above is driven by real sockets against a real `uvicorn.Server` (an ephemeral `127.0.0.1` port per test — never a routable interface) in three suites under `tests/integration/`: | Suite | What it drives | |---|---| | `test_ws_round_two_clients.py` | two scripted clients play a complete round (the lobby → the ready-ups → the start → the `countdown` → the `snap` stream → inputs → the shared `served` event and the byte-identical `round` frames → the mid-round spectator → the `to_lobby` return) | | `test_ws_abuse.py` | the guardrails: 2000 malformed frames (the `4404` close, the other connections untouched, `/healthz` still `200`), the 500-message burst (`rate_limited` → `4404`, the send-queue high-water mark within its bound), the size cap (64 KB refused `too_big` — text and binary — 4 KB boundary admitted), the `start`/`set_level`/`emote` flood (the host check and the emote throttle hold), 200 concurrent handshakes | | `test_ws_budgets.py` (`slow`) | the performance budgets, on a live 4-player room: the tick cost over 400 real ticks, the `snap` size, the downlink rate, the input→snapshot echo | **The test seams.** The suites reach the server's own state without a second protocol: `app.state.supervisor` (the room's `stats()`, the seat flags, the `mark_disconnected` verb the endpoint's `finally` runs on a genuine drop) and `app.state.ws_registry.stats()` (the connection census: `sockets`, the send-queue high-water mark `send_queue_max`, the process's peak RSS `memory_mb`). The scripted client (`tests/integration/conftest.py` `ws_client`) is a connected socket with a background reader (a merged snapshot view: full frames reset, delta frames patch, `e` events accumulate) and the verbs `send_model` / `expect` / `collect`. One rule the suites follow (and the reason a drain here is bounded by a window, not by a silence): a live room's `snap` stream never goes quiet while the round is `PLAYING` — a healthy server produces frames for the whole 150 s time limit, so "wait for silence" is a wait that only a *broken* server ends (the pre-fix tick crashed the room on its first sim event, and the unbounded drains "passed" on that abort; the fix made them hang, and the windows made them real). **The budget table** (the bounds the budget suite asserts; the *measured* column is this suite's run — the bound is the PLAN §10 number, the measurement is what a regression moves): | Budget | Bound | Measured (this run) | |---|---|---| | mean tick cost, 4-player room, 400 real ticks | ≤ 2 ms | 0.53 ms (p99 1.13 ms) | | `snap` frame size (the 4p wire shape) | ≤ 1.5 KB | 1111 B max (101 frames / 5 s window) | | downlink rate (one client, 5 s window, steady state) | ≤ 60 KB/s | 12.3 KB/s | | input→snapshot echo (send → the first `snap` after it) | ≤ 120 ms | 50 ms (one tick, at 20 Hz) | | 200 concurrent handshakes — p95 | < 1 s | 164 ms (wall 0.18 s for all 200) | | 200 concurrent handshakes — RSS growth | ≤ 128 MB | 23.4 MB | | send-queue high-water mark (the 500-burst) | ≤ 4 × `MAX_SEND_QUEUE` | 2 | Two measurement notes. The downlink window runs *after* a 2 s drain: the tick-measurement phase leaves the kernel's TCP buffer holding the window's share (the socket is unread while the ticks are measured), and without the drain the window measures the backlog's catch-up — a one-time burst — not the steady state a continuously-reading client sees. And the echo's `0 ms` samples are the frame already in the socket's buffer (sent by the tick between the send and the read); the bound applies to the slowest of the three samples.