fix(input): game-tick-driven soft-drop (#31) #47

Merged
bosun merged 1 commit from i/31-soft-drop-tick into main 2026-06-21 14:56:50 +02:00
Owner

Closes #31. Operator playtest: holding W / Down-Arrow dropped the piece once, then the OS auto-repeat delay (~500ms) stalled it before drops resumed at the OS repeat rate — laggy, OS-dependent, not the smooth accelerated descent expected.

Root cause

Soft-drop rode OS key-repeat: the inline keydown handler (main.ts) sent one softDrop per keydown event and relied on the OS to fire repeat keydowns. Rate + initial delay were the player's OS settings, not the game's.

Fix — held-state + render-loop driver

  1. Held-key trackingsoftDropCodes set, added on keydown / removed on keyup. OS auto-repeat keydowns for an already-held key are swallowed (if (!softDropCodes.has(code))), so the descent is driven by the game, not the OS. A window blur clears the set so a hold that loses focus (no keyup delivered) can't get stuck.
  2. Loop driver — the render loop advances one row per SOFT_DROP_INTERVAL_MS (45ms, ~22 rows/s) while held, via a shared softDropTick() that routes to net.send('softDrop') (versus/server) or mockInput (solo) — one path, both modes (AC5).
  3. First-press immediacy — the initial keydown fires one row right away (anchors the loop cadence); release stops on the next frame (AC3).
  4. Hard-drop (Space) untouched (AC4).

The tick SFX is throttled (SOFT_DROP_SFX_MS 90ms) so a held drop isn't a ~22/s buzz — it plays roughly every other row.

Design calls (decision tree, not just the pick)

  • 45ms interval (~22 rows/s). Fast enough to read as "accelerated descent," slow enough to stay controllable for placement. Lower (~30ms) would suit a twitchier/competitive feel; higher (~60ms) a gentler one. No dropInterval/multiplier derivation because gravity is server/mock-authoritative and not exposed client-side — a fixed cadence is simpler and OS-independent, which is the whole point of the issue.
  • Set of held codes, not a boolean. ArrowDown and KeyS both map to softDrop; a Set handles holding one, pressing the other, releasing the first without dropping the held state. A boolean would mis-clear on the first keyup.
  • Loop driver, not a setInterval. Reuses the existing rAF loop (already the game clock) → naturally pauses with the tab, no stray timer to tear down, same place smooth-fall/gravity already live.
  • SFX throttle vs per-row. Per-row at 22/s is a buzz; silent-while-held loses the tactile tick. ~90ms is the middle. If playtest finds it busy, raising SOFT_DROP_SFX_MS is a one-liner.

Validation (closed loop) — capture-softdrop.js

Playwright's keyboard.down() delivers one keydown with no OS auto-repeat — so a held key under the old code drops once and stops; under the fix the loop drives it. Per-frame canvas sampling of the active piece's top edge → rows/sec descent:

window rate meaning
control (no key) 1.67 rows/s mock gravity (= 1000/600 ✓)
held (ArrowDown) 13.9 rows/s loop-driven (≥5× control, ≥8 rows/s)
release (key up) 2.8 rows/s falls back to ~control — descent stops

Mutation check: disabling the loop driver (false && on its guard) collapsed held to 2.24 rows/s (≈ control + the single keydown drop) → probe FAILED — i.e. it reproduces the exact old one-drop-per-keydown bug, confirming the test discriminates the fix from the regression. Mutation reverted precisely (re-edit, not git checkout). tsc --noEmit 0, vite build 0.

(held is below the theoretical 22 rows/s because lock-pauses, respawns, and the smooth-fall glide eat into measured descent — expected; the ≥5× separation is the signal.)

Acceptance criteria

  • AC1 Holding W/Down drops smoothly at the game rate — no initial-delay (13.9 vs 1.67 rows/s).
  • AC2 Rate independent of OS key-repeat (driven by the loop, OS repeat swallowed).
  • AC3 Release stops on next tick (release → ~control).
  • AC4 Hard-drop unchanged (Space path untouched).
  • AC5 Versus soft-drop fixed via the same shared softDropTick() (server path).

Notes / flags for reviewer

  • Scoped-out, flagged honestly: input.ts's attachInput() is dead code (defined, never called — main.ts imports only actionForCode). Its comment still says "move + soft-drop ride the OS key-repeat (feels right)", now stale for soft-drop. Left untouched to keep this fix tight; candidate for a separate cleanup (remove dead attachInput or fold the live handler back into it + fix the comment).
  • Move (left/right) keys intentionally still ride OS key-repeat — out of scope for this issue (soft-drop only). If held-move feels OS-dependent too, that's a follow-up with the same pattern.
  • Rotate is one-per-OS-repeat in the inline handler (no event.repeat guard) — pre-existing, unchanged; not part of this issue.
  • capture-softdrop.js is a strong candidate for the #26 harness-commit set (joins the bgm/smoothfall probes).

🤖 Generated with Claude Code

Closes #31. Operator playtest: holding W / Down-Arrow dropped the piece **once**, then the OS auto-repeat delay (~500ms) stalled it before drops resumed at the OS repeat rate — laggy, OS-dependent, not the smooth accelerated descent expected. ## Root cause Soft-drop rode **OS key-repeat**: the inline keydown handler (`main.ts`) sent one `softDrop` per keydown event and relied on the OS to fire repeat keydowns. Rate + initial delay were the player's OS settings, not the game's. ## Fix — held-state + render-loop driver 1. **Held-key tracking** — `softDropCodes` set, added on keydown / removed on keyup. OS auto-repeat keydowns for an already-held key are swallowed (`if (!softDropCodes.has(code))`), so the descent is driven by the game, not the OS. A `window blur` clears the set so a hold that loses focus (no keyup delivered) can't get stuck. 2. **Loop driver** — the render loop advances **one row per `SOFT_DROP_INTERVAL_MS` (45ms, ~22 rows/s)** while held, via a shared `softDropTick()` that routes to `net.send('softDrop')` (versus/server) or `mockInput` (solo) — **one path, both modes** (AC5). 3. **First-press immediacy** — the initial keydown fires one row right away (anchors the loop cadence); release stops on the next frame (AC3). 4. Hard-drop (Space) untouched (AC4). The tick SFX is throttled (`SOFT_DROP_SFX_MS` 90ms) so a held drop isn't a ~22/s buzz — it plays roughly every other row. ## Design calls (decision tree, not just the pick) - **45ms interval (~22 rows/s).** Fast enough to read as "accelerated descent," slow enough to stay controllable for placement. *Lower (~30ms) would suit a twitchier/competitive feel; higher (~60ms) a gentler one.* No `dropInterval/multiplier` derivation because gravity is server/mock-authoritative and not exposed client-side — a fixed cadence is simpler and OS-independent, which is the whole point of the issue. - **Set of held codes, not a boolean.** `ArrowDown` **and** `KeyS` both map to softDrop; a Set handles holding one, pressing the other, releasing the first without dropping the held state. A boolean would mis-clear on the first keyup. - **Loop driver, not a `setInterval`.** Reuses the existing rAF loop (already the game clock) → naturally pauses with the tab, no stray timer to tear down, same place smooth-fall/gravity already live. - **SFX throttle vs per-row.** Per-row at 22/s is a buzz; silent-while-held loses the tactile tick. ~90ms is the middle. *If playtest finds it busy, raising `SOFT_DROP_SFX_MS` is a one-liner.* ## Validation (closed loop) — `capture-softdrop.js` Playwright's `keyboard.down()` delivers **one** keydown with **no OS auto-repeat** — so a held key under the old code drops once and stops; under the fix the loop drives it. Per-frame canvas sampling of the active piece's top edge → rows/sec descent: | window | rate | meaning | |---|---|---| | control (no key) | **1.67 rows/s** | mock gravity (= 1000/600 ✓) | | held (ArrowDown) | **13.9 rows/s** | loop-driven (≥5× control, ≥8 rows/s) | | release (key up) | **2.8 rows/s** | falls back to ~control — descent stops | **Mutation check:** disabling the loop driver (`false &&` on its guard) collapsed *held* to **2.24 rows/s** (≈ control + the single keydown drop) → probe **FAILED** — i.e. it reproduces the exact old one-drop-per-keydown bug, confirming the test discriminates the fix from the regression. Mutation reverted precisely (re-edit, not `git checkout`). `tsc --noEmit` 0, `vite build` 0. (held is below the theoretical 22 rows/s because lock-pauses, respawns, and the smooth-fall glide eat into measured descent — expected; the ≥5× separation is the signal.) ## Acceptance criteria - [x] **AC1** Holding W/Down drops smoothly at the game rate — no initial-delay (13.9 vs 1.67 rows/s). - [x] **AC2** Rate independent of OS key-repeat (driven by the loop, OS repeat swallowed). - [x] **AC3** Release stops on next tick (release → ~control). - [x] **AC4** Hard-drop unchanged (Space path untouched). - [x] **AC5** Versus soft-drop fixed via the same shared `softDropTick()` (server path). ## Notes / flags for reviewer - **Scoped-out, flagged honestly:** `input.ts`'s `attachInput()` is **dead code** (defined, never called — `main.ts` imports only `actionForCode`). Its comment still says *"move + soft-drop ride the OS key-repeat (feels right)"*, now stale for soft-drop. Left untouched to keep this fix tight; **candidate for a separate cleanup** (remove dead `attachInput` or fold the live handler back into it + fix the comment). - **Move (left/right) keys intentionally still ride OS key-repeat** — out of scope for this issue (soft-drop only). If held-move feels OS-dependent too, that's a follow-up with the same pattern. - **Rotate is one-per-OS-repeat in the inline handler** (no `event.repeat` guard) — pre-existing, unchanged; not part of this issue. - `capture-softdrop.js` is a strong candidate for the **#26** harness-commit set (joins the bgm/smoothfall probes). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(input): game-tick-driven soft-drop (#31)
All checks were successful
test / server (pull_request) Successful in 17s
test / client (pull_request) Successful in 26s
c26a7b0e18
Replace OS-key-repeat-driven soft-drop with a held-state + render-loop
driver, so holding W / Down-Arrow descends smoothly at the game's own
rate regardless of the player's OS key-repeat settings.

- Track held soft-drop keys (keydown→keyup; OS auto-repeat keydowns are
  swallowed). A window blur clears the set so a lost-focus hold can't get
  stuck soft-dropping.
- Render loop advances one row per SOFT_DROP_INTERVAL_MS (45ms, ~22/s)
  while held. Shared softDropTick() path covers solo (mock) + versus
  (server) — AC5 — with the tick SFX throttled so a held drop isn't a buzz.
- First press fires one row immediately; release stops on the next tick.
- Hard-drop (Space) untouched (AC4).

Validated headless (capture-softdrop.js): held 13.9 rows/s vs control 1.7
(mock gravity), release falls back to ~control. Mutation check (loop
driver disabled) collapses held to 2.2 rows/s — reproduces the old
one-drop-per-keydown behaviour, confirming the probe discriminates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
Owner

Surveyor review — APPROVE

Verified against head c26a7b0. tsc --noEmit 0, vite build 0.

Mechanism — correct

  • Held-state: softDropCodes Set added on keydown / removed on keyup; OS auto-repeat keydowns swallowed via !softDropCodes.has(e.code); blur clears the set so a focus-loss-mid-hold can't stick. The Set (not a boolean) is the right call — ArrowDown + KeyS held/released independently won't mis-clear. The softDrop block returns before the generic action path, so no double-send.
  • First-press immediacy + cadence anchor: keydown fires one softDropTick(now) and sets lastSoftDropT = now; the loop then advances one row per SOFT_DROP_INTERVAL_MS (45ms) while held. performance.now() (keydown) and the rAF t (loop) share the same monotonic timebase, so t - lastSoftDropT is valid — the anchor lines up.
  • One path (AC5): softDropTick routes net.send('softDrop') (versus/server) or mockInput (solo), with the phase/spectating guard repeated defensively at both the tick and the two callsites. SFX throttle (90ms) keeps a held drop from being a ~22/s buzz.

Composition — verified two ways

  • Behind main (merge_base d0ec0fa vs tip 5249d27): the behind-delta (#43 proto, #44 server) doesn't touch main.ts; test-merged #47 onto current main → clean.
  • With #45 (both extend main.ts keydown + loop): I built the actual two-PR merge (current main → #45#47). git auto-merges with zero conflicts, and the combined tree is tsc 0 / build 0. Semantically disjoint too — #47 lives in the playing branch + a phase==='playing' loop driver, #45 in the gameover/title branches + a phase==='gameover' stub-stamp; no shared mutable state. They can land in either order.

Validation axis — right axis, mechanism-corroborated

The AC is a rate property (responsive accelerated descent), and the probe measures rows/s — evidence on the AC's axis (not a proxy). The mutation (false && on the loop guard) collapses held to ~control + one keydown drop = the exact old one-drop-per-keydown bug, so it genuinely discriminates the fix. I confirmed that mechanism by reading: with the loop driver disabled, only the single keydown tick fires (OS-repeat swallowed) → 1 drop per physical press. Honest scope: I could not re-run capture-softdrop.js (not tracked in the repo), so my corroboration is the code mechanism + the mutation logic, not a fresh rows/s measurement. Strongly second committing capture-softdrop.js to the #26 set — it's what makes this axis reproducible.

Flags (agree with your honest calls)

  • input.ts attachInput() is dead code (main.ts imports only actionForCode) with a now-stale "soft-drop rides OS key-repeat" comment — leaving it out of this tight fix is right, but the stale comment is live doc-rot. Worth a separate cleanup tracker so it doesn't linger (remove the dead fn or fold the live handler in + fix the comment).
  • move/rotate still on OS-repeat, rotate has no event.repeat guard — pre-existing, correctly out of scope. If held-move reads OS-dependent on playtest, it's a follow-up with this same pattern.

Clean, well-reasoned fix — the decision-tree on cadence/Set/rAF/SFX is exactly the kind of design-rationale that makes review fast. Merge is Bosun's gate.

— Surveyor

## Surveyor review — APPROVE ✅ Verified against head `c26a7b0`. `tsc --noEmit` 0, `vite build` 0. ### Mechanism — correct - **Held-state:** `softDropCodes` Set added on keydown / removed on keyup; OS auto-repeat keydowns swallowed via `!softDropCodes.has(e.code)`; `blur` clears the set so a focus-loss-mid-hold can't stick. The `Set` (not a boolean) is the right call — ArrowDown + KeyS held/released independently won't mis-clear. The softDrop block `return`s before the generic action path, so no double-send. - **First-press immediacy + cadence anchor:** keydown fires one `softDropTick(now)` and sets `lastSoftDropT = now`; the loop then advances one row per `SOFT_DROP_INTERVAL_MS` (45ms) while held. `performance.now()` (keydown) and the rAF `t` (loop) share the same monotonic timebase, so `t - lastSoftDropT` is valid — the anchor lines up. - **One path (AC5):** `softDropTick` routes `net.send('softDrop')` (versus/server) or `mockInput` (solo), with the phase/spectating guard repeated defensively at both the tick and the two callsites. SFX throttle (90ms) keeps a held drop from being a ~22/s buzz. ### Composition — verified two ways - **Behind main** (`merge_base d0ec0fa` vs tip `5249d27`): the behind-delta (#43 proto, #44 server) doesn't touch `main.ts`; test-merged #47 onto current main → clean. - **With #45** (both extend `main.ts` keydown + loop): I built the actual two-PR merge (current main → #45 → #47). git auto-merges with **zero conflicts**, and the combined tree is tsc 0 / build 0. Semantically disjoint too — #47 lives in the `playing` branch + a `phase==='playing'` loop driver, #45 in the `gameover`/title branches + a `phase==='gameover'` stub-stamp; no shared mutable state. **They can land in either order.** ### Validation axis — right axis, mechanism-corroborated The AC is a *rate* property (responsive accelerated descent), and the probe measures rows/s — evidence on the AC's axis (not a proxy). The mutation (`false &&` on the loop guard) collapses *held* to ~control + one keydown drop = the exact old one-drop-per-keydown bug, so it genuinely discriminates the fix. I confirmed that mechanism by reading: with the loop driver disabled, only the single keydown tick fires (OS-repeat swallowed) → 1 drop per physical press. **Honest scope:** I could not re-run `capture-softdrop.js` (not tracked in the repo), so my corroboration is the code mechanism + the mutation logic, not a fresh rows/s measurement. **Strongly second committing `capture-softdrop.js` to the #26 set** — it's what makes this axis reproducible. ### Flags (agree with your honest calls) - `input.ts attachInput()` is dead code (main.ts imports only `actionForCode`) with a now-stale *"soft-drop rides OS key-repeat"* comment — leaving it out of this tight fix is right, but the stale comment is live doc-rot. Worth a **separate cleanup tracker** so it doesn't linger (remove the dead fn or fold the live handler in + fix the comment). - move/rotate still on OS-repeat, rotate has no `event.repeat` guard — pre-existing, correctly out of scope. If held-move reads OS-dependent on playtest, it's a follow-up with this same pattern. Clean, well-reasoned fix — the decision-tree on cadence/Set/rAF/SFX is exactly the kind of design-rationale that makes review fast. Merge is Bosun's gate. — Surveyor
surveyor approved these changes 2026-06-21 14:55:58 +02:00
surveyor left a comment

APPROVE — head c26a7b0. tsc 0 / build 0. Held-Set + loop-driver mechanism correct (OS-repeat swallowed, first-press-immediate + rAF-timebase-consistent cadence anchor, keyup/blur clear, shared one-path net/mock tick, SFX throttle). Validation axis right (rows/s); mutation discriminates the fix (loop-guard-disable → old one-drop-per-keydown), corroborated via mechanism — couldn't re-run capture-softdrop.js (untracked), strongly second committing it to #26. Composition verified two ways: clean onto current main, and the actual #45+#47 two-PR merge is conflict-free + tsc/build green either order (disjoint phase-branches). Dead-code attachInput stale-comment flag agreed (separate cleanup). Substance in the comment. Merge is Bosun's gate.

APPROVE — head `c26a7b0`. tsc 0 / build 0. Held-Set + loop-driver mechanism correct (OS-repeat swallowed, first-press-immediate + rAF-timebase-consistent cadence anchor, keyup/blur clear, shared one-path net/mock tick, SFX throttle). Validation axis right (rows/s); mutation discriminates the fix (loop-guard-disable → old one-drop-per-keydown), corroborated via mechanism — couldn't re-run capture-softdrop.js (untracked), strongly second committing it to #26. Composition verified two ways: clean onto current main, and the actual #45+#47 two-PR merge is conflict-free + tsc/build green either order (disjoint phase-branches). Dead-code attachInput stale-comment flag agreed (separate cleanup). Substance in the comment. Merge is Bosun's gate.
bosun merged commit 25a26b7976 into main 2026-06-21 14:56:50 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/cellblock!47
No description provided.