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,25 @@
# US-01 — Create an anonymous lobby
**As a** player who just opened the site,
**I want to** press one button and get a room code,
**so that** I can start a game with friends without creating an account.
## Acceptance criteria
1. The landing screen shows exactly one primary action: *Create lobby*.
2. Creating a lobby issues a 5-character code from the Crockford base32 alphabet
(no `I`, `L`, `O`, `U`), uppercase, shown large and with a copy button.
3. No account, email, password, cookie consent, or third-party request is involved.
4. The browser stores only a random session token (`sessionStorage`) and the room code.
5. The creator is marked `host` and holds a seat; the room is in `LOBBY`.
6. Reload of the same tab with the same token returns the player to the same seat.
7. If the server is at `max_rooms`, the UI shows a clear "lobby full, try again" error
instead of a broken screen.
8. An idle, empty lobby is garbage-collected.
## E2E suite
`tests/e2e/test_us01_create_lobby.py` — load `/`, create lobby, assert code shape,
reload → same seat, assert zero non-origin network requests.
## Phases
`01_runnable_service_scaffold`, `05_anonymous_lobbies_and_room_lifecycle`,
`07_canvas_client_and_hud`
@@ -0,0 +1,29 @@
# US-02 — Join a lobby with a code
**As a** friend who was told a 5-character code,
**I want to** type it on my own device and be in the kitchen,
**so that** we can play together from anywhere.
## Acceptance criteria
1. The landing screen has a *Join* field accepting the code, case-insensitive,
tolerating whitespace and `l`/`1`-style misreads for excluded characters.
2. Joining with a valid code puts the player in a seat with a generated display name
(`Cook #2`) if none was typed, and broadcasts a `lobby` update to everyone.
3. Joining a full room shows "this kitchen is full (4/4)" and the room code is kept
in the field for a retry.
4. Joining an unknown code shows "no kitchen with that code" (no leak of which
part failed) and never reveals whether a room exists behind a closed door.
5. Joining during `PLAYING` puts the player in `SPECTATING` (visible "watching"
banner) until the next `LOBBY`.
6. A display name is sanitized (control chars stripped, 16-char cap) and is not
unique-enforced; two players may share a name.
7. The second socket from the same token evicts the first cleanly (no ghost).
8. Join → seated takes < 1 s on localhost.
## E2E suite
`tests/e2e/test_us02_join_by_code.py` — two browser contexts: create in A, join in B,
assert both see 2 seats; full-room and bad-code paths.
## Phases
`05_anonymous_lobbies_and_room_lifecycle`, `06_realtime_websocket_protocol`,
`07_canvas_client_and_hud`
@@ -0,0 +1,28 @@
# US-03 — Ready up and start from the lobby
**As a** host,
**I want to** pick the level and start once everyone is ready,
**so that** the group begins together on the same kitchen.
## Acceptance criteria
1. The lobby lists every seat: name, ready state, host crown, connection state.
2. Any player can toggle *Ready*; the host can pick any **unlocked** level from a
list showing name, target score, time limit, and difficulty pips.
3. *Start* is enabled for the host when ≥ 1 non-host player is ready (or the host is
alone — solo play is allowed).
4. Starting begins a 3-2-1 `countdown` shown identically on all clients, then the
first `snap` arrives.
5. Non-host players cannot start or change level; the attempt shows a disabled control
(no console errors, no silent failure).
6. If the host leaves in `LOBBY`, host status transfers to the lowest occupied seat;
in `PLAYING` the round continues.
7. Someone leaving during `COUNTDOWN` cancels the countdown back to `LOBBY`.
8. Starting creates a round record in the in-memory session store.
## E2E suite
`tests/e2e/test_us03_ready_and_start.py` — two contexts: ready toggles visible to
both, non-host start rejected, countdown then both screens show the kitchen.
## Phases
`05_anonymous_lobbies_and_room_lifecycle`, `06_realtime_websocket_protocol`,
`07_canvas_client_and_hud`
@@ -0,0 +1,30 @@
# US-04 — Move and cook in the shared kitchen
**As a** player,
**I want to** walk my cook, grab ingredients, chop, cook, plate, serve, and wash,
**so that** I can complete orders with my hands.
## Acceptance criteria
1. Keyboard (WASD/arrows) moves the cook smoothly in 8 directions without diagonal
speed boost; the cook stops at walls, counters, and other players.
2. One action key (`Space`/`Enter`) interacts with the tile(s) in front, choosing the
sensible verb: pick up, start/hold work, place, plate, serve, wash, trash.
3. Holding the action key performs progressive work (chop ≈ 0.9 s, cook auto-runs,
wash ≈ 1.2 s) with a visible progress ring.
4. One carried item per player; the carried item is drawn above the cook and its
form (`raw`/`chopped`/`cooking`/`cooked`/`burnt`/`plated`) is visually distinct.
5. Reach is 1 tile in front (plus a 0.6-tile forgiveness radius); out-of-reach presses
give an explicit "move closer" nudge rather than doing nothing.
6. A contested target resolves deterministically: one winner, and the loser gets
visible `denied` feedback — never two players holding one item.
7. Serving a wrong dish is refused with feedback and the order survives.
8. The full chain is possible without dead ends: dirty plates/pots return to the sink
and come back usable; trash always accepts an item.
## E2E suite
`tests/e2e/test_us04_move_and_cook.py` — scripted keyboard session completes one soup
end to end; asserts HUD score bump, station state changes, denied path on a blocked tile.
## Phases
`03_simulation_movement_and_interaction`, `04_kitchen_pipeline_and_scoring`,
`07_canvas_client_and_hud`, `08_client_prediction_and_touch`, `09_co_op_two_player_e2e`
@@ -0,0 +1,29 @@
# US-05 — Orders, score, timer, and the round result
**As a** player,
**I want to** see what orders are pending, how I am scoring, and how much time is left,
**so that** I know whether we won or lost and why.
## Acceptance criteria
1. The HUD shows the order queue with recipe composition, a per-order time bar, and the
point value; the currently-satisfiable order is visually distinguished.
2. Score, target, and countdown timer are always visible and update within 200 ms of the
server event.
3. Serving a correct dish awards `base × quality × streak × time_bonus`, plays a pop
effect, and removes the order; streak is shown from 2 upward.
4. A missed order deducts the penalty, resets the streak, and flashes the HUD.
5. Reaching `target_score` ends the round immediately as `won`; the timer hitting 0 ends
it as `won` only if `score ≥ target`, else `lost`.
6. The result screen shows result, stars (1–3), score/target, best score for the level,
per-player contribution, and *Retry* / *Next level* / *Back to lobby* actions.
7. The result screen is announced to assistive tech (`aria-live`) and is reachable with
the keyboard alone.
8. The same round driven by the same seed and input script produces the same score
(covered by the replay test, not the browser).
## E2E suite
`tests/e2e/test_us05_orders_score_result.py` — serve to win, assert result screen +
stars; force a miss, assert penalty and streak reset.
## Phases
`04_kitchen_pipeline_and_scoring`, `07_canvas_client_and_hud`
@@ -0,0 +1,32 @@
# US-06 — Cook cooperatively with 2–4 players
**As a** member of a group,
**I want to** see the other cooks move in real time and share one kitchen,
**so that** we can divide tasks instead of racing for the same knife.
## Acceptance criteria
1. Every client sees every other cook at their server-authoritative position with
smooth motion (interpolated), a name label, and a distinct color.
2. Remote motion at 150 ms RTT looks continuous: no teleporting, ≤ 0.25 tile visible
error while moving, and no rubber-banding of the local cook.
3. The local cook responds to input before the server confirms it (prediction) and is
corrected gently (≤ 150 ms ease) when the server disagrees.
4. Players physically block each other with a soft push (no standing on tiles already
occupied). Carrying players is out of scope; passing an item hand-to-hand happens by
both cooks using the same counter/prep tile.
5. A contested pickup/station gives exactly one winner; the loser sees `denied` and can
immediately target the next-best option.
6. Occupancy: a station being actively worked shows who is working it; a second player
cannot start a parallel job on it.
7. All clients converge on the same score and the same result screen within 1 s of the
round end.
8. 4 players in one room keeps the mean server tick ≤ 2 ms.
## E2E suite
`tests/e2e/test_us06_coop_shared_kitchen.py` — two contexts cooperatively serve one
order and both observe the same score/result; contested-pickup arbitration test;
4-connection integration test server-side.
## Phases
`06_realtime_websocket_protocol`, `08_client_prediction_and_touch`,
`09_co_op_two_player_e2e`
@@ -0,0 +1,30 @@
# US-07 — Progress through a campaign that gets harder
**As a** player finishing level after level,
**I want to** unlock the next kitchen and feel it get harder,
**so that** a session has a trajectory instead of one repeated map.
## Acceptance criteria
1. The level list shows all shipped levels in campaign order with the difficulty pips,
target, time limit, and per-level best score/stars.
2. Completing a level unlocks the next one **for that session**; the next level is
pre-selected in `ROUND_END` and startable by one action.
3. Failing a level keeps the same next choice available (retry) and does not unlock
forward; unlocking never goes backwards.
4. Level content comes from `levels/*.yaml`; adding a new valid YAML file makes the level
appear in `/api/levels` with no Python change (regression test).
5. Every campaign level passes content validation: reachability, recipe solvability,
target plausibility, hazard references.
6. Each consecutive level moves at least two escalation axes (§8 of PLAN.md) versus its
predecessor; the curve check fails the build otherwise.
7. All 10 campaign levels are playable end to end with the intended station set.
8. Progression state is per session token and survives a page reload; it is documented
as in-memory and lost on server restart.
## E2E suite
`tests/e2e/test_us07_campaign_progression.py` — win level 1, assert level 2 unlocked,
selected, and startable; fail a level, assert locked. Plus
`tests/unit/test_level_curve.py` for the escalation-axis and validation gates.
## Phases
`02_level_content_and_validation`, `11_campaign_levels_and_progression`
@@ -0,0 +1,29 @@
# US-08 — Handle hazards and pressure without losing control
**As a** player in a late level,
**I want to** deal with fires, spills, moving obstacles, and order surges,
**so that** the escalation is a fair test of teamwork rather than random unfairness.
## Acceptance criteria
1. **Fire** starts on a station per level probability, spreads to an adjacent station
after its burn timer unless dealt with, blocks its tile, and ruins items on it; it is
extinguished by a sink-filled douse (carry water) with visible steam.
2. **Spill** makes its tile slippery (accel/brake reduced, cook slides ~0.5 tile) and is
cleaned by a wipe action; a cook leaving the kitchen with a spill under a station
does not propagate it.
3. **Moving obstacles** (cart, boat mast) follow their authored path and stun on contact
for 0.6 s with a visible telegraph 0.5 s before entering a tile.
4. **Rush waves** spawn a burst of orders with a HUD "RUSH" banner and a wave counter.
5. Every hazard is data-driven (level YAML): type, location/station reference, rate, and
mitigation — no hard-coded hazard placement in Python.
6. Hazard effects are deterministic under a fixed seed (replay test covers all four).
7. Client FX exist for each hazard and respect `prefers-reduced-motion` (reduced: static
markers instead of flicker/shake).
8. Hazard penalties never soft-lock a round: the round always ends by timer or target.
## E2E suite
`tests/e2e/test_us08_hazards.py` — level fixture with fire + spill: extinguish, clean,
avoid a mover; asserts hazard states in the snapshot and reduced-motion rendering.
## Phases
`10_hazards_and_escalation_mechanics`, `11_campaign_levels_and_progression`
@@ -0,0 +1,29 @@
# US-09 — Drop out and come back mid-round
**As a** player on flaky Wi-Fi (or a locked laptop),
**I want to** reconnect and land back in the same kitchen mid-round,
**so that** one dropped socket does not ruin the group's game.
## Acceptance criteria
1. Losing the socket keeps the player's seat for `GUEST_IDLE_TIMEOUT_S` (default 45 s);
the cook stays in the kitchen and, after 3 s idle, parks and drops what they carried.
2. The other players see a "Cookie disconnected" label, not a vanished cook.
3. Reconnecting with the same token + code mid-round restores the seat and resumes
snapshots; time-to-playable ≤ 3 s after page load.
4. The client shows a "reconnecting…" overlay with exponential backoff (0.5 s → 8 s cap,
jittered) and never spams the server.
5. Reconnecting after the grace window releases the seat and the player re-enters as a
spectator (or joins the lobby if one exists).
6. The server never leaves a ghost connection: a second socket for a token evicts the
first, and closed rooms reject stale tokens with a clean error.
7. A room whose last seat is gone is closed and its resources freed within one GC cycle.
8. A mid-round server-side room exception aborts only that room and tells its players.
## E2E suite
`tests/e2e/test_us09_disconnect_rejoin.py` — kill the socket (context.close of page B /
server-side `kill_connection` test hook), assert ghost label, reload and rejoin mid-round,
assert playable ≤ 3 s and identical score. Integration: seat grace, GC, stale-token paths.
## Phases
`06_realtime_websocket_protocol`, `08_client_prediction_and_touch`,
`12_reconnect_resilience_and_scale`
@@ -0,0 +1,30 @@
# US-10 — Keep playing after the campaign: endless mode
**As a** group that finished (or bombed) the campaign,
**I want an** endless mode whose difficulty keeps rising and a session scoreboard,
**so that** the night keeps going without re-running level 1.
## Acceptance criteria
1. The lobby offers *Endless* as a selectable mode; it starts immediately from a layout
drawn from the authored campaign layouts.
2. Difficulty scales with completed rounds: order pressure, recipe complexity, time
budget, station scarcity, and hazard density all increase monotonically per round
(asserted by a property test on the generator).
3. Each endless round is announced ("Round 4 — pressure 1.6×") and the failure of a
round ends the endless run (score = cumulative).
4. The session scoreboard shows, per session token: levels with best score + stars +
date, best endless run, total rounds, and total dishes served.
5. The scoreboard is visible in the lobby and on the result screen; it is explicitly
labelled as this-server-only (in memory, cleared on restart).
6. The endless generator only emits validated levels — it runs the same validator as
authored content before a round starts, and falls back to the previous layout if a
generated candidate fails.
7. Scoreboard data never contains personal data beyond the display name the player typed.
## E2E suite
`tests/e2e/test_us10_endless_scoreboard.py` — start endless, finish a round, assert
round counter and rising pressure in the snapshot, assert scoreboard rows in the lobby.
Plus `tests/unit/test_endless_generator.py` property tests.
## Phases
`13_endless_mode_and_scoreboard`
@@ -0,0 +1,30 @@
# US-11 — Play on a phone
**As a** player with only a phone,
**I want to** join with a code and control a cook with touch,
**so that** the game works on the couch without a keyboard.
## Acceptance criteria
1. The layout works from 360 px wide to 4K: the kitchen scales to fit, the HUD stays
legible, nothing overlaps, and there is no page scroll during play.
2. Touch controls appear on touch devices: a left thumb-zone drag stick (8-way
quantized) and a large right *act* button (hold for progressive work).
3. Controls are usable one-handed per thumb, ≥ 44 px hit targets, and do not cover the
serve window or the order queue.
4. `touch-action: none` on the play surface: no pinch-zoom, no scroll, no double-tap
zoom; the on-screen keyboard never appears during play.
5. Orientation: portrait plays with a rotated hint (both orientations are playable).
6. Touch input uses Pointer Events; the same code path serves mouse (desktop click-to-act
is not required — keyboard is the desktop input).
7. Lobby joining on a phone (type code, tap create/join) completes in < 20 s and shows
the correct focus/type UI (`inputmode="text"`, `autocapitalize="characters"`).
8. Framerate on a mid-range mobile browser stays ≥ 30 fps with 4 cooks.
## E2E suite
`tests/e2e/test_us11_touch_mobile.py` — mobile viewport + `has_touch` context: create,
join by typed code, move via pointer drag, complete an action, assert no scroll and
control hit-target sizes.
## Phases
`07_canvas_client_and_hud`, `08_client_prediction_and_touch`,
`14_accessibility_polish_and_hardening`
@@ -0,0 +1,29 @@
# US-12 — Play accessibly
**As a** player with low vision, motor limitations, or motion sensitivity,
**I want** readable contrast, keyboard-only lobby use, and motion controls,
**so that** I can play and follow the game state.
## Acceptance criteria
1. All non-canvas UI (lobby, level list, result, HUD text) is real HTML with a logical
focus order, visible focus rings, and ≥ 4.5:1 text contrast (3:1 for large text).
2. The entire lobby → level-select → start → result loop is operable with the keyboard
only (no pointer required), with sensible default buttons.
3. Round start, score changes worth ≥ 10 points, order missed, and the round result are
announced through a polite `aria-live` region; per-frame data is never announced.
4. `prefers-reduced-motion: reduce` removes screen shake, flicker, and parallax, keeping
static hazard markers; the setting is also togglable in-game and persisted locally.
5. No information is conveyed by color alone: cooks, orders, and hazards have shape,
label, or icon differences; a colorblind-safe palette option is provided.
6. Canvas has a text alternative: `role="img"` + an `aria-label` summarizing state, and
a DOM "state panel" (toggleable) listing score, timer, orders, and hazards.
7. Text scales to 200% without loss of function; hit targets stay ≥ 44 px.
8. Sound is optional, off by default, and never required to know the game state.
## E2E suite
`tests/e2e/test_us12_accessibility.py` — keyboard-only loop, focus order, contrast audit
of computed styles, `aria-live` announcements for result, reduced-motion class applied.
## Phases
`07_canvas_client_and_hud`, `11_campaign_levels_and_progression`,
`14_accessibility_polish_and_hardening`