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`.
46 KiB
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 istoo_big. - Every frame has a
typestring — 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
ptravels in the client'shelloonly. The server checks it there (outside[MIN_SUPPORTED_VERSION, PROTOCOL_VERSION]→protocol_mismatch, close4400) and the connection is then bound to the negotiated version. Every server→client frame also carriespso 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 — aninputwith aposfield is abad_field, full stop. helloandpingare 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, andprotocol.pyMESSAGE_WHITELIST, the single source) allows. A message outside its state iswrong_state.- Per-connection rate limit:
max_inputs_per_s(default 40;UNDERCOOKED_MAX_INPUTS_PER_S) via a token bucket — over it israte_limited(task 02). One socket per token: a secondhellowith the same token evicts the first (evicted, close4403, task 02).
Versioning policy
pis a single integer. The server accepts[MIN_SUPPORTED_VERSION, PROTOCOL_VERSION](today1..1); outside it the handshake is refused withprotocol_mismatchand close code4400.- 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: bumpPROTOCOL_VERSION(andMIN_SUPPORTED_VERSIONonly 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
hellocarrying an unsupportedpis refused before anything else happens on the socket — no seat is taken, nowelcomeis 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_fieldcarrying the frame'sseq, plus aresyncevent (below). Counted instale_seqs;last_seqis never lowered — a late duplicate of an earlier frame cannot un-accept what came after.seq > last_seq— accepted, andlast_seqadvances 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 ininput_gaps(a reconnect that re-sent from a stale counter is a condition worth seeing in the room's stats, not a refusal — theresyncpath 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
hellowith the same token reclaims the seat:welcome.rejoined = true,connected = true, oneghost_off, the sim's cook restored (connected = true, stateidle— 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 ofSNAPSHOT_HZ; a rejoining socket's first frame is a full one, itsneeds_fullresync). - 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 thehellowitherr room_gone+ close4401(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.