Skip to main content

The Board Model

Status: Audited against code 2026-07-12 · file: TrickList/src/components/companion/KaoriStage.tsx

How Kaori's snowboard is generated, how it moves during tricks, and the path to per-design board files (which is also the path to board unlockables and to skateboarding).

Related pages

Animation internals: Animation System · Board unlocks in the business model: Monetization · Board tracks for mocap clips: Motion Capture Pipeline

How the board is generated: 100% procedural, zero asset files

The snowboard is not a 3D model file. It is generated in JavaScript at mount inside KaoriStage.tsx — a segmented THREE.BoxGeometry that gets vertex-warped into a deck shape:

function makeBoardGeometry(length, thickness, width, kick): THREE.BufferGeometry {
const geometry = new THREE.BoxGeometry(length, thickness, width, 48, 1, 8);
const positions = geometry.attributes.position;
const half = length / 2;
const tipStart = 0.74; // outline starts rounding here (fraction of half-length)
const kickStart = 0.62; // tips start curving up here
for (let i = 0; i < positions.count; i++) {
const u = Math.abs(positions.getX(i)) / half;
if (u > tipStart) { // circular outline taper → rounded nose/tail
const v = (u - tipStart) / (1 - tipStart);
positions.setZ(i, positions.getZ(i) * Math.sqrt(Math.max(0, 1 - v * v)));
}
if (u > kickStart) { // quadratic Y lift → rocker / kicked tips
const k = (u - kickStart) / (1 - kickStart);
positions.setY(i, positions.getY(i) + kick * k * k);
}
}
geometry.computeVertexNormals();
return geometry;
}

The TrickBoard component assembles two of these plus binding hints:

PartGeometryMaterial
TopsheetmakeBoardGeometry(1.15, 0.03, 0.27, 0.09)sakura pink #f48fb8, roughness 0.35, transparent
BasemakeBoardGeometry(1.19, 0.014, 0.3, 0.09), offset −4mmwhite #f4f6fb — 4cm longer/3cm wider so a white rail rim peeks around the pink deck
Bindings ×2plain boxes 0.16 × 0.04 × 0.2 at x = ±0.24near-black #101319 — purely cosmetic, the feet are not attached to them

The pink was chosen to match Kaori's jacket and pop against the dark floor. Everything above — dimensions, taper/kick fractions, colors, binding spacing — is a hardcoded literal.

How it moves: no attachment, just shared state

The board is not parented to the character anywhere in the scene graph. <TrickBoard> and the VRM are siblings in the Canvas, synchronized only through the shared TrickDemoState ref each frame:

  • group.rotation.y = rootYaw — the same yaw (stance + trick spin) is applied to the board group and to vrm.scene, so they stay locked through rotations.
  • group.position.y = boardY + 0.045 — the deck rides at ankle height.
  • Materials fade with boardOpacity — the board fades in/out with the smoothed on-board stance weight.

The subtle part that makes it read correctly:

state.rootY = pose.height - hipDropFor(effectiveCrouch); // character sinks with knee bend
state.boardY = pose.height; // board follows only the jump arc

The board follows the jump arc; the character additionally sinks by the hip drop — so knee bends keep her soles planted on the deck, and in the air both share pose.height, which makes the board look strapped to her feet. That "strapped" assumption is load-bearing: it's correct for snowboarding and architecturally wrong for skateboarding, where the board separates from the feet and rotates on its own axes. TrickDemoState has no independent board channels today — a kickflip cannot be expressed (animation audit).

Should the board be a separate file? Yes — in two steps

The founder's instinct is right, and the coupling analysis says it's cheap: nothing outside TrickBoard reads the geometry — the whole system touches the board only through boardY / rootYaw / boardOpacity. Two recommended steps, in build order:

Step 1 — extract a BoardSpec (data, not assets) — do this first

Move the hardcoded literals into a per-design data object:

interface BoardSpec {
length: number; width: number; thickness: number; kick: number;
tipStart: number; kickStart: number;
deckColor: string; baseColor: string; bindingColor: string;
bindingSpacing: number;
}

This costs an afternoon, keeps the zero-asset-bytes advantage, and immediately unlocks colorway/shape unlocks (the monetization build order starts with board designs) and per-companion boards — a skateboard deck is the same warp with different proportions plus wheels/trucks meshes. Procedural = every design is a few hundred bytes of JSON, CDN-free.

Step 2 — .glb board props for premium designs

For real graphics (artwork topsheets, sponsor decks), swap the two procedural meshes inside TrickBoard for a loaded .glb — the frame logic stays identical. Requirements discovered in the audit:

  • Registration convention: board long axis = local X, deck top at y ≈ 0.045 + thickness; document it for every asset or a differently-sized model breaks stance visuals (binding spacing ±0.24 matches the character's foot splay only visually).
  • Fade plumbing: the fade-in/out uses per-frame material opacity — a glb's materials need transparent = true forced and opacity plumbed per material (or replace fade with a different reveal).
  • Texture constraints: same rules as the character — PNG/JPEG only on expo-gl, and expect to route textures through the stage's rebake path.
  • Delivery: per-design glbs reintroduce asset delivery — fold them into the planned CDN move (models off the app binary) rather than bundling.

And for skateboarding: the board becomes "another character"

Studio precedent (Reallusion's Vicon-captured skateboard pack) is explicit: never parent the board to a foot bone — the board is captured and animated as a separate object with its own motion track, synchronized with the rider. The generalization of our current design:

  • Add independent board channels to the trick data (boardRotX/Y/Z, or a keyframe track per trick — for clip-based tricks, a non-humanoid node track in the same .vrma/glTF file).
  • Constrain the board to the feet-midpoint only during grounded phases (read the VRM foot bones' world positions), release it during flip phases.
  • The current follow-the-demo-state pattern already is this system with the constraint permanently on — it extends rather than rewrites.