Files
tilt/README.md
T
ryanfitzpatrickio 7a196b6571 Prepare Cloudflare Workers/Pages deploy for the static front-end.
Add wrangler assets config, multi-page Vite build (game + studio),
cache/WASM headers, and deploy scripts so `npm run deploy` publishes dist.
2026-08-03 10:33:24 -05:00

505 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```
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
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 2030 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.