feat(demos): add hill climb racer demo#518
Open
stormmuller wants to merge 15 commits into
Open
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
documentation-site/src/pages/demos/hill-climb-racerdemo showing how to set up a hill-climb-racer-style car using existing physics primitives — no new/srcengine code was needed.RigidBodyinstances (no rigid frame). Each wheel hangs below the chassis on aLinearSpring/LinearDamperpair (the same components the Linear Spring and Damper demo uses for vehicle suspension) and is driven by anAngularVelocityMotorEcsComponent(as in the Torque and Motors demo) whose target speed tracks a throttle input.ChassisStabilizerEcsComponentcorrects 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.demos/hill-climb-racer).Controls
→/D— Accelerate←/A— Brake / ReverseR— RestartTest plan
npm run check-types,npm test,npm run lint,npm run cspell,npm run check-exportsall pass at the repo rootdocumentation-site:npm run typecheckandnpm run buildboth passnpm 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 transformnpx prettier --checkclean on all new/changed files🤖 Generated with Claude Code
https://claude.ai/code/session_01RzCfdqLrK5xAMLQNHH1Yss
Generated by Claude Code