phase: 06_realtime_websocket_protocol

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`.
This commit is contained in:
2026-10-09 11:13:25 -04:00
parent f51ba919af
commit f26849c1f3
44 changed files with 11525 additions and 72 deletions
+907
View File
@@ -0,0 +1,907 @@
# 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": <the stable `EventKind` name>,
"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": <player_id>, "seq": <n>}` 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": <player_id>, "seq": <n>}` 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.