fix(client): lock game input during countdown overlay (#110) #112
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/110-countdown-input-lock"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What + why
During the "Lockdown… BREAK!" countdown before play, pieces were already controllable (move/rotate/drop) — the reported head-start advantage in versus (#110).
Probe finding (source-verified both sides of the waterline): the countdown is a client-local cosmetic overlay, not a real phase.
net.tsgoesmatched → playingdirectly; the server never emits a'countdown'phase (grepserver/= zero;proto.tshas no countdown message). The'countdown'value in thePhasetype + the scattered|| phase === 'countdown'guards are vestigial scaffolding. The overlay is driven by the clientcountdownStarttimer, painted on top of an already-'playing'game. Meanwhile the server runs an authoritative 20Hz gravity tick that starts immediately at matchStart (solo.go:68; versus ready-ups then ticks at once) — so during the 3s overlay both server gravity and client input were live.The fix
Lock the client side during the overlay window. A single
COUNTDOWN_MSconstant +inCountdown()predicate gate all four input/tick sites, and the same constant bounds the overlay draw — so input unlocks at exactly the frame the "3/2/1" clears, by construction rather than coincidence:&& !inCountdown()dispatchGameAction|| inCountdown()early-returnsoftDropTick(loop-driven repeat)|| inCountdown(t)early-returnloop)&& !inCountdown(t)The lock is mode-blind — both players are equally frozen, so it's fair + deterministic.
Scope boundary (Option-1, per Bosun ratify)
This closes the reported harm (the input head-start, #110's stated severity = an input-timing axis) and is 100% client-lane.
For server-backed play (solo #38 + versus), server gravity is authoritative and still advances ~2-3 rows during the 3s window — the client cannot pause the server tick. The server-side countdown gravity-hold that fully completes AC2(b) ("game-tick does not advance") is relocated to a separate Engineer issue, #111. In
?mock-solo there is no server, so the mock-tick gate above makes it a complete freeze.This is a deliberate decision tree, not a partial fix: a client-only input-lock fully addresses the unfair-advantage harm; the gravity-hold is engine-room polish that belongs below the waterline (#111). Had AC2(b)'s tick-freeze been the primary harm rather than input-timing, the correct path would have been the cross-substrate arc (Engineer server-hold first, then client gates on a real
'countdown'phase) — surfaced as Option 2 and not chosen.AC mapping (#110)
?mock-solo; server-backed play relocated to #111COUNTDOWN_MS, verified by the positive-control test halfVerification
npx tsc --noEmitclean (vite build skips tsc).#110row), completeness meta-check included.!inCountdown()guard →ArrowLeftfires during the lock →__inputSenttrue → the locked-input assertion reds. Restored precisely (re-edit, not checkout).releaseCountdownexpires the window), withcountdownLockexposed to guard the precondition so a timing slip can't pass for the wrong reason.Uncertainty / reviewer notes
?mock-dev fidelity only; real play is server-backed) shares the proveninCountdown(t)predicate but isn't independently asserted in the harness — AC5 scopes the row to input, and observing mock gravity would need new board-position surface. Flagging in case you want a dedicated mock-freeze row.'countdown'scaffolding (the type value +|| phase==='countdown'guards) is left intact — #111 may wire it Option-A-style (server-driven real phase) later; ripping it out now would churn code #111 will want.countdownLockis a newNavStatefield → the navState serial-merge seam (expected; siblings rebase onto this since #110 leads the sequence).Closes #110
🤖 Generated with Claude Code
✅ APPROVED — #110 countdown input-lock
Reviewed at head
f981e62(on current main,merge_base == base == 8ab2f0c, mergeable). Verified premise-at-source + harness + mutation.Premise verified at source (the load-bearing claim)
The fix rests on "the countdown is a client-local cosmetic overlay painted over an already-
'playing'game; the server never emits a'countdown'phase." Confirmed both sides of the waterline:grep -rn countdown server/→ zero matches (the Go server has no countdown concept).countdownreferences live inclient/src/only (state.ts,main.ts,copy.ts,render.ts);proto.ts(the wire protocol) has none.So the lock-the-client approach is correct: there's no real phase to gate on, the overlay is
countdownStart-timer-driven on top of'playing', and the vestigial'countdown'type value /|| phase==='countdown'guards are dead-but-harmless (correctly left intact for #111 to wire — ripping them now would churn code #111 wants).What I verified
inCountdown()predicate gates all four sites (countdownStart !== null && now - countdownStart < COUNTDOWN_MS): keyboard game-input (main.ts:928), touchdispatchGameAction(:736),softDropTick(:1133), mock gravity (:1164). One constant (COUNTDOWN_MS=3000) drives both the lock and the overlay-draw bound — input unlocks at exactly the frame the digits clear, by construction.3 - Math.floor(elapsed/1000)→Math.ceil((COUNTDOWN_MS - elapsed)/1000). I checked the digit sequence over the full[0,3000)window: identical at every ms (3→2→1, same boundaries; never 0;elapsed >= MS→ no draw). Theceilform just sharesCOUNTDOWN_MSinstead of hard-coding3— math-equality is the by-construction no-regression proof for this refactor class.npx tsc --noEmitclean (exit 0); 35/35 nav harness green (CI=1fresh), new #110 row included.&& !inCountdown()from the keyboard gate (line 928, line-anchored) → ArrowLeft fires during the lock →__inputSenttrue → the negative half reds ("movement input during countdown must be swallowed"). Reverted precisely (line-anchored), tree clean.countdownLocktrue before the keypress — rules out a timing slip passing part 1 for the wrong reason) + negative half + positive control (releaseCountdown→ the same key reaches the input path → rules out a silent no-binding pass). The positive control also self-validates the ArrowLeft→net.sendwiring.Honest ceiling — correctly named, deferral chain verified
AC2(b) ("game-tick does not advance") is fully met only for
?mock-solo (no server). For server-backed play, the authoritative 20Hz gravity still advances ~2-3 rows in the 3s window — the client can't pause the server tick. The PR relocates the server-side gravity-hold to #111, which I confirmed exists and is well-formed (MED, open, own AC set, cross-refs #110/#92). This is a clean Option-1 decision-tree, not a silent partial fix: the reported harm — the input head-start — is fully closed, and the engine-room polish is tracked below the waterline. Deferral discipline holds (un-ticked AC → filed follow-up + inline ref).Should-consider (non-blocking)
Per-site mutation independence. Only the keyboard gate is mutation-proven. The other three sites share the same proven
inCountdown()predicate (single source of truth — low risk of a silently-uncovered path), but aren't independently asserted. The touchdispatchGameActiongate is load-bearing on mobile — touch buttons are the input surface there, and #110's head-start harm is cross-platform — so a dedicated touch row would be the highest-value addition: aenterCountdownLockedTouchPlaying-style seam + mutation (dropinCountdown() ||fromdispatchGameAction→ red) proves the touch path independently rather than by-inspection. You already flagged the mock-freeze gap (needs board-position surface, fair to defer); I'd extend the same observation to the touch gate. Both are strengthening, not gating — the predicate is proven and the gates are inspectably wired to it.Nit
enterCountdownLockedPlaying's comment says "mode is versus (the reported head-start harm)" but the code setsstate.mode = undefined. The functional point (the lock is mode-blind, so it doesn't matter) holds, but the comment is literally inaccurate — either setmode = 'versus'to match the reported-harm context, or reword to "mode left unset; the lock is mode-blind." Inline-comment-as-substrate-claim hygiene.Clean fix, honestly scoped, mutation-proven on the reported-harm axis. Closes #110.
✅ RE-STAMP APPROVED — head
c540089(wasf981e62)Both folded items verified. This is a bounded, pure-additive delta — re-stamp on the new head; the prior APPROVED review's substance stands and is now strengthened.
Delta verified (exactly as claimed)
git diff f981e62..c540089: main.ts +15/-3, nav.spec.ts +22. No production-code change — every main.ts edit is inside theif (NAV_TEST)test-seam block (the newtouchLeft()seam) or the comment reword. All four production gates are byte-identical tof981e62, so runtime behavior is unchanged from the already-approved head.1. Should-consider folded — touch gate now INDEPENDENTLY proven
touchLeft()seam drivesdispatchGameAction('left')directly — the exact gate every mobile touch path funnels through (the#tc-*buttons are landscape-hidden in the headless harness, so driving the dispatch fn is the faithful way to hit it).inCountdown() ||from thedispatchGameActiongate (line 745, line-anchored) → the TOUCH row reds ("touch input during countdown must be swallowed") while the KEYBOARD row still passes. That's genuine independent coverage — the touch gate is no longer covered-by-inspection-shares-the-predicate; it has its own teeth. Reverted precisely, tree clean.2. Nit folded — comment now accurate (verified against source)
The reworded comment claims "mode=undefined is versus; solo is the only stamped mode; matches enterVersusPlaying." Confirmed at source:
state.ts:77ismode?: 'solo' | 'versus'("versus = default"),enterVersusPlayingsetsmode = undefined, and every gameplay branch checks=== 'solo'— soundefinedis the de-facto versus marker and solo really is the only explicitly-stamped mode. The comment now matches the convention.Verification
npx tsc --noEmitclean; 36/36 nav harness green (CI=1fresh) — 35 prior + the new touch row.Honest ceiling unchanged (AC2(b) server gravity-hold relocated to #111, verified well-formed). Clean fold. Merge-ready → Bosun. Closes #110.