Bring over img2mesh puck machine, catch/block IK, damped stance blends, and mover-space paddle with hand-on-shaft IK. Keep main jersey skinned gear and underlayer paint, with the stick reparented to the mover.
tilt
Physics-driven hockey. Three.js for rendering, Box3D (wasm) for physics, with the character stack lifted from Ludus.
Where it's at
Two teams of three chase a puck around a rink, under AI or on an Xbox pad, hitting each other and each other's sticks. No nets, no goalies, no rules.
- Spike 1 — skating, the rink, physical presence, collision response.
- Spike 1.5 — a controller, so the feel can be judged by hand.
- Spike 2 — body checks: limb-level impacts, skeleton impulses, knockdowns and getting back up.
- Spike 3 — the Xbox layer (Skill Stick, analog triggers, rumble) and the puck: stick, possession, shooting, passing, poke checks.
- Spike 4 — the stick socketed to the hand, and animations for everything the controls can do: carry, hustle, wind-up, shot, pass, poke.
- Spike 5 (MVP) — nets, a goalie, and a working shootout: alternating attempts, goal detection, a scoreboard, and bots that shoot.
npm install
npm run dev # http://localhost:5174
npm test # headless: sim, rink, AI, pose, input, physics, hits
npm run capture # boots the app headless and screenshots it into shots/
In the browser:
| Xbox | keyboard | |
|---|---|---|
| — | P / Tab | take control of the skater the camera is on, or hand back |
| left stick | WASD | skate (relative to the camera, not to the skater) |
| RT | Shift | hustle — analog, so half-throttle is a real thing |
| LT | Space | hockey stop |
| right stick | arrows | Skill Stick: stickhandle, and pull back + push to shoot |
| A | J | pass to the nearest teammate |
| X | K | shoot |
| B | L | poke check |
| — | C | cycle broadcast → follow each of the six skaters → broadcast |
| — | R | faceoff: reset everyone and the puck |
| — | [ ] | tune puck magnetism live (see below) |
| drag / wheel | orbit / zoom |
Whichever input was touched last wins, so a pad can be picked up mid-game. The pad also rumbles on any hit you are part of, harder when you are the one taking it.
window.tilt exposes the match, camera, input and physics world for poking at
from the console.
npm run capture writes lineup, broadcast, follow, closeup and side
into shots/, and fails on any console error — which makes it the quickest
check that a change did not break the render path.
What it does
- NHL-dimension rink (200 × 85 ft, 28 ft corners) with the boards as static Box3D bodies and markings baked into one canvas texture.
- Six skaters — two teams of three, starting in a faceoff lineup in their own halves — built from Ludus's 23-bone skeleton, procedural lofted body meshes and distance-field skinning, each with the full 18-capsule / 17-joint ragdoll attached and kinematically driven.
- Skating locomotion with real momentum: you carve, you glide, and you cannot turn on a rail at full speed.
- Waypoint AI with separation and board avoidance.
- Board contact and skater-on-skater contact solved by Box3D and fed back into the sim as momentum.
- Body checks that vary with the pose: shoulder, hip, body, arm or leg, graded from a bump through a stagger to a knockdown, with the victim's ragdoll going dynamic, taking the impulse, hitting the ice and getting back up.
- A regulation puck (76 mm, 170 g) as a continuous-collision cylinder, sticks with kinematic blade colliders, and a possession model on a runtime dial.
- Shooting with wind-up power off the Skill Stick, passing, poke checks, and contact knocking the puck off whoever is carrying it.
Roster size is a parameter, not a constant: createMatch({ perTeam, teams }),
and the lineup, collision layers and tests all follow from it. 5-on-5 works
today — it just has nothing to play for yet.
The one idea worth knowing
Everything else follows from how skating is modelled. A runner's velocity points where they push, so friction is isotropic and stopping is nearly instant. A skate glides almost freely along its own length and bites hard across it. So movement is two separate things:
- The carve. Each step, the momentum vector is rotated toward the blade
line at
edgeGrip. It is a rotation, not lateral friction, so turning redirects speed rather than destroying it — which is why a skater leans into a turn and comes out of it somewhere they were not pointing. A hard carve costs a little (carveScrub); a lazy one is nearly free. - Speed along that line, which the stride adds to and a small drag removes.
That is shared/skaterSim.js. It is pure numbers with no three.js import, and
it is where the feel lives. Current tuning: 7.4 m/s flat out, ~13% of speed
lost per second of glide, a hockey stop inside a second from full speed.
Layout
shared/ pure sim — no three.js, node-testable, server-ready
rink.js rink geometry, containment, the board outline
skaterSim.js intent → velocity. the carve, the stride, the drag
ai.js waypoint brains and steering
scalar.js angle and scalar helpers
src/
core/ math + seeded rng (from Ludus, unchanged)
character/
skeleton.js 23 bones (from Ludus, unchanged)
body.js procedural lofted body (from Ludus, unchanged)
skinning.js distance-field weights (from Ludus, unchanged)
skater.js assembly: mesh + kit + ragdoll + proxy + stick + animator
skaterGear.js helmet, pads, jersey, pants, socks, skates, gloves
stick.js stick mesh and the kinematic blade collider
goalie.js assembly: mesh + gear + pad/body colliders + animator
goalieGear.js pads, mask, trapper, blocker, chest, paddle
gearMesh.js loft / carved-shell / swept-bar builders for gear
physics/
bridge.js three ↔ Box3D types, collision layers
ragdoll.js 18 capsules, 17 joints (from Ludus, filters retargeted)
world.js rink world: ice + boards
bodyProxy.js the one dynamic capsule per skater
puck.js the one body that genuinely needs continuous collision
anim/
skateAnimator.js pose buffer, state crossfade, two-bone leg IK
poses/skate.js the numbers: stance, lean, arm carry, hockey stop
goalieAnimator.js stance selection, leg IK onto the ice, paddle grip
poses/goalie.js ready, butterfly, shuffle, reach
studio/
img2mesh.js character studio: pose presets, fixed views, capture API
render/ rink, materials, camera
game/
match.js the loop
input.js Xbox pad + keyboard, Skill Stick, camera-relative stick
hits.js severity, limb resolution, impulses
possession.js who has the puck, and the magnetism dial
tools/
capture.mjs headless boot, viewport assertions, screenshots
hitprobe.mjs fire skaters at each other and print what lands
img2mesh.mjs shot sheet of player + goalie for gear iteration
Two deliberate departures from Ludus
The animator does not own movement. In Ludus the animator integrates the fighter's position. Here the sim and the Box3D proxy own it and the animator is told where the body ended up. Anything else has the pose fighting the collision response.
The feet are not planted in world space. A walking foot is stationary while it bears weight; a skate is gliding the entire time, including through the push. Foot targets are authored in mover-local space and scaled by stride amplitude, so a glide collapses them to a pair of blades sitting under the hips with no separate "glide" pose to keep in sync. Planting them is exactly what would have made this read as running on ice.
Why there is a proxy capsule
The ragdoll is kinematic while a skater is on their feet, and kinematic bodies do not respond to each other — two rigs driven through one another generate contacts and resolve none of them. So physical presence lives in one dynamic capsule per skater, and the ragdoll rides on top as the visible, hittable skeleton.
The loop per substep is read → step → write: pull position and velocity out of
Box3D, let the skating sim edit that velocity, write it back, solve. Reading
velocity back rather than only writing it is the point — a board hit or a
shoulder arrives as a change to vx/vz that the sim carries forward as
momentum.
The sim runs inside the substep loop rather than once per frame, because momentum only survives a collision if the thing that resolved it and the thing that integrates motion agree about the timestep.
When a skater goes down, the two swap jobs. The ragdoll goes dynamic and
becomes the body, and the proxy is disabled — not merely ignored, because a
body left enabled still occupies space and would leave an invisible upright
bollard on the ice where the skater used to be. Getting up reverses it: read
where the pelvis actually ended up, put the proxy there, move the sim to match,
hand the skeleton back to the animator and crossfade out of the collapsed pose.
That round trip is the seam with nowhere to hide, so most of test/hits.mjs is
about it.
Getting up without teleporting
The reverse handoff is the fiddly half, and the naive version has a specific, very visible failure: the skater flies out by however far they slid, then snaps back over the crossfade.
The cause is that while limp the ragdoll writes the body's displacement into the
root bone, because the mover has been parked where they fell for the whole
knockdown. The world pose is moverAtFallPosition x bigRootOffset. Teleporting
the mover onto the pelvis without touching that offset applies the displacement
a second time, and the crossfade then drags it back as the root decays to its
skating value.
So getUp re-expresses the root in the new mover frame — inverse(newMover) x oldRootWorld — making the world pose across the handoff bit-for-bit identical.
The crossfade then has no position to undo and only interpolates lying to
skating. Measured across a 4.25 m slide: every bone moves 1-3 mm at the
handoff, and the pelvis drifts under 10 cm over the entire get-up.
Three smaller things fall out of the same fix. Facing is taken from the pelvis-to-chest line flattened onto the ice, because the pelvis' own forward axis points at the floor on someone lying face-down. The foot IK targets are re-read from where the blades actually are, or the legs drag across the rink to catch up with a stale target. And intent is suppressed while rising, so they stand up where they lay instead of skating off mid-animation.
How a hit knows what kind of hit it is
Two questions, and conflating them is what makes hits feel like one canned event.
Did a hit land is a physics question, answered by the proxy capsules — they are what actually collide. Closing speed and mass give severity.
What kind of hit was it is a pose question, and the proxy cannot answer
it: a capsule contact tells you two bodies met at roughly hip height, not that
a shoulder went through a chest. So on the frame of impact we go back to the
two 18-capsule ragdolls, which are posed, and find the closest pair of limbs.
That pair is the hit — spine3 → pelvis is a shoulder into the body, pelvis → thighL is a hip check, and a shoulder arriving at a head is the one that should
draw a penalty. 324 segment-segment tests, only on the frame something lands.
This comes out genuinely varied because it follows the skating pose rather than a dice roll: running down a stationary skater leads with the shoulder, while a head-on between two skaters both crouched low at speed is a hip check.
Two things that had to be got right, both found by looking at the output:
- Nobody checks with their head. A skater at speed is pitched ~30° forward, which makes the head the leading part of the body geometrically, so an unrestricted search credited almost every hit to a headbutt. Only shoulders, chest, hips and thighs can deliver. The victim side stays unrestricted, so head shots still register.
- A check drives you down and back, not over the hitter. Putting the whole impulse at the contact point — which sits well above the centre of mass — is mostly torque, and cartwheeled the victim over the attacker's head. Most of it now goes through the pelvis centre, with a third at the contact point to shape the fall.
Possession is a dial, not a decision
This is the one genuinely open design question in the game, so it is built as a
dial rather than as an answer. magnetism runs 0..1 between the two models
every hockey game has to choose between:
- 0 — pure physics. The puck is always a free rigid body and the only thing that moves it is the blade collider pushing it. Authentic, and skittery to the point of being unplayable.
- 1 — hard attach. The puck is placed at the carry point every frame. Totally controllable, looks glued, and kills the scrambles that are the reason to build a physics-driven hockey game at all.
In between, the puck's velocity is blended toward whatever would carry it to the
stick, so it mostly follows but can be jostled off the blade. It sits at 0.72
today, which is a guess, not a finding — press [ and ] while playing and
find the real answer by hand.
Two things the puck taught us immediately
A carrier with nothing to fear is untouchable. The first minute of 3-on-3 with a puck produced one possession change and one hit: a skater picked it up and kept it for the entire minute while five others followed them around. Possession only becomes a contest once losing it is possible, so contact now knocks the puck loose (a stagger is enough — it does not need a knockdown) and there is a poke check that both the player and the bots use.
Everyone chasing looks like a bug. With all six converging on the puck the hit system fired constantly — 28 hits a minute — but the game was one moving scrum with nobody anywhere else on the ice. Only the nearest skater per side chases now; the rest find space. Contact drops to a believable handful a minute and the mean separation goes from a huddle to 7.7 m.
| everyone chases | nearest chases | |
|---|---|---|
| hits / min | 28 | 4 |
| possession changes / min | 35 | 13 |
| mean separation | huddle | 7.7 m |
The stick is held, and the puck follows it
The first pass hung the stick off the mover and positioned it so the blade sat wherever the puck was being carried. That put the blade in the right place and the hands nowhere near it — the stick floated.
It is now parented to a socket on the right hand, authored in grip space: the origin is the top hand, the shaft runs down −Y, the blade is at the far end. The hands carry the stick, which is the correct dependency order.
That inverts the puck relationship. possession no longer picks a carry
point and drags the stick to it; it reads where the blade actually is and
carries the puck there. Stickhandling became an arm pose plus a blade target,
which is what it is in real life, and the puck can no longer be somewhere the
stick is not.
Aimed, not bolted
The obvious authoring — a fixed socket rotation per stance — does not survive an animated arm. That rotation composes with the hand's own world rotation, so a grip tuned to put the blade on the ice for one arm pose swings it into the air in another, and every stride is a different arm pose. Measured before the fix: the blade sat between 0.55 m and 0.97 m off the ice depending on gait.
So a stance is a blade target plus a roll, and the stick aims itself:
- Height is solved exactly, direction is aimed. Pointing straight at the
target and hoping the length works out puts the blade short of an on-ice
target, which means above it. Solving
dyfrom the height difference makes blade height exact for any arm pose and any stick length. - The aimed axis is grip-to-blade, not the shaft's −Y. The blade sits forward of the shaft end by the toe offset, ~6° off axis; aiming −Y left the blade 10 cm above where the height solve said it would be.
Blade height is now 0.03 m across every skating stance, and 0.62 m drawn back on a wind-up. There is a pose test for exactly that.
Animations
poses/stickwork.js authors carry, wind-up, shot, pass and poke as override
layers, not states — you keep skating while you shoot, and a shot that stopped
the legs would read as a cutscene. Arms are replaced; the spine is multiplied,
because it is already carrying the skating lean and the bank, and overwriting it
stood everybody upright the moment they picked up a stick.
Hustle is a continuous parameter rather than an action: as the throttle goes down the stick eases out in front and the left hand comes off it, so half a trigger is half a dangle. A wind-up is held for as long as the Skill Stick is pulled back; shot, pass and poke run once and blend out.
The shootout
The MVP: one shooter, one goalie, one puck, and a result. Attempts alternate, so it is two players trading chances rather than a drill. P takes the shooter (control follows whoever is up), R restarts.
The puck starts on the dot at centre ice and the shooter a few metres back, so picking it up is part of the attempt — that is the only moment the carry model has to prove it can gain possession rather than keep it, and starting glued to the puck skipped it. Losing the handle mid-attempt does not end anything either; in a one-on-one the puck getting away from you is part of the rush. Only a goal, the goalie covering it, the puck leaving the picture, or the clock finishes an attempt.
A goalie is deliberately not a skater. The skating sim is a carve model — momentum dragged onto a blade line — and a goalie almost never carves. Reusing it would mean fighting the locomotion for every metre. So it is a purpose-built entity that plays the angle: stand on the line between puck and net, a set depth out, with a lateral speed limit and a reaction lag. The lag is what makes them beatable; a goalie always exactly on the angle is a wall, not a goalie.
Saves are physics, not a dice roll. The pads and body are kinematic colliders and the puck is a bullet. A shot either hits a pad or it does not. There is no save percentage anywhere.
Four bugs it took to get the first goal
The first build produced 0 goals from 30 attempts, and each fix revealed the next. Worth recording because every one of them looked like a goalie problem:
- The net was backwards. For the +X end the back panel was placed at
line − depth, a metre in front of the goal line — a solid wall across the mouth. Every shot in the game bounced off it before it could cross. - Both clamps in
goalieSpotwere inverted. Between them they teleported the goalie onto the puck and then pinned them to the goal line, throwing away all the angle the depth was there to buy. - A redundant "fumble" check stripped the puck off every shooter. It was a function of stiffness and magnetism, and after stiffness went up it fired tighter than the break radius it was backing up — twenty of twenty-four attempts ended with nobody ever shooting.
- Shots were hitting the shooter's own stick. The puck sits exactly on the blade — that is what carrying means — and the follow-through then swept that kinematic collider through the same point. Shots stopped six metres short or flew twelve wide. The puck is now stepped clear of the blade on release.
Plus two tuning errors worth naming: bots aimed at the centre of the net, which is where the goalie stands by construction; and shot spread was 0.22 rad at 8 m — ±1.76 m of scatter against a net 1.83 m wide.
Currently around 15 goals per 29 attempts. That is a number to tune, not a finding — real NHL shootouts convert about a third. It jumped from 7-in-31 the moment shooters started skating onto the puck instead of spawning on it, because they now carry real speed into the shot.
Bots can shoot now
handleShooting used to sit behind if (control), so only a human could ever
shoot — a bot picked the puck up and carried it until somebody took it away, and
a minute of play produced zero shots. They now pick a corner, alternate sides,
and their accuracy falls off with range.
Performance
Simulation, physics and animation, excluding rendering:
| roster | skaters | ms/frame |
|---|---|---|
| 3-on-3 | 6 | 0.24 |
| 5-on-5 | 10 | 0.38 |
A full 5-on-5 plus goalies is well inside a 60 Hz budget with the render cost
still to come. (npm run capture reports 20–30 fps, but that is SwiftShader
software rasterisation in a headless browser, not a real GPU.)
Tests
1171 checks, all headless, npm test:
-
skaterSim — top speed, acceleration curve, glide decay, braking, the carve preserving momentum, resistance to instant reversal, determinism, and the
applyIntentseam surviving garbage input. -
rink — containment maths including the corner arcs, which a plain rectangle test gets wrong.
-
ai — a 3-on-3 for a simulated minute: nobody leaves the ice, nobody stands inside anybody, waypoints actually get reached, and the lineup puts each team in its own half with index order matching team order. Also a 5-on-5, as the cheapest check that steering does not fall over with a full side on the ice.
-
pose — the animator, driven headlessly: no NaN, blades on the ice, torso angle, arm carry, elbow bend, bank into a turn, the stop pose. This is the only way "the skater looks wrong" gets caught by anything but a human squinting at a screenshot.
-
input — camera-relative steering. Its own file because the failure is silent and infuriating: a sign flip means the stick works from one camera angle and inverts from another, which reads as a physics bug. Checks that forward is always away from the camera, that the four directions stay square, that "right" is the camera's right and not its left, and end to end that holding forward from any camera angle and any starting facing puts the skater where the stick pointed.
-
physics — the Box3D claims: boards hold at full speed including in the corner seams, contact costs speed, two skaters cannot occupy the same ice, a bump transfers momentum, the ragdoll follows the skeleton, and a six-body pile-up at centre ice resolves without anyone escaping or interpenetrating.
-
hits — the handoff, mostly. A knockdown must disable the proxy, leave the frozen sim position where it is rather than skating a disabled capsule around the rink, move the sim to wherever the body actually slid to on the way up, and restore the collision filter. Plus the things that were wrong when first looked at: nobody delivers a check with their head, a knockdown never lifts the hips above standing height, the victim carries on down the ice rather than bouncing back, a longer run-up hits harder, and more than one kind of check is reachable.
The render path is covered separately by npm run capture, which boots the app
headless at DPR 2 and asserts the canvas fills the window at two sizes. That
check exists because it didn't: running captures at DPR 1 hid a canvas-sizing
bug that made the element twice the window on any retina display.
Not built yet
Full roadmap, ordered by difficulty: ROADMAP.md.
Nets, goalies, scoring, offside/icing, penalties, faceoffs, gear textures, netplay.
Where the next spike plugs in
Nets and scoring. It is the shortest path from "physics demo you can play"
to "game you can win". Two static goal frames with a trigger volume behind the
line, a whistle, and a faceoff reset — match.reset() already puts the puck at
centre ice and stands everybody up. Everything needed to detect a goal exists;
the puck is a real body with a real position.
Then, roughly in order of how much they would improve the thing:
- Goalies — a seventh skater per side with a different brain and a bigger collider. No new systems.
- Arm IK onto the stick. The stick is positioned from the carry point and
the arms do not yet reach for it.
solveGrabArmin the Ludus animator is exactly this problem, already solved, and can be ported. - Penalties. Hits already carry
headshot,blindside, the delivering part and the struck region, so boarding, charging and elbowing have the data they need without any new detection. - Positional AI. The brains know four states — carrying, chasing, supporting, defending — and pick between them off one nearest-to-puck test. Real forechecking and zone coverage is the next big behavioural step.
- Netplay.
shared/is still pure, deterministic and three.js-free, andapplyIntentis a clamped entry point that never trusts what it is given.
Three soft spots worth knowing
The lower hand does not quite reach the shaft. The left arm is 0.55 m and the natural two-handed grip point is ~1 m from the left shoulder on this skeleton, so the IK grips the nearest reachable point and still ends about 0.25 m short. It reads as reaching for the stick rather than holding it. Fixing it properly means either a longer reach from a shoulder/spine contribution or accepting a higher grip; both are pose work, not architecture.
Staggers are still visually unverified. Knockdowns were tested hard — peak hip height, direction of travel, the full proxy/ragdoll handoff, and that nothing jumps on the way back up. The stagger path (physics deflecting the pose while animation shows through) is only asserted to enter the right state. It is far more common in play than a knockdown.
Possession changes may be too frequent. Around sixteen a minute in a 3-on-3 with no zones, no goalies and no reason to hold position is plausible but untuned. It will want revisiting once there is a net to protect.