Skip to content

Add 3D WebGL board rendering with Three.js - #196

Closed
adrienbrault wants to merge 29 commits into
mainfrom
claude/threejs-interactive-board-dt3k6x
Closed

adrienbrault wants to merge 29 commits into
mainfrom
claude/threejs-interactive-board-dt3k6x

Conversation

@adrienbrault

Copy link
Copy Markdown
Owner

Adds an optional 3D WebGL board that renders beneath the DOM grid, bringing depth, animation, and visual polish to the Sudoku experience. The 3D board is enabled by default but can be toggled off in settings, with graceful fallback to the 2D DOM board if WebGL is unavailable.

Key Changes

  • 3D Board Engine (src/lib/board-scene/): Complete WebGL rendering system built with Three.js

    • scene.ts: Main BoardScene class managing the 3D board lifecycle, camera, lighting, and frame loop
    • tile.ts: Individual cell tiles with spring-based animations for lift, scale, rotation, and visual effects
    • particles.ts: CPU-simulated particle system for sparks and confetti effects
    • effects.ts: Cursor frame and ripple rings that respond to user interactions
    • choreography.ts: Animation sequences for reveal, placement, clearing, and victory celebrations
    • glyphs.ts: Canvas-rendered digit textures that follow the app's digit color mode and dark mode
    • theme.ts: CSS design token resolution so the 3D board matches the DOM board's palette exactly
    • scene-layout.ts: Geometry and lighting setup; mirrors DOM grid pixel geometry so tiles land under cells
    • scene-state.ts: State diffing and tile painting logic; applies board changes as animations
    • spring.ts: Damped harmonic spring physics for smooth, interruptible animations
  • Cell Visuals Extraction (src/lib/cell-visuals.ts): Centralized computation of per-cell highlight state (selection, conflicts, hints, drag targets, same-number highlights). Both DOM and WebGL renderers now consume the same CellVisual[] array, ensuring they never disagree about what is selected or highlighted.

  • Board Event Diffing (src/lib/board-events.ts): Detects which cells changed between board snapshots and which rows/columns/boxes became complete, enabling targeted animations instead of re-rendering the entire board.

  • 3D Board Preference (src/lib/board-3d.ts): localStorage-backed toggle for the 3D board, with WebGL capability detection. Preference persists across sessions.

  • React Integration

    • src/hooks/useBoard3D.ts: Hook exposing the 3D board preference and WebGL support status
    • src/components/BoardSceneLayer.tsx: Lazy-loaded component hosting the WebGL canvas; handles context loss and first-frame signaling
    • src/components/BoardDepthToggle.tsx: Settings UI to toggle the 3D board on/off
  • Board Component Updates (src/components/Board.tsx): Refactored to use centralized cell visuals and conditionally render the 3D board layer. The DOM grid remains on top for all gestures, focus, and accessibility.

  • Layout Utilities (src/lib/board-layout.ts): Pixel geometry calculations (padding, gaps, cell offsets) shared between DOM and WebGL so they stay perfectly aligned.

  • Tests: Added unit tests for spring physics, board diffing, board layout calculations, 3D preference persistence, and the useBoard3D hook.

  • Styling (src/index.css): CSS for the 3D board container and grid transparency when the WebGL board is active.

  • Dependencies: Added three@^0.186.1 to package.json.

Implementation Details

  • Lazy Loading: The 3D board and Three.js are loaded only when needed (via React's lazy and Suspense), keeping the main bundle size unchanged for users who disable it.
  • Graceful Degradation: If WebGL context is lost or unavailable, the DOM grid remains fully functional; the 3D layer simply doesn't render.
  • Animation Continuity: Springs preserve velocity when interrupted, so rapid taps flow smoothly into each other rather than snapping.
  • Theme Sync: The 3D board reads CSS design tokens on mount and subscribes to DOM mutations, so dark mode and digit color changes apply instantly.
  • Pixel-Perfect Alignment: The WebGL board mirrors the DOM grid's padding, gaps

https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV

The board is getting a 3D scene with lighting, tile depth and
particle effects. Three.js is loaded lazily by that renderer only,
so the initial bundle and every other screen stay unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
The WebGL board sits under the DOM grid and must place each tile
exactly where its DOM cell is, gaps included, and resolve a pointer
to the cell beneath it for hover effects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
Gives the WebGL renderer one source for where each cell sits and
which cell a pointer is over. Gap pixels resolve to the following
cell so hover never flickers off while crossing a seam.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
The 3D board animates what just happened: a digit popping in, one
being erased, a row, column or box being completed. Those moments
are derived from two snapshots rather than threaded through every
game action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
The 3D board diffs each render against the previous one to trigger
pop-in, pop-out and house-completion ripples. A house only counts
once all nine digits are distinct, so a wrong digit that fills a row
does not get celebrated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
The settings popover and the board are separate components that
both need the toggle, so the preference behaves like a tiny store
rather than per-component state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
On by default, persisted in localStorage, and broadcast to every
subscriber so the settings toggle and the board stay in step. The
WebGL probe runs once and releases its context, and short-circuits
where WebGL2 does not exist at all (jsdom, legacy browsers) so the
board silently keeps its DOM rendering there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
The settings popover flips the preference while the board is
mounted; the board must pick it up without a reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
useSyncExternalStore keeps all mounted copies consistent, and the
active flag folds in WebGL support so callers ask one question.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
The flags were computed inline while rendering each DOM cell. The
WebGL board needs the exact same states, so they now come from
computeCellVisuals and both renderers read the one result. Behavior
is unchanged; the Board tests cover it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
Tile lifts, digit pops and the selection cursor all move on springs
so interrupted animations retarget smoothly instead of restarting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
Fixed sub-steps keep stiff springs stable when a frame is dropped,
and kick() lets an effect add an impulse (a press, a bounce) while
the resting position stays owned by the board state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
The project enables erasableSyntaxOnly, which rejects constructor
parameter properties; assign the fields explicitly instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
Biome formatting for the test introduced by the parent commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
A self-contained WebGL renderer for the board: beveled glossy tiles
on box plates, canvas-drawn glyphs and pencil notes, and contact
shadows that grow as tiles lift. It reads the CSS design tokens, so
dark mode and every digit palette (tinted, colors, emoji) follow the
DOM board exactly.

It is driven by board state alone and animates the difference
between renders: digits pop in with a ripple and sparks, erased ones
spin out, conflicts shake, a completed row, column or box lights up
in a wave, and a solved board flips tile by tile under confetti. A
selection cursor glides between cells and the board tilts toward
the pointer. Rendering stops when nothing moves and idle pulses run
at half rate, and prefers-reduced-motion drops the motion but keeps
the state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
The three.js scene is lazy-loaded under the existing grid, which
turns see-through once the first 3D frame is on screen. Keeping the
DOM grid on top means taps, drag-select, digit drags, keyboard
focus and screen-reader semantics are untouched, and drop previews
still draw over the scene. Without WebGL, or if the context is lost,
the board simply stays DOM-rendered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
Players on weak devices or who prefer the flat board need a way
out, and the switch must not appear where WebGL cannot render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
Sits with the other display preferences and is hidden when the
browser has no WebGL, where it would do nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
With the 3D board, solving triggers a flip wave and confetti across
the tiles; opening the result after 300ms covered it immediately.
The flat board keeps its original timing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
The scene file ran past the 300-line lint limit; expose the glyph
cache as a field instead of a getter to bring it back under.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
Free the WebGL context when the scene is disposed instead of
waiting for GC; browsers cap live contexts and every game screen
mounts a fresh board.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
The lazily loaded scene often arrives after the 600ms reveal window,
so it skipped its entrance and tiles just appeared. Capture the
reveal intent at mount and hand that to the scene's first frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
Move theme painting of the board body into scene-layout so the
scene file stays under the 300-line lint limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
The landing demo renders a Board too, so the landing page was
loading three.js and redrawing a scene the demo keeps animating.
In software-rendered WebGL that starved the main thread badly enough
that e2e clicks on the landing buttons timed out. Boards now opt in
with threeD; solo and multiplayer games do, the demo stays DOM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
The first pass made every cell a thick glossy key with deep grooves
and a grey falloff across its face, which read as a dated keyboard
rather than a sudoku grid. Cells are now thin, square-edged and
matte, butt up to the same 1px lines as the flat board, and are lit
so their faces match the design tokens (three.js lights are scaled
by pi, which the old intensities ignored). Depth is kept for
interaction: selected, hovered and matching tiles lift further and
cast a contact shadow, so the 3D shows up when something happens
instead of on every cell at rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Deploying sudoku with  Cloudflare Pages  Cloudflare Pages

Latest commit: d961e24
Status: ✅  Deploy successful!
Preview URL: https://f80cfc03.sudoku-4cc.pages.dev
Branch Preview URL: https://claude-threejs-interactive-b.sudoku-4cc.pages.dev

View logs

The three.js scene files need a WebGL context and 2D canvases that
jsdom does not provide, so they read as 0% and pulled global
coverage from ~95% to 77%, failing the CI gate. They are verified by
the Playwright screenshots instead. spring.ts stays measured, as do
the pure modules the renderer relies on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
Without a GPU (blocklisted driver, some VMs, headless browsers) the
browser still hands out a WebGL context but rasterizes on the CPU.
The scene then renders on the main thread and taps stall for
seconds; in e2e runs clicks on the board timed out. The probe now
asks for failIfMajorPerformanceCaveat, so those players keep the
fast DOM board. A "force" preference value opts back in, for
capturing the 3D board in headless screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
Headless Chromium only offers software WebGL, which the app now
refuses, so every existing scene shows the DOM board. These scenes
force the WebGL board on, in light and dark, so layout and styling
regressions in the renderer stay visible in review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
…tion

The glows kept breathing for players who asked the OS for reduced
motion, and that pulse kept the render loop running at 30fps while
the board sat idle. Freezing the pulse clock honors the setting and
lets the loop stop between changes; on slow GPUs this was enough
continuous work to delay taps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcxowZMreMKHPQKo4FB2HV
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.

2 participants