Scaffold project and author 14-phase roadmap

Browser co-op kitchen game: FastAPI + native WebSockets, server-authoritative
20 Hz simulation, in-memory anonymous lobbies, data-driven YAML levels, Canvas 2D
vanilla ES-module client with locally authored SVG art and no external CDNs.

.agents/PLAN.md records the LOCKED decisions; .agents/user_stories/ holds the 12
flow contracts; .agents/phases/todo/ breaks them into 14 sequential, test-gated
phases (71 task files), each leaving the app launchable and the suite green.

LLM-Generated: true
This commit is contained in:
2026-10-05 17:05:56 -04:00
commit f84c6ebaef
105 changed files with 5752 additions and 0 deletions
@@ -0,0 +1,53 @@
# Phase 08 — Netcode hardening, prediction, and touch input
**Story:** `US-06_coop_shared_kitchen`, `US-09_disconnect_and_rejoin`, `US-11_play_on_phone`
**Context:** `.agents/PLAN.md` §4 (client interpolation), §10 (latency budgets), A4, A9
## Objective
Make remote play feel local and stay unbroken: a resilient connection lifecycle with
backoff and resume, client-side prediction with gentle reconciliation against the
authoritative snapshots, on-screen touch controls, and measurable budgets for echo delay,
drift, and reconnect time under injected latency.
## Dependencies
- `07_canvas_client_and_hud` (complete) — renderer fed by the snapshot buffer, keyboard
input, `net/connection.js`, `window.__uc` telemetry
- `06_realtime_websocket_protocol` (complete) — `UNDERCOOKED_SIM_LATENCY_MS`, the test-only
socket-kill seam, snapshot resync rules
## Tasks
1. `01_connection_lifecycle_and_backoff.md` — reconnect with jittered backoff, resume semantics, protocol-mismatch stop, input flush on drop
2. `02_input_channel_and_seq_discipline.md` — input pacing, change-only sends, seq ownership, resync handling, blur/hidden safety in the net layer
3. `03_client_prediction_and_reconciliation.md` — mirrored integrator, ring buffer, gentle correction, divergence telemetry
4. `04_touch_controls_and_responsive_layout.md` — thumb stick, action button, safe areas, viewport/scroll lockdown
5. `05_netcode_budget_and_latency_suite.md` — the §10 budgets as tests: echo ≤ 120 ms, drift bound, rejoin ≤ 3 s, 4-client latency matrix
## Testing & Quality
- E2E with latency injection (`UNDERCOOKED_SIM_LATENCY_MS` = 0 / 60 / 150 / 250) and the
server's socket-kill seam; assertions read `window.__uc` telemetry and server stats, not
screenshots.
- Client/server constant parity test: `MAX_SPEED`, `ACCEL`, `RADIUS`, `REACH`, `STEP_DT`
must be identical between `src/undercooked/sim/movement.py` and
`app/static/js/shared/constants.js` (fails if either drifts).
- Budgets asserted, not printed: input→echo ≤ 120 ms; visible error ≤ 0.25 tile at 150 ms;
corrections ≤ 4 per 10 s steady movement; rejoin-to-playable ≤ 3 s; 0 lost frames per
connection over a clean 30 s round.
- Coverage: **>90%** on new/modified server code; client paths covered by the e2e matrix.
- The phase is "Complete" only when the **entire** project test suite passes 100%.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
stands after this phase — fixing every inconsistency found, **including pre-existing
ones not caused by this phase**.
- Tasks update `docs/CLIENT.md` (connection lifecycle, prediction rules, touch controls)
and `README.md` (mobile play, controls, what "connection lost" means).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us06_prediction.py -q` → green at 0 and 150 ms injected latency
- [ ] killing the socket mid-round (server seam) shows the reconnecting overlay and returns to playable ≤ 3 s
- [ ] touch controls move the cook and complete an action on a 390×844 touch context, with no page scroll or zoom
- [ ] the constant-parity test fails if a movement constant is changed on one side only (demonstrated once, then reverted)
- [ ] `uv run pytest tests/e2e/test_us11_touch_mobile.py -q` → green
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases (level 1 soup chain from phase 07 still passes)
@@ -0,0 +1,57 @@
# Task 01 — Connection lifecycle, backoff, and resume
**Phase:** `08_client_prediction_and_touch` · **Story:** `US-09_disconnect_and_rejoin`
## Objective
Give the client a connection that survives reality: dropped Wi-Fi, laptop sleep, server
restart, and protocol mismatch each get a distinct, correct behavior.
## Work
1. `app/static/js/net/connection.js` (harden the phase-07 stub):
- explicit lifecycle enum (`IDLE, CONNECTING, OPEN, RECONNECTING, FAILED, CLOSED`);
- jittered exponential backoff (0.5 s base, ×2, 8 s cap, ±25 % jitter), attempt counter
exposed, `online`/`offline` events used to pause and resume immediately,
- `hello` carries the stored token; a `welcome.rejoined` response clears the overlay, a
`room_gone` error routes to `LANDING` with the code preserved in the join field,
- stop-reconnecting conditions (each shows a distinct message): protocol mismatch
(`4400`), abuse close (`4404`), room gone (`4401`), repeated `room_full` (`4402`) —
reconnect only for network-level failures,
- on drop: flush the pending input queue, zero the intent, tell the renderer to freeze
(`snapshots.freeze()`), show the `RECONNECTING` overlay with attempt count and a
"back to lobby" escape hatch,
- on resume: the client must not wait up to `FULL_SNAPSHOT_EVERY_TICKS` for the next
full frame. Add `snap` to the `welcome` payload (server includes the current full
snapshot when the room is `PLAYING`): a documented wire change, so bump
`PROTOCOL_VERSION` to `2`, update `docs/PROTOCOL.md`, `MIN_SUPPORTED_VERSION` policy
(server accepts only 2), and let `tests/unit/test_protocol_doc_sync.py` prove the doc
and models move together. Record the tick gap in telemetry.
- never leak timers: `close()` clears backoff timers and listeners (test for listener
count stability across 5 reconnect cycles).
2. `app/static/ui/overlay.js` — `RECONNECTING`, `FROZEN`, `SPECTATING`, `RATE_LIMITED`
overlays: `aria-live="polite"`, non-blocking, keyboard-dismissable where sensible.
3. `tests/e2e/test_us09_reconnect.py`:
- server seam kill mid-round → overlay within 1 s, playable ≤ 3 s, same seat and color,
score continuous (no reset),
- kill during `LOBBY` → rejoins the lobby with the ready state intact,
- server restart (fixture restarts uvicorn on the same port) → distinct message, no
infinite spinner, join field keeps the code,
- offline event (`context.set_offline(True)`) → backoff visibly slows and stops the
moment `online` fires,
- protocol mismatch (client sends a bogus `p`) → stops reconnecting, shows `error` copy
with a reload affordance,
- 5 reconnect cycles leave the listener/timer count flat (no leak).
4. `docs/CLIENT.md` — lifecycle diagram, backoff parameters, and the stop-reconnect matrix.
## Testing & Quality
- E2E: every lifecycle branch, with the seam and offline emulation.
- Coverage: **>90%** on any server-side change; leak check via listener counts.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us09_reconnect.py -q` → green
- [ ] rejoin-to-playable ≤ 3 s measured in the test (assert, not logged)
- [ ] protocol mismatch does not loop reconnects (asserted by attempt count ≤ 1)
- [ ] listener/timer count after 5 reconnect cycles equals the count after 1 (asserted)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,48 @@
# Task 02 — Input channel and sequence discipline (client side)
**Phase:** `08_client_prediction_and_touch` · **Story:** `US-04_move_and_cook`, `US-06_coop_shared_kitchen`
## Objective
Own the outbound input channel: one place that paces sends, owns `seq`, survives tab
changes, and behaves sanely when the server rejects or drops a sequence.
## Work
1. `app/static/js/net/input_channel.js`:
- single writer of `input` messages (keyboard and touch both call
`setIntent(vec)`, `setAction(bool)` — neither sends directly),
- send-on-change plus a keep-alive at ≥ 5 Hz; send cadence capped at
`min(settings.max_inputs_per_s × 0.5, 30)/s` (the client stays well under the server's
rate limit so legitimate play never trips it),
- `seq` ownership: monotonic per connection, persisted only in memory, zeroed on new
connection, `err{seq}` handled by adopting the server's `seq` hint (from the `resync`
event payload) rather than resuming a broken count,
- drop-oldest pending slot when the socket backpressures (one pending frame is enough;
an old intent is worse than a missing one),
- `freeze(reason)` on: `document.hidden`, `blur`, overlay open, `ROUND_END`, spectating
role — zero intent, action released, sends paused (keep-alive only),
- telemetry: `sent`, `dropped`, `staleErrs` on `window.__uc`.
2. `app/static/js/input/*` refactor: keyboard (phase 07) and touch (task 04) become pure
intent producers; assert no other module calls `connection.send({type:"input"})`
(grep test).
3. `tests/e2e/test_us06_input_channel.py`:
- input send rate stays within the cap while holding a direction for 10 s, and
change-only (no send storm while idle),
- tab hidden → exactly one zero-intent send, then silence; unhide resumes,
- overlay open (reconnecting) → no inputs reach the server (server stats unchanged),
- forced seq error (test hook rewinds `seq`) → adopted cleanly, no error loop,
- spectator role → no inputs sent at all.
4. `docs/CLIENT.md` — input channel rules, seq ownership, freeze reasons table.
## Testing & Quality
- E2E: rate caps, freeze semantics, seq recovery; server-side stats used as ground truth.
- Coverage: **>90%** on server-side changes (test hooks), client verified behaviorally.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us06_input_channel.py -q` → green
- [ ] a grep test proves only `input_channel.js` sends `input` messages
- [ ] idle client sends ≤ 6 messages/s (asserted from server `stats()`)
- [ ] seq error recovery does not loop (≤ 1 error in 5 s, asserted)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,64 @@
# Task 03 — Client prediction and reconciliation
**Phase:** `08_client_prediction_and_touch` · **Story:** `US-06_coop_shared_kitchen`
## Objective
Make the local cook feel instantly responsive without ever becoming authoritative:
dead-reckon from local input, reconcile against server snapshots gently, and stay bounded
under latency.
Prediction is *presentation only*: the server state remains the truth the rules run on, and
no client-side value is ever sent back as authoritative.
## Work
1. `app/static/js/net/predict.js`:
- mirrors the server's movement integrator (accel/decel/max speed/radius) — keep the
mirrored constants in `app/static/js/shared/constants.js` with a comment naming
`src/undercooked/sim/movement.py` as the source of truth.
- `pushLocalInput(input)` → applies to the local ghost immediately and stores
`{seq, applied}` in a ring buffer of `PREDICT_WINDOW = 60` entries.
- `reconcile(snapshot)`: re-simulate the buffered inputs newer than `snapshot.t` from the
snapshot's position for the local player; if the resulting position differs from the
ghost by < `SNAP_ACCEPT = 0.15` tile, snap silently; if larger, ease toward the
corrected position over ≤ 150 ms (never teleport: US-06 criterion 3) and keep easing
bounded so a bad frame cannot fling the cook.
- Anti-rubber-band rules: at most one correction in flight; corrections during a
contested `denied` are applied immediately (the server won on purpose); when
`stun`/`denied` is active, prediction pauses.
- Divergence telemetry: `window.performance` counters exposed on `window.__uc` for tests
(`maxCorrPx`, `corrCount`).
2. `app/static/js/net/echo.js` — input echo suppression: the local cook's rendered
position always prefers the predicted ghost, never the raw snapshot, to avoid double
motion.
3. Tests (Playwright, deterministic; drive the client against the real server with
`UNDERCOOKED_SIM_LATENCY_MS` set by a fixture):
- `tests/e2e/test_us06_prediction.py`:
- input → visible echo ≤ 120 ms (measure key-down timestamp → pixel change in the
cook region),
- at 150 ms injected latency the visible error vs. the authoritative snapshot stays
≤ 0.25 tile while moving (read `window.__uc` + a test-only debug hook that exposes
the latest snapshot),
- corrections ≤ 4 per 10 s of steady movement (no rubber-banding),
- a forced divergence (test hook sets ghost position 3 tiles off) eases to the server
position in ≤ 150 ms without teleport frames ≥ 0.5 tile,
- prediction pauses while stunned (assert the ghost matches the snapshot).
4. `docs/CLIENT.md` — prediction/reconciliation section with the constants table and the
"presentation only" rule.
## Testing & Quality
- E2E with injected latency; assertions read telemetry rather than screenshots.
- Constant parity (browser-free): `tests/unit/test_client_constants_parity.py` parses the
numeric constants out of `src/undercooked/sim/movement.py` (`ast`, no import tricks) and
out of `app/static/js/shared/constants.js` (regex on `export const NAME = value`) and
fails on any mismatch for `MAX_SPEED`, `ACCEL`, `DECEL`, `RADIUS`, `REACH`, `STEP_DT`.
The mirrored integrator itself stays in a pure ES module and is exercised through the
browser in the suite above.
- Coverage: **>90%** on new server-side code; client logic covered by the e2e assertions.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us06_prediction.py -q` → green at 0 and 150 ms injected latency
- [ ] a test fails if `MAX_SPEED`/`ACCEL`/`RADIUS` differ between `movement.py` and `constants.js` (proven by temporarily changing one value)
- [ ] `window.__uc.maxCorrPx` stays within the bound during a 30 s scripted movement
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,58 @@
# Task 04 — Touch controls and responsive layout
**Phase:** `08_client_prediction_and_touch` · **Story:** `US-11_play_on_phone`
## Objective
Make the game playable with two thumbs on a phone: a drag stick, a hold-to-work action
button, and a layout that survives every viewport without scroll or zoom.
## Work
1. `app/static/js/input/touch.js` (Pointer Events only):
- left half of the play surface is a floating thumb zone: touch-down anchors the stick,
drag sets the intent, quantized to 8 directions with a 12 px dead zone and a 55 px rim,
release → zero intent; stick rendered as a light vector overlay (no DOM layout thrash),
- right side: one large *act* button (≥ 72 px), press → action `true`, release →
`false`, long-press (≥ 400 ms) also satisfies the trash hold,
- multi-touch: stick and action simultaneously (2 pointers tracked by `pointerId`);
a third pointer (a second player's finger on one device) is ignored, not glued,
- no `touchstart`/`click` double-fire, no 300 ms delay reliance, `setPointerCapture` for
the stick, `pointercancel` treated as release,
- controls auto-hide on non-touch devices and can be forced on via
`?controls=touch` (test hook + accessibility preference).
2. Layout lockdown in `app/static/css/screens.css` + `play.js`:
- `touch-action: none` **scoped to the play surface and stick zone only**;
`overscroll-behavior: none`; `height: 100dvh` with a `100svh` fallback;
`viewport-fit=cover` + `env(safe-area-inset-*)` padding; `user-select: none` during
play. Do **not** add `maximum-scale`/`user-scalable=no` to the viewport meta — that
blocks accessibility zoom everywhere. Pinch-zoom stays available on DOM screens, and on
the play surface `touch-action: none` is what stops in-game zoom/pan/scroll.
- HUD reflow: order rail to the top edge, score/timer compact, controls never overlap the
serve window or order rail (asserted geometrically in the test),
- orientation: both portrait and landscape playable; a hint suggests landscape below
500 px height without blocking play.
3. `tests/e2e/test_us11_touch_mobile.py` (`devices["iPhone 13"]`-style context plus a
360×640 custom context):
- create + join by typing the code (US-02 on mobile) with the right input attributes,
- pointer drag moves the cook in the expected direction and stops on release,
- act button completes a chop (progress ring visible, `CHOPPED` event),
- simultaneous stick+act works (one pointer each),
- no page scroll/zoom: `document.scrollingElement.scrollTop` stays 0 and
`visualViewport.scale` stays 1 during vigorous drags,
- control hit targets ≥ 44 px (computed `getBoundingClientRect`),
- controls absent on a desktop context, forced with `?controls=touch`.
4. `docs/CLIENT.md` — touch mapping, dead zone/rim numbers, safe-area strategy, and the
"pinch zoom stays on DOM screens" rationale.
## Testing & Quality
- E2E on two mobile viewports + a desktop negative case.
- Coverage: **>90%** on any server-side change.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us11_touch_mobile.py -q` → green on iPhone-13 and 360×640 contexts
- [ ] stick + act simultaneously completes a chop (asserted via the `CHOPPED` event)
- [ ] no scroll or zoom during play (asserted), pinch-zoom still works on the lobby screen
- [ ] every touch control ≥ 44 px (asserted from bounding boxes)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,47 @@
# Task 05 — Netcode budget and latency matrix
**Phase:** `08_client_prediction_and_touch` · **Story:** `US-06_coop_shared_kitchen`, `US-09_disconnect_and_rejoin`
## Objective
Turn PLAN §10's promises into an executable latency matrix so "it feels fine on my
machine" can never again be an argument.
## Work
1. `tests/e2e/conftest.py` — a `latency` fixture that parameterizes the server's
`UNDERCOOKED_SIM_LATENCY_MS` over `{0, 60, 150, 250}` (server restarted per value, or a
per-room override via the test seam if cheaper) and exposes
`measure(page, fn) -> Metrics` reading `window.__uc` plus CDP `performance` data.
2. `tests/e2e/test_us06_latency_matrix.py` — for each latency value, a 30 s scripted round
with two clients:
- input → visible echo ≤ 120 ms **local** (this is local prediction, so it must stay
flat across all latency values — the point of the phase),
- remote-cook visible error vs. authoritative ≤ 0.25 tile while moving,
- corrections ≤ 4 per 10 s of steady movement, `maxCorrPx` within the ease bound,
- zero dropped frames attributable to the net layer (`stats().netStallFrames == 0` at
≤ 150 ms; ≤ 2 at 250 ms),
- score/result identical on both clients within 1 s of `round`.
3. `tests/e2e/test_us09_latency_matrix.py` — for each latency value: kill mid-round via the
server seam, assert rejoin-to-playable ≤ 3 s (≤ 4 s at 250 ms), no duplicate seat, no
score discontinuity, and the ghost label appears for the other client within 1 s.
4. `tests/integration/test_server_latency_flag.py` — the `UNDERCOOKED_SIM_LATENCY_MS`
injects the configured delay within ±20 % on localhost (server-side check so the matrix
is trustworthy) and `0` adds nothing measurable.
5. `BUDGETS.md` (repo root) — the numbers table with the **measured** values per latency
setting from this run, dated, and the rule "a phase that regresses a budget must update
PLAN.md with the user's sign-off, not relax the test". `README.md` links it.
6. `.agents/validate.sh` — run the netcode matrix as a `slow` stage (so it is gated, not
optional) and print the measured table on failure.
## Testing & Quality
- E2E matrix across 4 latency values, asserting the PLAN §10 budgets.
- Coverage: **>90%** on any server-side change (latency flag, test seams).
- Full suite green; the whole pipeline gate passes with the matrix included.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us06_latency_matrix.py -q` → green at 0/60/150/250 ms
- [ ] `uv run pytest tests/e2e/test_us09_latency_matrix.py -q` → green (rejoin ≤ 3 s at ≤ 150 ms)
- [ ] `BUDGETS.md` records this run's measured numbers with a date
- [ ] `bash .agents/validate.sh` includes the matrix stage and exits 0
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases (the phase-07 soup-chain suite still passes)