# tilt Physics-driven hockey. Three.js for rendering, Box3D (wasm) for physics, with the character stack lifted from [Ludus](../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. ```bash 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/ npm run deploy # vite build + Cloudflare Workers static assets ``` The browser-based **Animation Studio** lives at [`/animation.html`](http://localhost:5174/animation.html). Upload a reference video, generate pose keys directly on Tilt's 23-bone rig, correct individual bones with the rotation gizmo, optionally seed and bake butt/grip/blade stick landmarks, and export either reloadable `.tiltanim.json` or a JavaScript pose module for `src/anim/poses/generated/`. The Capture source menu has two mocap paths: - **Browser MediaPipe** processes one uploaded video locally for the quickest reference-to-pose workflow. - **Local FreeMoCap** sends the same uploaded video through an isolated local [FreeMoCap](https://github.com/freemocap/freemocap) worker, then loads the filtered motion directly into the editor. Run `npm run freemocap:setup` once before `npm run dev`. Existing `freemocap_data_by_frame.csv` and `*_body_3d_xyz.csv` files remain available through the advanced import. The FreeMoCap worker is intentionally local-development only; a static Cloudflare deployment cannot run its Python/native processing stack. Browser MediaPipe remains available everywhere. Cloudflare setup, Pages alternative, and CI notes: **[DEPLOY.md](DEPLOY.md)**. 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: 1. **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. 2. **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 animationStudio.js reference mocap, pose editor, timeline, clip storage mediapipePose.js browser pose detection and native-rig retargeting freemocapImport.js FreeMoCap body XYZ CSV adapter 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 `dy` from 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: 1. **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. 2. **Both clamps in `goalieSpot` were 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. 3. **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. 4. **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 `applyIntent` seam 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](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. `solveGrabArm` in 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, and `applyIntent` is 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.