Files
ducoterra f26849c1f3 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`.
2026-10-09 11:13:25 -04:00

46 KiB
Raw Permalink Blame History

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

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 ps: 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.

{
  "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):

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):

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.