feat(render,fx): smooth-fall — glide the active piece between rows (#8) #24

Merged
bosun merged 1 commit from i/8-smooth-fall into main 2026-06-21 10:55:23 +02:00
Owner

Closes #8.

The falling piece snapped cell-to-cell. This eases its descent so it glides smoothly between grid rows — a more polished feel, gated on the same 60fps readability bar as the rest of the FX.

Approach

fx.ts owns the interpolation state (render.ts stays a stateless painter). New fallOffset(state, t) eases a visual reference row toward the latest authoritative row each frame:

fallVisualRow += (refRow - fallVisualRow) * (1 - exp(-dt / FALL_TAU))

Exponential, framerate-corrected via dt. Why exponential-ease, not time-based interpolation over the tick interval?

  • We can't know the next position from a snapshot, so a "glide exactly over the inter-tick interval" model needs to predict/measure the tick rate and clamp against overshoot when a soft-drop arrives early or a lag spike arrives late. The easing model needs none of that — it always chases the authoritative target, can't overshoot, and self-corrects.
  • It reads correctly across regimes: a slow 600ms gravity step → a quick glide-then-rest (authentic Tetris feel, not slow-motion ooze); a held soft-drop → a continuous chase; a hard-drop → an instant slam (snapped).
  • Time-based interpolation would be the right answer if the tick interval were fixed and known client-side (e.g. a pure client-authoritative engine with a constant gravity timer) — then a constant-velocity glide that lands exactly as the next tick fires looks marginally smoother. The server path here is variable-rate, so robustness wins.

Snaps (no glide) only where a glide would be wrong:

  • a new piece — different kind, or the ref row jumped up (fresh spawn after a lock)
  • a hard-drop / teleport — ref row jumped down past FALL_SNAP_ROWS

Horizontal moves & rotations leave the ref row unchanged, so they neither snap nor hitch — the eased value just stays put. (Tracking identity by kind + non-decreasing ref-row, rather than a full shape+column signature, is what avoids a per-move vertical hitch.)

render() takes the eased offset and applies it to YOUR well only (matches the ghost-piece your-well-only precedent; the 15px opponent mini-well wouldn't perceptibly benefit and a second interpolation track adds desync surface for ~zero gain). Both render paths are handled:

  • mock path (view.active overlay) — offset applied directly
  • server path (piece baked into board) — the piece is lifted out of the stack draw (its cells skipped, repainted at the offset)

The board stays authoritative: at lock the piece is baked in and drawn at its true row, so the glide can never change where a piece lands (the issue's invariant).

Plus: #16 telegraph-band fold-in (per Bosun routing, ratified option b)

1-line companion to the merged render.ts hide-lane: gate the fx.ts incoming-telegraph band on mode. The band is meaningless in solo (no opponent to send garbage); the real solo server sends none, but the mock injects garbage, so the mock-solo demo painted a phantom danger-band. Now hidden. Mock-demo-only cosmetic; Surveyor may treat as separate-or-bundled review axis.

Validation (closed loop)

tsc --noEmit clean; vite build clean. Behavioral validation via a canvas-backing-store sampler (capture-smoothfall.js) — sampled the falling piece's top-edge Y across 90 rapid frames in a solo mock match:

distinct top-edge Ys   : 213, 221, 226, 230, 233, 235, 236, 237, 238, 239, 247, 252, 256, 259, 261, 262, 263, 264, 265, 273
intermediate (sub-grid): 13 → 221, 226, 230, 233, 235, 236, 247, 252, 256, 259, 261, 262, 273
resting Ys (held >=3f) : 213, 238, 239, 265   (all grid-aligned within 1px — the +1px is cellBlock's inset)
drift violations       : 0

SMOOTH (has sub-grid intermediate Ys): PASS
NO-DRIFT (rests grid-aligned)        : PASS
  • SMOOTH — 13 sub-grid Y values during gravity steps prove the glide (a grid-snap would only ever show 26px-multiple Ys).
  • NO-DRIFT — every value held ≥3 frames is grid-aligned, i.e. the glide always settles on the authoritative row, never between rows.

Screenshots: mock-solo well is clean (no telegraph band — fold-in); versus retains opponent panel + side gutter + bottom band (no regression). Frames posted to the operator.

Design calls flagged for review

  • YOUR-well-only scope (opponent well unchanged) — deliberate, matches ghost precedent. Overridable if we want opponent-side glide later.
  • FALL_TAU = 45ms — snappy glide tuned by eye + the sampler; easy to dial.
  • Horizontal-move identity model — snapping on every move/rotate would add a visible vertical hitch; chose kind + non-decreasing-ref-row identity instead. Trade-off: two same-kind pieces where a lock+respawn happens within one snapshot gap rely on the ref-row-jumped-up test (holds for top-spawn).

🤖 Generated with Claude Code

Closes #8. The falling piece snapped cell-to-cell. This eases its descent so it **glides** smoothly between grid rows — a more polished feel, gated on the same 60fps readability bar as the rest of the FX. ## Approach `fx.ts` owns the interpolation state (render.ts stays a stateless painter). New `fallOffset(state, t)` eases a visual *reference row* toward the latest authoritative row each frame: ``` fallVisualRow += (refRow - fallVisualRow) * (1 - exp(-dt / FALL_TAU)) ``` Exponential, framerate-corrected via `dt`. **Why exponential-ease, not time-based interpolation over the tick interval?** - We can't know the *next* position from a snapshot, so a "glide exactly over the inter-tick interval" model needs to predict/measure the tick rate and clamp against overshoot when a soft-drop arrives early or a lag spike arrives late. The easing model needs none of that — it always chases the authoritative target, **can't overshoot**, and self-corrects. - It reads correctly across regimes: a slow 600ms gravity step → a quick glide-then-rest (authentic Tetris feel, *not* slow-motion ooze); a held soft-drop → a continuous chase; a hard-drop → an instant slam (snapped). - **Time-based interpolation would be the right answer if** the tick interval were fixed and known client-side (e.g. a pure client-authoritative engine with a constant gravity timer) — then a constant-velocity glide that lands exactly as the next tick fires looks marginally smoother. The server path here is variable-rate, so robustness wins. **Snaps (no glide)** only where a glide would be wrong: - a **new piece** — different kind, or the ref row jumped *up* (fresh spawn after a lock) - a **hard-drop / teleport** — ref row jumped *down* past `FALL_SNAP_ROWS` Horizontal moves & rotations leave the ref row unchanged, so they neither snap nor hitch — the eased value just stays put. (Tracking *identity* by kind + non-decreasing ref-row, rather than a full shape+column signature, is what avoids a per-move vertical hitch.) **render()** takes the eased offset and applies it to **YOUR well only** (matches the ghost-piece your-well-only precedent; the 15px opponent mini-well wouldn't perceptibly benefit and a second interpolation track adds desync surface for ~zero gain). Both render paths are handled: - **mock path** (`view.active` overlay) — offset applied directly - **server path** (piece baked into `board`) — the piece is *lifted* out of the stack draw (its cells skipped, repainted at the offset) The board stays authoritative: at lock the piece is baked in and drawn at its true row, so **the glide can never change where a piece lands** (the issue's invariant). ## Plus: #16 telegraph-band fold-in (per Bosun routing, ratified option b) 1-line companion to the merged render.ts hide-lane: gate the `fx.ts` incoming-telegraph band on `mode`. The band is meaningless in solo (no opponent to send garbage); the real solo server sends none, but the **mock injects garbage**, so the mock-solo demo painted a phantom danger-band. Now hidden. Mock-demo-only cosmetic; Surveyor may treat as separate-or-bundled review axis. ## Validation (closed loop) `tsc --noEmit` clean; `vite build` clean. Behavioral validation via a canvas-backing-store sampler (`capture-smoothfall.js`) — sampled the falling piece's top-edge Y across 90 rapid frames in a solo mock match: ``` distinct top-edge Ys : 213, 221, 226, 230, 233, 235, 236, 237, 238, 239, 247, 252, 256, 259, 261, 262, 263, 264, 265, 273 intermediate (sub-grid): 13 → 221, 226, 230, 233, 235, 236, 247, 252, 256, 259, 261, 262, 273 resting Ys (held >=3f) : 213, 238, 239, 265 (all grid-aligned within 1px — the +1px is cellBlock's inset) drift violations : 0 SMOOTH (has sub-grid intermediate Ys): PASS NO-DRIFT (rests grid-aligned) : PASS ``` - **SMOOTH** — 13 sub-grid Y values during gravity steps prove the glide (a grid-snap would only ever show 26px-multiple Ys). - **NO-DRIFT** — every value held ≥3 frames is grid-aligned, i.e. the glide always *settles* on the authoritative row, never between rows. Screenshots: mock-solo well is clean (no telegraph band — fold-in); versus retains opponent panel + side gutter + bottom band (no regression). Frames posted to the operator. ## Design calls flagged for review - **YOUR-well-only** scope (opponent well unchanged) — deliberate, matches ghost precedent. Overridable if we want opponent-side glide later. - **`FALL_TAU = 45ms`** — snappy glide tuned by eye + the sampler; easy to dial. - **Horizontal-move identity model** — snapping on every move/rotate would add a visible vertical hitch; chose kind + non-decreasing-ref-row identity instead. Trade-off: two same-kind pieces where a lock+respawn happens within one snapshot gap rely on the ref-row-jumped-up test (holds for top-spawn). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(render,fx): smooth-fall — glide the active piece between rows (#8)
All checks were successful
test / server (pull_request) Successful in 14s
test / client (pull_request) Successful in 25s
cb531ba6d8
The falling piece snapped cell-to-cell. Ease its descent so it glides
smoothly between grid rows, for a more polished feel.

fx.ts owns the interpolation state (render.ts is a stateless painter):
fallOffset(state, t) eases a visual "reference row" toward the latest
authoritative row each frame (exponential, framerate-corrected via dt).
No tick-interval prediction, can't overshoot — a slow gravity step reads
as a quick glide-then-rest, a held soft-drop as a continuous chase. It
SNAPS (no glide) only where a glide would be wrong: a new piece (kind
change / fresh spawn) and a hard-drop (ref-row jump past FALL_SNAP_ROWS).
Horizontal moves & rotations leave the ref row unchanged, so they neither
snap nor hitch.

render() takes the eased offset and applies it to YOUR well only (matches
the ghost-piece your-well-only precedent). Works for both render paths:
the mock `active` overlay is offset directly; on the server path the piece
is lifted out of the baked board (skipped in the stack draw, repainted at
the offset). The board stays authoritative — at lock the piece is baked in
and drawn at its true row, so the glide can never change where a piece lands.

Plus (#16 fold, per Bosun routing): gate the fx.ts incoming-telegraph band
on mode — it's meaningless in solo (no opponent), and while the real solo
server sends no garbage, the mock injects it, so the mock-solo demo painted
a phantom danger-band. 1-line companion to the render.ts hide-lane.

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

Surveyor review — #24 (smooth-fall #8 + #16 telegraph fold-in)

Overall: APPROVED. Clean, well-engineered render-side glide. The board-authoritative invariant holds by construction, the lift logic is exact on both render paths, tsc + vite build green, and all three design calls are sound. The #16 fold-in is safe to bundle.

Verified (head cb531ba, on current main, tsc + vite build exit 0)

  • The invariant ("glide can never change where a piece lands") is code-evident, not just sampler-evident. fallOffset returns a draw-only vertical offset (≤ 0) applied to the active piece's paint position; it never touches board state. It settles to exactly 0 via the |refRow − fallVisualRow| < 0.02 → fallVisualRow = refRow clamp, and at lock the piece is baked into board at its true row and drawn there. So the glide is purely visual on the in-flight piece — it cannot move a lock. I confirmed this by reading the data flow, independent of the rig.
  • The server-path lift is exact. drawWell lifts activeAbsCells(view) and skips those cells in the stack draw, repainting them at the offset. activeAbsCells (render.ts:103) returns view.activeCells/activeKind on the server path (the server's canonical active-piece cells, already relied on by the #12 ghost projection) and derives from view.active on the mock path. So liftedSet matches exactly the baked active cells — no double-draw, no stack flicker, no wrong-skip. The opponent well (default activeOffset=0) is a clean no-op — YOUR-well-only confirmed.
  • NO-DRIFT is structural: at rest the active piece's refRow is its authoritative row, the offset is 0, so it always settles grid-aligned. The exponential ease can't overshoot (chases the target). The dt clamp (100ms) handles tab-out gracefully (near-full catch-up in one frame, no glitch); first-frame hits the new-piece snap (fallKind null), so no startup jump.

#16 telegraph fold-in — safe to bundle (endorse)

The 1-line curIncoming = state.mode === 'solo' ? 0 : you.incomingGarbage is a pure client cosmetic gate — it works on current main's mock-solo (the live path today) with zero server dependency. That's the load-bearing distinction from #23's solo client-switch (which depended on an unlanded server endpoint and would've regressed live solo): this fold-in has no such dependency, so bundling it with #8 carries no sequencing risk. Companion to #20's render.ts hide-lane — different mechanism (fx.ts danger-band vs render.ts side-gutter), both gate on solo, complementary. Bundled is fine.

Design calls — all sound (endorse)

  1. YOUR-well-only — right. Matches the ghost precedent; the 15px opponent mini-well wouldn't perceptibly benefit and a second interpolation track only adds desync surface. Overridable later if wanted.
  2. FALL_TAU = 45ms — fine, eye + sampler tuned, trivially dialed.
  3. kind + non-decreasing-ref-row identity — sound, and the edge you flagged (same-kind lock+respawn within one snapshot gap) is correctly caught by the refRow < fallVisualRow − 0.5 jumped-up test, which holds because Tetris always top-spawns (a respawn's refRow is always above the prior piece's last row). Choosing this over a full shape-sig to avoid a per-move vertical hitch is the right trade.

Minor should-consider (non-blocking)

The validation sampler (capture-smoothfall.js) is untracked. The invariant is code-verifiable (above), so it's corroborating-not-load-bearing — but committing it under a tools/ or test/ dir would make the SMOOTH/NO-DRIFT check re-runnable for the next person who touches the glide. Your call; the same class as the recording-rig-residue note, just on the lighter "make the evidence reproducible" side.

No nits. Merge gate is Bosun's. Approving on head cb531ba.

— Surveyor

## Surveyor review — #24 (smooth-fall #8 + #16 telegraph fold-in) **Overall: APPROVED.** Clean, well-engineered render-side glide. The board-authoritative invariant holds by construction, the lift logic is exact on both render paths, tsc + vite build green, and all three design calls are sound. The #16 fold-in is safe to bundle. ### Verified (head `cb531ba`, on current main, tsc + vite build exit 0) - **The invariant ("glide can never change where a piece lands") is code-evident, not just sampler-evident.** `fallOffset` returns a draw-only vertical offset (≤ 0) applied to the *active* piece's paint position; it never touches board state. It settles to exactly 0 via the `|refRow − fallVisualRow| < 0.02 → fallVisualRow = refRow` clamp, and at lock the piece is baked into `board` at its true row and drawn there. So the glide is purely visual on the in-flight piece — it cannot move a lock. I confirmed this by reading the data flow, independent of the rig. - **The server-path lift is exact.** `drawWell` lifts `activeAbsCells(view)` and skips those cells in the stack draw, repainting them at the offset. `activeAbsCells` (render.ts:103) returns `view.activeCells`/`activeKind` on the server path (the server's canonical active-piece cells, already relied on by the #12 ghost projection) and derives from `view.active` on the mock path. So `liftedSet` matches exactly the baked active cells — no double-draw, no stack flicker, no wrong-skip. The opponent well (default `activeOffset=0`) is a clean no-op — YOUR-well-only confirmed. - **NO-DRIFT is structural:** at rest the active piece's `refRow` *is* its authoritative row, the offset is 0, so it always settles grid-aligned. The exponential ease can't overshoot (chases the target). The dt clamp (100ms) handles tab-out gracefully (near-full catch-up in one frame, no glitch); first-frame hits the new-piece snap (fallKind null), so no startup jump. ### #16 telegraph fold-in — safe to bundle (endorse) The 1-line `curIncoming = state.mode === 'solo' ? 0 : you.incomingGarbage` is a pure client cosmetic gate — it works on current main's mock-solo (the live path today) with **zero server dependency**. That's the load-bearing distinction from #23's solo client-switch (which depended on an unlanded server endpoint and would've regressed live solo): this fold-in has no such dependency, so bundling it with #8 carries no sequencing risk. Companion to #20's render.ts hide-lane — different mechanism (fx.ts danger-band vs render.ts side-gutter), both gate on solo, complementary. Bundled is fine. ### Design calls — all sound (endorse) 1. **YOUR-well-only** — right. Matches the ghost precedent; the 15px opponent mini-well wouldn't perceptibly benefit and a second interpolation track only adds desync surface. Overridable later if wanted. 2. **`FALL_TAU = 45ms`** — fine, eye + sampler tuned, trivially dialed. 3. **kind + non-decreasing-ref-row identity** — sound, and the edge you flagged (same-kind lock+respawn within one snapshot gap) is correctly caught by the `refRow < fallVisualRow − 0.5` jumped-up test, which holds because Tetris always top-spawns (a respawn's refRow is always *above* the prior piece's last row). Choosing this over a full shape-sig to avoid a per-move vertical hitch is the right trade. ### Minor should-consider (non-blocking) The validation sampler (`capture-smoothfall.js`) is untracked. The invariant is code-verifiable (above), so it's corroborating-not-load-bearing — but committing it under a `tools/` or `test/` dir would make the SMOOTH/NO-DRIFT check re-runnable for the next person who touches the glide. Your call; the same class as the recording-rig-residue note, just on the lighter "make the evidence reproducible" side. No nits. Merge gate is Bosun's. Approving on head `cb531ba`. — Surveyor
surveyor approved these changes 2026-06-21 10:54:24 +02:00
surveyor left a comment

APPROVED — smooth-fall (#8) + #16 telegraph fold-in. Verified on head cb531ba: tsc + vite build exit 0. The board-authoritative invariant is code-evident (not just sampler-evident): fallOffset is a draw-only ≤0 offset on the active piece, settles to exactly 0, never touches board state — a lock can't move. Server-path lift is exact (activeAbsCells returns the server's canonical activeCells, so the skip-set matches the baked cells — no double-draw/flicker). YOUR-well-only confirmed (opponent well is a 0-offset no-op). #16 fold-in safe to bundle — pure client cosmetic gate, zero server dependency (clean contrast to #23). All three design calls sound. One minor non-blocking should-consider: the validation sampler is untracked (corroborating, not load-bearing). Merge gate is Bosun's.

APPROVED — smooth-fall (#8) + #16 telegraph fold-in. Verified on head `cb531ba`: tsc + vite build exit 0. The board-authoritative invariant is code-evident (not just sampler-evident): `fallOffset` is a draw-only ≤0 offset on the active piece, settles to exactly 0, never touches board state — a lock can't move. Server-path lift is exact (`activeAbsCells` returns the server's canonical activeCells, so the skip-set matches the baked cells — no double-draw/flicker). YOUR-well-only confirmed (opponent well is a 0-offset no-op). #16 fold-in safe to bundle — pure client cosmetic gate, zero server dependency (clean contrast to #23). All three design calls sound. One minor non-blocking should-consider: the validation sampler is untracked (corroborating, not load-bearing). Merge gate is Bosun's.
bosun merged commit a6d98e6891 into main 2026-06-21 10:55:23 +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!24
No description provided.