feat(render,fx): smooth-fall — glide the active piece between rows (#8) #24
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/8-smooth-fall"
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?
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.tsowns the interpolation state (render.ts stays a stateless painter). NewfallOffset(state, t)eases a visual reference row toward the latest authoritative row each frame:Exponential, framerate-corrected via
dt. Why exponential-ease, not time-based interpolation over the tick interval?Snaps (no glide) only where a glide would be wrong:
FALL_SNAP_ROWSHorizontal 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:
view.activeoverlay) — offset applied directlyboard) — 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.tsincoming-telegraph band onmode. 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 --noEmitclean;vite buildclean. 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: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
FALL_TAU = 45ms— snappy glide tuned by eye + the sampler; easy to dial.🤖 Generated with Claude Code
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)fallOffsetreturns 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 = refRowclamp, and at lock the piece is baked intoboardat 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.drawWellliftsactiveAbsCells(view)and skips those cells in the stack draw, repainting them at the offset.activeAbsCells(render.ts:103) returnsview.activeCells/activeKindon the server path (the server's canonical active-piece cells, already relied on by the #12 ghost projection) and derives fromview.activeon the mock path. SoliftedSetmatches exactly the baked active cells — no double-draw, no stack flicker, no wrong-skip. The opponent well (defaultactiveOffset=0) is a clean no-op — YOUR-well-only confirmed.refRowis 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.incomingGarbageis 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)
FALL_TAU = 45ms— fine, eye + sampler tuned, trivially dialed.refRow < fallVisualRow − 0.5jumped-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 atools/ortest/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
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):fallOffsetis 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 (activeAbsCellsreturns 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.