Skip to content

Code-review follow-ups: obstacle radius, SeriesTally extraction, trail perf, test coverage#3

Merged
tim4724 merged 5 commits into
mainfrom
chore/review-followups
Jun 21, 2026
Merged

Code-review follow-ups: obstacle radius, SeriesTally extraction, trail perf, test coverage#3
tim4724 merged 5 commits into
mainfrom
chore/review-followups

Conversation

@tim4724

@tim4724 tim4724 commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Follow-ups from a thorough code/architecture review. Correctness, coverage, performance, and maintainability — no behavior change in the live game (verified end-to-end).

Changes

Area What Why
fix(slopes) One obstacleRadius(kind) source of truth (shared/slopes.js), consumed by builder + scenery; engine fallback made kind-agnostic; RAMP_HALF_S named; dead ramp.radius dropped The per-kind default was duplicated across 3 files and inverted (builder rock=0.85/tree=0.7 vs engine rock=0.7/tree=0.8). Latent footgun — live path unaffected since the builder always stamps a radius.
test(centerline) First direct tests for the Catmull-Rom spline Centerline.js had zero coverage — engine tests use a stub, so the real interpolation + divide-by-zero nudges never ran under test.
perf(trails) Scope per-point trail GPU uploads to the changed slot (addUpdateRange) Was re-uploading two full ~48KB ring buffers per recorded point per skier — the dominant trail bandwidth cost on weak hardware. Index left full-upload (wrap seam).
fix(controller) Bind keyboard listeners in start()/stop(), not the constructor stop() only removed pointer/orientation listeners — keyboard listeners leaked on re-instantiation.
refactor(display) Extract SeriesTally from the 1170-line main.js (dependency-free, unit-tested); share runTag; cap showSoundHint poll The trickiest run-to-run state now lives in one testable unit (it previously had no unit coverage, only e2e).

Verification

  • 171/171 unit tests (incl. new seriesTally + centerline + obstacleRadius tests)
  • 15/15 e2e over the real relay — the gold-standard test of the series orchestration the refactor touched
  • Browser-verified the final + mid-series boards and live ski trails (no NaN, no console errors, no visual artifacts)
  • Adversarial diff review (3 reviewers + completeness critic): clean

Deliberately out of scope

Hostile-peer defenses don't apply to a local couch party game (the relay-stamped from is the real trust boundary), so these were skipped by design: reclaim-token auth, message-size cap, PAUSE debounce, and the style-src CSP nit. Also skipped: car→Skier rename (churn) and the MODULE_TYPELESS test warning (risky fix).

🤖 Generated with Claude Code

tim4724 and others added 5 commits June 21, 2026 18:40
The per-kind obstacle radius default was duplicated across three files and
disagreed: SlopeBuilder used rock=0.85/tree=0.7, while SkiEngine (rock=0.7/
tree=0.8) and SlopeScenery's hitbox outline had it inverted. The builder
always stamps an explicit radius so the live path was unaffected, but the
mismatched dead fallbacks were a latent footgun.

Add obstacleRadius(kind) to shared/slopes.js as the single source of truth for
the procedural kinds (rock/tree); SlopeBuilder and SlopeScenery consume it. The
import-free SkiEngine can't import it, so its fallback becomes kind-agnostic
(o.radius || 0.8) — unreachable in the live path since the builder supplies the
radius. Also promote the engine's ramp half-length literal to a named
RAMP_HALF_S (value unchanged) and drop the dead, never-read ramp.radius field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mgpq3wWcaK5ArAhgKW22ch
Centerline.js had no direct tests — the engine tests use a stub centerline, so
the real spline interpolation (endpoint stencil clamping, degenerate-span
nudges, the divide-by-zero guards) never ran under test. Add tests over the real
Centerline + three: endpoint exactness, s<0/s>length clamping without NaN,
finite unit-length tangents across the run, arclength monotonicity, and
degenerate (2-point / duplicated-point / zero-length span) cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mgpq3wWcaK5ArAhgKW22ch
addPoint() set posAttr/nrmAttr.needsUpdate, re-uploading both full ~48KB ring
buffers on every recorded point even though only one 4-vert slot (12 floats)
changed — the dominant trail bandwidth cost on weak hardware. Scope the uploads
to the changed slot via BufferAttribute.addUpdateRange(vb, VPP*3); three's GL
backend clears the ranges after each upload, so re-adding per point is correct.
The index buffer is deliberately left full-upload (the ring wrap seam makes
incremental index updates unsafe); buffer contents and index logic are unchanged.

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

TiltInput and SwipeInput added window keydown/keyup listeners from the
constructor (_bindKeys) but stop() only removed the pointer/orientation
listeners, so the keyboard listeners leaked on re-instantiation. Bind the
handlers once and register/unregister them in start()/stop(), symmetric with the
pointer listeners. Handler bodies, keys, and preventDefault behavior are
unchanged; keyboard input only matters during driving, which is exactly the
start()/stop() window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mgpq3wWcaK5ArAhgKW22ch
…ap sound hint

main.js was a 1170-line god file. Lift the cross-run scoring (run index/length/
over-flag, per-player banked scores, points folding and standings-row derivation)
into display/SeriesTally.js — a dependency-free, THREE-free class with seriesPoints
injected, so it loads under Node and gets real unit tests (tests/seriesTally.test.js)
the logic previously lacked (only e2e covered it). main.js keeps the lifecycle/IO
around it. Behavior is preserved end-to-end (15/15 e2e over the relay).

Also:
- Share the "Run X of N" / "Final standings" header wording between the big screen
  and the phone board via shared/seriesFormat.js (runTag), the one bit that was
  genuinely duplicated between the two render paths.
- Bound showSoundHint's autoplay-poll interval to ~30s so a kiosk that never
  unlocks audio doesn't poll forever (a later gesture still clears the hint).
- Sync CLAUDE.md to the new SeriesTally location.

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

claude Bot commented Jun 21, 2026

Copy link
Copy Markdown

Claude finished @tim4724's task in 6m 17s —— View job


Code Review

  • Read changed files and diff
  • Review obstacle radius unification (slopes.js, SlopeBuilder.js, SlopeScenery.js, SkiEngine.js)
  • Review SeriesTally extraction (SeriesTally.js, main.js, seriesFormat.js)
  • Review trail performance fix (SkiTrails.js)
  • Review controller keyboard listener fix (SwipeInput.js, TiltInput.js, ui.js)
  • Review new tests (centerline.test.js, seriesTally.test.js, slopes.test.js)

No correctness bugs found. The logic across all five change areas is sound. Notes below are test-quality observations only.

fix(slopes) — obstacle radius unification

obstacleRadius(kind) in slopes.js:50 is the right place for this. The inverted-default bug (builder had rock=0.85/tree=0.7, engine had rock=0.7/tree=0.8, scenery debug had yet another) is cleanly resolved. The SkiEngine's new kind-agnostic fallback (0.8) is correct since the engine is supposed to receive a builder-stamped radius and only falls back for hand-built test tracks. RAMP_HALF_S naming is a good catch.

perf(trails) — scoped GPU uploads

The addUpdateRange(vb, VPP * 3) call in SkiTrails.js:100 is correct. Confirmed from three.module.js:203:

gl.bufferSubData(bufferType, range.start * array.BYTES_PER_ELEMENT, array, range.start, range.count);

The vendored Three.js uses element-index units for {start, count}, which matches vb = slot * VPP * 3 (already an element index) and VPP * 3 = 12 elements per slot. The comment in the code matches the implementation. Index stays full-upload — the ring-seam concern noted in the comment is real.

fix(controller) — keyboard listener lifetime

The start()/stop() symmetry in both SwipeInput.js and TiltInput.js is correct. The bound method pattern (binding in the constructor, registering in start(), removing in stop()) matches the pointer/orientation listener pattern already used. The _typing() guard is preserved.

refactor(display) — SeriesTally extraction

The refactor is clean. Key correctness checks:

  • fold/buildRows split: fold() is called in advanceToNextRun before teardown, never on the final run. buildRows layers the live run's points on top each render. The guard if (!raceEnded || tally.seriesOver) return in advanceToNextRun correctly prevents folding the final run. ✓
  • endCurrentRun() sets seriesOver before broadcastStandings(true) reads tally.seriesOver. ✓
  • rekey() is a direct port of the old inline reassignment. ✓
  • stagePreview() stores { points } — consistent with how buildRows reads .points from the scores map. ✓
  • Co-champion tie-breaking in buildRows: rows.sort((a, b) => b.score - a.score || a.place - b.place) uses the final run's finish rank as tiebreaker (the place field holds r.rank pre-sort). ✓

Tests

centerline.test.js — Good coverage of the real Catmull-Rom math: boundary clamping, finite/unit-tangent sweep, divide-by-zero nudge at duplicate points, two-point degenerate case.

seriesTally.test.js — Solid. Covers: lobby state, run advancement, fold+overlay non-double-count, DNF, co-champions on tied scores, rekey, reset, stagePreview.

slopes.test.js:40-46 — one mild gap: The obstacleRadius test checks the raw generateSlope() output (not the built slope). Since generated defs carry no explicit radius, o.radius || obstacleRadius(o.kind) reduces to obstacleRadius(o.kind) trivially. The two hard-coded value assertions on lines 35-36 are the actually meaningful part. A builder-level test (calling buildSlope() and checking the resolved .radius on resulting obstacles) would more directly validate the "single source of truth" claim — but that requires Three.js and is heavier to set up. Acceptable given the e2e coverage.

Fix this →


Overall: clean, well-motivated changes with strong test coverage. The co-champion sort, the trail ring-buffer math, and the fold/buildRows split are all correct. Ready to merge.

@tim4724 tim4724 merged commit 295af21 into main Jun 21, 2026
6 checks passed
@tim4724 tim4724 deleted the chore/review-followups branch June 21, 2026 16:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant