Skip to content

feat(demos): add hill climb racer demo#518

Open
stormmuller wants to merge 15 commits into
devfrom
claude/hill-climb-racer-demo-saahst
Open

feat(demos): add hill climb racer demo#518
stormmuller wants to merge 15 commits into
devfrom
claude/hill-climb-racer-demo-saahst

Conversation

@stormmuller

Copy link
Copy Markdown
Member

Summary

  • Adds a new documentation-site/src/pages/demos/hill-climb-racer demo showing how to set up a hill-climb-racer-style car using existing physics primitives — no new /src engine code was needed.
  • The chassis and both wheels are independent RigidBody instances (no rigid frame). Each wheel hangs below the chassis on a LinearSpring/LinearDamper pair (the same components the Linear Spring and Damper demo uses for vehicle suspension) and is driven by an AngularVelocityMotorEcsComponent (as in the Torque and Motors demo) whose target speed tracks a throttle input.
  • Terrain is a chain of static ground segments built from a procedurally generated height profile (flat launch pad → smoothly-ramped rolling hills), plus a demo-only camera-follow system (the engine's built-in camera only supports input-driven pan/zoom) that keeps the car in view as it drives across a course much wider than the canvas.
  • Because the suspension is just two independently-solved springs (no shared control-arm geometry like a real suspension), small disturbances can accumulate into a persistent chassis tilt over time. A light demo-only ChassisStabilizerEcsComponent corrects this — strong enough to pull the chassis back level once nothing else is disturbing it, but weak enough that acceleration/braking still visibly pitches the chassis, the "leaning" feel the genre is named for.
  • Registered the demo in the docs site navbar (demos/hill-climb-racer).

Controls

  • / D — Accelerate
  • / A — Brake / Reverse
  • R — Restart

Test plan

  • npm run check-types, npm test, npm run lint, npm run cspell, npm run check-exports all pass at the repo root
  • documentation-site: npm run typecheck and npm run build both pass
  • Ran the built demo in a real browser (npm run start) and drove the car: it settles onto its suspension, accelerates, visibly leans under throttle, climbs into the hills, and the Restart button correctly resets it to its spawn transform
  • npx prettier --check clean on all new/changed files

🤖 Generated with Claude Code

https://claude.ai/code/session_01RzCfdqLrK5xAMLQNHH1Yss


Generated by Claude Code

claude and others added 15 commits July 21, 2026 07:10
Composes existing physics primitives (LinearSpring/LinearDamper suspension,
AngularVelocityMotor-driven wheels) into a drivable hill-climb-racer-style
car on a procedurally generated hill course, with a demo-only camera-follow
system and a light chassis-leveling stabilizer to keep a twin-spring
suspension from drifting to a permanent tilt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzCfdqLrK5xAMLQNHH1Yss
wheelDropHeight (the suspension's rest length) was large enough that the
chassis floated well above the wheels at rest, reading as a disconnected
body rather than a car. Shrinking it brings the wheels in snug under the
chassis while keeping ample margin over the equilibrium sag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzCfdqLrK5xAMLQNHH1Yss
A single LinearSpring/LinearDamper anchor only constrains a wheel's
distance from that point, leaving it free to swing around it like a
pendulum - which is exactly what was happening (wheels swinging loose and
the chassis collapsing onto them). Mounting each wheel to two chassis
anchors spread fore-and-aft, wishbone-style, triangulates its position
instead, leaving only the springs' own give as suspension travel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzCfdqLrK5xAMLQNHH1Yss
…eJoint

Replace the two-spring "wishbone" mount with the technically correct
construction: each wheel gets a small intermediate "upright" body, pinned
to the chassis by a PrismaticJoint (constrained to slide only vertically,
rotation locked to the chassis) and to the wheel by a RevoluteJoint
(position pinned, rotation free so the wheel can still spin), with a
LinearSpring/LinearDamper pair along the same axis supplying the
suspension force. Unlike springs alone, both joints are hard,
iteratively-solved constraints with no lateral give, so the wheel only
ever travels along that one axis - no swinging, no sideways slop.

Also fixes a mass-ratio instability this surfaced: a near-massless upright
sandwiched between the much heavier wheel and chassis made both joint
solvers blow up the instant a wheel hit the ground. Giving the upright a
mass close to the wheel's fixes it, and suspension stiffness was lowered
since the joints (not the spring) now carry the lateral load.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzCfdqLrK5xAMLQNHH1Yss
Build the terrain from flat, unrotated columns instead of rotated slabs
matching each segment's slope, and shrink the height-profile amplitudes so
consecutive columns differ by only a few units instead of tens - a gentle
staircase instead of jagged, angled terrain.

Also phases the column grid so a boundary lands exactly between the car's
two wheels at spawn, rather than under one of them: with the previous
phase the rear wheel straddled a seam right where the car first settles,
which was enough asymmetry in the collision response to tip the chassis
noticeably even at rest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzCfdqLrK5xAMLQNHH1Yss
Aligns each wheel's spawn position with its already-tilted PrismaticJoint
axis (the axis itself was tilted in a prior commit) so the wheel starts
exactly on its suspension's constraint line - the anchor-to-wheel line now
splays outward into a trapezoid rather than a rectangle, and a
perpendicular impact has a component along the suspension for the spring
to absorb instead of landing entirely on the joint's hard constraint.

Also adds the classic Hill Climb Racer mid-air tilt control: a new
AirControlEcsComponent/system pair tracks each wheel's ground contacts via
PhysicsWorld.collisionStarts/collisionEnds, and while both wheels are
airborne, applies a chassis torque from the throttle input - gas pitches
the nose up and back, brake pitches it down and forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzCfdqLrK5xAMLQNHH1Yss
…ise elevation

Triples the course length (6000 -> 20000), shrinks ground columns from 150
to 60 units wide for finer-grained terrain, and roughly doubles the
rolling-hill amplitude and climb rate for more pronounced elevation
changes. Columns are now narrower than a wheel's diameter, so a wheel
regularly straddles two or three columns at once rather than one - already
handled correctly since AirControlEcsComponent tracks each wheel's ground
contacts as a count, not a single flag. Verified stable over a full drive
across the new course with no instability.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzCfdqLrK5xAMLQNHH1Yss
Two behaviors were fighting the player instead of responding to them:

- ChassisStabilizerEcsComponent applied its leveling torque unconditionally,
  including mid-air, so it was constantly opposing AirControlEcsComponent's
  deliberate tilt input. Extracts ground-contact tracking (previously
  duplicated inside AirControlEcsComponent) into a shared
  GroundContactEcsComponent/system, and gates the stabilizer to run only
  while at least one wheel is grounded.

- AirControlEcsSystem now drives the chassis's angular velocity towards a
  target proportional to throttle - the same targetVelocity/maxTorque
  approach AngularVelocityMotorEcsComponent already uses for the wheels -
  instead of applying a constant torque. This gives direct, bounded control:
  releasing the input targets zero rotation and actively cancels existing
  spin instead of only bleeding off through angular drag.

- WheelDriveEcsSystem drove the wheel's motor to targetVelocity=0 at full
  torque whenever throttle was neutral, which braked the wheel to a dead
  stop instantly - an always-on parking brake that prevented coasting
  downhill. It now drops the motor's maxTorque to 0 at neutral throttle
  instead, so the wheel spins freely under gravity and rolling contact.

Verified in-browser: wheel angular velocity now tracks rolling speed after
release instead of snapping to zero, and chassis angular velocity responds
to air-control input within ~100-200ms instead of ramping up over seconds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzCfdqLrK5xAMLQNHH1Yss
… size

The upright's mass and the suspension's spring/damper stiffness were both
still tuned for the car's original dimensions, from before the chassis and
wheels were scaled up roughly an order of magnitude in an earlier commit:

- uprightDensity (6) no longer kept the upright's mass anywhere near the
  wheel's (now ~15,700 vs the upright's ~1,200, a ~13:1 ratio) - reintroducing
  the ill-conditioned joint-solver mass ratio a previous fix specifically
  raised this density to avoid, which showed up as sticky/heavy-feeling
  wheel contact. Raised to 80 to bring the upright back to a comparable mass.

- suspensionStiffness/suspensionDamping (120,000/20,000) were unchanged
  despite the car weighing roughly 12x more, so the springs sagged far
  beyond their intended travel - visibly dangling the wheels away from the
  body, and lengthening the effective lever arm for weight transfer enough
  to make braking flip the car much harder than intended. Raised as far as
  this rig's PrismaticJoint/RevoluteJoint solver tolerates before the spring
  impulse starts outpacing collision correction and the wheel sinks through
  the ground instead of settling (empirically ~2,700,000); settled on
  2,000,000/330,000 for margin.

Verified in-browser: resting suspension sag is consistent and far smaller
across repeated loads, hard braking from speed now only pitches the chassis
a few degrees instead of flipping it, and 10s of continuous driving over
the hills produces no instability.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzCfdqLrK5xAMLQNHH1Yss
Raising uprightDensity in the previous commit (fixing a wheel-mount solver
instability) added a lot of mass that wasn't accounted for - each upright
went from ~1,200 to ~16,000, pushing the car's total mass up by roughly
50% - without a matching torque increase. The wheel motor was left
torque-starved: measured in-browser, holding the throttle only got the car
oscillating between ~50-95 units/s indefinitely, nowhere near
maxWheelSpeed, instead of accelerating.

Scaled motorMaxTorque up to match (~1.8x, matching the mass increase with
some margin to keep the "overpowered" feel the demo is going for).
Verified in-browser: the car now accelerates into the hundreds of units/s
within a few seconds and covers real ground, while hard braking from speed
still only pitches the chassis a modest amount rather than reintroducing
the flip a previous commit fixed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzCfdqLrK5xAMLQNHH1Yss
Measured with instrumented driving: holding the throttle, the two wheels'
angular velocities diverged wildly - one stayed near the speed a rolling
wheel would need for the car's actual velocity, while the other spun up
towards maxWheelSpeed almost immediately and stayed there. The chassis
pitches under throttle by design, which continuously and briefly unloads
one wheel or the other; an unloaded wheel has essentially no resistance
but its own rotational inertia, so at the drivetrain's current torque it
accelerates towards its target speed near-instantly regardless of whether
that target is at all useful.

maxWheelSpeed (350 rad/s, a 35,000 units/s peripheral speed) was inherited
from before the car's dimensions and drivetrain torque were both scaled up
several times over, and was never reconsidered against what the car can
actually reach - it left an unloaded wheel free to burn torque spinning
uselessly fast instead of quickly regaining grip once it lands, wasting
half the drivetrain's output and reading as sticky, inconsistent
acceleration rather than smooth power delivery. Capped it to 30 rad/s,
close to the car's actual achievable speeds, so an unloaded wheel can't
run away nearly as far before it matters again.

Verified in-browser: sustained forward speed now reaches several hundred
units/s within a few seconds (previously it oscillated in place, torque-
starved), the wheels' angular velocities track much closer together while
driving, and hard braking from speed still only pitches the chassis a
modest amount.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzCfdqLrK5xAMLQNHH1Yss
…ng speed

The previous fix (a fixed maxWheelSpeed cap) only bounded how badly an
unloaded wheel could run away - it didn't address why one wheel kept
running away in the first place. Reading resolve-collision.ts's friction
model clarified the real mechanism: each wheel's available grip is
fundamentally tied to its instantaneous normal impulse, and the chassis
pitches under throttle by design (see ChassisStabilizerEcsComponent),
continuously and briefly unloading one wheel or the other. An unloaded
wheel has essentially nothing but its own rotational inertia to resist the
motor, so it accelerates toward whatever fixed target it's given almost
instantly regardless of whether the car can use that speed - wasting
roughly half the drivetrain's output on pure wheel spin instead of
useful propulsion.

WheelDriveEcsSystem now clamps the throttle-desired target to within
maxSlipAngularSpeed (6 rad/s) of the wheel's *current* rolling speed,
computed from the chassis's own velocity every tick, instead of driving
straight toward a fixed ceiling. maxWheelSpeed can go back to being a
generous, largely aspirational cap (350) since the slip clamp is what
actually keeps a wheel grounded in reality.

Verified in-browser: the two wheels' angular velocities now track each
other and the chassis's actual rolling speed much more closely instead of
one idling near-stationary while the other spins toward its cap, sustained
speed reaches into the hundreds of units/s, and hard braking from a level,
grounded state still only produces a modest, controlled pitch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzCfdqLrK5xAMLQNHH1Yss
…iving dynamics

The previous slip-clamp fix had a real bug: it clamped every wheel's target
speed to within 6 rad/s of the car's current rolling speed unconditionally,
including while grounded. At rest that clamp is nearly 0, so full throttle
from a stop only ever requested ~6 rad/s regardless of maxWheelSpeed - the
motor was starved by the demo's own code, not by physics, which is why
accelerating barely moved the car.

GroundContactEcsComponent is refactored to track one body's contact count
and attach directly to that body's own entity, rather than living on a
separate shared entity with both wheels' state bundled into one component.
This lets WheelDriveEcsSystem query a wheel's own grounded state jointly
with its other components, and branch correctly: grounded, the throttle-
desired target is used directly and the engine's own Coulomb friction model
(not an artificial clamp) is what correctly limits slip, the same way it
would for a real tire. Only while actually airborne - where a wheel has
nothing but its own rotational inertia to resist the motor - does the slip
clamp apply. AirControlEcsComponent/ChassisStabilizerEcsComponent, which
live on the chassis rather than either wheel, now hold direct references to
both wheels' GroundContactEcsComponent objects instead of relying on a
shared aggregate entity.

Fixing that revealed a second, real issue underneath it: with the
suspension axis tilted (the trapezoid geometry), each wheel's ground-
friction reaction force couples into that corner's spring loading, and with
no shared geometry (anti-roll bar, rigid axle) tying the two independently-
sprung corners together, hard driving torque was enough for the rear
corner to unload faster than the other could compensate, letting it lift
off and the chassis pitch hard while still notionally grounded - confirmed
directional-independent (reproduced under both forward and reverse
throttle) via instrumented testing, ruling out a one-sided force-direction
bug. Attempting to remove or reduce the axis tilt made this dramatically
worse (immediate, violent instability at rest), confirming the tilt itself
isn't the fault and shouldn't be touched further. suspensionStiffness is
reduced instead (2,000,000 -> 1,000,000, damping scaled to match): every
stiffness reduction tested has improved dynamic stability without
exception, and this rig's independent-spring suspension needs real margin
under driving load, not just at rest - resting sag is a documented,
deliberate trade-off now, not a value to keep tuning up.

Verified in-browser: launching from a full stop reaches ~150 units/s
within 200ms with the chassis staying within a couple degrees of level and
both wheels grounded almost continuously (previously: crawling forward
while spiking to a large uncommanded tilt), sustained acceleration and
hard braking both stay controlled over extended testing, and 10s of
continuous driving produces no instability or console errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzCfdqLrK5xAMLQNHH1Yss
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants