The obvious approach, and why it jitters
The lazy version: find the water's height and tilt directly under the bike's center, and
glue the bike to it. The wave field hands you an exact surface normal for free, so this is
almost no code. It also feels awful.
A real sea has chop — little ripples riding the big swells. A single center sample sees
every one of those ripples and the bike snaps to match each, twitching constantly. Set
the demo below to Center only and turn the sea up: the bike chatters,
tilting to ripples far smaller than itself. A bike is not a point; it shouldn't pretend to
be one.
Four probes, not one
So we give the bike a footprint. Four probes sample the surface around
the hull — bow, stern, port, starboard — and the bike's pose comes from how they differ:
- Height is the average of the four — the bike rides the mean water level, not whatever ripple sits dead-center.
- Pitch (nose up/down) is the bow-vs-stern height gap:
(bow − stern) / length.
- Roll (lean) is the starboard-vs-port gap, the same way.
Because the probes straddle the hull, ripples smaller than the footprint average out
between them, while long swells — wider than the bike — still tilt it the way you'd
expect. Switch the demo to 4 probes and widen the footprint: the chatter
smooths into a confident ride, and the pitch/roll readouts settle. The game's defaults are
a 1.6 m × 0.8 m footprint, matching the bike's visual size.
One sea, water and land. In the real game each probe takes the higher of
the wave height and a ground raycast, so a bike straddling a shoreline reads terrain on
one side and wave on the other and transitions smoothly. The demo shows the water half of
that.
Looking ahead at speed
There's one more trick. If the probes sat at a fixed spread, a fast bike would only start
pitching up after its nose was already climbing a wave — too late, and it feels
mushy. So the fore/aft spread grows with speed: the faster you go, the
further ahead the bow probe reaches, and the bike begins tipping to meet a slope before it
arrives. Drag Approach speed up and watch the probes stretch (the reach
readout climbs); the bike anticipates the next face instead of reacting to it. The real
system extends the reach by speed × 0.05, capped at 1.4 m so a very fast
bike doesn't over-anticipate.
Keeping it from buzzing
Even with a footprint, raw slope readings flicker frame to frame over choppy water — and
on bumpy terrain in the full game, over every little trimesh edge. So the slope is run
through a gentle low-pass filter (about a 50 ms time constant)
before it becomes pitch and roll. Fast enough to feel responsive on a real wave face, slow
enough that single-frame bumps don't kick the bike. That smoothing is on in the demo's
4-probe mode — it's part of why the readouts glide instead of snapping.
Read the code
The footprint sampler is
sampleSurfaceFootprint in src/game/systems/hover.ts. In the game its
four heights feed spring forces through the Rapier rigid body rather than setting the pose
directly — but the sampling, the differential pitch/roll, the speed-scaled reach, and the
50 ms slope filter are all exactly what you're playing with above.