Generated: 2026-10-01
Add nine game modes, two versus rules and mutators to Blockfall over three
gated releases, per game-modes-PRD.md. The keystone is R1 (mode config +
tick clock + per-mode records), which every later mode reuses as data. The
core stays deterministic; times are ticks; the frozen
Game/GameEvent/Action contract reopens exactly once (T2) and
GameSnapshot/MatchSnapshot wire shapes stay byte-stable until the R3
protocol bump.
Owner decisions locked in (2026-10-01):
- Plan covers all three releases, with hard release gates between them.
- Sprint and Dig gravity: fixed at level 1 (
levels_advance = false). - Mutator runs: no record (mode play counters still increment).
- Switch: queued garbage follows the board (swap exchanges board, hold, next queue and pending garbage).
- Race rule: unchanged (no same-pieces retrofit).
Assumptions for the remaining PRD open questions (flip before their task runs if the owner decides otherwise):
- Daily Challenge: first completed run of the day is recorded; share line is display-only text on the result screen (Bevy 0.19 has no clipboard).
- Zen top-out: wipes the whole stack.
game-modes-PRD.mdcheck-in besidePRD.md(+ §14 item 3 close + CHANGELOG): done as the final docs task (T25), pending the owner checkbox.
- Rust toolchain per
rust-toolchain.toml(clippy + rustfmt included). - Validation loop:
cargo test --workspace,cargo clippy --workspace -- -D warnings,cargo fmt --all --check. - Headless drivers already present:
TETRIS_BOT=1,TETRIS_1V1=garbage|race,TETRIS_NET=host:<port>|join:<addr>,TETRIS_SEED,TETRIS_CONFIG_DIR, nightly#[ignore]d soaks (cargo test --workspace -- --ignored). - No new dependencies. No accounts, servers, or >2-player matches (PRD non-goals).
- Wire stability in R1/R2: do NOT add/reorder fields in
GameSnapshot,MatchSnapshot, orAttackRule(all bincode-encoded forsnapshot_hash/MatchStart). NewGameEventvariants are safe (events never cross the wire) but must be appended at the end of the enum. The R6 protocol bump happens only in T19. Game::new(seed)keeps its exact current behavior; mode behavior arrives viaGame::with_config(seed, &ModeConfig)whose default config reproduces Marathon bit-for-bit (gated by the golden test T1).- The core never reads a clock or date; the Daily seed is derived in the app.
- Countdowns (Sprint/Dig 3 s, Ultra warning, Switch swap warning) are tick budgets, not wall time. Solo start countdowns gate stepping in the app bridge so core tick 0 == first playable frame.
state.rsT1 contract: only additive changes (newAppStatevariant); fix non-exhaustive matches it creates.- Every task ends with the repo green: tests + clippy
-D warnings+ fmt.
Release 1 Release 2 Release 3
T1 ──► T2 ──┬──────────────────────────────────────┐
├── T3 ── T4 ──┐ │
│ ├── T10 ─┐ │
└── T5 ─┬── T8 ─┼───────┼─┐ │
T6 ───────────────┼───────┼─── T7 ─┼─ T9 ─ T11 ═ GATE ═╗
│ │ │ ║
╚═══════╧════════╧══╗ ║
║ ║
T12 ── T13║ ║
T14 ══════╣ ║
T15 ── T16║ ║
T17 ══════╩═══ T18 ═ GATE ═╗
║
T19 ── T20 ── T21 ─┐ ║
T23 ── T24 ├─ T22
│ ║
T25 ═ FINAL GATE ◄═╧═════╝
- depends_on: []
- location:
crates/tetris-core/tests/marathon_regression.rs(new) - description: Pin today's behavior before any core change: for 3 fixed
seeds, run scripted action logs (moves, rotations, drops, holds, ticks to
top-out), assert exact final
GameSnapshotfield values AND the exact bincode byte length + hash of the snapshot (wire-shape canary). This test is the R1 release gate's "Marathon unchanged" criterion. - validation:
cargo test -p tetris-core marathonpasses on current code; stays green through T2–T11. - status: Completed
- log: 2026-10-01 — 3 seeds (31337, 20261001, 42) driven to top-out by a
deterministic scripted driver using only the public
GameAPI: per-piece cycle = optional Hold (every 4th) + rotation + column moves + optional SoftDrop (every 3rd) + HardDrop + 2 gravity ticks; placements chosen by a greedy solver mirroringcore_bridge's bot weights, so all 8Actionvariants are exercised per seed and each log is reproducible from the seed alone. Pins per seed: scalar snapshot fields (score/level/lines/combo/b2b/ game_over/hold/hold_used/next), FNV-1a-64 digest over the 10×22 board, exact bincode byte length (fixint + reject-trailing codec, mirrored fromprotocol.rs) and FNV-1a-64 over those bytes (same loop assnapshot_hash). Rich goldens: seeds clear 49/164/485 lines, reach levels 5/17/49, seed 42 ends b2b-armed.bincodeadded as tetris-core dev-dep (workspace-inherited); no production code touched. Green: workspace tests, clippy-D warnings, fmt. Canary proven: flipping seed 42's fnv1a64 by 1 fails with the Golden diff, restored value passes. Gotchas: (a) a purely blind scripted rush tops out but never completes a row (~0/100k seeds clear a line) — the greedy placement phase is what makes line clears/combo/level/b2b values non-trivial, keep it if regenerating; (b) goldens assume the driver's deterministic phase cadence (cycles % 4hold,% 3soft-drop,% 5rotation round trip) and the solver's tie-break — regenerate all three seeds together if the driver ever changes; (c) T2'sGame::newdelegation must reproduce these exact values — that diff is the gate signal. - files edited/created:
crates/tetris-core/tests/marathon_regression.rs(new),crates/tetris-core/Cargo.toml(+bincodedev-dep),Cargo.lock
- depends_on: [T1]
- location:
crates/tetris-core/src/mode.rs(new),crates/tetris-core/src/game.rs,crates/tetris-core/src/event.rs,crates/tetris-core/src/lib.rs - description: The one deliberate reopening of the frozen contract.
mode.rs:ModeConfig { start_level: u32, levels_advance: bool, goal: Option<Goal>, clock_ticks: Option<u64>, start_board: Option<StartBoard>, on_block_out: BlockOutBehavior }withDefault== today's Marathon (level 1, advancing, no goal/clock, empty board, end on block-out);Goal::Lines(u32) | Goal::GarbageCleared;StartBoard::BuriedGarbage { rows: usize }(one hole per row, no two adjacent rows share a hole column, holes drawn from a splitmix64 stream derived from the seed);BlockOutBehavior::End | WipeAndContinue(behavior wired in T14, plumbed here).Game::with_config(seed, &ModeConfig);Game::newdelegates with the default config. Game internals:ticks: u64incremented intick(), publictick_count();levels_advance=falsepins gravity tointerval_for(start_level)and suppressesLevelUp; fixedstart_levelseedsgravity::interval_forwithout touchinggravity.rssemantics. Terminal handling: reaching goal or clock expiry freezes the game (all furthertick/applyare no-ops, like game-over) and emitsGameEvent:: GoalReached { tick }/GameEvent::TimeUp { tick }— new variants appended to the enum (T1 wire-canary must stay green:GameSnapshotgets NO fields; terminal state is exposed via aGame::finished_reason() -> Option<FinishReason>getter, not the snapshot).garbage_rows_left()getter counts rows containing ≥1Piece::Garbagecell. - validation: Unit tests in
mode.rs/game.rs: default config ==Game::new(snapshot equality over scripted play); fixed-level config never emitsLevelUp;Goal::Lines(40)emitsGoalReachedexactly once and freezes;clock_ticks: 100emitsTimeUpat tick 100 even with no pieces placed; buried-garbage start board satisfies the no-adjacent-hole invariant over 100 seeds. T1 golden test still passes. - status: Completed
- log: 2026-10-01 — commit
0e5e63b. Newmode.rs:ModeConfig { start_level: u32, levels_advance: bool, goal: Option<Goal>, clock_ticks: Option<u64>, start_board: Option<StartBoard>, on_block_out: BlockOutBehavior }(all serde;Default== Marathon),Goal::Lines(u32) | GarbageCleared,StartBoard::BuriedGarbage { rows }+StartBoard::build(seed)(holes from splitmix64seed ^ 0xFF51AFD2AFEDDAB9, redraw-if-equal-previous ⇒ no adjacent-row shared hole;rowsclamped to 20 so hidden spawn rows stay empty — first-piece spawn verified live for 100 seeds; also usable directly by T20 for identical duel boards),BlockOutBehavior::End | WipeAndContinue(plumbed only — until T14 wires the wipe it acts like End),FinishReason { TopOut, GoalReached, TimeUp }.game.rs:Game::with_config(seed, &ModeConfig)(newdelegates with the default);ticks: u64incremented once per livetick()(stops when frozen),tick_count();finished: Option<FinishReason>is the internal terminal flag —tick/applygate onfinished.is_some(); block-out setsgame_over+finished = TopOuttogether (snapshot unchanged), goal/clock setfinishedwithgame_over == false. Goal evaluated post-line-clear inlock_and_spawn(bothLinesandGarbageClearedfire — T3 risk reduced), skipping the next spawn (active staysNone); clock evaluated end-of-tick()(ticks >= n), top-out/goal win when concurrent;levels_advance=falsesuppresses level changes +LevelUp(level pinned ⇒ gravity naturallyinterval_for(start_level)from tick 0 — verified descent at exactly 12 ticks for level 5).event.rs:GameEvent::GoalReached { tick }/TimeUp { tick }appended (indices 10/11; bincode canary green). New getters:finished_reason(),garbage_rows_left(),tick_count(). 16 new tests (bit-identical default==new incl. bincode bytes, fixed-level, both goals, clock at tick 100 with zero pieces, 100-seed buried-board invariants, terminal-reason exposure);cargo test -p tetris-core162 passed (marathon golden included); RED first captured (E0433/E0599 on unresolved T2 API), then GREEN. Gotchas: (a)tetris-app/src/audio.rs::sfx_for_eventis an exhaustiveGameEventmatch — appending the T2 variants broke app compilation; a minimal 2-arm silent fix (GoalReached | TimeUp => return None) is in the working tree but deliberately unstaged per the T2 staging rule — whoever lands the next app-crate commit must include it (or T5/T8 re-derive it); proper cue mapping is T8's. (b)TimeUpfreeze keeps the active piece in the snapshot (TimeUp≠ top-out visually); goal/top-out freeze withactive == None. (c)Goal::GarbageClearedwith no garbage at start fires on the first lock — configs pairing it must set a buriedStartBoard(documented on the variant). (d)ticksdoes not advance once frozen (a clock-frozen game'stick_count()stays== clock_ticks). - files edited/created:
crates/tetris-core/src/mode.rs(new),crates/tetris-core/src/game.rs,crates/tetris-core/src/event.rs,crates/tetris-core/src/lib.rs(+ unstaged: minimalcrates/tetris-app/src/audio.rscompile arm, see log)
- depends_on: [T2]
- location:
crates/tetris-core/src/mode.rs,crates/tetris-core/src/game.rs(+ tests) - description: Make
Goal::GarbageClearedfire whengarbage_rows_left()hits 0 (evaluate afterlock_and_spawn's line clear). Note garbage rows shift down with the stack; the metric is rows containingPiece::Garbage, so clearing any garbage cell in a row retires that row. Sprint config isGoal::Lines(40); confirm Sprint needs no further core work (config-only, exercised in T4). - validation: Core unit test: seed a buried board, script clears to zero
garbage ⇒
GoalReached { tick }; top-out before that ⇒GameOveronly, no goal. Same-seed replay equality test for a full Dig run. - status: Completed
- log: 2026-10-01 — commit
98b5099. T2 fully covered goal firing:lock_and_spawnalready evaluatesGoal::GarbageClearedafterclear_full_rows(), freezes and emitsGoalReached— no production change needed (diff is test-only + doc). 6 new tests ingame.rs: scripted Dig clear (full Dig config + hand-made paired well board, public-API driver drops Os into the cols 4-5 well / balanced-dumps everything else: 87 steps, garbage 10→0, exactly oneGoalReached { tick }at tick 56, frozen after,finished_reason() == GoalReached, no GameOver/LevelUp), top-out under the Dig config w/ realBuriedGarbageboard (Z stack + O lock ⇒GameOver,TopOut, garbage intact, no goal ever), same-seed replay equality (events- snapshots + final bincode bytes; seeds 1 vs 2 diverge),
LineClearedstrictly-before-GoalReachedin the final lock's batch (and GoalReached ends the batch), retire/shift semantics (lone garbage cell counts; row retires via line clear; survivors shift down counted — documents AC5), Sprint configLines(40)fires exactly once at 40 lines, level pinned 1, neverLevelUp, freezes ⇒ Sprint needs no further core work. RED-equivalent (T2 already correct ⇒ behavior-red impossible): scratch-disabledGarbageCleared => false⇒ scripted/replay/batch-ordering tests all fail (goal never fires ⇒ driver runs past budget), restored ⇒ green; scratch run with disabled firing captured.mode.rs:Goal::GarbageCleareddoc now states the retire-a-row-on-any-garbage-cell-cleared semantics. GREEN:cargo test -p tetris-core159 lib + 6 invariant + 3 marathon golden pass (canary untouched); clippy--all-targets -D warningsclean;cargo fmt --all --checkclean. GOTCHA (matters for T4/T10/T20): aBuriedGarbage { rows: 10 }board is NOT clearable by any simple scripted or greedy solver. Each row's hole is only reachable while that row is the band top (band below is solid garbage; only a vertical-I-in-the-hole-column drop completes a 1-hole row), and every dig leaves its I's debris directly above the new band top, permanently blocking that column for all deeper digs. Clearing all 10 rows therefore needs hole columns never reused (~10!/10^10 of seeds) and a legal place for the ~25+ non-I pieces dealt meanwhile — every column is a future descent column, so naive stacks always die (empirical: 0/200k seeds win with a top-down vertical-I + hold-fishing driver; balanced dump-zone drivers win only against hand-made paired-well boards). Win requires the deep line-clearing dig heuristic T10 is charged with building; T4's "Dig config completes headlessly" criterion and T20's duel win-path depend on it.GarbageClearedfiring itself is proven correct regardless of the start board (hand-made garbage via the test-friendlierg.board =path used in the AC tests).
- snapshots + final bincode bytes; seeds 1 vs 2 diverge),
- files edited/created:
crates/tetris-core/src/game.rs(tests only),crates/tetris-core/src/mode.rs(doc only)
- depends_on: [T2, T3]
- location:
crates/tetris-core/tests/modes_headless.rs(new) - description: Pure-core proof (no Bevy) using the greedy solver pattern
from
core_bridgereimplemented againstGamedirectly, or a scripted hard-drop driver: (a) Sprint config (40 lines, fixed level 1) completes withGoalReached; (b) Dig config (10 buried rows, fixed level 1) completes withGoalReached; (c) Ultra config (clock 7 200 ticks, marathon progression) emitsTimeUpat exactly tick 7 200 with a non-zero score and score stands (no top-out needed); top-out before the clock also ends it with score kept. These are the PRD's "Solo modes complete headlessly" CI criterion; re-run in CI on every push (not#[ignore]d). - validation:
cargo test -p tetris-core modes_headlessgreen in CI. - status: Completed
- log: 2026-10-01 — commit
9c6a96c. New integration test file, test-only (no production change). 5 tests, ~0.9 s debug runtime, NOT#[ignore]d: (a) Sprint seed 31 337, greedy hard-drop driver (marathon-solver weights) clears 40 lines ⇒GoalReachedexactly once at the goal tick,finished_reason() == GoalReached, level pinned 1, noLevelUp/GameOver, hard freeze, bounded ≤ 6000 steps. (b) Dig — REALBuriedGarbage { rows: 10 }boards complete; the hand-made fallback was NOT needed. The nub-down heuristic (fill the CURRENT top buried row's hole with a nub-down piece — T nub-down, J/L at 180°, or a vertical-I foot — clearing exactly that row; greedy dumps; hold- fishing when an upcoming/held piece can dig) won 6 of 40 probe seeds (9, 28, 30, 31, 35, 36 — all six pinned in the test, ~65–441 steps); most losses die at 9/10 rows. This SUPERSEDES the T3 GOTCHA ("0/200k seeds win"): that was driver-specific — T3's vertical-I digs bury their own debris column, the nub-down top-down line does not (documented in the test underDIG-SOLVABILITY). T10: reuse this heuristic; win rate ~15% ⇒ seed-fish. Per seed: exactly oneGoalReached,garbage_rows_left() == 0, noGameOver/LevelUp, frozen. (c) Ultra seed 42, greedy one-piece-per-30-ticks for marathon progression ⇒TimeUp { tick: 7200 }exactly (once),tick_count() == 7200, score > 0,game_over == false, 7201st tick + applies empty, tick clock frozen. Companion: blind hard-drop pile-up ⇒TopOutwell before tick 7200,GameOver(noTimeUp), score retained > 0, frozen. (d) Replay: same-seed Sprint × 2 — events, per-cycle snapshots and final bincode bytes identical; seed 42 run differs (non-vacuity). RED verified: pinning the Ultra tick to 7 199 and seed 23 (a losing dig seed) each fail the respective test, restored ⇒ green. GREEN:cargo test -p tetris-core159 lib + 6 invariant + 3 marathon golden + 5 headless pass;cargo clippy -p tetris-core --all-targets -- -D warningsclean;cargo fmt -p tetris-core --checkclean (workspacefmt --allstill shows OTHER agents' in-flight tetris-app WIP files — untouched by this task). Gotchas: (a)Game::board/install_boardare private — an integration test CANNOT install a hand-made board, so the documented fallback path was infeasible fromtests/anyway (T3's paired-well fixture only works in the crate's own unit tests); the nub-down win makes it moot; (b) dig/dump scoring must match the probe exactly to keep the pinned seeds winning; (c) greedy survives the 7 200-tick Ultra clock ONLY at the one-piece-per-30-ticks cadence — a per-tick-pace driver places ~2× more pieces and can top out before the clock. - files edited/created:
crates/tetris-core/tests/modes_headless.rs(new)
- depends_on: [T2]
- location:
crates/tetris-app/src/core_bridge/mod.rs,crates/tetris-app/src/modes.rs(new) - description:
modes.rs:ModeIdenum (Marathon, Sprint, Ultra, Dignow; later modes add their variant in their own task) with one cataloguefn mode_config(id) -> ModeConfig+ display name + one-line description, so menus/records/catalogue share a single source.GameCoregainsstart_mode(seed, ModeConfig)(keepingrestart_withfor Marathon and honoringTETRIS_SEEDlikerestart_run);restart_on_r_systemretries the current mode, not raw Marathon. ACountdownresource gates stepping for modes with a pre-roll (Sprint/Dig: 180 ticks, HUD shows 3-2-1 viaSimPaused-style hold that still drains events); core tick 0 therefore equals the first playable frame. OnGoalReached/TimeUpevents the bridge flipsAppState::GameOver(terminal reason exposed for T9 viaGame::finished_reason()). Countdown vs pause: the pre-roll is its ownCountdown(u32)resource consumed inFixedUpdateonly whileAppState::PlayingandSimPaused(false); it does NOT reuseSimPaused(a pause+resume viaresume_gameinscreens_menu.rsmust never cancel the remaining 3-2-1). Actions arriving during the countdown keep being held inPendingActionsexactly like a freeze frame. Also pre-declare the fullModeIdcatalogue now (all ten modes with configs, names, descriptions) behind anis_shipped(id) -> boollist, so later mode tasks add no catalogue rows — they only flipis_shipped— which keepsmodes.rsfree of same-wave edit conflicts. - validation: Headless bridge tests (MinimalPlugins pattern already in the
file): starting Sprint runs the countdown before the first core tick; Ultra
TimeUpflipsAppState::GameOver; Sprint top-out yields no goal event. - status: Completed
- log: 2026-10-01 — commit
ce46eee.modes.rs: ALL TENModeIds pre-declared (+ModeId::ALL);mode_config(Marathon =default; Sprint =Lines(40)+ fixed level 1; Ultra = clock 7200 + marathon progression; Dig =GarbageCleared+BuriedGarbage{10}+ fixed level 1; Survival = placeholderdefaultuntil T12's feed field; Zen = fixed level 1 +WipeAndContinue(wired T14); BotLadder/Daily/DigDuel/Switch = placeholder configs — versus campaigns/rules start viastart_versus/AttackRule, and Daily resolves at runtime to Sprint/Ultra/Dig in T17);display_name/descriptionper mode;is_shipped= Marathon/Sprint/ Ultra/Dig only (later tasks flip only this);pre_roll_ticks= 180 for Sprint/Dig, 0 else;mode_keymaps all ten ontorecords::ModeKey;format_time_ticks=m:ss.hh, 60 Hz floor division, centiseconds truncated.core_bridge:ActiveMode { id, config }lives as aGameCorefield (the design-note's non-send option — one new resource entity only, no borrow conflicts;restart_withresets it to Marathon so legacyrestart_runstays consistent);GameCore::start_mode(seed, id)=Game::with_config+ steps/seed/events reset; free fnstart_mode_run(id, core, countdown, app_state, Option<&mut Records>) -> seedhonorsTETRIS_SEEDlikerestart_run, re-arms the mode's pre-roll andbump_playscentrally;Countdown(u32)resource +countdown_system(FixedUpdate, beforecore_bridge_system) decrements only whilePlaying && !SimPaused— pause freezes the remaining budget, never cancels;PendingActionsheld through the pre-roll exactly like freeze frames, core tick 0 = first playable frame;core_bridge_systemsteps only whileCountdown == 0and flipsAppState::GameOveronGoalReached/TimeUplikeGameOver(CoreEvent forwarding unchanged);restart_on_r_systemretriescore.active_mode.id;bot_drive_systemidles during a pre-roll. Pre-roll frame math for T7/T8: 180 ticks = 179 fully-frozen frames + the frame whose decrement exhausts the budget, which is core tick 0 (the first playable frame); HUD 3-2-1 =ceil(countdown.0 / 60); playable iffCountdown == 0; terminal reason viacore.game.finished_reason(). RED captured (E0583modes+ 7×E0425ActiveMode/Countdown/start_mode_run) → GREEN:cargo test -p tetris-app377 pass,cargo test --workspacegreen (T1 golden included), clippy-D warningsclean,fmt --checkclean. Gotchas: (a) the plan's example "10 235 ticks → 2:43.91" is off by 400 — at 60 Hz truncation 10 235 is 2:50.58, and 9 835 ticks is what yields 2:43.91 (both pinned informat_time_tickstests; T8 must not assert 2:43.91 against 10 235); (b) the two screens_menu submenu-click tests (one_v_one_submenu_clicks…,dummy_resources_cannot_reroute…) are entity-iteration-order sensitive —rect_of("settings")grabs the first match, which at HEAD is the pause panel button (y=494) that happens to be inert; measured sensitivity: +1 newinit_resourcegreen, +2 red, +3/+4 still red. T5 passes only because it kept new resource entities to exactly one (Countdown). T7/T8 add more resources and WILL break them — fix deterministically in T7's screens_menu pass (makebutton_rects/rect_ofsort by (label, y) and pick the intended root); do not work around by avoiding resources. (c)screens_menuStart/Retry still callrestart_run(Marathon) — switch them tostart_mode_runin T7/T9; until thenactive_modeself-heals (reset to Marathon byrestart_with). (d)start_mode_runreturns the resolved seed. (e) the Ultra headless test runs the full 7 200-step clock (~1 s). - files edited/created:
crates/tetris-app/src/modes.rs(new),crates/tetris-app/src/core_bridge/mod.rs,crates/tetris-app/src/lib.rs(mod modes;)
- depends_on: []
- location:
crates/tetris-app/src/records.rs(new),crates/tetris-app/src/settings_persist.rs,crates/tetris-app/src/screens_menu.rs(best label call sites) - description: Replace the single
best.jsonshape with a per-mode records file reusing this module's atomic-write/load discipline:Record { BestTime{ticks}, BestScore{score,level,lines}, LifetimeLines, HighestRung, Daily{date,result} }keyed byModeIdstring; plus per-mode play counters (PRD success metric). Migration: old{score,level,lines}shape loads into the MarathonBestScoreand is rewritten in the new shape on first save (serde-tagged enum or versioned wrapper; corrupt file still falls back to defaults like today).PersistedBestScoreresource stays (title screen reads it) but becomes a view over the Marathon entry. Single-writer fix (critical): todaysettings_persist::best_score_systemforce-records everyGameEvent::GameOverintoPersistedBestScore— with modes live that would record Sprint/Dig/Survival top-outs as Marathon bests (PRD: no result) while Ultra'sTimeUprecords nothing. This task retires that system as a writer:Recordsbecomes the sole recorder, the legacybest_score_systemis reduced to refreshing thePersistedBestScoreview from the Marathon entry (no disk writes), and the game-over screen (T9) is the only caller ofrecord_run, gated by terminal reason × mode (Sprint/ Dig top-out → nothing; Ultra either ending → score; Survival GameOver → time). API:Recordsresource,record_for(mode),record_run(mode, outcome)returningboolis_record,bump_plays(mode). - validation: Round-trip tests under
TETRIS_CONFIG_DIR(env-lock pattern already insettings_persist): legacy file migrates to Marathon; corrupt file ⇒ defaults; time/score/rung/daily record updates; play counters increment. Existingapp_boots_and_game_over_persists_best_scoreadapted. - status: Completed
- log: 2026-10-01 — new
records.rs:Recordsresource (per-modeRecord+ per-mode play counts,BTreeMapkeyed byModeKey = &'static strwith constantsMARATHON, SPRINT, ULTRA, DIG, SURVIVAL, ZEN, BOT_LADDER, DAILY, DIG_DUEL, SWITCH+ALL_MODE_KEYS), internally-tagged#[serde(tag = "kind", rename_all = "snake_case")]Record { BestTime{ticks}, BestScore{score,level,lines}, LifetimeLines{total}, HighestRung{rung}, Daily{date,result} }, APIrecord_for(key) -> Option<&Record>,record_run(key, record) -> bool(time: lower wins; score/lifetime/rung: higher wins; daily: latest differing content; cross-variant replaces),bump_plays(key) -> u64,plays(key) -> u64,add_lifetime_lines(n) -> u64(saturating, Zen).best.jsonwrapper{"version":1,"records":{...},"plays":{...}}; unknown record kinds/fields skipped per-entry (forward compat); load = raw-JSON dispatch: top-levelscore⇒ legacy migrate to MarathonBestScore, else versioned shape; corrupt ⇒ defaults +warn!, never panics. A pure load never writes (downgrade safety). Persistence reusessettings_persist::{write_atomic, SAVE_DEBOUNCE_SECS, CONFIG_DIR_ENV}+RecordsSaveQueue(debounce/force, force-flush on improved Marathon game-over, flush onAppExit);RecordsPlugin(init resources, Startup load, Update flush, Last exit flush) mounted fromSettingsPersistPlugin. Single-writer fix done:save_to/save_once/flush_system/exit_flush_systemwrite settings.json only (save_to'sbestparam kept, ignored);best_score_systemnow callsRecords::record_run(MARATHON, …)from theGameCoresnapshot onCoreEvent(GameOver)and forces a records flush only when improved; newbest_score_view_systemrefreshesPersistedBestScorefrom the Marathon entry (view only, assign-on-difference);screens_menu.rsuntouched (API compatible).records::save_tois the onlybest.jsonwriter. Tests: legacy-migration/never-rewritten-on-load, corrupt/missing ⇒ defaults, unknown-kind tolerance, per-variant improvement rules, saturating lifetime lines, play counters, read-only-boot writes nothing, exit flush, view tracks record and never regresses, settings exit flush never creates best.json. GREEN:cargo test -p tetris-app364 pass, workspace green, clippy-D warningsclean, fmt clean. Validation done in a cleangit worktreeat HEAD because another agent's WIP core edits (GoalReached/TimeUp) broke the shared tree'saudio.rsmid-task — verify against the merged tree too. Gotchas for T7/T9/T14/T16/T17: (a) T9 gatesrecord_runper mode × terminal reason and should alsobump_plays— T6 deliberately does NOT bump plays on game-over (would double-count); (b) only Marathon auto-records via the interimbest_score_system— remove it in T9 when the result screen callsrecord_rundirectly, and gate it so non-Marathon game-overs don't write the Marathon record; (c)add_lifetime_lineskeys offZEN; (d) env-mutating tests must holdsettings_persist::ENV_LOCK(historical flake); (e) legacy files are rewritten to the new shape only on first real save, and legacy{score,level,lines}is detected by the top-levelscorekey — the new wrapper must never putscoreat top level. - files edited/created:
crates/tetris-app/src/records.rs(new),crates/tetris-app/src/settings_persist.rs,crates/tetris-app/src/lib.rs(mod records;) —screens_menu.rsNOT touched (view kept API-compatible)
- depends_on: [T5, T6]
- location:
crates/tetris-app/src/screens_modes.rs(new),crates/tetris-app/src/screens_menu.rs,crates/tetris-app/src/state.rs,crates/tetris-app/src/touch.rs - description: Add
AppState::ModeSelect(additive; fix exhaustive matches). Title's "Start" now goes to a scrolling list: one row per solo mode (Sprint/Ultra/Dig + Marathon), each showing name, one-line description and its record from T6. Row press →start_modevia T5. Keep the existing 1 v 1 / Online / Settings / Quit buttons. Reuse themenu_buttonmarker /Interactionclick pattern; must be touch-operable and readable in portrait (scroll container; PRD risk "ten entries crowd the phone menu"). - validation: Headless UI test: button click moves
AppStatetoModeSelect, row click starts the rightModeConfig; manual portrait APK check (start, scroll, select, back). - status: Completed
- log: 2026-10-01 — commit
3a6f486(screens_modes.rsnew,screens_menu.rs,state.rs,lib.rs;touch.rsintentionally untouched — see touch note).AppState::ModeSelectadded afterTitle; all existingmatchsites have wildcards (nothing broke). NewModeSelectPluginmounted afterMenuScreensPlugin: root spawned once (hidden like the T17 menu roots, handlers state-gated), rows rendered fromModeId::ALLfiltered byis_shipped(never hardcoded) viaspawn_mode_rows; each row =ModeRowButton { id }+ display name + description +record_linerefreshed live onRecords::is_changed(). Record formats (pure fnsrecord_line/group_thousands):-,Best 2:43.91(format_time_ticks),Best 123 456(space thousands),Lines N,Rung N,date: result— ASCII only (bundled font subset). Row press →open_mode_select-pair helperstart_mode_row→ sharedstart_mode_run(seed/pre-roll/plays) — Sprint row test asserts pre-roll 180 + gate. Scrolling: native bevy_uioverflow: Clip/Scroll+ScrollPosition;mode_scroll_systemdrives it fromMessageReader<MouseWheel>(90 px/notch) and vertical per-finger touch drags (MessageReader<TouchInput>), clamped by pureclamp_scrollagainstComputedNode.size/content_size;ui_focus_systemclipping makes scrolled-out rows inert (verified). MessageReader is load-bearing: bevy_ecs 0.19Messages::updateonly swaps buffers when the resource changed, soiter_current_update_messagesre-delivers or drops messages written between updates (bevy_ecs docs: arrival "unpredictable") — a wheel test written that way flaked ~50 % until converted; same codebase pattern astouch.rs/input.rs, do NOT "simplify" back. Keyboard: Esc → Title, Enter → first shipped row; pause chord already gates on Playing/Paused (verified inert). screens_menu test-helper fix (T8 canary):button_rectsnow returns root-scoped(root, label, pos)sorted viatotal_cmp,rect_of_under(root, label)replaces first-matchrect_of; new churn testroot_scoped_rect_resolution_survives_resource_and_entity_churn— the two submenu canaries now survive the extra-resource churn T8 proved was order-flipping. Title "Start" →ModeSelect; adapted tests:start_button_launches_fresh_playing_runclicks the Sprint row (pre-roll asserted), versus quit/winner-menu flows land onModeSelect. Touch parity: notouch.rschange needed —ui_focus_systempresses buttons fromTouchesfed byMessages<TouchInput>; two headless portrait 411×731 tests tap a row and Back through the real winit-shaped pipeline. Manual portrait APK check deferred to the T11 gate (same as T8's). Gotchas: (1) Pause "Restart" and Game-Over R still userestart_run; confirm in T9 they resume the mode selected here, not Marathon. (2) Pre-existing flakes observed during validation, NOT from this tree:core_bridge::tests::ultra_time_up_flips_game_over_and_exposes_terminal_reason(~1–5 %, load-correlated, reproduces on pristinec3e42ffat 1/60 isolated without this commit) andcore_bridge::net::gateway::tests::real_gateway_host_registers_and_is_announced(real UDP, flakes under CPU contention) — worth a core_bridge owner's look before the T10 CI gates bake them in. Verified: workspace green, app 404/404 ×5 onc551afd+ these changes, wheel test 15/15, clippy--workspace --all-targets -D warnings+ fmt--checkclean. - files edited/created:
crates/tetris-app/src/screens_modes.rs(new),crates/tetris-app/src/screens_menu.rs,crates/tetris-app/src/state.rs,crates/tetris-app/src/lib.rs
- depends_on: [T5]
- location:
crates/tetris-app/src/hud.rs,crates/tetris-app/src/audio.rs - description: Add a clock label and per-mode goal counter to the solo
HUD, shown only when the mode requests it (
ModeIdcatalogue flag): Sprint = count-up clock (mm:ss.hh fromGame::tick_count()), lines left, pieces placed (countPieceLockedevents client-side); Ultra = count-down fromclock_ticks(score already shown); Dig = clock + garbage rows left (garbage_rows_left()). Tick→time formatting helper lives inmodes.rs. Data carrier:GameSnapshotgains no fields (wire rule) andHudFixtureonly injects snapshots — so the bridge writes aModeHudInforesource (clock ticks, goal text inputs: lines-left / garbage-left / pieces-placed, feed queue + next-row countdown for T13, swap-timer field reserved for T21) every fixed step from the core getters; HUD systems readModeHudInfo, and extendHudFixtureto also accept it. Ultra: warning sound in the last 10 s (one shot at tick 6 600 via comparison againstModeHudInfo, using existingbevy_kira_audioWAV assets; if no fitting asset exists, reuse the most urgent existing cue). - validation: Headless HUD tests (fixture pattern
HudFixturealready exists): label text formats ticks correctly (e.g. 9 835 →2:43.91; 10 235 →2:50.58— 60 Hz truncation); marathon run shows no clock; ultra warning fired-once behavior. - status: Completed
- log: 2026-10-01 — commit
725b5b8(core_bridge/mod.rs,hud.rs,audio.rs).ModeHudInforesource (the task's single new resource,init_resourceinCoreBridgePlugin):{ mode_id, clock_ticks, clock_limit, countdown, lines_left, garbage_left, pieces_placed, show_hud, feed_pending, feed_next_row_in, swap_in }— last three reservedNone(T13 feed / T21 swap);show_hud = goal.is_some() || clock.is_some()so Marathon renders neither row.mode_hud_refresh_system(FixedUpdate, aftercore_bridge_system) refreshes fromtick_count()/garbage_rows_left()/Countdownevery step;pieces_placedcountsPieceLockedfromMessageReader<CoreEvent>, reset by watchingGameCore::steps == 0(both start paths zero steps before the first step and the system runs post-step, so the first playable frame reports 1 — documented inline). HUD: three newHudTextSlots (Clock=TIME\nm:ss.hhviamodes::format_time_ticks, count-down whenclock_limitpresent;Goal=Lines left: N+Pieces: P(Sprint) /Garbage: N(Dig) / absent (Ultra);Countdown= big centeredceil(n/60), clock/goal hidden while pre-roll runs). Layout: landscape = right panel below the next queue (below even a 6-slot queue, above window bottom); portrait = deck row outer margins; 3-2-1 centered over the field in both — never overlapping. Fixture pattern extended: tests writeModeHudInfodirectly + runUpdateonly (bridge refresh lives inFixedUpdate, so fixtures are never clobbered;HudFixturesnapshot usage unchanged/back-compat). Ultra audio decision: discovered shipped cues are all generated placeholder blips (assets/generate.py); none reads as a warning andGameOver's 440→110 Hz sweep would falsely signal run-end at T-10s → edge-detected system onModeHudInfo(remaining ≤ 600 crossing, Local-tracked, re-arms on restart) firesinfo!("ULTRA warning…")+SfxDirector:: note_edge_cue("ultra-warning")counter; manual_check: swap in a real warning WAV (pending_sfx.push_back(…)) when one ships. Gotchas: (1) PROVEN atc3e42ff— adding ANY single resource entity (even a dummy, at any init position, even lazily on first Update — all 10 positions probed) flips the twoscreens_menusubmenu-click canaries viabutton_rects/rect_offirst-match-by-iteration-order luck; fix lives in T7's deterministic-helper commit (sortedbutton_rects, root-scopedrect_of_under— verified green WITH my resource when combined in the shared tree; my commit alone onc3e42ffleaves those two red until T7 lands — integration ordering, not T8 code). (2) Lazy first-Update resource insertion additionally breaks netesc_on_listening_clears_the_upnp_state— keep build-timeinit_resource. (3) 1 pre-roll tick (180) → display 3, but post-pre-roll first playable frame renders0:00.01(tick 1), not0:00.00. Verified: app 404/404 + workspace green with T7 tree helpers (worktree-pinned evidence in T8 session report); clippy-D warnings+ fmt--checkclean. - files edited/created:
crates/tetris-app/src/core_bridge/mod.rs,crates/tetris-app/src/hud.rs,crates/tetris-app/src/audio.rs
- depends_on: [T5, T6, T8]
- location:
crates/tetris-app/src/screens_menu.rs(game-over screen code) - description: The game-over screen renders per terminal reason and mode:
Sprint/Dig completed → final time (mm:ss.hh) + "New record"/best time;
Sprint/Dig top-out → explicitly no result shown; Ultra → score stands
either way + record line. Retry re-runs the same mode (seed fresh unless
TETRIS_SEED); "Menu" returns toAppState::ModeSelect. UsesGame::finished_reason()+Records::record_run(records write only when the PRD says a result exists: top-out in Sprint/Dig records nothing). - validation: Headless tests per terminal reason (drive core to each
outcome); record written/not-written assertions through
Records. - status: Completed
- log:
5161a83. Single terminal recorder now owns all result writes:terminal_record_system(screens_menu, Update, BEFOREmenu_button_clicksso a same-frame Retry can't skip it) fires once per GameOver entry (state.is_changed()gate; bridge writes state in FixedUpdate so the next Update sees it), folds the result via the pure tableterminal_record(ModeId, FinishReason, &GameSnapshot, ticks) -> Option<Record>—(Marathon, TopOut) | (Ultra, TimeUp|TopOut) => BestScore{score,level,lines};(Sprint|Dig, GoalReached) => BestTime{game.tick_count()};_ => None— latchesTerminalResult{mode, reason, ticks, new_record}for the display layer, and on improvement force-flushesRecordsSaveQueue(improved results hitbest.jsonwithin one frame;best_score_view_systemkeeps the title Marathon view following). T13 extension = one row:(ModeId::Survival, FinishReason::TopOut) => Some(Record::BestTime { ticks }). Headlineresult_text(): Sprint/Dig GoalReached →Time m:ss.hh(format_time_ticks), Sprint/Dig TopOut →No result, Ultra →Time up/Top out, Marathon → empty; rendered by a newResultTextlabel whoseNode.displaytogglesFlex/None, so Marathon's screen keeps today's exact layout. Best line per mode: non-Marathon GameOver reads the mode's ownrecord_linefromRecords; title/Marathon keep thePersistedBestScoreview verbatim. Retry:start_new_run→retry_run(both GameOver Play-again and pause Restart now route throughstart_mode_run(active_mode.id)— same mode, fresh seed unlessTETRIS_SEED, pre-roll re-armed; T7 board note honored). GameOver "Menu" →open_mode_select(not Title). Retired interimsettings_persist::best_score_system(function + registration): it folded EVERY GameOver into the MarathonBestScore, so Sprint/Dig top-outs polluted the Marathon record — RED evidencescreens_menu::tests::sprint_top_out_records_nothing_anywherefailed pre-fix with "a Sprint top-out must not touch the Marathon record (PRD: no result)" on the full persistence tree, green post-retirement (plussettings_persist::tests::game_over_writes_no_record_from_settings_treeas the permanent gate: game over ⇒ noRecordsentry, nobest.json). Marathon disk regression kept green end-to-end (marathon_top_out_persists_best_score_to_disk). Verified: app 415/415, workspace 14 suites green, clippy--workspace --all-targets -D warnings- fmt
--checkclean at the commit boundary (HEAD-pinned worktree + the two files; shared-tree run incl. parallel T10 WIP also 418/418 green).
- fmt
- files edited/created:
crates/tetris-app/src/screens_menu.rs,crates/tetris-app/src/settings_persist.rs(retirement of the interim recorder only — it lived there and owned the pollution)
- depends_on: [T3, T4, T5]
- location:
crates/tetris-app/src/core_bridge/mod.rs(bot mode + solver),.github/(if job list needs the new test names) - description: Two parts. (a) Dig-aware solver: the existing greedy
bot_moveweights cover the stack and will bury Dig's holes rather than dig them (it tops out well before clearing 10 garbage rows). Give the bot a Dig-aware heuristic (e.g. weight the lowest reachable hole strongly when the board containsPiece::Garbage, target the hole column) without changing marathon behavior (marathon boards have no garbage ⇒ heuristic inactive). (b) ExtendTETRIS_BOT=1to run a named mode (TETRIS_BOT=sprint|ultra|dig, plain=1stays marathon), loggingBOT mode_done mode=sprint time_ticks=…/mode=ultra score=…/mode=dig time_ticks=…, and — crucially —BOT mode_abort mode=… ticks=…with nonzero-exit semantics on top-out, so a silent early death fails the test instead of hanging the budget. Ultra: top-out before tick 7 200 still yieldsmode_done(score stands either way per PRD). Add one workspace-level hidden-window integration test running Sprint and Dig to completion (PRD: "the bot finishes Sprint and Dig"); Ultra's exact-tick end is already covered purely in core by T4. - validation:
cargo test --workspacegreen;TETRIS_BOT=sprint cargo run(desktop) completes a Sprint run headless-ly and exits 0. - status: Completed
- log:
2d4a4b9. (a) Dig-aware solver: ported T4's nub-down heuristic verbatim intocore_bridge(bot_move_mode(snapshot, dig_aware)+top_buried/dig_landing/dump_landing/dig_score_landing/dig_landings+ hold-fish via a newBotMove::holdone-shot the shared executor pushes asAction::Hold). Coexistence: the legacybot_move(weights/tie-breaks untouched) stays the only path for!dig_awareand fordig_awareboards without aPiece::Garbagecell, so Marathon/Sprint/Ultra decisions are bit-identical — pinned bydig_heuristic_is_inert_without_garbage_cells(100+ sampled marathon decisions,bot_move_mode(snap,true) == bot_move(snap)) and by the unchanged versus path (bot_side_drivealways calls withdig_aware = false;bot_drive_systemonly opts in whenactive_mode.config.goal == GarbageCleared). Unit proof of activation:dig_heuristic_targets_top_buried_hole_on_garbage_boards. (b) Named modes:parse_bot_valueaccepts1|marathon|sprint|ultra|dig(case-insensitive; unknown ⇒ bot off + warn); named bots start from Title viastart_mode_run(TETRIS_SEED + pre-roll honored). Exactly one machine line per terminal:BOT mode_done mode=sprint time_ticks=…/mode=dig time_ticks=…(GoalReached,AppExit::Success),BOT mode_done mode=ultra score=… ticks=…(TimeUp OR TopOut — score stands),BOT mode_abort mode=… ticks=…for Sprint/Dig top-out withAppExit::error()nonzero exit (Bevy 0.19 renamedAppExit::Failure→Error(NonZero<u8>);AppExit::error()is the nonzero variant). Ultra paced at one hard drop per ~30 ticks (ULTRA_BOT_PACE_TICKS, T4's proven cadence — free-run greedy tops out before the clock). Lifecycle runs insidebot_marathon_systemas a plain function, not a new registered system: registering any additionalUpdatesystem perturbs the schedule enough to flip the netplay UI fixtures' same-frame edge tests (esc_on_listening_clears_the_upnp_statedeterministically failed with a 4-system tuple; graph-shape-preserving branch passes 4/4). (c)tests/bot_modes.rs(hidden-window-style MinimalPlugins +LogPlugin::custom_layerinfo!-capture, env-mutex serialized, 1.7 s): Sprint 31337 →BOT mode_done mode=sprint time_ticks=522observed, exit Success; all six T4 pins 9/28/30/31/35/36 win through the app wiring (probe seeds 1–40 through the app: 6/40 wins — exactly T4's core win set, 0 stalls → gravity + step-executor + hold mechanics preserve the core driver's outcomes); seed 5 pinned as the abort seed (34/40 seeds top out, abort line +AppExit::errorasserted); ultra survives past 1200 ticks with ≤ 60 pieces (cadence proof) and no terminal line;=1/=marathonboot-to-Title regression; unknown value inert + warns. RED evidence: all 7 tests failed pre-impl (named env values never started a mode; captured logs stopped atstart_mode … (from TETRIS_SEED)with no terminal line); GREEN after. Verified: app 418/418, workspace all suites green, clippy--workspace --all-targets -- -D warnings+ fmt--checkclean (HEAD- pinned worktree + the two app files; shared-tree run incl. parallel T9 WIP also green). Deviation:lib.rsmod→pub modforcore_bridge/modes/records/state(binary-only crate; required for anytests/integration test to reach the bridge). - files edited/created:
crates/tetris-app/src/core_bridge/mod.rs,crates/tetris-app/src/lib.rs(visibility only),crates/tetris-app/tests/bot_modes.rs(new)
- depends_on: [T7, T8, T9, T10]
- location: repo-wide
- description:
cargo test --workspace+cargo clippy --workspace -- -D warnings+cargo fmt --all --checkgreen (T1 golden proves Marathon unchanged — PRD gate). Manual: Android APK portrait check — every R1 mode started, played, left with touch only (PRD G5). Playtest Sprint/Ultra/Dig; tune any "starting value" constants. Oldbest.jsonon a real profile migrates and the title screen still shows the Marathon best. - validation: All of the above recorded in the plan log. No R2 task starts before this is green.
- status: Completed
- log: R1 automated gate GREEN at commit e5c2411 (2026-10-01).
cargo test --workspace= 15/15 suites ok (0 failures), incl.marathon_golden_snapshot_*canary (3 passed) proving Marathon unchanged through T2-T10, plus new T10bot_modes.rsheadless gates (Sprintmode_done time_ticks=522, Dig on pinned seeds {9,28,30,31,35,36}, Ultra cadence, abort path =>AppExit::error).cargo clippy --workspace --all-targets -- -D warningsclean;cargo fmt --all --checkclean. Legacybest.jsonmigration + single-writer pinned by T6/T9 tests. MANUAL (deferred to owner, cannot run headless here): Android APK portrait touch check per R1 mode; Sprint/Ultra/Dig playtest to tunePRE_ROLL_TICKS, 1800-tick decay consts; real-profile migration on device. Known pre-existing flakes (not R1 regressions):ultra_time_up~1-5% under load;real_gateway_host_registers_and_is_announcedunder UDP contention. - files edited/created: (gate — no code; this plan entry only)
- depends_on: [T11]
- location:
crates/tetris-core/src/mode.rs,crates/tetris-core/src/game.rs(+ tests) - description:
GarbageFeed { interval_ticks: 300, decay_ticks: 1800, decay_by: 15, floor_ticks: 60 }(named constants, tunable) inModeConfig. Game-internal: feed timer queues 1 row per interval; the whole pending batch (capversus::MAX_GARBAGE_PER_LAND= 4, surplus trickles like versus) lands on the player's next lock by reusingversus::push_garbage_rows(alreadypub(crate)); hole column per batch from an independent splitmix64 stream derived from the seed (never the bag stream — bag draws must stay untouched). Overflow past the ceiling tops out exactly like a block-out. Exposepending_garbage()andticks_to_next_rowgetters for the HUD. - validation: Unit tests: first row lands on the first lock after tick 300; interval decays 15 per 1800 ticks down to the 60 floor; cap-4 trickle; overflow tops out; same-seed replay determinism; marathon regression (T1) untouched.
- status: Completed
- log: 2026-10-01 — commit
4f910b6(onlymode.rs+game.rs).mode.rs:GarbageFeed { interval_ticks, decay_ticks, decay_by, floor_ticks }(serde,Default== constsFEED_INTERVAL_TICKS=300 / FEED_DECAY_TICKS=1800 / FEED_DECAY_BY=15 / FEED_FLOOR_TICKS=60) +pub interval_at(t) -> u64=max(floor, interval - decay_by * (t / decay_ticks))(checked_div ⇒decay_ticks: 0disables decay; floor above start wins; saturating).ModeConfig.garbage_feed: Option<GarbageFeed>(Default None ⇒ every pre-T12 config bit-identical).game.rs:FeedState { rules, next_queue_tick, pending, hole_rng }(None without feed). Queue schedule: intick()right after the tick increment,ticks >= next_queue_tick⇒pending += 1andnext_queue_tick = ticks + interval_at(ticks)— the wait is committed at each queue event from the interval in force there, so decay applies once per fully-elapsed 1800-tick window (window 0's 300 divides 1800 ⇒ first post-decay row exactly at tick 1800, then 285 spacing; floor 60 from window 16). Queue events depend only on game ticks, never locks; a frozen game never queues (tick gating) and the countdown freezes in place. Landing point (documented, versus parity): insidelock_and_spawn, after this lock's merge+clear+scoring/level bookkeeping, before the goal check and before the next spawn (versus lands after the whole Game call incl. spawn and tops out on active-overlap; landing pre-spawn is the equivalent outcome here — a stack buried into the spawn area ends viaspawn's own block-out, singleGameOver); at mostversus::MAX_GARBAGE_PER_LAND(4) rows per lock viaversus::push_garbage_rows, surplus trickles; exactly one hole column per landing batch (constant within batch, versus-style) from an independent splitmix64Rng::new(seed ^ FEED_HOLE_SALT)(0x51F0_7D3A_9C6B_4E21— different fromBURIED_GARBAGE_SALTand the bag stream;peek_nextpinned unchanged). Top-out: push overflow ⇒ install pushed board, clear active,game_over = true,finished = Some(TopOut), emitGameOveronce and return before the goal check/spawn — top-out wins over a same-lockGoal::Lines(tested: exactly one GameOver, no GoalReached); clock already loses to locks by T2 ordering. No new GameEvent variants, no GameSnapshot fields (T1 golden green). Getters:pending_garbage() -> u32(queued-not-landed; 0 without feed),ticks_to_next_row() -> Option<u64>(None without feed; ticks until the NEXT QUEUE event — pending rows don't affect it; freezes while the game does). RED captured (E0422/E0425/E0433GarbageFeed, E0560garbage_feed, 27×E0599 getters) → GREEN: tetris-core 171 lib + 6 invariant + 3 marathon golden + 5 headless pass (9 new game.rs tests: queue-at-300/land-on-lock withpending_garbage()visibility, decay sequence pinned to tick 40k incl. per-window recurrence + 60-floor run, cap-4 trickle 6⇒4+2 with per-batch single hole, overflow top-out frozen, landing-topout-beats-goal same lock, same-seed replay of events + snapshots + getter traces, bag-peek untouched under feed, default ==Game::newincl. getters); 4 new mode.rs tests (defaults, interval_at windows/floor, degenerate configs). Workspace green (app 420), clippy--workspace --all-targets -D warnings+fmt --all --checkclean. UNSTAGED working-tree fixups (T2 precedent, whoever lands the next app-crate commit must stage them):crates/tetris-app/src/modes.rscrates/tetris-app/src/screens_menu.rs— three exhaustiveModeConfig { … }literals (Sprint/Dig/Zen + test helpergoal_on_first_lock) gained..ModeConfig::default(); behavior-neutral (garbage_feed: None), required because a new struct field breaks exhaustive literals; T13 (which editsmodes.rsanyway) should keep them. Gotchas: (a) hole stream derives from the game seed — the T13 app config only needsgarbage_feed: Some(GarbageFeed::default()); (b) rows queue mid-air and are only observable viapending_garbageuntil a lock — the app HUD reads both fields (T8's reservedModeHudInfo.feed_pending/feed_next_row_in); (c) decay windows count from game tick 0 — with T5's 0-tick Survival pre-roll that is feed time (no pre-roll is planned; if one is ever added, feed would need a base offset).
- files edited/created:
crates/tetris-core/src/mode.rs,crates/tetris-core/src/game.rs(+ unstaged:..default()compile fixups incrates/tetris-app/src/modes.rsandcrates/tetris-app/src/screens_menu.rs, see log)
- depends_on: [T12, T8]
- location:
crates/tetris-app/src/modes.rs,crates/tetris-app/src/hud.rs,crates/tetris-app/src/screens_menu.rs - description: Catalogue entry (config: feed + no goal + marathon
gravity). HUD: elapsed clock, the existing queued-garbage meter (reuse the
versus pending-garbage display, fed from
pending_garbage()), and time until the next row. Result on top-out = time survived, recorded asBestTimeunder Survival. - validation: Headless test: bot survives ≥ 30 s of feed then eventually tops out with a recorded time; HUD fixture shows queue count and countdown-to-next-row strings.
- status: Completed
- log:
b7ae3ea. App-only (core untouched). Catalogue: Survival ={ garbage_feed: Some(GarbageFeed::default()), ..Default::default() },is_shippedflipped (description now names the rising feed); the mode list is data-driven —screens_modes::shipped_modes()filtersModeId::ALLthroughis_shipped, zero changes there (itsrows_are_data_driven…test picks the new row up automatically). Bridge:mode_hud_refresh_systemnow fills the reservedModeHudInfofields whenconfig.garbage_feed.is_some()(feed_pending = Some(pending_garbage()),feed_next_row_in = ticks_to_next_row();Noneotherwise —swap_instill reserved) andshow_hud= goal || clock || feed. No new resources (resource-count discipline kept: the meter is a newHudTextSloton the existingHudTextEntitiespool). HUD: newHudTextSlot::Feed—GARBAGE +N\nNEXT s.sin the versus pending meter's idiom (sameGARBAGE_COLOR, the versus one is a startup-spawned+Nper-side text with no reusable piece, so only the idiom is shared, not coupled toMatchSnapshot.pending), landscape right panel below the goal row (-23.5c), portrait under the deck row; count-up clock comes free with the T8 clock slot. Recording:terminal_recordgained exactly the one row(Survival, TopOut) → BestTime{tick_count()};result_textgained(Survival, Some(TopOut)) → "Time m:ss.hh"(same line as Sprint/Dig completions; best line follows viarecord_line). TDD: RED = 7 targeted failures (catalogue feed/is_shipped, bridge feed fields queued/matrix row/headline), GREEN: app 426 pass. Tests: catalogue feed+ship; bridge queue-at-305-steps-before-any-lock + countdown + land on first lock (board_has_garbage); Marathon-past-350-steps fields stayNone/show_hudfalse; HUD fixtures (queue count,+2/2.0s/5.0s, pre-roll + marathon gating, layout bounds); end-to-end gravity-only Survival run: the feed buries the bot past the 30 s mark (assertsticks >= 1800and ≥ 4 landed garbage rows on top-out; stable over repeated random-seed runs),BestTime{tick_count}under SURVIVAL, Marathon record untouched,Time m:ss.hhheadline +NEW RECORD!. Validation:cargo test -p tetris-app426/7 green,cargo test --workspaceall suites green,cargo clippy --workspace --all-targets -- -D warningsclean,cargo fmt --all --checkclean. - files edited/created:
crates/tetris-app/src/modes.rs,crates/tetris-app/src/core_bridge/mod.rs,crates/tetris-app/src/hud.rs,crates/tetris-app/src/screens_menu.rs
- depends_on: [T11, T5]
- location:
crates/tetris-core/src/game.rs(WipeAndContinuebranch in the spawn/block-out path),crates/tetris-app/src/modes.rs,crates/tetris-app/src/hud.rs,crates/tetris-app/src/records.rs - description: Implement
BlockOutBehavior::WipeAndContinue: on spawn collision, clear the board (whole stack — owner decision), re-spawn the colliding piece at its spawn state, and emitGameEvent::StackWiped(new variant, appended to the enum — wire-safe, events never cross the network). NoGameOverever fires; score/lines keep accumulating. App: catalogue entry (fixed level 1, no goal); HUD shows session lines + lifetime lines (Records::LifetimeLines— incremented on every line clear event, persisted via the debounced save path); exit via pause → menu. - validation: Core test: force a block-out with
WipeAndContinue, assert play continues, noGameOver, board empty, same-seed replay identical. App test: lifetime lines persist across a simulated restart of the config dir. - status: Completed
- log:
9bd01ea. Core (game.rs::spawn): on a spawn collision underWipeAndContinue, the whole stack is wiped (owner decision),feed.pendingis reset defensively, and the colliding piece re-spawns at its spawn state (always fits an empty board).GameEvent::StackWiped { tick }(appended, index 12; events never cross the wire) is emitted immediately before the re-spawn'sPieceSpawned; never alongsideGameOver.game_overstaysfalse/finished_reason()staysNone. Counters kept as-is — score, lines, level and combo/b2b (the wipe is not a lock: never scores, never emitsLineCleared/PerfectClear, never resets chains). End behavior 100% unchanged (canary + explicit parity test).install_board/versus top-out paths untouched (unreachable for Zen: solo-only, no feed). Hold swaps block out through the same spawn path. App:is_shipped(Zen)flipped (description polished;screens_modesdata-driven rows picked the mode up automatically); HUD gainsHudTextSlot::Lifetime(LIFETIME <n>fromRecords::LifetimeLinesviarecord_for(ZEN), Zen-only, pooled slot reusing the feed meter's row — they never coexist; no new resources) while session lines ride the existingLINESstat;zen_lifetime_lines_system(hud.rs, Update, drainsCoreEventin every mode) folds each ZenLineClearedintoRecords::add_lifetime_lines— debounced save, no per-line disk writes; exit via pause → "Quit to title" verified end-to-end (600-piece pile never leavesPlaying; Zen has no terminal-matrix row by design). Lifetime survives app exit: exit-flush test writesAppExit→Records::loadof the isolatedTETRIS_CONFIG_DIRshows the total. TDD: RED = continuation test failed with[PieceLocked, ScoreChanged, GameOver](WipeAndContinue acted like End) → GREEN. New core tests (7): wipe+continuation incl. event-order (StackWiped→PieceSpawned) and all- pieces-arm lemma, counter/chain preservation (hold-path wipe), 3-wipe survival, tick_count through wipes, same-seed replay equality across wipes, End parity, defensive pending-feed reset. App tests (5): lifetime == snapshot.lines (seed 7, 4 clears), marathon clears never touch the Zen counter, LIFETIME 0 before first record, exit-flush persistence, zen pause-quit + never-ends. Validation:cargo test -p tetris-core(177+6+3+5 incl. T1 golden),-p tetris-app(431+7),cargo test --workspace15 suites green,cargo clippy --workspace --all-targets -- -D warningsclean,cargo fmt --all --checkclean. Files beyond the task location list (all forced/minimal):tetris-core/src/event.rs(theGameEventenum itself — whereStackWipedhad to be appended);tetris-app/src/audio.rs(silentStackWipedarm in the exhaustivesfx_for_event, same class as T2's terminal arms, pre-approved);tetris-app/src/screens_modes.rs(one test's "hypothetically unshipped" row switched Zen→BotLadder since Zen now renders in the real list). - files edited/created:
crates/tetris-core/src/game.rs,crates/tetris-core/src/event.rs,crates/tetris-core/src/mode.rs,crates/tetris-app/src/modes.rs,crates/tetris-app/src/hud.rs,crates/tetris-app/src/screens_menu.rs,crates/tetris-app/src/audio.rs,crates/tetris-app/src/screens_modes.rs(test-only)
- depends_on: [T11]
- location:
crates/tetris-app/src/core_bridge/versus.rs - description: Replace the
BOT_LOCK_COOLDOWN_STEPSconstant read insideversus_bot_systemwith a per-side cooldown stored onVersusMatch(bot_cooldown_ticks: [u32; 2]). Non-breaking: keepstart_versussignature and behavior exactly as today (fills the default constant) and addstart_versus_with_cooldown(..., [u32; 2])for the ladder (T16) — no call-site churn inscreens_menu.rs/harness.rs. Pure plumbing: default behavior byte-identical (existing versus/harness/soak tests pass unmodified). - validation:
cargo test --workspacegreen with defaults; a unit test starts a bot-vs-bot match at 10-tick cooldown and asserts a materially higher lock rate than the 60-tick default. - status: Completed
- log:
fbf0232. Pure plumbing inversus.rs(single file;net/harness.rsinspected — itsSoakPlayeris test-local pacing, never constructsVersusMatchcooldown state, untouched;mod.rsre-exports viapub use versus::*so no export churn).VersusMatch::bot_cooldown_ticks: [u32; 2](pub) replaces the constant read inversus_bot_system(bot_cooldown[index] = bot_cooldown_ticks[index]on drop; countdown field unchanged);VersusMatch::newdefaults it to[BOT_LOCK_COOLDOWN_STEPS; 2];setup_net_mirrorresets it to the default alongsidebot_cooldown = [0; 2](coversstart_net_matchand the guestpending_startrebuild);start_versuskeeps its signature and delegates with the default;start_versus_with_cooldown(..., cooldowns: [u32; 2])is the real body. TDD: RED = E0425start_versus_with_cooldown+ E0609bot_cooldown_ticks→ GREEN. Tests:start_versus_sets_default_and_param_cooldown_ticksandper_match_cooldown_scales_the_bot_lock_rate(bot-vs-bot, fixed 300Match::tickwindow, RNG pinned after the entry point for reproducibility; [10, 10] locks 42 vs [60, 60] locks 10 — 4.2× ≥ the ≥3× bar; Race with an unreachable target keeps the pace measurement free of garbage truncation). Default-identity: every existing call path (menu, R-restart,TETRIS_1V1harness, net mirror) flows the constant default; all pre-existing versus/harness/soak tests pass unmodified. Validation on HEAD-pinned worktree (c91cb74 + this file only — shared tree was red from T12's in-flight core WIP at validation time): app 420+7 green,cargo test --workspaceall suites green,cargo clippy --workspace --all-targets -- -D warningsexit 0,cargo fmt --all --checkclean. - files edited/created:
crates/tetris-app/src/core_bridge/versus.rs
- depends_on: [T15, T6, T7]
- location:
crates/tetris-app/src/screens_ladder.rs(new),crates/tetris-app/src/modes.rs,crates/tetris-app/src/records.rs - description: Ladder flow screen entered from mode select: 8 rungs,
start_versus_with_cooldown(Garbage, Human, Bot, [c; 2])per rung with cooldown constants[120, 104, 82, 60, 44, 30, 19, 10](named const array, rung 4 == today's 60; tuning lives here only). Win (viaVersusWinner) unlocks the next rung; loss retries. PersistHighestRunginRecords; locked rungs disabled in the list; versus HUD gains the rung number. Rematch/menu buttons reuse the T26 versus overlay flow. - validation: Headless test: bot at 10-tick cooldown beats a 120-tick
ladder seat (win path unlocks), winner resource drives unlock + record;
rung persistence round-trip under
TETRIS_CONFIG_DIR. - status: Complete
- log:
b760f52. Ladder screen (newscreens_ladder.rs, mounted asBotLadderPlugin) rides insideAppState::ModeSelect(no newAppStatevariant): the BotLadder mode-select row routes toopen_ladderinstead ofstart_mode_run, and the list hides while the ladder shows. Zero new Bevy resources: the campaign state isLadderOrigin{Closed,Screen, Match{rung}}added as a field on the existingVersusFlowresource (additive;VersusStageuntouched). Rung press =start_versus_with_cooldown(Garbage, Human, Bot, [0, RUNG_COOLDOWNS[r]])— tuning lives ONLY inpub const RUNG_COOLDOWNS: [u32; 8] = [120, 104, 82, 60, 44, 30, 19, 10](rung 4 == the 60 default, pinned by test). Left (human) crown on aMatch-origin flow foldsHighestRung{rung}via a newrecords::highest_beaten_runghelper + forced save; bot crown records nothing (retry). Flow isolation: onlyLadderOrigin::Matcharms the record fold and the HUD badge, so plain 1v1/netplay crowns never touchbot_ladderrecords (test-pinned); the overlay Menu on a ladder crown walks back to the ladder screen (pause quit likewise), Rematch re-arms the rung's own cooldown (the R key in versus.rs still restarts at the 60 default — core_bridge off-limits, accepted). Versus HUD badge:RUNG n/8re-targets the pooled Status slot while ladder-origin (Garbage rule can never show the slot'sFINISHEDanyway) — no new entities.is_shipped(BotLadder)flipped, description now "Beat eight bots of rising speed.". RED (E0583 ladder module missing) → GREEN: 14 new ladder tests (entry, cooldown[0,120]on rung 1, locked-rung inertness, win-unlock + Menu→ladder, bot-win retry, rematch cooldown, isolation, persistence round-trip underTETRIS_CONFIG_DIRproving rung 3 recorded ⇒ rung 4 playable after reload, fast-bot-beats- slow-bot[120,10]bot-vs-bot ⇒ Right wins, T15 rate-test style with pinned match RNG) + 2 HUD + 1 records helper test. Validation at this commit: app lib 448 + 7 integration green,cargo test --workspaceall 15 suites green (pre-existing canaries incl. the resource-churn submenu tests green unmodified — resource count unchanged), clippy--workspace --all-targets -- -D warningsexit 0,cargo fmt --all --checkclean. - files edited/created:
crates/tetris-app/src/screens_ladder.rs(new),modes.rs,records.rs,hud.rs,lib.rs,screens_menu.rs(flow field + overlay/pause ladder branches),screens_modes.rs(row routing, list-hide gate, Escape walk-back, defensiveVersusFlowinit, and therows_are_data_drivenhypothetical swapped BotLadder→Daily — T16 shipped so T17 must swap it to another unshipped mode, comment pinned in the test)
- depends_on: [T6, T7]
- location:
crates/tetris-app/src/daily.rs(new),crates/tetris-app/src/modes.rs,crates/tetris-app/src/screens_menu.rs(result line),crates/tetris-app/src/records.rs - description: App-side (core never sees a date): today's UTC date
(
SystemTime→ civil date helper) → seed via splitmix64; weekday maps the day's mode among Sprint/Ultra/Dig by a namedconst DAILY_ROTATION(weekday-index % 3). Mode-select row shows today's mode + "your result / not yet". First completed run of the day recordsDaily { date, ticks-or-score }; retries the same day update nothing (first-counts, owner decision). Result screen shows the share lineBlockfall Daily 2026-10-01 · Dig · 1:42.35as display-only selectable text (no clipboard in Bevy 0.19 — owner decision fallback). - validation: Unit tests: date→seed stable and distinct across dates; rotation mapping; first-run-wins recording (second run ignored); share line format.
- status: Complete
- log:
d494f33.daily.rs(new) is the single source: chrono-freeCivilDate(Hinnantcivil_from_days/days_from_civil, epoch-day arithmetic),utc_today(the onlySystemTimetouch — core stays date-free),daily_seed= splitmix64 over the packed date,DAILY_ROTATION = [Sprint, Ultra, Dig]indexed byweekday(Mon=0) % 3(Mon Sprint / Tue Ultra / Wed Dig / Thu Sprint / Fri Ultra / Sat Dig / Sun rolls back onto the head),share_line(exactYYYY-MM-DD+ U+00B7 separators), and a thread-localset_today_overridetest seam (render::set_portrait_overrideprecedent — App::update runs systems on the calling thread). Date injection: pure fns takeCivilDateparams; flow paths readtoday()(override-aware). Start with seed:start_mode_run_with_seed(id, ..., seed_override: Option<u64>)added to core_bridge withstart_mode_rundelegatingNone— bookkeeping (forced seed wins overTETRIS_SEED, pre-roll re-arm, play bump) stays in ONE place;daily::start_dailywraps it and returns(date, mode, seed). Daily attempt state:DailyAttempt{Idle, Active{date}}as aVersusFlowfield (T16LadderOriginprecedent — zero new Bevy resources; resource-churn submenu canaries green unmodified). Recording gate lives indaily::finish_daily_attempt(T6'srecord_runreplaces Daily content unconditionally): stored date == attempt date ⇒ ignore (share line quotes the stored first result), new date replaces; Sprint/ Dig daily top-out earns nothing, Ultra top-out records the standing score.terminal_record_systemconsumes the marker at Game Over (later retry = plain run; pause→Restart also clears it); the normal per-mode matrix is NOT suppressed for daily runs. UI: mode-select gains aDailyRowButtonbanner (Daily · Dig — Not yet/— 1:42.35, synced fromRecords+ state) — NOT a catalogueModeRowButton;is_shipped(Daily)stays false and the three modes stay normal on their rows (the banner press is what arms the marker); result screen headline becomes the share line viaTerminalResult.daily_share(display-only label). RED (E0583 daily module missing) → GREEN: 20 new tests (civil-date round trips incl. epoch/leap, weekday pins, seed stable + 10-date distinct, 7-weekday rotation cycle, share line exact incl. zero-padded1:42.35= 6 141 ticks, first-run-wins gate + new-day replace, mode-rule result strings, banner render/status, banner press starts today's mode with today's seed + pre-roll, normal rows never carry/keep the marker, daily Sprint goal records + second run no-op, Ultra daily top-out records score, Sprint daily top-out records nothing, plain Marathon never touches DAILY). Validation at commit: app lib 467 + 7 integration green,cargo test --workspaceall 15 suites green (T1 golden canary holds), clippy--workspace --all-targets -- -D warningsexit 0,cargo fmt --all --checkclean. - files edited/created:
crates/tetris-app/src/daily.rs(new),crates/tetris-app/src/core_bridge/mod.rs(seed-override paramstart_mode_run_with_seed;start_mode_rundelegates),crates/tetris-app/src/screens_menu.rs(VersusFlow.dailymarker, terminal consume + daily fold,TerminalResult.daily_shareheadline, pause-Restart clear),crates/tetris-app/src/screens_modes.rs(Daily banner row + press routing + sync, hypothetical swapped Daily→DigDuel),crates/tetris-app/src/lib.rs(mod daily;)
- depends_on: [T13, T14, T16, T17]
- location: repo-wide
- description: Full validation suite green (wire canary T1 still holds — R2 must not have touched snapshot serialization). Playtest Survival feed constants and ladder rung curve (PRD risk: trim to fewer rungs if uneven). Manual Android portrait check for Survival/Zen/Bot Ladder/Daily.
- validation: All green + playtest notes in log. No R3 task starts before this gate.
- status: Completed
- log: R2 automated gate GREEN at commit cc0b748 (2026-10-01).
cargo test --workspace= 15/15 suites ok incl. T1 golden canary (3 golden, 0 fail) — proves Marathon behavior ANDGameSnapshot/MatchSnapshotwire shape untouched across R2 (T12–T17); T6 single-writer + migration, T13 Survival record path, T14 Zen exit-flush persistence, T16 rung persistence, T17 first-run-wins all pinned by tests.cargo clippy --workspace --all-targets -- -D warningsclean;cargo fmt --all --checkclean. MANUAL (deferred to owner): Android APK portrait check for Survival/Zen/Bot Ladder/Daily; Survival feed pacing + rung-curve playtest (FEED_*consts in coremode.rs,RUNG_COOLDOWNSinscreens_ladder.rs). R3 (T19 protocol bump) unlocked by this gate. - files edited/created: (gate — no code; this plan entry only)
- depends_on: [T18]
- location:
crates/tetris-core/src/versus.rs,crates/tetris-app/src/core_bridge/net/protocol.rs,crates/netplay-gateway(only if it inspectsAttackRule) - description: The only wire-breaking task.
AttackRulegainsDigandSwitch { swap_interval_ticks: u32, warning_ticks: u32 }appended (existing variant indices unchanged).Matchgains a match-leveltickscounter +MatchEvent::SwapWarning { at_tick }/MatchEvent::BoardSwapped { tick }, all serialized intoMatchSnapshot(new fields → new bytes — that's what the bump covers).PROTOCOL_VERSION"0.1.0" → "0.2.0" (handshake then refuses mixed builds — PRD says ship desktop + Android together, note in changelog).snapshot_hash = FNV-1a over bincode(MatchSnapshot)automatically covers the new fields; add a test asserting old-rule snapshots differ from new-struct snapshots so a stale field can't ride silently. - validation: Protocol roundtrip tests for every new variant; version
handshake rejection test for old↔new;
snapshot_hashcoverage test; gateway self-test still passes (cargo run -p netplay-gateway -- --self-test). - status: Completed
- log: T19 complete at commit 81bfdb6 (2026-10-01).
AttackRule::Dig(unit) +AttackRule::Switch { swap_interval_ticks, warning_ticks }appended afterRace— bincode indices pinned by canaries (core + protocol): Garbage=0, Race=1, Dig=2, Switch=3.Match::settlegained documented no-attack placeholder arms (Dig | Switch { .. } => {}); behavior lands in T20/T21.MatchEvent::SwapWarning { at_tick }/BoardSwapped { tick }appended, no emission logic yet. Match clock: explicitMatch::advance_match_clock() -> Vec<MatchEvent>called EXACTLY ONCE per fixed step after both sides ticked, mirrored identically inversus_bridge_system(local) andlockstep::apply_batch(host+guest netplay) — pure call-sequence logic, both peers run identical bridge code; gated on open match (winner set ⇒ frozen, snapshots stay byte-stable); pinned by tests (match_ticks == lockstep.tick1:1 on both peers, bridgematch_ticks == steps).MatchSnapshotgained appendedmatch_ticks: u64+swaps_done: u32(T21 swap bookkeeping);snapshot_hashcoverage test proves snapshots differing ONLY in the new fields hash differently.PROTOCOL_VERSION"0.1.0"→"0.2.0" (constant is single source; handshake tests already dynamic); roundtrip fuzz cases +net_msg_strategycover Dig/Switch; rule-index patch test proves a stale 0/1-only peer rejects new indices. Gateway confirmed rule-agnostic (wire.rs/room.rsnever inspectAttackRule— untouched). Validation:cargo test -p tetris-coregreen incl. T1 golden canary 3/3 untouched;cargo test -p tetris-app473+7 green (10k-case fuzz ×2, exhaustive truncation, lockstep/relay; 12 consecutive full-suite runs + 16 isolated loopback runs); 20-match soak--release --ignoredgreen; gateway 37+2--self-testPASS;cargo test --workspacegreen; clippy-D warningsclean; fmt clean. NOTE T20/T21: loopback mirror tests that snapshot across the input-delay pipeline (guest lags by design) normalizematch_ticksbefore hash-equality — tick-aligned equality is what the production per-60-tickSnapshotHashexchange checks.
- files edited/created:
crates/tetris-core/src/versus.rs,crates/tetris-app/src/core_bridge/net/protocol.rs,crates/tetris-app/src/core_bridge/versus.rs,crates/tetris-app/src/core_bridge/net/harness.rs,crates/tetris-app/src/core_bridge/net/lockstep.rs
- depends_on: [T19]
- location:
crates/tetris-core/src/versus.rs,crates/tetris-app/src/core_bridge/versus.rs,crates/tetris-app/src/screens_menu.rs(rule buttons),crates/tetris-app/src/hud.rs - description: Core:
AttackRule::Dig— both sides start with the same 10 buried garbage rows (same hole columns on both boards, derived from the match seed), same piece sequence (Matchsame-pieces option: both side game seeds identical), no garbage ever sent. Win: first side whosegarbage_rows_left()hits 0; a top-out loses immediately (opponent wins). App: new rule button under 1 v 1 (Garbage / Race / Dig) drivingstart_versuslocally first (no protocol exposure beyond T19's); versus HUD gains garbage-rows-left per side. - validation: Core tests: identical boards+sequences from one seed; first-to-clear crowns winner; top-out hands win over; replay determinism. Local human-vs-bot Dig Duel plays through.
- status: Completed
- log: 2026-10-01 — commit
987e067. Core:Match::newunderAttackRule::Digdraws ONE side seed (left's first draw) and builds both games withGame::with_config+ newversus::dig_side_config()— the documented mirror of solo Dig (modes::mode_config(ModeId::Dig): level pinned 1 /levels_advance:false,Goal::GarbageCleared,StartBoard::BuriedGarbage) so gravity is fixed at level 1 and the side game freezes in lockstep with the match crown. Board/hole derivation stays single-source (mode::StartBoard::BuriedGarbage::build): versus and solo Dig from the same seed produce the identical buried board (test-pinned);DIG_DUEL_GARBAGE_ROWS = 10mirrorsmodes::DIG_GARBAGE_ROWS.settle()Dig arm: never sends garbage, crowns the first side whosegarbage_rows_left() == 0on a lock via the existing winner path (WinnerCrowned— Garbage-rule precedent, overlay/rematch flow unchanged); top-out keeps the existing immediate opponent-crown (checked before the Dig arm, so same-lock top-out wins, mirroring core goal precedence). Tie rule: crowning is synchronous with the zeroing settle, so a "tie" is only possible within one bridge frame and goes to the first-settled side — fixed Left-then-Right bridge order ⇒ deterministic and symmetric on both peers (pinned bydig_same_frame_double_zero...both orders). T19'snew_rules_are_no_attack_placeholderssplit to Switch-only (renamedswitch_rule_is_a_no_attack_placeholder). App:RuleDigButtonthird button in the rules submenu →start_versuswithAttackRule::Dig(bot seat works; ladder still hard-wires Garbage); versus HUD Pending slot doubles asDUG n/10(rows of the side's own board containingPiece::Garbage, computed fromGameSnapshot.board— no snapshot/wire change) under Dig,+Nmeter unchanged for Garbage/Race. Human-vs-bot Duel plays through to a crowned winner (top-out). DEVIATION (reported on board): one stale T19 net-test expectation adapted —protocol.rs::match_clock_is_deterministic_...asserted placeholder-eradig.match_ticks == 40; real rules legitimately crown seed 99's duel at step 35 (T19 clock freeze), replaced with determinism-equality +match_ticks <= 40 && winner.is_some(); wire untouched, no protocol exposure. RED captured first (4 failing dig tests against the placeholder: empty boards, no crowns) → GREEN. Validation: tetris-core 188 lib + 6 + 3 + 5 green (marathon golden untouched), app 476 + 7 green,cargo test --workspacegreen, clippy--workspace --all-targets -- -D warningsclean,cargo fmt --all --checkclean. - files edited/created:
crates/tetris-core/src/versus.rs,crates/tetris-app/src/core_bridge/versus.rs,crates/tetris-app/src/screens_menu.rs,crates/tetris-app/src/hud.rs,crates/tetris-app/src/core_bridge/net/protocol.rs(test-expectation adaptation only, see log)
- depends_on: [T19, T20]
- location:
crates/tetris-core/src/versus.rs,crates/tetris-app/src/core_bridge/versus.rs,crates/tetris-app/src/screens_menu.rs,crates/tetris-app/src/hud.rs - description: Core: Garbage attacks as usual; every
swap_interval_ticks(default 1 800) the two sides' entire game states swap (board, bag state, hold, active piece, per-game counters) together with their pending garbage (decision: follows the board). Swap executes at the tick boundary after both sides ticked, deterministically on both peers;SwapWarningemittedwarning_ticks(default 180 = 3 s) before. Winner = opponent of the side that tops out (as Garbage). App: rule button + swap countdown in the versus HUD. - validation: Core tests: swap happens at exactly the right tick; state equivalence after swap (left-after == right-before); warning lead time; pending garbage moves with the board; replay determinism; no double-swap on asymmetric action timing.
- status: Completed
- log: 2026-10-01. Commit e7916cb (code) + (this docs commit). Core
versus.rs: Switch garbage attacks share the Garbage send/land path via a privateAttackRule::garbage_attacks()(settle + land arms widened toGarbage | Switch { .. }— same table, byte-identical attack stream, pinned byswitch_attack_stream_is_identical_to_the_garbage_rule). Swap executes insideMatch::advance_match_clockafter both sides ticked (next-frame inputs not yet applied): boundary =(swaps_done+1) * interval;std::mem::swap(&mut left, &mut right)+pending.swap(0, 1)swaps_done += 1⇒BoardSwapped { tick }. Pending follows the board (owner decision — queued batches travel with the board they were aimed at, not the queuer; a send created on the boundary frame settles before the clock advance and thus travels). Sides stay identities: top-out crownsside.other()even on an inherited stack. Warning:lead = match_ticks + warning_ticks, fires whenlead >= interval && lead.is_multiple_of(interval)— exactly once per boundary, never at tick 0; a warning longer than one interval rides the previous swap tick (SwapWarningof the next boundary emitted before that tick'sBoardSwapped).swap_interval_ticks == 0is inert (no swap, no warn); a crowned match's frozen clock (T19 gate) can never cross a boundary. No newMatchSnapshotfields — HUD derives the countdown frommatch_ticks/swaps_done/rulealone. App: Switch button in the 1v1 rules submenu (screens_menu.rs, defaultsSWITCH_SWAP_INTERVAL_TICKS = 1_800,SWITCH_WARNING_TICKS = 180); versus HUD Status slot doubles as the match-wideSWAP {secs}countdown (ceil-seconds;GARBAGE_COLORinside the warning window, white plain countdown,FINISHED_COLORotherwise; precedence ladder badge > swap > FINISHED —hud.rs). Bridge tests pin the full swap throughadvance_match_clockincl. bot-vs-bot crown through a boundary. DEVIATION: the 4th rules button shifted submenu geometry (rules Back now overlaps the title Settings row), so the two pre-existing click-through canaries inscreens_menu.rstap 11 px below the title Settings rect via a guardedclick_through_probe(assert-fails loudly if layout shifts again) — same tests, updated tap premise. TDD RED: 11 of the 12 new core Switch tests failed against the T19 placeholder (switch_rule_is_a_no_attack_placeholder: no attacks, no warnings, no swaps; onlyswitch_warning_never_fires_at_tick_zeropassed vacuously) → GREEN. Validation: tetris-core 199 lib + 6 + 3 + 5 green (marathon golden untouched), app 508 + 7 green,cargo test --workspacegreen, clippy--workspace --all-targets -- -D warningsclean (fixed 2 lints:is_multiple_of,div_ceil),cargo fmt --all --checkclean. One observed flake:screens_modestoggle canary failed once under workspace load, passed in isolation + all reruns (pre-existing pixel-click flake class, file untouched by T21). Not pushed. T22 note:lockstep.rsalready pipesadvance_match_clockevents intoVersusEvent(no net change needed);protocol.rsmatch_clock_is_deterministicuntouched (40 steps < first warning at tick 1 620).
- files edited/created:
crates/tetris-core/src/versus.rs,crates/tetris-app/src/core_bridge/versus.rs,crates/tetris-app/src/screens_menu.rs,crates/tetris-app/src/hud.rs
- depends_on: [T20, T21]
- location:
crates/tetris-app/src/core_bridge/net/{protocol.rs,session.rs,gateway.rs,online_ui.rs,harness.rs},crates/netplay-gateway/tests/* - description: Online UI: host picks the rule (Garbage/Race/Dig/Switch) —
it travels on
MatchStartalready; guest mirrors (unknown variants are gone post-bump). Extendsoak_rule(i)inharness.rsso the 20-match nightly soak (-- --ignored) covers each new rule, and add Dig/Switch cases to the relay loopback + NAT-punch e2e tests. Zero snapshot-hash mismatches is the PRD gate. Switch risk: consider a brief input freeze on swap ticks — playtest locally first, implement behind a named const if needed. - validation:
cargo test -p tetris-app -- --ignoredsoak green for every rule; relay e2e green; version-skew handshake test. - status: Completed
- log: 2026-10-01. Commits 5992bec (code) + (this docs commit). Host rule
picker:
OnlineRuleDigButton+OnlineRuleSwitchButtonmarkers inonline_ui.rsspawned beside the existing two, dispatch arms call the samehost_startpath (AttackRule::Dig/Switch { SWITCH_SWAP_INTERVAL_TICKS, SWITCH_WARNING_TICKS }— the production defaults already exported by T21'sscreens_menu.rs, imported not redefined; rule still travels onMatchStart, guest mirrors untouched). Bevy 0.19 constraint: tupleQueryDatacaps at 15 elements and the exclusive click query was exactly at 15, so the fourHas<RuleButton>markers ride one nested sub-tuple (flat query otherwise would not compile);ClickFlagsgaineddig/switch. Two new UI tests (four-rule coverage + portrait-safe stacking). Soak:soak_rule(i)now rotatesi % 4over Garbage/Race/Dig/Switch, 20 per rule ⇒ 80 matches; test renamednetplay_soak_20_matches→netplay_soak_20_matches_per_rule(CI invokes by module selection--release -p tetris-app -- --ignored, so the rename is CI-safe; the stale name lingers innetplay-plan.md§754/§770- one CI comment — docs-only, left for T25). Switch soak interval
300/60 (soak-only constant, documented in place) so every match crosses
≥1 swap boundary inside the 20-match budget — production default stays
1 800/180. Per-rule win conditions: Dig crowns with winner
buried_rows == 0(guest-mirrorburied_rowsprobe pins the buried start boards to exactlyDIG_DUEL_GARBAGE_ROWS) or loser dead; Switch requiresloser_deadandsnapshot.swaps_done >= 1⇒ a soak match that somehow never swapped fails. E2e: direct-UDP and gateway-relay crowns refactored to sharedrun_pair_crown_match/relay_crown_matchhelpers with per-rule wrappers —e2e_bot_vs_bot_dig_match_over_udp,e2e_bot_vs_bot_switch_match_over_udp_crossing_a_swap(interval 240, hash comparison forced ≥ tick 240 so both peers' streams are compared past the swap), plus the same two through the real gateway relay (..._through_gateway_relay).crates/netplay-gateway/tests/{relay_loopback, nat_punch_e2e}: no rule variants added — that crate has zero dependencies and its tests are rule-agnostic byte-relay wire proofs; rule-carrying relay e2e necessarily lives inharness.rsagainst the real gateway (design note, PRD intent preserved). Version skew:assert_kicks_wrong_version(version)extracted, newold_0_1_0_build_is_refused_by_the_0_2_0_hostexercises the exact historical bump (pinsPROTOCOL_VERSION != "0.1.0"). Input-freeze verdict: NOT shipped, no const needed — headless probe = soak + swap-crossing e2e: bots fire inputs every 60-tick cooldown around ~110 swap boundary crossings (33 155 Switch ticks at interval 300) with both peers'SnapshotHashstreams compared across every boundary ⇒ zero mismatches, zero desyncs; inputs straddling swap ticks stay bit-deterministic (swap executes insideadvance_match_clockafter both sides ticked, before next-frame inputs — T21 ordering). Measured soak (release): 80 matches / 394 772 ticks / 6 539 hash boundaries compared / 0 mismatches — per rule wall: Garbage 22.6 s (530 cmp), Race 77.8 s (1 855), Dig 150.8 s (3 611; longest match 37 939 ticks — bots dig out their buried stacks, long matches are the rule's nature), Switch 23.0 s (543); garbage storm healthy (916 sent / 898 landed, 21 cap hits); total 274 s release, 271 s-- --ignoreddebug — nightly- friendly. Validation: app 515 lib + 7 integration green (was 508+7, +7 new tests),cargo test --workspacegreen (core 199 + invariants/golden canary untouched),cargo run -p netplay-gateway -- --self-testPASS, clippy--workspace --all-targets -- -D warningsclean (one new lint fixed: type alias in the picker test),cargo fmt --all --checkclean. Test updates: soak rename (above) + helper extractions of the two pre-existing e2e and the version-mismatch test — same assertions, shared bodies. No core-crate edits;GameSnapshot/AttackRulewire untouched;gateway.rs/lockstep.rs/protocol.rsunchanged (clock already wired in T19/T21). Not pushed.
- one CI comment — docs-only, left for T25). Switch soak interval
300/60 (soak-only constant, documented in place) so every match crosses
≥1 swap boundary inside the 20-match budget — production default stays
1 800/180. Per-rule win conditions: Dig crowns with winner
- files edited/created:
crates/tetris-app/src/core_bridge/net/online_ui.rs,crates/tetris-app/src/core_bridge/net/harness.rs,crates/tetris-app/src/core_bridge/net/session.rs
- depends_on: [T18, T7]
- location:
crates/tetris-app/src/mutators.rs(new),crates/tetris-app/src/screens_modes.rs,crates/tetris-app/src/input.rs,crates/tetris-app/src/render.rs,crates/tetris-app/src/hud.rs,crates/tetris-app/src/records.rs - description:
Mutatorsresource selected on the mode-select screen per run (toggles: Invisible, No Hold, No Ghost, One Preview, 20G). Wiring per PRD R7: No Ghost = render skips ghost cells; One Preview = HUD next queue forced to 1; 20G =ModeConfig.start_level = 20(uses R1); No Hold = filterAction::Holdin the bridge beforePendingActionsreaches the core (core untouched); Invisible in T24. Mutated runs never write best records (owner decision) but still bump mode play counters — enforce centrally inRecords::record_run(mutated: bool). - validation: Headless tests per mutator: hold press is a silent no-op
with No Hold;
snapshot_with_nextuntouched but HUD shows 1; ghost absent in drawn cells (render test patterncells_of(app, CellKind::...)exists); 20G config start level; record suppressed + play count incremented with any mutator active. - status: Completed
- log: 2026-10-01. Commits 218ec6a (code) + (this docs commit). New
mutators.rs:Mutators(u8)manual bitset (NO_HOLD=1, NO_GHOST=2, ONE_PREVIEW=4, TWENTY_G=8,INVISIBLE=128reserved for T24 — in noSELECTABLE),empty/is_empty/contains/toggle/label/Bitor, zero new deps. Storage: selection =GameCore.selected_mutators(field on the existing NonSend resource — zero new Bevy resource entities, submenu/resource-churn canaries green untouched; the frozenstart_mode_run/start_mode_run_with_seedfree functions stay signature-identical because they already take&mut GameCore); snapshot-at-start =ActiveMode.mutatorswritten only byGameCore::start_mode(the one start path — rows, Enter, daily banner, R/Play-again retries, bots all route through it; later selection toggles never touch a live run, tested both directions). 20G =start_level: 20override insidestart_modebeforeGame::with_config(config +Gamesee it; core untouched). No Hold = filtered incore_bridge_system(core_bridge/mod.rs, wherePendingActionsreach the core;input.rsuntouched — HOLD binding unchanged; queued holds filter at apply time). No Ghost =render_playfielddropsCellKind::Ghostcells (drawn-cell test: 0 vs 4 baseline; snapshot'sghost_rowstillSome). One Preview =sync_hud_previewspins queue to 1 (beatsSettings.next_queue_size; fixtures unaffected; other modes' HUD tests green). Records: central gateRecords::record_run_mutated(key, record, mutated)— mutated ⇒ no content write, no NEW RECORD marker,record_runkept as raw fold for never-mutated writers (screens_ladderuntouchable, tests); plays stay ungated (bump_playsat start).terminal_record_systempasses!active_mode.mutators.is_empty()and skips the whole daily fold for mutated runs ⇒ a mutated daily run also writes noDailyrecord (owner decision, commented). Mutator toggle row on mode-select: 4MutatorToggleButtons fromMutators::SELECTABLEin fixed order, compact + wrap-friendly (portrait sizes), label sync system; selection persists across navigation within a session, never persisted to disk. TDD: RED = 21 targeted failures (bridge no-op/snapshot/20G, render ghost, HUD preview, e2e record/daily gates, all 4 UI tests) → GREEN 497 app lib tests + 7 integration. Validation:cargo test -p tetris-appgreen,cargo test --workspacegreen (T1 golden canary holds), clippy--workspace --all-targets -- -D warningsclean, fmt clean. Not pushed. - files edited/created:
crates/tetris-app/src/mutators.rs(new);crates/tetris-app/src/core_bridge/mod.rs;crates/tetris-app/src/records.rs;crates/tetris-app/src/screens_menu.rs;crates/tetris-app/src/screens_modes.rs;crates/tetris-app/src/render.rs;crates/tetris-app/src/hud.rs;crates/tetris-app/src/lib.rs
- depends_on: [T23]
- location:
crates/tetris-app/src/render.rs - description: Render-only: locked cells fade out over/after 1 s
(60 fixed steps). Render layer tracks per-cell lock age: when the board
snapshot grows a cell between frames, stamp
spawn_frame; fade alpha from age. Sprite pool already inrender.rscarries per-cell state. Must not touch the core or snapshot; active piece and ghost unaffected. - validation: Render test: after a lock, the locked cells' sprite alpha
matches the fade curve at sampled ages; disabled mutator ⇒ today's exact
colors (regression test reuses
drawn()helper). - status: Completed
- log: 2026-10-01. Commits 7e4a151 (code) + (this docs commit). Render-only per-cell lock fade in
render.rs:CellPool(existing solo sprite-pool resource — zero new resources) gainslock_ages: [[u16; COLS]; ROWS](fixed 22×10, no per-frame allocation; ages saturate atFADE_TOTAL_TICKSso they never collide with theNO_LOCK = u16::MAXunstamped sentinel) +fade_last_steps.advance_lock_agesruns only whileActiveMode.mutatorscarries INVISIBLE: newly present board cells stamp age 0; present cells age by theGameCore::stepsdelta (frame-rate independent, frozen during pause AND pre-roll —stepsonly counts applied core ticks); vanished cells unstamp. Continuity rule keyed by (col,row): a cell that vanishes and reappears is new ⇒ fade restarts (documented artifact: rows sliding down into an occupied (col,row) keep the old age, into a free one stamp fresh). Fresh-run reset:steps == 0 || steps < lastclears the table ⇒ ages never leak across runs; a run that starts with a stack (Dig buried garbage) stamps it as just-locked — full grace then normal fade, never retro-faded (chosen + tested). Constants:FADE_GRACE_TICKS = 30(full alpha),FADE_TOTAL_TICKS = 60(1 s),FADE_FLOOR_ALPHA = 0.0(invisible until cleared); curve viapub fn lock_fade_alpha(age)— grace, then linear to floor.sync_pooltakesOption<&ages>; onlyCellKind::Boardsprites getwith_alpha, active piece/ghost/HUD/versus pools untouched; core + snapshot never read from render (no feedback). Seam deviation:mutators.rs— INVISIBLE appended toSELECTABLE(5 entries) + docs + the two T23 seam pin tests flipped (that file always needed more than the "one line": array len + pins).screens_modes.rs2-line test tweak: toggle row spawns dynamically fromSELECTABLE(no labels to add) butmutator_toggles_render_in_fixed_order_off_by_defaulthardcoded!contains(INVISIBLE)— pin removed (T21 worker notified on board). RED→GREEN: fade tests capturedalpha [1.0,...]at mid/floor ages pre-wiring (INVISIBLE drew today's colors) → green after. Tests (render::t24_tests, 6): curve unit; per-cell alpha at ages 0/30/45/≥60 via drawn-sprite probes atcell_centerpositions; active full + ghostGHOST_ALPHAwith INVISIBLE on; disabled ⇒ exactpiece_colorconstants forever (200 frames); refill-restarts-fade (internallock_agesall-NO_LOCKafter reset, alpha 1.0 refilled); Dig start board never retro-faded then fades normally. Validated at HEAD-pinned worktree (parallel T21 WIP inversus.rsuncompilable in shared tree): app 503+7 green incl. T23 ghost/NO_GHOST tests,cargo test --workspace15 suites green (T1 golden canary), clippy--workspace --all-targets -D warningsclean,fmt --all --checkclean. - files edited/created:
crates/tetris-app/src/render.rs;crates/tetris-app/src/mutators.rs(SELECTABLE seam + pin tests);crates/tetris-app/src/screens_modes.rs(2-line T23 pin removal)
- depends_on: [T22, T23, T24]
- location: repo-wide;
PRD.md§14 item 3;CHANGELOG.md; check-in ofgame-modes-PRD.md - description: Full validation incl. per-rule 20-match soaks and relay
e2e; clippy/fmt; Android APK portrait check for Dig Duel, Switch and
mutators (mode screens reachable with touch only; Switch swap warning
visible). Docs: bump
PROTOCOL_VERSIONnote in README/CHANGELOG ("desktop and Android must update together"), addgame-modes-PRD.mdbesidePRD.mdand close §14 item 3 (pending the owner checkbox), update the README modes table. - validation: All green; changelog reviewed.
- status: Completed
- log: R3 automated gate GREEN at commit 4473366 (2026-10-01).
cargo test --workspace= 15/15 suites ok incl. T1 golden canary (3 golden — Marathon +GameSnapshotwire untouched end-to-end; the only wire change of the plan was T19's versioned bump); per-rule soakcore_bridge::net::harness::tests::netplay_soak_20_matches_per_rulePASS in debug (-- --ignored, 272 s; release run at T22: 80 matches / 394,772 ticks / 6,539 hash boundaries, 0 mismatches, every rule crowned); gateway--self-testPASS;cargo clippy --workspace --all-targets -- -D warningsclean;cargo fmt --all --checkclean. NOTE:cargo test --workspace -- --ignoredalso picks up netplay-gateway'scrash_free_soak_1h(1-hour nightly long-runner — out of scope here; run nightly). Docs shipped with this gate: version 0.3.2 → 0.4.0; CHANGELOG 0.4.0 entry (modes, Dig Duel + Switch, mutators, PROTOCOL 0.2.0 "desktop and Android must update together"); README modes table + rules/mutators bullets; stalenetplay_soak_20_matchesname refs annotated innetplay-plan.md. DEFERRED TO OWNER: check-in ofgame-modes-PRD.md+ closing PRD §14 item 3 (owner checkbox pending — both left untouched/untracked); Android APK portrait touch check (mode screens reachable w/ touch, Switch swap warning visible); playtest tuning of named consts (SurvivalFEED_*,RUNG_COOLDOWNS,SWITCH_SWAP_INTERVAL_TICKS/SWITCH_WARNING_TICKS,FADE_*). - files edited/created:
Cargo.toml,CHANGELOG.md,README.md,netplay-plan.md, this plan entry
Shared-file hubs (modes.rs catalogue pre-declared in T5 minimizes this,
hud.rs, screens_menu.rs, records.rs, versus.rs core+bridge): at
most one task per wave may edit a given file; the coordinator serializes
colliding tasks — parallelism is the default, same-file tasks queue.
| Wave | Tasks | Can Start When |
|---|---|---|
| 1 | T1 | Immediately |
| 2 | T2, T6 | T1 done |
| 3 | T3, T5 | T2 done |
| 4 | T4, T7, T8 | T3+T2→T4; T5(+T6)→T7; T5→T8 |
| 5 | T9, T10 | T5, T6, T8→T9; T3, T4, T5→T10 |
| 6 | T11 — R1 gate | T7–T10 done |
| 7 | T12, T15 | R1 gate passed (T14 waits: same core game.rs as T12) |
| 8 | T13, T14, T17 | T12→T13; T11→T14; T6, T7→T17 |
| 9 | T16 | T15 (and ladder UI files free) |
| 10 | T18 — R2 gate | T13, T14, T16, T17 done |
| 11 | T19 | R2 gate passed |
| 12 | T20, T23 | T19→T20; T18→T23 (mutators touch hud.rs — serialize with T20's HUD edits) |
| 13 | T21, T24 | T20→T21; T23→T24 |
| 14 | T22 | T20, T21 done |
| 15 | T25 — R3 gate + docs | T22, T24 done |
- Regression gate everywhere: T1 golden snapshot + bincode-canary test
runs on every task; wire shape of
GameSnapshot/MatchSnapshotmay only change in T19. - Core: unit tests per mode config in
mode.rs/game.rs/versus.rs; same-seed replay test per new behavior (event streams + snapshots equal);modes_headless.rsbot-completion tests run on every CI push. - App: headless
MinimalPlugins/hidden-window tests following the existing patterns incore_bridge/mod.rs,hud.rsfixtures andrender.rsdrawn-cell probes. - Netplay: protocol roundtrip + hostile-fuzz (existing proptest covers
new variants once added to the strategy), 20-match nightly soak
(
cargo test --workspace -- --ignored) extended per rule, relay loopback + NAT-punch e2e. - Persistence: every records test isolates
TETRIS_CONFIG_DIRunder the existingENV_LOCK; migration tested from a literal legacybest.json. - Phone parity: manual Android APK portrait check at each release gate (start / play / leave each shipped mode with touch only).
- CI commands:
cargo test --workspace,cargo clippy --workspace -- -D warnings,cargo fmt --all --check.
- Reopening the frozen core contract → done once in T2, default config = Marathon, hard-gated by T1 golden test at T11.
- Wire format break lands too early → bincode byte-canary assertion in T1
fires in CI the moment anyone touches
GameSnapshot/MatchSnapshotbefore T19; protocol bump isolated to T19. - Old/new builds can't interop post-R3 → version handshake (existing mechanism) + changelog: ship desktop and Android together.
- Phone menu crowding → scrolling list, one line per mode (T7); versus rules stay under 1 v 1 / Online, not in the solo list.
- Ladder curve uneven (greedy bot gets faster, not smarter) → speeds are a single named const array in T16, tunable without touching logic; ship fewer rungs if needed.
- Switch disorients under input delay → 3 s warning mandatory; optional swap-tick input freeze behind a const, decided after local playtest (T21) before online soak (T22).
- Survival/Ultra/Switch timings are guesses → all named constants, playtest tuning at the release gates (T11/T18).
best.jsonmigration corrupts a real profile → additive read of the legacy shape before ever writing the new one; atomic writes already in place; corruption falls back to defaults as today.- Zen lifetime-lines persistence churn → piggyback on the existing debounced save, not per-line writes.