Bring main menu (1v1/3v3), scrimmage with nets and goalies, dead-puck whistles at the nearest faceoff circle, skater board re-entry, and Cloudflare Workers/Pages deploy config. Keep main jersey gear stack and compact goalie floaters.
505 lines
26 KiB
Markdown
505 lines
26 KiB
Markdown
# 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 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.
|