Generated: 2026-09-25 Source: PRD.md (v0.1)
Build the game defined in the PRD: a pure, deterministic rules crate (tetris-core) wrapped by
a Bevy presentation binary (tetris-app). Tasks are atomic, single-file-ownership-scoped, and
dependency-explicit so independent agents can run in parallel. Strict milestone gating applies:
M1 tasks gate all app work; M2 slice gates M3/M4 polish.
Resolved decisions (from author, 2026-09-25):
- Flat colored cells — no art asset pipeline; sprites/themes are post-v1.
- M2 vertical slice includes 180° rotation and hold (both are core rules anyway).
- CI is local-only until M5 — no GitHub Actions until release hardening milestone.
Netplay v0.1 (2026-09-29): T24–T26 in this plan plus N1–N8 of netplay-plan.md together form netplay v0.1 — the local versus core/bridge/on-screen stack here, extended to online 1v1 lockstep there.
- Rust stable 1.95 (
rustup; Bevy 0.19.1 MSRV is rustc 1.95.0 — bumped from 1.90 during T1),git, Linux dev box (cross-builds for Win/macOS only in M5). - Verified dependency versions (crates.io, 2026-09-26 amendment — author directive: use
latest stable Bevy, supersedes the 0.18.1 pin):
bevy = 0.19.1(max stable; 0.20.0-rc.1 exists — do not use). Feature collections: usedefault-features = false, features = ["2d", "ui"](2dalready pullsui,audio,scene,picking).bevy_kira_audio = 0.26.0— declaresbevy ^0.19.0, so compatibility with 0.19.1 is confirmed from its manifest (T18's compile spike is now a formality; fallback remains Bevy's built-inaudiofeature, already enabled by2d).serde = 1.0.229+serde_json,dirs = 7.0.0,proptest = 1.11.0(dev-dep).
- Bevy 0.19 references for app tasks: standard UI widgets emit
ValueChange<T>events;AutoDirectionalNavigationfor keyboard/gamepad menu focus;EasyScreenshotPluginfor M5 README screenshots; fixed-timestep update pattern per Bevy 0.19FixedTimestepdocs. API names verified at T1 scaffold; app tasks re-verify againstcargo docbefore use. - Parallel-agent rules:
- T1 pre-creates the full module tree and ALL workspace dependencies so parallel tasks
never co-edit
Cargo.tomlorlib.rs/mod.rswiring. After T1: no dependency additions; manifest metadata edits are allowed only in T21 (bin/name) and T23 (wasm feature +getrandom/js). - T1 defines one Bevy
Pluginstub per app module (RenderPlugin,InputPlugin,HudPlugin,MenuScreensPlugin,SettingsScreenPlugin,AudioPlugin,JuicePlugin) and registers all of them inmain.rsat T1. Later app tasks only fill their ownPlugin::build()—main.rsis never re-edited. - Single-owner data types:
Settings,AppState,RebindingCapture,EffectsQualitystructs/enums live instate.rs(created T1); other tasks only read or persist them. - The core→app contract (
Game::snapshot()+GameEventvariants) is fixed by T8 and treated as frozen; app tasks T11/T13/T18/T19 code against it verbatim.
- T1 pre-creates the full module tree and ALL workspace dependencies so parallel tasks
never co-edit
T0 (PRD/scaffold, done)
└─ T1 ─┬─ T2 ──┬─ T4 ──┬─ T6 ─┐
│ ├─────── T7 ────┼─ T8 ─┬─ T9
├─ T3 ──────────────────┘ │
└─ T5 ─────────────────────────┤
├─ T10 ─┬─ T11 ── T13 ─┐
T9 (tests, parallel) │ ├─ T12 ────────┼─ T14 ─┬─ T17 ─┐
│ ├─ T15 ─┬─ T16 │ ├─ T21 ├─ T20 ─┐
│ └─ T18 ─┴─ T19 ┘ │ │ ├─ T22 ── T23
└──────────────(events)────────┴───────┴───────┘
Textual form:
T1: [] workspace scaffold (module stubs + all deps)
T2: [T1] core types & board
T3: [T1] 7-bag randomizer
T5: [T1] gravity/level curve
T4: [T2] SRS rotation + kicks + 180
T6: [T2,T3,T4] actions, hold, lock delay
T7: [T2,T4] scoring, combo/B2B, T-spin detection
T8: [T4,T5,T6,T7] Game facade (headless deterministic loop)
T9: [T8] proptest invariants + fuzz soak
T10: [T1,T8] app bootstrap (window, fixed-step, GameCore, events)
T11: [T10] renderer (grid, ghost, letterbox)
T12: [T10] input (bindings, DAS/ARR → Actions)
T15: [T10] settings/best-score persistence
T18: [T10] audio (SFX/BGM, ducking)
T13: [T11] HUD (score/level/lines, next×5, hold box)
T14: [T10,T11,T12,T13] vertical slice integration + playtest ← M2 gate
T16: [T12,T15] settings screen + rebinding UI
T17: [T14,T15,T16] title/pause/game-over screens, best display ← M3 gate
T19: [T11,T15,T18] juice (flash, freeze, shake) + effects setting
T21: [T14] icon, screenshots, README/docs polish
T20: [T17,T19] CI (GH Actions) + 3-OS release builds ← M5 opens
T22: [T20,T21] v0.1.0 release, tag
T23: [T22] WASM build (stretch, M6)
- depends_on: []
- location:
PRD.md,README.md,.gitignore - description: Git init, PRD v0.1, README, gitignore. (Done — committed.)
- validation:
git logshows root commits. - status: Completed
- log: 2026-09-25 — commits b9ef318, b8588f2.
- files edited/created: PRD.md, README.md, .gitignore
- depends_on: []
- location:
Cargo.toml,rust-toolchain.toml,crates/tetris-core/**,crates/tetris-app/**,rustfmt.toml - description: Cargo workspace with
tetris-core(lib) andtetris-app(bin, Bevy 0.19.1,default-features = false, features = ["2d","ui"]). Pre-create the FULL module stub tree so parallel tasks never share files: core =board.rs,piece.rs,bag.rs,srs.rs,gravity.rs,actions.rs,lock.rs,hold.rs,score.rs,tspin.rs,game.rs,event.rs,prng.rs; app =main.rs,state.rs,core_bridge.rs,render.rs,input.rs,hud.rs,screens_menu.rs,screens_settings.rs,settings_persist.rs,audio.rs,juice.rs(all compiling stubs). Instate.rsdefine the shared types with their final shapes:Settings(serdeSerialize/Deserialize, PRD defaults: DAS 150 ms / ARR 33 ms / volumes / next-queue 5 / effects quality),AppStateenum (Title/Playing/Paused/Settings/ GameOver),RebindingCaptureresource,EffectsQualityenum — single owner here, later tasks only consume. Inmain.rsregisterPluginstubs for every app module (RenderPlugin,InputPlugin,HudPlugin,MenuScreensPlugin,SettingsScreenPlugin,AudioPlugin,JuicePlugin); later tasks fill only their ownbuild(). Declare ALL workspace deps here (bevy, bevy_kira_audio, serde, serde_json, dirs, proptest dev-dep). Add[profile.dev] opt-level = 1for deps. - validation:
cargo testgreen incl. a headless smoke test (App withvisible: falsewindows builds and exits after N frames);cargo runopens a window manually;cargo fmt --checkandcargo clippy --all-targets -- -D warningsclean. - status: Completed
- log: 2026-09-26 — commit 429b93e. Bevy bumped 0.18.1→0.19.1 per author directive;
bevy_kira_audio 0.26.0 compat confirmed (builds against 0.19.1, no fallback needed).
Toolchain:
rust-toolchain.tomlpinned 1.90→1.95 — Bevy 0.19.1's MSRV is rustc 1.95.0 (cargo refused to resolve under 1.90); prerequisites' "Rust stable 1.90" line is superseded by this. 0.19 API drift fixed in stubs:init_resourcerequiresFromWorld/Default(RebindingCapturegiven a final shape withcapturing: boolflag — a presence-only unit struct was incompatible withmain.rs's permanentinit_resourcewiring); plugin tuples no longer implementPluginGroup(plainadd_plugins((..))still works); winit refuses event-loop creation off the main thread, so the hidden-window smoke test usesMinimalPlugins + WindowPlugin(no winit) — fully headless, no display needed, <0.1 s.cargo build/test --workspacegreen (2 headless smoke tests),cargo fmt --checkclean,cargo clippy --all-targets -- -D warningsclean. TDDreason_not_testable: scaffold task; the headless smoke test is itself the acceptance artifact. Deferred manual check: interactivecargo runwindow (no display exercised in this environment). - files edited/created: Cargo.toml, Cargo.lock, rust-toolchain.toml, rustfmt.toml, crates/tetris-core/Cargo.toml, crates/tetris-core/src/{lib,actions,bag,board,event,game, gravity,hold,lock,piece,prng,score,srs,tspin}.rs, crates/tetris-app/Cargo.toml, crates/tetris-app/src/{main,state,core_bridge,render,input,hud,screens_menu, screens_settings,settings_persist,audio,juice}.rs
- depends_on: [T1]
- location:
crates/tetris-core/src/{piece.rs,board.rs} - description:
Pieceenum (I,J,L,O,S,T,Z), cell-gridBoard(10×22, 2 hidden rows): spawn/collide/merge/line-scan/shift-down,is_empty(). Also the pure ghost helperghost_row(board, piece_state) -> row(lowest valid row for the piece in its current rotation — keeps ghost logic out of the renderer). Pure, no RNG. - validation: Unit tests — collision at walls/floor/stack, full-row detection, shift-down
ordering, spawn rows, out-of-bounds guards,
ghost_rowon partial-stack fixtures.cargo test -p tetris-coregreen. - status: Completed
- log:
- 2026-09-26 (T2): Implemented test-first (RED captured, then GREEN: 28 unit tests,
12 in
piece.rs, 16 inboard.rs). Design decisions: piece geometry fully owned bypiece.rs—Piece::cells(rot)returns the 4 pure SRS rotation-state cell tables (no kicks;srs.rsleft as stub);Rotation= Spawn/Cw/R180/Ccw with mod-4clockwise()/counter_clockwise()/half_turn()plusindex()/from_index();PieceState.row/colis the bounding-box top-left (3x3 JLSTZ, 4x4 I, 2x2 O) and may be negative. Spawn convention for T8:spawn_state(piece)places box atrow=0(cells land fully in hidden rows 0..2) withcol=(COLS-box_size)/2→ guideline cols (I 3..=6, JLSTZ box 3..=5, O 4..=5).collidestreatsrow<0as non-colliding (block-out detection);merge/setsilently skip OOB cells (never panic). Line clear is in-placeclear_full_rows(&mut self) -> usize, bottom-up survivor compaction preserving shift-down ordering across non-adjacent lines.ghost_row(&Board,&PieceState)->i32free fn: rests on floor/stack, returns input row unchanged when already colliding. AddedBoard::get/setaccessors (needed by tests/fixtures and T8GameSnapshotboard cells). Notes for T4: consumePiece::cells+Rotation::indexfor kick tables; JLSTZ/I tables verified against canonical SRS; rotation tables are kick-free by design.cargo fmt --checkandcargo clippy -p tetris-core --all-targets -- -D warningsclean.
- 2026-09-26 (T2): Implemented test-first (RED captured, then GREEN: 28 unit tests,
12 in
- files edited/created:
crates/tetris-core/src/piece.rs,crates/tetris-core/src/board.rs
- depends_on: [T1]
- location:
crates/tetris-core/src/{prng.rs,bag.rs} - description: Dependency-free seedable PRNG (splitmix64/xorshift).
Bag::new(seed),next(),peek(n); every 7 consecutive deals are a permutation of all pieces; same seed → same sequence. - validation: Unit tests — permutation invariant over 1000 bags, determinism, peek ≤ queue depth auto-refills.
- status: Completed
- log:
- 2026-09-26 (T3): Implemented test-first (RED captured: 20 compile errors, then GREEN:
14 unit tests — 6 in
prng.rs, 8 inbag.rs; crate total 42, T2's 28 untouched). PRNG choice: SplitMix64 (Rng::new(seed),next_u64(),next_below(n)— unbiased Lemire multiply-shift, no deps, no std RNG/wall-clock; seed 0/1/MAX verified non-degenerate). Bag:Bag::new(seed),next() -> Piece,peek(n) -> Vec<Piece>; internalRefCell<BagState { rng, queue: VecDeque<Piece> }>sopeek(&self, n)auto-refills across bag boundaries and always returns exactlynpieces without consuming (repeat peeks stable). Each bag is a fresh Fisher–Yates shuffle ofPiece::ALLdriven by the PRNG; permutation invariant confirmed over 1000 bags (every aligned group of 7 is a full permutation), plus determinism (same seed → identical 700-piece sequences), seed divergence, peek/drain agreement over 100 rounds, and first-piece distribution (all 7 appear across 200 seeds). PRNG not exposed. Notes for T8:Game::new(seed)should own oneBag::new(seed)and callbag.next()per spawn (keep&mut selfaccess;nextis&mut self,peekis&self); for the snapshot's next-queue (1–6 pieces) usebag.peek(n)— it never disturbs the deal sequence, so replays from the same seed stay identical.cargo fmt --checkandcargo clippy -p tetris-core --all-targets -- -D warningsclean.
- 2026-09-26 (T3): Implemented test-first (RED captured: 20 compile errors, then GREEN:
14 unit tests — 6 in
- files edited/created:
crates/tetris-core/src/prng.rs,crates/tetris-core/src/bag.rs
- depends_on: [T2]
- location:
crates/tetris-core/src/srs.rs - description: Precomputed cell offsets per (piece, rotation state 0–3 + 180 states if
modeled), JLSTZ and I kick tables per SRS spec,
try_rotate()returning success + kick index (stored for T-spin). 180°: kick trials per common guideline (no-kick first, then standard set). Pure function of board + piece state. - validation: Table-driven tests for every piece/state/direction incl. classic kicks (I against left wall, T spin setup, O no-op). Kick index reported correctly.
- status: Completed
- log:
- 2026-09-26 (T4): Implemented test-first (RED captured: unresolved-import compile
error, then GREEN: 13 unit tests; crate total 69, the 56 pre-existing tests
untouched). API:
RotateDir {Clockwise, CounterClockwise, HalfTurn},RotationAttempt {state: PieceState, kick_index: u8},try_rotate(&Board, &PieceState, RotateDir) -> Option<RotationAttempt>,kick_offsets(RotateDir, Piece, Rotation) -> &'static [(i32, i32)]. Coord convention: tables hold the SRS spec's (x, y) with +y UP; board rows grow DOWN, sotry_rotateappliescol += x, row -= y— the JLSTZ 0→R and I 0→R rows are pinned verbatim in tests to catch sign errors. Tables JLSTZ_CW/CCW and I_CW/CCW are indexed[from][trial], 5 trials each, first always (0,0), verbatim per spec; O returns the single no-offset trial and always succeeds at kick_index 0. 180 policy (SRS defines none): guideline-styleHALF_TURN = [(0,0), (-1,0), (1,0), (0,-1), (1,-1)]— in place first, then ±1-col nudges, 1-row drop, drop+right nudge; same set for every transition; new rot =ps.rot.half_turn();Nonewhen wedged.kick_indexis the 0-based trial index (0 = no offset, 4 = far SRS kick). Note for T7: persistkick_indexfrom the last successful rotation — the T-spin Mini rule hinges on kick_index == 4 vs < 4, sotspinneeds the index, not just the rotated state. Tests: open-space index 0 for all 7 pieces × 8 quarter transitions and all half turns; I floor kicks off both walls (index 4 CW left, index 3 CCW right); T left-wall kick (index 1); O no-op success in every direction; 1-high-slit wedged I → None for every quarter dir; half-turn open index 0, notched-slot kick (index 1), 2×3-chamber wedged → None; negative-row overhang rotates at index 0 (T2 convention).
- 2026-09-26 (T4): Implemented test-first (RED captured: unresolved-import compile
error, then GREEN: 13 unit tests; crate total 69, the 56 pre-existing tests
untouched). API:
- files edited/created:
crates/tetris-core/src/srs.rs
- depends_on: [T1]
- location:
crates/tetris-core/src/gravity.rs - description: Tetris Worlds seconds-per-row table for levels 1–19, ≥20G cap; convert to
tick counts at the core's 60 Hz logical clock;
interval_for(level)+level_for(lines)(level up every 10 lines). - validation: Boundary tests: level 1 = 1.000 s/row = 60 ticks @ 60 Hz, level 19 = 1/19 s, level 20 = 1/20 s = 3 ticks (20G cap), lines 9/10/19/20 → levels 1/2/2/3.
- status: Completed
- log:
- 2026-09-26 (T5): Implemented test-first (RED captured: 26 compile errors, then GREEN:
14 unit tests; crate total 56, T2/T3's 42 untouched). API:
TICK_HZ = 60,GRAVITY_CAP_LEVEL = 20,TICKS_20G = 3,interval_for(level: u32) -> u32(whole ticks between forced rows),level_for(lines_cleared_total: u32) -> u32(= lines/10 + 1). Rounding rule:round(60/level)ticks, halves round up via integer(2*60 + level) / (2*level)— level 1 = 60 (exact 1 s/row), level 8 = 7.5 → 8, level 19 = 3.16 → 3 ticks (0.050 s/row, nearest whole tick to 1/19 s), level 20 = 3 exact. Clamp policy: level 0 treated defensively as level 1; every level ≥ 20 clamps toTICKS_20Gbefore any division (u32::MAX safe; >20G speeds out of scope). Covers plan boundaries plus monotonic non-increasing interval over levels 1..=30 and level_for 0/9/10/19/20 → 1/1/2/2/3 sanity sweep over 0..100 lines.cargo fmt --checkandcargo clippy -p tetris-core --all-targets -- -D warningsclean. Core stays pure: no wall-clock, interval is consumed per logical tick by T8's Game loop.
- 2026-09-26 (T5): Implemented test-first (RED captured: 26 compile errors, then GREEN:
14 unit tests; crate total 56, T2/T3's 42 untouched). API:
- files edited/created:
crates/tetris-core/src/gravity.rs(replaced stub)
- depends_on: [T2, T3, T4]
- location:
crates/tetris-core/src/{actions.rs,lock.rs,hold.rs} - description:
Actionenum (Move/SoftDrop/HardDrop/RotateCw/RotateCcw/Rotate180/Hold) — the input schema PRD §13 requires locked early. Lock delay: 500 ms grounded at 60 Hz, reset on successful move/rotate, max 15 resets then force-lock; hard drop locks same tick. Hold: one-per-piece flag, first press swaps in from bag head, hold cell persists. - validation: Unit tests — force-lock after 15 resets, timer resets on successful shift but not on failed move, double-hold rejected, first-hold consumes bag not previous hold slot.
- status: Completed
- log:
- 2026-09-26: TDD (RED stubs-only → GREEN).
Actionenum per PRD §13 (Copy/Eq/Debug + serde);LockTimer30-tick grounded countdown (LOCK_DELAY_TICKS=30,MAX_RESETS=15), reset only on successful manipulation, 16th reset force-locks, unground cancels window,force_now()for next-tick lock;HoldSlotwithtry_hold(active, bag_next) -> Option<HoldSwap>(None = rejected double press), first press consumes bag head, cell persists,end_piece()clears used flag. 14 new unit tests; suite 83 passed.cargo fmt --all --checkandcargo clippy -p tetris-core --all-targets -- -D warningsclean.
- 2026-09-26: TDD (RED stubs-only → GREEN).
- files edited/created:
crates/tetris-core/src/actions.rs,crates/tetris-core/src/lock.rs,crates/tetris-core/src/hold.rs
- depends_on: [T2, T4]
- location:
crates/tetris-core/src/{score.rs,tspin.rs} - description: PRD §6.7 table incl. ×level multiplier, flat drop points, B2B ×1.5 for Tetris/T-spin chains, combo 50×n×level capped at 10, perfect-clear 3500 (board empty after lock). T-spin: 3-corner rule + last-kick exception; classify full/mini/none using kick index from T4.
- validation: Table tests per PRD row; B2B break on plain clear; combo cap; TSD vs TST vs mini fixtures; drop points unaffected by level.
- status: Completed
- log:
- 2026-09-26 (T7): Implemented test-first (RED captured: unresolved-import compile errors
for
detect_tspin/TSpinKind/ScoreState/b2b_scale, then GREEN: 22 unit tests; crate total 105, the 83 pre-existing tests untouched).tspin.rs:TSpinKind {None, Mini, Full}+detect_tspin(&Board, &PieceState, last_action_was_rotation: bool, last_kick_index: u8) -> TSpinKind— pure 3-corner classifier on the four diagonal corners of the T's 3×3 box; corners project to walls/floor = filled, rows above the top edge = open; front corners = nub side per rotation. Full = both front corners filled; Mini = one front + both back, upgraded to Full whenlast_kick_index == 4(far SRS kick relaxes the front-corner rule, per T4 note); non-T piece or no preceding rotation → None. T8 must track:last_action_was_rotation(set true by a successful rotation, false by any successful move/drop) andlast_kick_index(RotationAttempt.kick_indexof the latest successful rotation, reset to 0 on move/drop).score.rs: PRD §6.7 table copied exactly — Single/Double/Triple/Tetris 100/300/500/800, T-spin mini/full (no lines) 100/400, T-spin single/double/triple 800/1200/1600, all ×level; soft/hard drop points arrive pre-computed (1/2 per cell) and are FLAT (never level-scaled); B2B ×1.5 for consecutive difficult clears (Tetris or any T-spin with ≥1 line; mini-with-lines scores the T-spin row literally per the table), floor-rounded (x + x/2), broken by any plain line clear, untouched by no-clear locks; combo50 × min(n, 10) × levelwhere n = consecutive line-clearing locks − 1 (0 = first clear, reset on no-clear); perfect clear +3500 flat whenever the board is empty after merge+clear, stacking on top of line-clear/combo (no B2B/combo interaction beyond stacking — PRD specifies none). Stateful API:ScoreState {total, b2b, combo}withon_lock(lines: usize, tspin: TSpinKind, drop_points: u64, level: u32, board_empty_after_clear: bool) -> ScoreDelta {points, b2b_now, combo_now}per lock, plusnew()/reset(). Tests: 13-row PRD table × level 1 and 2 (pins ×level); Tetris→Tetris and Tetris→TSD→Tetris B2B chains (incl. combo stacking); broken by single; no-clear T-spin neither triggers nor breaks B2B; mini-single is difficult; combo ladder 0..500 with cap at n=10 (and 11th+ clear stays 500) + reset + ×level; PC flat 3500 at levels 1 and 5, PC+Tetris under B2B = 1200+50+3500; drop points flat at level 5;b2b_scalefloor on odd inputs; tspin fixtures: TSD full, TST (nub-right) full, mini (1 front + 2 back, kicks 0..3) with kick-4 upgrade, wall/floor corner fixtures (box col −1 Cw, floor-resting Spawn), above-top corners open, <3 corners None even at kick 4, no-rotation lock None, all non-T pieces None. fmt/clippy clean.
- 2026-09-26 (T7): Implemented test-first (RED captured: unresolved-import compile errors
for
- files edited/created:
crates/tetris-core/src/score.rs,crates/tetris-core/src/tspin.rs
- depends_on: [T4, T5, T6, T7]
- location:
crates/tetris-core/src/{game.rs,event.rs} - description:
Game::new(seed)+tick()/apply(Action)→Vec<GameEvent>(PieceSpawned/PieceLocked/LineCleared/ScoreChanged/LevelUp/TSpinDetected{kind}/ HoldPerformed/PerfectClear/ComboChanged{n}/GameOver). Block-out game over at spawn; top-out only per PRD. Frozen contract for app tasks: alsoGame::snapshot() -> GameSnapshotwith board cells, active piece state,ghost_row, hold contents + hold-used flag, next queue (1–6), score/level/lines/combo/b2b — enough to render a full frame from scratch (e.g. right after restart) with events alone not required. All rules delegate to T2–T7 modules. - validation: Scripted integration tests — same seed + same action log → identical score & final board (replay determinism); marathon fixture: Tetris + TSD + combo sequence scores exactly expected value; block-out triggers GameOver; snapshot completeness test (fields cover everything T11/T13/T18 need; assert TSpinDetected/HoldPerformed/PerfectClear/ ComboChanged emitted in a fixture run).
- status: Completed
- log:
- 2026-09-26 (T8): Implemented test-first (RED captured: 13 compile errors on
tests-only skeleton, then GREEN: 6 integration tests; crate total 111, the
105 pre-existing tests untouched). Frozen contract for T9-T23:
Game::new(seed) -> Game,Game::tick() -> Vec<GameEvent>,Game::apply(Action) -> Vec<GameEvent>,Game::snapshot() -> GameSnapshot,Game::snapshot_with_next(n)/Game::peek_next(n)(cap 6,NEXT_PREVIEW=5).GameEvent::{PieceSpawned{piece,state}, PieceLocked{piece,state}, LineCleared{lines}, ScoreChanged{total,delta}, LevelUp{level}, TSpinDetected{kind}, HoldPerformed{stored,incoming,from_bag}, PerfectClear, ComboChanged{n}, GameOver}(Clone/Debug/PartialEq/Eq/serde).GameSnapshot{board, active: Option<PieceState>, ghost_row: Option<i32>, hold: Option<Piece>, hold_used, next: Vec<Piece>, score, level, lines, combo, b2b, game_over}. Semantics: gravity perinterval_for(level)(counter reset on spawn); lock timer per T6 (grounded countdown, successful move/rotate re-arm, exhaustion force-locks insideapply); hard drop locks same tick with drop points merged into the singleon_lockcall (soft +1, hard +2/cell accumulated across the piece); T7 tracking wired:last_action_was_rotationtrue only on successful rotation, false on move/soft/hard drop (gravity is not a player action),last_kick_index= latestRotationAttempt.kick_index, 0 on move/drop/spawn. Block-out at spawn →GameOveronce, thentick/applyare no-ops; hold press keepshold_usedarmed for the swapped-in piece (flag resets only when the piece locks) — T6end_piececalled from the lock path only. Event order per lock: PieceLocked → TSpinDetected → LineCleared → ScoreChanged → ComboChanged → PerfectClear → LevelUp → PieceSpawned/GameOver;ScoreChanged/ComboChangedonly on change. Tests: fresh-snapshot completeness (queue = bag tail after first deal), replay determinism (seed+action-log → identical events+snapshots, seeds diverge), marathon with exact hand-computed score (Tetris+PC 4336 → Single-combo 4522 → TSD-combo 5822 → B2B Tetris+LevelUp 7208 → no-clear combo reset 7248; emits TSpinDetected{Full}, PerfectClear, ComboChanged 1/2/3/0, LevelUp), block-out GameOver then no-ops, hold swap/reject/re-arm, drop-point merge (37 = 3 soft + 17×2 hard). Gotcha for T9: full-row compaction shifts survivors down, so multi-step fixtures must rebuild the board per step. fmt/clippy clean.
- 2026-09-26 (T8): Implemented test-first (RED captured: 13 compile errors on
tests-only skeleton, then GREEN: 6 integration tests; crate total 111, the
105 pre-existing tests untouched). Frozen contract for T9-T23:
- files edited/created:
crates/tetris-core/src/event.rs,crates/tetris-core/src/game.rs
- depends_on: [T8]
- location:
crates/tetris-core/tests/{invariants.rs,soak.rs} - description: proptest: no overlapping cells ever, score monotonic, board invariants,
hold/lock invariants under random action sequences. Wall-clock-bounded soak (PRD §12 "1 h
crash-free"):
#[ignore]-gated test running random actions for ≥1 h CPU time, executed by the dedicated nightly CI job added in T20 (never in per-push CI). - validation:
cargo test -p tetris-coregreen;cargo test -p tetris-core -- --ignoredcompletes the timed soak without panic; shrinking yields minimal failing cases on injected bugs (spot-check). - status: Completed
- log:
- 2026-09-26:
tests/invariants.rs— 3 proptest properties @ 256 cases (randomVec<(Action, 0..5 ticks)>drives) + 3 plain tests;tests/soak.rs—#[ignore]dcrash_free_soak_1h(splitmix64-driven random play, 1 h wall-clock budget, per-step spot-checks). Properties asserted every step: active piece never overlaps filled/OOB cells (board.collidesfalse while!game_over); score monotonic non-decreasing; board dims valid (22×10,full_rowsindices in range) and never a full row left uncleared (incl. explicit scan after everyLineCleared);hold_usedexactly mirrors the event model (true afterHoldPerformed, re-armed only byPieceLocked);game_overfreezes all state (apply/ticksilent no-ops, snapshot unchanged); snapshot next-queue length in 1..=6; combo/b2b never invalid (combou32≥ 0 and ≤ total lines;combo/linescross-checked against event stream). - GREEN:
cargo test -p tetris-core→ 111 lib + 6 invariants passed, 0 failed, soak ignored, whole suite in 0.04 s (well under 60 s budget).cargo fmt --all --checkclean;cargo clippy -p tetris-core --all-targets -- -D warningsclean; soak binary confirmed compiling viacargo test -p tetris-core --no-run. - Spot-check (RED evidence for the assertions without touching core source): two
negative-control tests plant deliberate violations into crafted
GameSnapshots (active O overlapping filled cells; a full uncleared row; empty/oversized next queue; combo > lines) and assert the shared checkers reject each — proving the invariant expressions bite, so a real regression would shrink to a minimal case. - Run soak:
cargo test -p tetris-core -- --ignored(nightly CI only, per plan).
- 2026-09-26:
- files edited/created:
crates/tetris-core/tests/invariants.rs(new),crates/tetris-core/tests/soak.rs(new),tetris-plan.md
- depends_on: [T1, T8]
- location:
crates/tetris-app/src/{main.rs,state.rs,core_bridge.rs} - description: Window (1280×720, resizable), camera, 60 Hz fixed-timestep simulation
schedule ahead of render.
GameCoreresource wrappingtetris-core::Game(seeded from time),PendingActionsqueue consumed by the fixed-step bridge,SimPausedgate on the bridge (used later by T19 freeze-frames; render keeps running), insertion of the T1-ownedSettings/AppState/RebindingCaptureresources, core events drained into BevyEvents<GameEvent>. Initial state: Playing; Playing↔GameOver transition minimal (full screens in T17). - validation: Headless test: simulated ticks advance core; restart resets core AND
snapshot is fully re-renderable from tick 0; no sim work in render schedule;
SimPausedtruly halts core stepping while events still drain. - status: Completed
- log: 2026-09-26 — commit 52b1c97. Names later tasks import from
core_bridge:GameCore(fieldsgame,seed,steps,pending_events;restart()/restart_with(seed)) — non-send resource (Res<NonSend<GameCore>>/NonSendMut<GameCore>/world.non_send()) becauseGame's bagRefCellmakes it!Sync;PendingActions { pub queue: VecDeque<Action> }(push());SimPaused(pub bool);CoreEvent(pub GameEvent)message newtype consumed viaMessageReader<CoreEvent>(add_message::<CoreEvent>()). Bevy 0.19.1 API drift absorbed:Events<T>→Messages<M>,add_event→add_message,send→write(core crate stays Bevy-free → newtype needed anyway by orphan rules). Systemcore_bridge_systeminFixedUpdate(sub-schedule ofbevy::app::FixedMain, run insideRunFixedMainLoopbetweenPreUpdateandUpdate, i.e. ahead of render);Time<Fixed>overwritten to 60 Hz (TimePlugin default 64). Flow: Playing && !SimPaused → drainPendingActions→game.apply()each →game.tick()→ events buffered inGameCore::pending_events, then flushed toMessages<CoreEvent>every fixed step even while gated (actions HOLD, not dropped).GameEvent::GameOver→AppState::GameOver. Guarded Startup system spawnsCamera2d(render.rs stub has none). 8 new headless tests (127 workspace total green). - files edited/created:
crates/tetris-app/src/core_bridge.rs
- depends_on: [T10]
- location:
crates/tetris-app/src/render.rs - description: Flat colored cells (author decision) drawn exclusively from
Game::snapshot()— board, active piece, and ghost (ghost row comes from the core'sghost_row, the renderer computes no rules); cell size derived from window with letterboxed 10×20 view; PRD piece colors; full-redraw per frame acceptable at this scale. - validation: Unit-test the letterbox rect math (cell size/offset at several window sizes); headless render-app smoke test; visual: stack/active/ghost correct under resize; ≥58 FPS avg measured via frame-time log in a 60 s scripted run (method stated in test).
- status: Completed
- log: 2026-09-26 — commit 62d38e3. Cells drawn only from
GameCore.game.snapshot()(non-send: system paramOption<NonSend<GameCore>>); zero rules recomputed. OneSpriteentity per cell (Bevy 0.19.1:Spriteitself is the component —Sprite2dgone; color-tint +custom_size, built-in quad from sprite pipeline), pooled in privateCellPoolresource, fully refreshed eachUpdate(≤~210 entities; sim already ran:RunFixedMainLoopprecedesUpdate, so latest snapshot is read once per frame — sim/render decoupling is free). Visible field = board rowsHIDDEN_ROWS..ROWS(10×20), square cells, centered letterbox; cells aboveHIDDEN_ROWS(spawn buffer) are clipped — hidden active piece still draws its ghost. Layers via transform-z (renderer relies onTransparent2dsort key = z; noZIndexin 0.19): Board 0 < Ghost 1 < Active 2. Ghost = active colorwith_alpha(0.3). Plain background (no grid lines) — allowed by plan. Pub API for T13/T17/T19:PIECE_COLORS: [Color; 7]+PIECE_RGBindexed byPiece::ALLorder (I cyan, J blue, L orange, O yellow, S green, T purple, Z red — Guideline palette; PRD §3 names pieces only, no hexes) andpiece_color(Piece) -> Colorfor HUD mini-grids/hold box;GHOST_ALPHA: f32;letterbox(window_w, window_h) -> (cell, offset_x, offset_y)for any playfield-anchored UI/juice math;frame_cells(&GameSnapshot) -> Vec<SnapshotCell>({row, col, piece, kind}, clipped to visible rows,.color()/.z()) reusable for HUD previews;CellKind {Board, Active, Ghost}+pub struct PlayfieldCell { pub kind }marker (T19 juice can filter/transform sprites by kind);VISIBLE_ROWS = 20. 4 new tests (letterbox math at 5 window shapes; hidden-spawn clipping; scripted actions → entity counts vs independently recomputed expectation + palette/ghost-alpha/ position/z checks; resize reflow): 131 workspace total green. fmt/clippy clean. Deferred to T14 human gate: ≥58 FPS frame-time log run + visual resize sanity. M3 playtest addendum (2026-09-27): clipping the 2-row spawn buffer made piece cells vanish outside the top boundary when kicks/spawn touched rows 0-1 (author-confirmed glitch). Renderer now draws all 22 rows:VISIBLE_ROWS = ROWS,frame_cellsclips only to the full board, letterbox/HUD anchors follow; PRD §4 amended. Tests updated (spawn_buffer_cells_are_drawnreplaces the clipping test). - files edited/created:
crates/tetris-app/src/render.rs
- depends_on: [T10]
- location:
crates/tetris-app/src/input.rs - description: Rebindable
KeyBindingsresource (owned here): PRD §9 defaults plus wheel-up/down → RotateCw/Ccw and the X / Shift aliases; the PRD §6.4 "Q/E" note is superseded by §9 + wheel (resolve in favor of §9, record in code comment). APausechord lives in the same table but is consumed by the menu-state handler (T17), never emitted as a coreAction. DAS/ARR state machine runs on the fixed-step schedule in tick counts (defaults: DAS 150 ms → 9 ticks, ARR 33 ms → 2 ticks; convert Settings ms at load, clamp ARR ≥1 tick), so repeat cadence is framerate-independent; suppress emission whileRebindingCaptureis active (set by T16). Soft-drop rate multiplier from Settings. Emits intoPendingActions. - validation: Tick-driven tests (no wall-clock sleeps): held key → one press move, then
first repeat after exactly DAS-ticks, then ARR-tick cadence; release stops; rotate/hold/
hard-drop single-trigger per press; emission suppressed while
RebindingCaptureset; wheel events map to rotations. - status: Completed
- log: 2026-09-26.
gameplay_input_systemruns onFixedPreUpdate— theFixedMainsub-schedule immediately before the bridge'sFixedUpdatedrain, giving the PRD §10.3input → apply actions → core steporder with same-step queue consumption. Repeat machine is tick-count based (no wall clock):RepeatTimer::advance()per fixed step; DAS/ARR ms converted via pureticks_for(ms, hz)(150→9, 33→2, ARR clamped ≥1); press fires on the step it is sampled, first repeat exactly 9 ticks later, then every 2nd — verified by integration test at[1, 10, 12, 14, 16]over 16 steps (press tick consumes step 1). Per-press-tick edge detection is snapshot-based (InputMachine::prev_pressed), notjust_pressed, so multiple fixed steps per frame can't double-fire and reopening the gate never replays a stale press. Single repeat focus across left/right ⇒ at most one action kind per step; reversal press emits immediately and steals the focus; release resets that key's timer, release-of-focus falls back to the still-held other direction. Soft drop has no DAS: press + held cadencesoft_drop_period_ticks(mult, 60)(×20 → every 3rd tick). Wheel: Bevy 0.19.1 renamed the wheel event to aMouseWheelMessage (MessageReader) — aggregated per step, net direction, ≤1 rotation/kind, gated consumption (drained even while suppressed so nothing bursts later). Emission gated onAppState::Playing∧!RebindingCapture.capturing;Pauseslot in the table, never emitted here. PRD §6.4 "Q/E" superseded by §9 + wheel, recorded in the module doc comment. §9 table + plan implemented verbatim;InputPluginalsoinit_resourcesButtonInput<KeyCode>/MouseWheelmessage defensively for headlessMinimalPluginstests. New pub items for T16 rebinding UI:KeyBindings(slot/slot_mut/set_slot/reset_slot/default_slot),Bind(Key/WheelUp/WheelDown),BindSlot+ALL_BIND_SLOTS(display order), capture helperpressed_key_to_bind(&ButtonInput<KeyCode>) -> Option<Bind>, andRepeatTimer(new(ms, ms)/with_ticks/press/advance/release/is_held,DEFAULT_DAS_TICKS/DEFAULT_ARR_TICKS) for live DAS/ARR preview. Tests: +14 (7 pure state machine, 7 headless integration incl. core-step handoff); workspace 145 passed, 0 failed; fmt/clippy(-D warnings) green. - files edited/created:
crates/tetris-app/src/input.rs(full implementation; main.rs registration already in place from scaffold).
- depends_on: [T11]
- location:
crates/tetris-app/src/hud.rs - description: bevy_ui: score/level/lines, next×5 mini-grids (respecting Settings queue
size 1–6), hold box with used-state dimming, pause-key hint (reflecting the current bound
pause chord), from
Game::snapshot()+ events. - validation: HUD matches core state during scripted game incl. hold-dim and queue refill; survives window resize with letterbox.
- status: Completed
- log: Snapshot-driven HUD (Text2d/Sprite, no UI layout): reads only
GameSnapshotviaNonSend<GameCore>(test hookHudFixtureoverrides the source). Panels anchored to the letterboxed playfield throughrender::letterbox—hud_anchor/hud_text_center/hold_center/next_centerpub for T17 reuse.HudTextSlotscore/level/lines + conditional combo (COMBO xN, hidden at 0) /B2Bindicators + pause hint rebuilt fromKeyBindings::slot(BindSlot::Pause)on rebinding. Next queue: 1–6 root pool (clampsSettings.next_queue_size, caps atnext.len()), 4 mini cells each viarender::piece_colorPiece::cells(Rotation::Spawn)footprint; hold box 0/4 cells,GHOST_ALPHAdim whilehold_used. All update-in-place; despawn/rebuild only on count or piece change (no_entity_growth_across_framesasserts zero leaks). Tests: +8 (headlessMinimalPlugins+ window +CoreBridgePlugin+HudPlugin); workspace 170 passed, 0 failed; fmt/clippy (-D warnings) green. Addedbevy_text+default_fontfeatures to tetris-app for Text2d.
- files edited/created:
crates/tetris-app/src/hud.rs(full implementation; main.rs registration already in place from scaffold),crates/tetris-app/Cargo.toml(bevy features).
- depends_on: [T10, T11, T12, T13]
- location:
crates/tetris-app/src/,PRD.md(status bump) - description: Wire the full loop to a human-playable slice: spawn→play→lines→level-up→ game over→restart (R). 180 + hold active in slice per author decision. Fix feel defects found while playing (input latency, DAS tuning defaults).
- validation: Automated part — scripted headless marathon replay through the wired bridge
(spawn→lines→level-up→game over→restart) with zero panics and frame-time log ≥58 FPS avg;
cargo test/clippy/fmtgreen. Human part — agent runs the manual checklist (full game, hard/soft drop, hold, 180, ghost, restart feel) in a real window, posts results, and stops for author sign-off; the gate passes only on author approval. - status: Completed — automated part GREEN; M2 gate AUTHOR-APPROVED 2026-09-27 (author playtested the slice, signed off)
- log: Post-approval playtest defect (2026-09-27): down-arrow caused permanent soft-drop
speed-up —
gameplay_input_systemnever calledRepeatTimer::release()on soft-drop key-up, so one tap kept emittingSoftDropevery cadence tick forever. Fixed ininput.rs(release on not-held) + regression testsoft_drop_release_stops_repeat_forever. Automated part complete, commitad87aae. All glue lives incore_bridge.rs(main.rs/state.rs untouched). Restart:restart_run()shared path (GameCore::restart()/restart_with+AppState::Playing);restart_on_r_systeminUpdatefires onKeyCode::KeyRwhileAppState::GameOver—KeyBindings(T12) owns only the 8 action slots + pause and has no restart slot, so R is hardcoded per plan (documented in code); system takesOption<Res<ButtonInput<KeyCode>>>so MinimalPlugins headless worlds don't panic. Seed:TETRIS_SEED=<u64>env overrides the wall-clock seed at startup (seed_from_env_at_startup) and on every restart; the seed used (envvswall clock) isinfo!-logged. Bot (TETRIS_BOT=1):bot_drive_systeminFixedUpdatebeforecore_bridge_system— greedy 1-ply solver (rotations × slide/drop BFS-reachable resting slots on board copy viaghost_row/merge- clear; Dellacherie-flavoured weights: clears 4500 ≫ holes 500 ≫ agg height 25 ≫
bumpiness 12) executed closed-loop (one action/tick, stall counter replans/force-drops
so it can never hang);
bot_marathon_systemlogsBOT game_done seed= score= level= lines=+MARATHON game_doneper game, auto-restarts via the R-equivalent path after 0.5 s, exitsAppExit::Successafter 2 games;marathon_fps_systemlogsMARATHON fps_avg=<x> frame_ms=<y>every 2 s from real window frame times. Headless test:core_bridge::tests::wired_bot_bridge_full_loop_then_fresh_restart(wired bridge, no window, seed 42): realFixedUpdateschedule with the wired bot → observedPieceSpawned,LineCleared,LevelUpevents, forced block-out →GameOver AppState::GameOver, then shared restart path → structurally fresh snapshot (empty board, score/lines 0, active+ghost present, steps reset) — zero panics. Plusbot_solver_prefers_line_clear_over_flat_stackfixture test. 202 tests green. REAL-WINDOW marathon (cargo build --release;TETRIS_SEED=42 TETRIS_BOT=1 timeout 120 ./target/release/tetris-app, Wayland desktop): exit status 0 via AppExit (no timeout kill); 13 fps windows, fps_avg 60.05 (min 59.9), frame_ms avg 16.65 ≥ 58 gate;MARATHON game_done games_done=2(both games deterministic from seed 42: score 27306, level 6, 54 lines); restart linerestart: seed 42 (from TETRIS_SEED)between games; 0 panics; log noise = 1 startup WARN (sctk_adwaitaunknown button, env) + 11 one-time startup ERRORs (bevy_asset: Path not found assets/sfx/*.wav) — pre-existing T18 placeholder assets not in repo, zero runtime spam. Gates:cargo test --workspace202 passed (200 prior + 2 new);cargo fmt --all --checkclean;cargo clippy --all-targets -- -D warningsclean. PRD status header bumped toM2-vertical-slice. HUMAN PART OPEN — manual checklist (§12 + T14 validation) for author sign-off: (1) full game start→game over in real window; (2) hard drop feel/latency; (3) soft drop cadence; (4) hold swap (once per piece); (5) 180 rotation kicks; (6) ghost accuracy; (7) R restart feel from game over; (8) DAS/ARR defaults feel right; (9) sustained 60 FPS by eye. Gate passes only on author approval.
- clear; Dellacherie-flavoured weights: clears 4500 ≫ holes 500 ≫ agg height 25 ≫
bumpiness 12) executed closed-loop (one action/tick, stall counter replans/force-drops
so it can never hang);
- files edited/created:
crates/tetris-app/src/core_bridge.rs(restart glue, seed env, bot solver/lifecycle/marathon logging, T14 tests),PRD.md(status header bump).
- depends_on: [T10]
- location:
crates/tetris-app/src/settings_persist.rs - description: Load/save for the T1-owned
Settingsand aBestScoreserde model → JSON atdirs::config_dir()/<app>/(no new/divergent settings structs here); load at startup with corrupt-file → defaults + warn; atomic save (tmp+rename); debounced save on change. - validation: Round-trip unit tests w/ temp dir; corrupt file recovery test; input DAS/ARR and next-queue size read from loaded settings.
- status: Completed
- log:
settings.json(T1Settingsserialized directly + a field-for-fieldBindingsFilemirror of T12KeyBindings) andbest.json(score/level/lines) live indirs::config_dir()/tetris/(e.g.~/.config/tetris/), overridable viaTETRIS_CONFIG_DIRenv var (tests inject a temp dir; a lock serializes env-mutating tests). Missing/corrupt file → defaults +warn!, never panics; saves are atomic (.json.tmp+ fsync + rename). Saves: debounced 0.4 s after any Settings/bindings fingerprint change, immediate onCoreEvent(GameEvent::GameOver)(final score/level/lines read fromGameCoresnapshot, max-kept viarecord_final_run), and a last-chance flush onAppExit(Last). Bind wire format (BindDto):{"Key":"KeyA"}(BevyKeyCodeserde names via newbevy/serializefeature) and"WheelUp"/"WheelDown". Pub API for T16/T17:SettingsPersistPlugin, resourcePersistedBestScore { score, level, lines }(T17 renders it; T16 just mutatesSettings/KeyBindings— persistence is automatic),load()/save_once(...), path-injectedload_from(&Path)/save_to(&Path, ...)twins,config_dir(),record_final_run(&mut best, score, level, lines) -> bool, constsAPP_DIR_NAME/SETTINGS_FILE/BEST_FILE/CONFIG_DIR_ENV. 13 tests (round-trip defaults + edited incl. wheel binds + customKeyCodes, missing/half/corrupt file → defaults, DTO string-format pinning, max-score logic, no.tmpleft behind + valid JSON, debounce-arms-without-write, exit flush, env override, and an integration test booting MinimalPlugins + CoreBridge + persist → defaults at Startup, hard-drops to game over on the fixed schedule, best score lands on disk). Gate: 157 workspace tests green,cargo fmt --all --check+cargo clippy --all-targets -- -D warningsclean. Deps: no new crates —bevy/serializefeature enabled in tetris-app forKeyCodeserde. - files edited/created:
crates/tetris-app/src/settings_persist.rs(full impl),crates/tetris-app/Cargo.toml(bevy+serializefeature),Cargo.lock
- depends_on: [T12, T15]
- location:
crates/tetris-app/src/screens_settings.rs - description: Settings UI in the
SettingsScreenPluginstub (Bevy 0.19 widgets +ValueChangeevents,AutoDirectionalNavigation): key rebinding capture flow (incl. thePausechord — sets/clearsRebindingCaptureso T12 mutes emission during capture), master/SFX/music volumes, DAS/ARR, next-queue size (1–6), effects quality Low/Med/High (PRD §8 "all juice skippable"). Applies live toSettings; persists via T15. - validation: Change DAS slider mid-game → repeat cadence changes within one setting
reload and survives restart; rebind + pause chord persist and rebind takes effect; effects
quality persists; entering capture blocks gameplay actions (assert via
RebindingCapture); screen content verified viaAppState::Settingstransition test — end-user reachability from title/pause is verified in T17 (its gate). - status: Completed
- log: Bevy 0.19 retained UI spawned once on
Startupunder aSettingsRootnode (Node flex column +Text/TextFont/TextColorlabels,Buttonwidgets withInteraction,ImageNodeslider fills) and toggledVisibility::Visible/Hiddenby the plain-resourceAppState::Settingscheck — no new AppState variants; hidden UI also stops receivingInteraction(Bevy clears it for invisible nodes). Rows: master/SFX/music volume sliders (0.0–1.0), DAS (0–500 ms) / ARR (0–200 ms) sliders with 10 ms snap, Effects Low→Med→High cycler and Next-queue 1–6 cycler (PRD §8/§6.3), one bind row perALL_BIND_SLOTSslot (name + currentBindlabels via pubslot_display/bind_display/slot_namehelpers + per-slot reset button), footer Back + global "Reset keys" (reset_all_bindings). All logic is pub handlers taking plain refs —begin_rebind,cancel_rebind,apply_capture(&ButtonInput<KeyCode>, ...),reset_all_bindings,slider_value_from_pos/norm_from_pos/snap_ms/apply_slider/slider_norm,cycle_effects,cycle_next_queue_size,handle_back, label formatters — systems are thin glue (button_clicksonChanged<Interaction>,slider_pointeronPointer<Press|Drag| Release|DragEnd>messages with press-capture focus,sync_labels). Capture flow: row click →begin_rebindsets sharedRebindingCapture.capturing(T12's emission gate mutes gameplay; no second gate added) +CaptureTarget.slotand the row shows "press key…"; next framepressed_key_to_bindSome →set_slot(vec![bind])+ clear;Escapecancels without binding; leaving Settings mid-capture also clears. Persistence: screen only mutatesSettings/KeyBindings— T15 fingerprints and auto-saves; DAS mid-game reload works because T12 readsSettingsper fixed step. T17 chaining: enter by settingAppState::Settings(title/pause buttons);track_settings_entryrecords the screen we arrived from intoSettingsReturn(Playing maps to Title) and the Back button (handle_back) restores it and resets the memory — so Pause→Settings→Back returns to Pause; T17 may also writeSettingsReturn.statebefore entering.SettingsReturn+ all marker components (BindRow,SliderTrack,BackButton,EffectsButton,QueueButton,ResetSlot) are pub for T17 wiring/tests. 21 tests (13 unit: capture begin/key/Esc/idle, slider clamp+snap, apply/reset slot+global, cycles, back, label formatting; 8 headless integration w/ MinimalPlugins + hidden WindowPlugin: rows ==ALL_BIND_SLOTS::len()+ visibility toggling, click→capture, key→KeyBindingsupdated + cleared, Esc cancel, capture cleared on exit, back returns to recorded state, reset buttons, effects/queue cycling, Settings mutation round-trip through T15'ssave_to/load_from— temp dir injected via the path twins rather thanTETRIS_CONFIG_DIR, which T15's parallel env-locking tests own). Visual pointer-drag fidelity (real picking coords throughPointer<Press/Drag>) deferred to T14 on-device; the drag glue shares the unit-testedslider_value_from_posmath. Gate: 200 workspace tests green (170 prior + 21 T16 + 9 T19 landed meanwhile),cargo fmt --all --checkandcargo clippy --all-targets -- -D warningsclean. - files edited/created:
crates/tetris-app/src/screens_settings.rs(implementation + tests; plugin already registered inmain.rsby T1)
- depends_on: [T14, T15, T16]
- location:
crates/tetris-app/src/screens_menu.rs - description: Menu screen logic in
MenuScreensPluginfor the T1-ownedAppState(Title, Playing, Paused, Settings, GameOver). Pause via the bound chord fromKeyBindings(never a coreAction), overlay w/ resume/restart/settings/quit; game-over shows final score, level, and lines + persisted best (PRD §7.4); hooks T16's settings screen; BGM ducking wired to T18's audio API. - validation: All PRD §7 screens reachable by input (incl. title/pause → settings — this closes T16's deferred check); sim frozen while paused (zero core ticks, inputs buffered); best updates only on game over and displays score+level+lines.
- status: Completed — M3 gate AUTHOR-APPROVED round 4 (2026-09-27); rounds 1–3 defects fixed (d1cd482 gray window, 61ad177 mid-air locks, 685091d headroom clipping)
- log: Round-1 defects (2026-09-27): (1) flat gray window —
sync_root_visibility's query used bareHas<>markers overVisibility(a default component on every entity), so it hid camera/sprites/HUD/menu children and rendered only the root panel background; fixed with anOr-filter + regression testroot_visibility_sync_never_touches_foreign_entities(d1cd482). T21's capture-panel finding was this same bug, not a GPU quirk. (2) Blocks stuck in the air / inter-block gaps — move-lockout reset budget consumed while airborne, force-locking falling pieces after 16 air manipulations; fixed grounded-only per PRD §6.5 (61ad177, core+game regression tests). Soft-drop release defect was fixed earlier under T14. Round-2 capture check: title wordmark now BLOCKFALL; bot presses Start from Title (marathon runs unaffected). Round-3 defect: L piece 'turned into 3 squares' — SRS up-kicks (y=+2 offsets) can lift cells above row 0, andpush_visibledropped negative rows, losing the nub. Fix in two parts (685091d): renderer now draws a 10x24 field (22 board rows + 2 headroom rows,SnapshotCell.rowisi32, letterbox/HUD follow viaVISIBLE_ROWSDRAWN_TOP), andsrs::try_rotaterejects kick trials whose cells would sit aboveMIN_ROT_ROW(= -HIDDEN_ROWS), bounding the ratchet the same way spawn bounds it. Tests:headroom_rows_render_kick_lifted_cells,rotation_kicks_never_lift_cells_above_headroom(223 workspace tests green, fmt/clippy clean). Post-approval defect (v0.1.0, same day): author still saw 'L turns into partial E / P'. Root cause NOT rendering: the L piece'sCw/Ccwshape-table entries had the nub attached to the MIDDLE of the 3-cell bar (the T-piece signature) instead of its end — nothing pinned the shape tables, so all rotation/kick tests agreed with the wrong shape. Fixed Cw nub to (2,2) and Ccw to (0,0) (pure 90-degree rotations of spawn, matching the verbatim SRS kick tables); addedall_rotations_are_pure_rotations_of_spawn(property pin for all 28 states) +l_piece_rotations_keep_nub_at_bar_end; 225 tests green. v0.1.0 GitHub artifacts predate this fix -> roll v0.1.1 after retest.MenuScreensPluginimplements Title / Pause / GameOver on the frozen T1AppStatemachine (threeStartup-spawned UI roots toggled by state, T16 Button/Interactionpattern). Startup state: PRD §7.1 wants Title, butAppState::default()isPlayingand T1's permanentstate.rssmoke tests run this exact plugin tree and assertPlaying— resolved by registering the Title flip (startup_goto_title) as aStartupsystem under#[cfg(not(test))]: the shipped binary opens on Title, the test binary starts as before and drives state explicitly (pure handler is directly tested). Pause chord:KeyBindings::slot(BindSlot::Pause)(default Esc/P, wheel binds honored) read directly in this plugin — never a coreAction, suppressed whileRebindingCapture.capturing; toggles Playing↔Paused and sets/clearsSimPaused. Pause vs SimPaused vs juice: user pause owns the flag;resume_game/goto_title/start_new_runnever write it whileJuiceFreeze.owns_pause(juice re-asserts and releases only what it set, per juice.rs contract); the bridge'sAppState != Playinggate double-protects stepping. Pause → Settings leavesSimPausedset (frozen through T16 round trip, verified). Restart: all restart paths go throughcore_bridge::restart_run(T14 R-key stays the only R binding — not doubled). Best score: display-only read ofPersistedBestScore(T15'sbest_score_systemremains the sole writer onGameEvent::GameOver— no double write); NEW RECORD shown whenfinal == best && best > 0; game-over shows final score/level/lines from theGameCoresnapshot. Quit:AppExit::Success+QuitRequestedlatch (headless observable; App consumes the message). Pause overlay offers Resume/Restart/Settings/ Quit to title (PRD §7.3)/Quit; GameOver offers Play again/Menu/Quit; Title showsv{CARGO_PKG_VERSION}+ best + pause-key hint. Settings round trips verified from both Title and Pause via T16track_settings_entry/handle_back. Test-harness note:ButtonInput::clear()keeps held state — re-press tests mustreset(key)to produce freshjust_pressededges. Bevy 0.19 gotchas handled:MouseWheelfieldsx/y,Messages::iter_current_update_messages, query aliases only valid insideSystemParamstructs (bare alias fn args break the HRTB). Tests: 15 new (6 pure handler + 9 headless integration w/ MinimalPlugins + WindowPlugin- CoreBridge + Input + Menu + Settings): starts-at-title (default Playing + Title flip),
Start → fresh Playing, chord → Paused +
SimPaused(true)+GameCore.stepsunchanged across 5 fixed steps + resume restores, capture suppresses chord, pause→Settings→Back round trip (frozen throughout), Resume/Restart/Quit-to-title buttons, GameOver shows final score/level/lines + best + NEW RECORD (and blank when best ≠ final), Play again → fresh Playing, GameOver Menu → Title, Quit →QuitRequested.cargo test --workspace: 218 passed, 0 failed (203 prior + 15 new).cargo fmt --check: clean.cargo clippy --workspace --all-targets -- -D warnings: clean. Manual/device pass (mouse picking, overlay look, audio duck interplay) pending author.
- files edited/created:
crates/tetris-app/src/screens_menu.rs(impl),tetris-plan.md
- depends_on: [T10]
- location:
crates/tetris-app/src/audio.rs,crates/tetris-app/assets/ - description: First sub-step: verify
bevy_kira_audio 0.26.0compiles against Bevy 0.19.1 (kira 0.26.0 declaresbevy ^0.19— compat confirmed pre-spike); if not, fall back to built-in audio (2dfeature) — record choice. Wire core events → SFX set (move/rotate/lock/clear/tetris/t-spin/level/hard-drop/hold/game-over), looping BGM, per-volume settings, ducking on pause. - validation: Machine-checked with an
EventReadercount against a fixture spawning everyGameEventvariant — exactly one SFX play per event incl. TSpinDetected/HoldPerformed/ PerfectClear/ComboChanged (no duplicates on re-entrant frames); volumes react to programmaticSettingschanges (slider wiring verified in T16); pause ducks music. - status: Completed
- log: Kira real path used —
bevy_kira_audio 0.26.0compiles and runs against Bevy 0.19.1; no fallback needed. Workspace dep gainedfeatures = ["wav"](default is ogg-only → no WAV loader). Headless behavior: kira'sAudioManagerinit failure warns and degrades (no panic) on this host; the committed interop test still runs with theAudioEnabled(false)guard so device-less CI boxes stay deterministic — an unguarded experiment run also passed (no cpal panic). Assets (assets/generate.py, committed, deterministic seed): 44.1 kHz mono 16-bit —sfx/move(5.3 KB),sfx/rotate(4.5),sfx/lock(8.0),sfx/line-clear(13.3),sfx/tetris(25.2, 0.285 s),sfx/tspin(19.4),sfx/level-up(22.1),sfx/hard-drop(6.2 noise),sfx/hold(6.2),sfx/game-over(26.5, 0.3 s),bgm_loop.wav(176 KB, exactly 2.000 s, integer-period seam → click-free loop). SfxDirector pub API:plays: HashMap<&'static str, u32>(per event-variant label, exactly one increment perCoreEventmessage viaMessageReader— no polling),pending_sfx: VecDeque<Sfx>(drained by the kira system),master/sfx/music_volume,ducked,sfx_gain,music_gain,bgm_started, methodshandle_event,set_gains,set_ducked;DUCK_GAIN = 0.25. Mapping:sfx_for_eventis a wildcard-free match (new variants fail to compile): LineCleared{4}→tetris, other LineCleared→line-clear, PerfectClear→tetris fanfare (own label), ComboChanged→move tick; spawn/score silent. Two typed kira channels (SfxTrack,MusicTrack) carrymaster*sfx/master*music*duckgains; BGM starts once, loops, ducks onSimPaused(true)orAppState != Playing. Guard:AudioEnabled(pub bool)(plugin defaults true; tests insert false before the plugin) makes allAudioControlcalls inert while counting/gains stay live; the kira stack is only wired at all whenAssetPluginis present (keeps T1 MinimalPlugins smoke tests device-free). Move/ rotate/hard-drop WAVs ship as cues but noGameEventexists for them — T12/T19 can callSfxDirector::handle_event-adjacent playback viapending_sfxif desired (deferred, out of scope). Asset root resolves relative to process CWD, so run the binary fromcrates/tetris-app/(or shipassets/next to the executable — T20). 5 new tests (162 workspace total, was 157): exhaustive mapping + distinct wavs, every-variant fixture one-play-per-counter incl. TSpinDetected/HoldPerformed/ PerfectClear/ComboChanged with empty-second-frame duplicate check, settings-change gain reaction, SimPaused/AppState duck + restore, kira headless wiring smoke. - files edited/created:
crates/tetris-app/src/audio.rs(rewritten from stub),crates/tetris-app/assets/generate.py,crates/tetris-app/assets/sfx/*.wav(10),crates/tetris-app/assets/bgm_loop.wav,Cargo.toml(wav feature),Cargo.lock.
- depends_on: [T11, T15, T18]
- location:
crates/tetris-app/src/juice.rs - description: Line-clear flash + sim freeze via the T10
SimPausedbridge gate — ≤4 ticks (≤80 ms @ 60 Hz), render loop unaffected; on resume, coalesce queued move actions to at most one per tick so a backlog can't teleport the piece or burn lock-delay resets; hard-drop shake (subtle, decaying), lock flash, ghost alpha;EffectsQualityLow/Med/High from Settings disables shake/freeze at Low (selector UI in T16). - validation: Freeze never exceeds 4 ticks (asserted in test); input held through freeze applies coalesced on resume with correct lock-reset count; Low effects → zero camera motion and no freeze; 60 FPS sustained.
- status: Completed
- log: Data-driven juice in
JuicePlugin: pureJuiceState(flash/shakeEffectSlotelapsed/duration/amplitude) +effect_plan(event, quality)matrix;juice_from_events(MessageReader<CoreEvent>, max-of stacking — concurrent triggers never sum) →freeze_gate→juice_painterchained inUpdate, so timers tick real render time unaffected bySimPaused. Channels decay quadratically to exactly 0 (slots self-reset; overlay alpha and camera return to rest, no drift; camera shaken via delta application so external transform writes compose). Overlay = always-spawned full-screen whiteSprite(JuiceOverlay, z=10 above T11's board/ghost/active layers). Freeze semantics choice: PRD/plan call for a sim freeze (not visual-only) —JuiceFreezeguard togglesSimPausedfor exactly N owned frames (N ≤ 4 = ≤80 ms @ 60 Hz cap asserted in test); it only takes the flag when free (T17 pause wins, ticks defer), burns ticks only while owning, and writesfalseexactly once at release even if external code toggled the flag meanwhile (T17 re-asserts its own pause after release). Quality gating (peak amp / dur; freeze = frames): LineCleared n — Low flash (0.20+0.06n)/(.08+.02s), no shake/freeze; Med flash (0.30+0.10n), shake (1.5n px)/(.12+.03s), freeze min(n,3); High flash (0.35+0.13n), shake (3n px)/(.20+.05s), freeze min(n+1,4). TSpin — Low flash .30/.12; Med .50/.18 + shake 5/.20 + freeze 2; High .65/.22 + 8/.30 + 3. PerfectClear — Low .50/.20; Med .80/.35 + 8/.35 + 4; High 1.0/.45 + 12/.50 + 4. LevelUp — Low .20/.15; Med .40/.20 + 2/.15; High .50/.25 + 3/.20. GameOver — Low .40/.40; Med .60/.50 + 4/.40; High .75/.60 + 6/.60. PieceLocked lock-flash — Low none; Med .12/.05; High .16/.06 + micro-shake 1/.06. Tests: +9 (pure scheduling/quality/decay/max-of/freeze-guard incl. external-toggle + external-pause-defer; headless integration: LineCleared{4}+PerfectClear → shake>0, ≤4 paused frames, overlay alpha + camera return to rest; Low → zero camera motion, no freeze). Workspace 179 passed, 0 failed; fmt/fmt-check/clippy (-D warnings) green. Deferrals: dedicated hard-drop shake has no core event (approximated by lock-flash; add a row ineffect_planif the core grows the event); move-action coalescing on freeze exit is input-layer (T12) — bridge drains per step and the backlog through a ≤4-frame freeze is bounded by real input events; 60 FPS sustained deferred to the T20/hardware pass. - files edited/created:
crates/tetris-app/src/juice.rs(full implementation; main.rs registration already in place from scaffold)
- depends_on: [T17, T19]
- location:
.github/workflows/,docs/ - description: GitHub Actions (deferred per author until M5): fmt + clippy
-D warnings+cargo testmatrix (Linux; Windows/macOS release jobs), tagged releases attach 3-OS release binaries. Nightly job:cargo test --release -p tetris-core -- --ignored(T9's wall-clock soak) with failure notifications. - validation: CI green on tag push; artifacts downloadable for ubuntu/windows/macos.
- status: Completed (2026-09-27)
- log: Created
.github/workflows/ci.yml:linuxjob on push/PR (ubuntu-latest;- Post-review (2026-09-27): first CI run failed on Linux (missing
libwayland-dev/libxkbcommon-dev-> fixed); Windows release job failed (stalewindows0.54 in lock vs windows-core 0.62 wgpu-hal bounds -> lock updatedbf1b8b6); added Windows compile-check job to ci.yml. Nightly 1 h core soak: GREEN in CI (workflow_dispatch run 36324710184, 14:06->15:06Z, PRD §12 wall-clock criterion met). aptlibasound2-dev libudev-devfor headless Bevy 0.19 compile;cargo fmt --all --check,cargo clippy --all-targets -- -D warnings,cargo test --workspace) andnightly-soakjob (cron nightly + workflow_dispatch,timeout-minutes: 30,cargo test --release -p tetris-core -- --ignored) gated with an event-nameifso it never fires on push/PR. Both use dtolnay/rust-toolchain@stable (fmt/clippy components)
- Swatinem/rust-cache@v2. Created
.github/workflows/release.yml: onv*tag push, matrix ubuntu/windows/macos, same Linux apt deps,cargo build --release --workspace, per-OS packaging with standard target triples (Linuxx86_64-unknown-linux-gnu, Windowsx86_64-pc-windows-msvc; macOS viauname -mmapping toaarch64-apple-darwin/x86_64-apple-darwin) intoblockfall-<tag>-<triple>.tar.gz/.zip, attached via softprops/action-gh-release@v2 glob patterns. Archives are binary-only — assets load relative to CWD; README gained a "CI & releases" note documenting this andcargo bundleas future work (no bundler added). Local gate:cargo fmt --all --check+cargo clippy --all-targets -- -D warnings+cargo test --workspace→ 223 passed, 0 failed. YAML validated withpython3 -c "import yaml,glob;..."→ OK.
- Post-review (2026-09-27): first CI run failed on Linux (missing
- files edited/created:
.github/workflows/ci.yml,.github/workflows/release.yml,README.md(CI & releases section),tetris-plan.md
- depends_on: [T14]
- location:
crates/tetris-app/assets/,README.md,assets/ - description: App icon, window icon; capture screenshots via
EasyScreenshotPlugin; README gameplay section + screenshots; final name decision executed (rename binary/crate display name; PRD §14 #1 closes). - validation:
cargo runshows icon; README renders screenshots; name grep consistent. - status: Completed (2026-09-27)
- log: Name decision (PRD §14 #1): user-visible name Blockfall — genre-generic, no
Tetris trademark exposure; flat-color art kept (already the implemented style). Executed as
[[bin]] name = "blockfall"incrates/tetris-app/Cargo.toml(package id deliberately staystetris-appso all-p tetris-appflags keep working); window title "Blockfall" applied at runtime byapply_window_identityinjuice.rsbecausemain.rsis frozen.cargo runverified from repo root andcrates/tetris-app. Icon:crates/tetris-app/assets/icon.png(1024×1024, deterministic PIL generatorgenerate_icon.py, palette =render.rs::PIECE_RGB). Bevy 0.19 removed the runtime window-icon API (Window::icon/WindowIconabsent inbevy_window0.19.1;bevy_winitonly references winit'sset_window_iconin a comment) — icon therefore ships as the packaging/desktop asset (GNOME matches app-id →.desktopIcon=; documented in README for T20). No runtime set is possible without touching winit internals. Screenshots: noEasyScreenshotPluginexists for Bevy 0.19 and no CLI captor on this Wayland session (no grim/spectacle; GNOME Shell D-Bus screenshot API returnsAccessDenied). Added an env-gated hookTETRIS_SHOT=/abs/a.png@90,/abs/b.png@1200inJuicePluginusing Bevy 0.19's built-inScreenshotcomponent +save_to_diskobserver (zero cost when unset). Captured real frames fromTETRIS_BOT=1 TETRIS_SEED=7runs:assets/screenshots/gameplay-combo.png(level 4, COMBO ×2),gameplay-mid.png(level 7, 63 lines),gameplay-deep.png(level 11, 103 lines). README: features, controls table (PRD §9), build/run incl. audio-asset CWD note, dev env-var table, icon note, project layout, MSRV (1.95 pinned toolchain), dev commands. Capture caveat for author (this machine: NVIDIA RTX PRO 6000, driver 610.57, Vulkan): while the T17MenuScreensPlugin/SettingsScreenPluginUI roots exist, window captures come back filled with the hidden panel background (~75% of frames) — the screenshot pass appears to race those roots on this driver. Final shots were taken from a detached worktree at 20289ff with the reduced plugin set (CoreBridge/Render/Juice/Input/Hud/Audio; title system disabled). Worth a visual check of the hidden roots at the M3 gate; re-capturing on main is oneTETRIS_SHOTrun away. Gates:cargo build --releaseok;cargo test --workspacegreen (218 tests);cargo fmt --all --check+cargo clippy --workspace --all-targets -- -D warningsclean. - files edited/created:
README.md(rewrite),crates/tetris-app/Cargo.toml([[bin]] blockfall),crates/tetris-app/src/juice.rs(title +TETRIS_SHOThook),crates/tetris-app/assets/icon.png,crates/tetris-app/assets/generate_icon.py,assets/screenshots/gameplay-{combo,mid,deep}.png.
- depends_on: [T20, T21]
- location: repo root (tag),
PRD.md - description: Version bump, changelog, PRD status → Released v1, tag
v0.1.0, release notes; verify success criteria PRD §12 (playtest cadence, green nightly wall-clock soak run from T20's schedule, clippy, fresh-machinecargo run). - validation: Tag triggers T20 pipeline; fresh clone +
cargo runworks on clean machine. - status: In Progress — v0.1.0 tag + docs done (
998f23b); soak GREEN (bf1b8b6era); GitHub artifacts incomplete (Windows job failed pre-lockfix, archives predate L-shape fix028c17a) -> supersede with v0.1.1 after author retest - log:
- files edited/created:
- depends_on: [T22]
- location:
Cargo.toml(feature),index.html,.cargo/config.toml,crates/tetris-app/src/settings_persist.rs, docs - description:
trunk-driven WASM build: Bevy wasm-safe feature subset,getrandom/js(manifest-metadata exception per T1 rule), asset compression, input focus quirks pass.settings_persist.rs:#[cfg(target_arch = "wasm32")]backend on web storage (or an explicit, logged no-op) sincedirs/fs fail on wasm; wasm audio spike sub-step mirroring T18's compat check. Only if M1–M5 accepted. - validation: Playable in Firefox + Chromium at ≥30 FPS; desktop build unaffected
(
cargo teststill green). - status: Not Completed
- log:
- files edited/created:
- depends_on: [T8]
- location:
crates/tetris-core/src/versus.rs(new),crates/tetris-core/src/lib.rs, contract exception incrates/tetris-core/src/game.rs - description:
Matchover two independentGames:AttackRule::Garbage(attack table N lines → N queued, consecutive-chain clears add +1; batch pushes in under the receiver's stack on its next lock, one hole column per batch) andAttackRule::Race { target_lines }(default 40, no garbage).MatchEvent,MatchSnapshot,Side,pending_attack. Top-out (normal or garbage overflow) crowns the opponent; post-winnerapplyis a no-op. All deterministic from the match seed (per-side game seeds + hole draws from one splitmix64 stream). Contract exception: additive crate-internalGame::install_boardhook (garbage push-up);Game's public API andboard.rsuntouched. - validation:
cargo test --workspacegreen incl. 15 newversus::tests;cargo fmt --check,cargo clippy --all-targets -- -D warnings. - status: Completed
- log: 2026-09-27 — versus.rs + 15 tests; gates green (240 total tests, 225 pre-existing untouched).
- files edited/created:
crates/tetris-core/src/versus.rs(new),crates/tetris-core/src/lib.rs,crates/tetris-core/src/game.rs(contract exception)
- depends_on: [T24, T10, T12, T14]
- location:
crates/tetris-app/src/core_bridge/(core_bridge.rs→core_bridge/mod.rsviagit mv, new submoduleversus.rs),crates/tetris-app/src/input.rs(additive), contract extension incrates/tetris-core/src/versus.rs - description: Local 1v1 running in the app.
VersusMatch(NonSend — owns twoGames) plusVersusWinner/VersusEvent(Messages)/VersusHarnessresources andVersusBridgePlugin(mounted fromCoreBridgePlugin::build();main.rsfrozen). Two cores step at 60 Hz inFixedUpdatewhile active: per-sideVersusActionsqueues drained +Match::tickper side; the solo step/bot/input systems gate onVersusMatch.active(noCoreEventfires during versus, solo core frozen). Fixed P1/P2 keyboard presets ininput.rs(VersusBindings: P1 A/D, W CW, E CCW, S+Shift soft, Space hard, Q hold; P2 arrows, Period CCW, Slash+Numpad0 hard, Comma hold), DAS/ARR via reusedShiftRepeat/RepeatTimer; no versus rebinding UI (settings edits solo only). Bot sides reuse the T14 greedy solver through a factoredbot_side_drive/push-closure executor. Shared pause (SimPaused/AppState::Paused) freezes both cores and holds the queues; R restarts;start_versus/end_versusfree functions (restart_run pattern);Match::tick(side)core addition routes lock-delay locks/top-outs through the same versus rules. Headless harnessTETRIS_1V1=garbage|race: bot-vs-bot at startup, logsVERSUS match_done .../VERSUS winner=... fps_avg=..., exits after 2 matches. - validation:
cargo test --workspacegreen (254 tests: 240 pre-existing untouched + 12 app versus tests + 2 core tick tests);cargo fmt --check,cargo clippy --all-targets -- -D warningsclean; live runTETRIS_1V1=garbagecrowned two bot matches at fps_avg≈59.9 and exited 0. - status: Completed
- log: 2026-09-27 — module split via
git mv(history preserved); versus submodule + additive input half + coreMatch::tick/settlerefactor; gates green (254 tests, 1 soak ignored as before); live env-harness run verified (2 matches, winner=Right both, fps_avg 59.8/59.9). Nothing committed (per task instruction). - files edited/created:
crates/tetris-app/src/core_bridge.rs→crates/tetris-app/src/core_bridge/mod.rs(moved; versus guards + bot refactor + plugin hookup),crates/tetris-app/src/core_bridge/versus.rs(new),crates/tetris-app/src/input.rs(additive versus input),crates/tetris-core/src/versus.rs(additiveMatch::tick+ sharedsettle+ 2 tests)
- depends_on: [T25, T11, T16]
- location:
crates/tetris-app/src/render.rs,crates/tetris-app/src/hud.rs,crates/tetris-app/src/screens_menu.rs - description: Renders both versus boards side by side and exposes the full menu flow.
render.rsfactors the playfield transform intoFieldLayout(fit= the exact solo letterbox math,cell_centersampling) withversus_layouts(w, h)returning two layouts at centers(∓w/4, 0)sharing one half-width-fitted cell size; whileVersusMatch.activerender_playfielddraws both boards into aVersusCellPoolspool (cells taggedVersusCellSide(Side)), despawning/keeping the soloCellPoolclean and vice versa so exit→solo renders like a fresh app.hud.rsadds a static versus HUD spawned at Startup (per side: Score/Lines/Level + pending-garbage "+N", 2 mini next previews, small hold box) anchored throughversus_panel_anchorsoff the shared layout;sync_versus_hudrefills it from the liveMatchSnapshot(orVersusHudFixture), andsync_solo_hud_visibilityhides/restores the solo panels (roots getVisibilityinserted on demand — preview roots carry no render component).screens_menu.rsadds the Title → "1 v 1" → rules (Garbage/Race) → opponent (Human/Bot) flow on aVersusFlowstage machine (Esc and Back walk it; P1 is always the local human), a winner overlay ("PLAYER 1/2 WINS"/"BOT WINS", Rematch viastart_versuswith the same rule/controllers, Menu viaend_versusto Title), and the pause chord is blocked once a match is decided so the overlay is never covered. Versus root visibility is owned bysync_root_visibility/sync_versus_menu_visibility; solo pixels and solo menu flow are untouched (FieldLayout::fitequality pinned by test). - validation:
cargo test --workspacegreen (269 tests: 254 pre-existing untouched + 15 new: 4 render layout/lifecycle, 5 versus HUD, 6 menu flow/winner overlay);cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warningsclean. - status: Completed
- log: 2026-09-27 — entry written retroactively (absent at task start; PRD §16 does not exist, versus spec is §15). Dual-viewport pools, versus HUD with pending "+N", menu flow, winner overlay, pause guard; one-behavior note: Back keeps the chosen rule; pause is blocked only when entering a fresh pause after a winner. Nothing committed (per task instruction).
- files edited/created:
crates/tetris-app/src/render.rs,crates/tetris-app/src/hud.rs,crates/tetris-app/src/screens_menu.rs
| Wave (earliest) | Tasks | Can Start When |
|---|---|---|
| 1 | T1 | Immediately |
| 2 | T2, T3, T5 | T1 |
| 3 | T4 | T2 |
| 4 | T6, T7 | T4 (T6 also T3) |
| 5 | T8 | T4–T7 |
| 6 | T9, T10 | T8 (T9), T1+T8 (T10) |
| 7 | T11, T12, T15, T18 | T10 |
| 8 | T13, T16, T19 | T11 / T12+T15 / T11+T15+T18 |
| 9 | T14 (M2 gate) | T10–T13 |
| 10 | T17 (M3 gate), T21 | T14, T15, T16 |
| 11 | T20 | T17, T19 |
| 12 | T22 | T20, T21 |
| 13 | T23 (stretch) | T22 |
- Core (every merge):
cargo test -p tetris-core— table-driven unit tests (SRS kicks, scoring rows, gravity boundaries) + integration replay tests (T8) + proptest invariants and 1M-tick soak (T9). - App (every merge):
cargo clippy --all-targets -D warnings,cargo fmt --check, compile-fresh; logic stays in core so app tests stay thin. - Manual gates: M2 checklist in T14, M4 "feels good" pass over T18/T19, M5 fresh-machine run in T22. Human gates require author sign-off: the agent runs the checklist, posts results, and stops — it must not self-certify a gate.
- Regression discipline: any rule bug found in playtesting first reproduced as a
tetris-coreunit test, then fixed.
- bevy_kira_audio 0.26 ↔ Bevy 0.19 compat → resolved: kira 0.26.0 requires
bevy ^0.19.0. T18 keeps a short compile spike; fallback to built-in audio already enabled by the2dfeature. Zero schedule impact. - Parallel file contention (agents editing same files) → T1 pre-creates module stubs and
ALL dependencies; plan assigns exclusive file ownership per task; no shared
Cargo.tomledits after T1. - Bevy 0.19 API churn at upgrade time → pinned minor; upgrades only between milestones, core crate keeps it cheap (PRD §13).
- Freeze-frame vs fixed-step bridge can eat inputs or double-lock pieces → T19 freezes simulation stepping only and buffers actions; covered by its validation clause.
- SRS 180° behavior is not in original SRS spec → kick policy explicitly chosen in T4, documented there, pinned by table tests.
- Core→app contract drift — three app tasks code against T8's events/snapshot → contract is declared frozen in Prerequisites and pinned by a T8 snapshot-completeness test; app tasks needing contract changes must serialize through a plan amendment, not local edits.
- Trademark ("Tetris") → rename executed in T21 before any public release (PRD §14).