Skip to main content
← All demos

Car

→

Accelerate

D

Accelerate

←

Brake / Reverse

A

Brake / Reverse

R

Restart

game.ts
const renderLayers = {
foreground: 1 << 0,
};

export const createCarGame = async (): Promise<Game> => {
const { game, world, renderContext, time } = createGame('demo-game');

// `isStatic: true` since this camera's position is driven by
// `createCameraFollowEcsSystem` rather than `createCameraEcsSystem`'s
// input-driven pan/zoom.
const cameraEntity = createCamera(world, {
isStatic: true,
zoom: 0.5,
cullingMask: renderLayers.foreground,
clearColor: new Color(0.6, 0.6, 0.8),
verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS,
});

const random = new Random('car');

const { throttleInput, restartInput } = createInputs(world, time);

const terrain = await createTerrain(world, renderContext, random);

const chassisEntity = await createCar(
world,
renderContext,
renderLayers.foreground,
terrain.groundPosition,
throttleInput,
restartInput,
);

addCameraFollowComponent(world, cameraEntity, {
targetEntity: chassisEntity,
offset: { x: 140, y: 70 },
smoothTime: 0.25,
maxSpeed: 3000,
});

const collisionPairs: CollisionPair[] = [];
const collisionManifolds: CollisionManifold[] = [];
const contactConstraints: ContactConstraint[] = [];

// Each wheel mount chains two joints through its upright (chassis <->
// upright via the prismatic joint, upright <-> wheel via the revolute
// joint), and both mounts share the chassis body - the single-iteration
// default is enough for an isolated joint, but this shared-body chain
// needs several more per tick to stay stable at this rig's mass/torque
// scale (confirmed empirically: with the default of 1, the chassis
// tumbles and the car flies apart within the first second).
const jointIterations = { iterations: 8 };

// `createCollisionResolutionEcsSystem`'s default `maxBiasSpeed` (3 units/
// second) is tuned for Box2D's own meters-scale default (its
// `contactPushMaxSpeed` is `3.0 * b2_lengthUnitsPerMeter`) - this course's
// world units are pixel-scale instead (`gravity` above is -600, roughly
// 60x real-world `g`, and `wheelRadius` alone is 100 units), so a wheel
// that lands hard after catching air off a hill can end up tens of units
// deep in the terrain in a single tick, and 3 units/second of correction
// then takes many seconds to dig it back out - long enough to read as the
// wheel being stuck clipped into the ground rather than momentarily
// compressed into it. Scaling the cap up by roughly the same ~60-100x
// this course's units are bigger than Box2D's assumed meters (confirmed
// empirically: 300 clears a hard landing within a fraction of a second,
// matching how quickly the suspension itself settles, without changing
// resting behavior on flat ground - the cap only matters once penetration
// is already large) restores the "quickly digs itself back out" feel
// `maxBiasSpeed` is meant to provide at this course's actual scale.
const collisionResolutionOptions = { maxBiasSpeed: 300 };

// `createCarResetEcsSystem` may teleport every body back to its spawn
// transform, so it runs first. `createGroundContactEcsSystem` recomputes
// each wheel's grounded state from this tick's `collisionManifolds`
// (populated by narrow-phase, just before it), and
// `createWheelDriveEcsSystem` (sets each wheel's motor target from
// `throttleInput`, but only requests full speed while that wheel's own
// ground contact says it's grounded) / `createChassisStabilizerEcsSystem`
// / `createAirControlEcsSystem` must run after it in this same list, so
// they see this tick's grounded state rather than last tick's. The
// suspension's spring/damper forces run before collision resolution (like
// gravity), and the prismatic/revolute joints that hard-constrain each
// wheel mount run after it, so they get the "last word" on velocity each
// tick. `createCameraFollowEcsSystem` only needs to run before
// `createRenderEcsSystem`, so this tick's camera position is reflected in
// this tick's render.
world.addSystem(createCarResetEcsSystem());
world.addSystem(createGravityEcsSystem(time));
world.addSystem(createBroadPhaseEcsSystem(collisionPairs));
world.addSystem(
createNarrowPhaseEcsSystem(collisionPairs, collisionManifolds),
);
world.addSystem(createGroundContactEcsSystem(collisionManifolds));
world.addSystem(createWheelDriveEcsSystem());
world.addSystem(createLinearSpringEcsSystem(time));
world.addSystem(createLinearDamperEcsSystem(time));
world.addSystem(
createCollisionResolutionEcsSystem(
collisionManifolds,
contactConstraints,
time,
collisionResolutionOptions,
),
);
world.addSystem(createPrismaticJointEcsSystem(time, jointIterations));
world.addSystem(createRevoluteJointEcsSystem(time, jointIterations));
world.addSystem(createAngularVelocityMotorEcsSystem(time));
world.addSystem(createChassisStabilizerEcsSystem(time));
world.addSystem(createAirControlEcsSystem(time));
world.addSystem(createCameraFollowEcsSystem(time));
world.addSystem(createCameraEcsSystem(time));
world.addSystem(createTerrainRenderEcsSystem(renderContext));
world.addSystem(createRenderEcsSystem(renderContext));
world.addSystem(createEulerIntegrationEcsSystem(time));

return game;
};

This demo composes several of the physics engine's existing primitives into a drivable car: the chassis, both wheels, and a small invisible 'upright' body per wheel (a wheel hub/knuckle) are all independent entities with their own RigidBodyEcsComponent, and each wheel is driven by an AngularVelocityMotorEcsComponent (see the Torque and Motors demo). A demo-only GroundContactEcsComponent/system pair, attached to each wheel's own entity, tracks how many static bodies that wheel is touching, recomputed every tick from that tick's collision manifolds. While a wheel is grounded, its motor's target speed tracks the throttle input directly, and the engine's own Coulomb friction model - not an artificial clamp - is what correctly limits how much of that becomes real acceleration versus slip, the same way it would for a real tire. Only while a wheel is airborne does WheelDriveEcsSystem clamp its target to a bounded slip band around its current rolling speed (derived from the chassis's own velocity every tick): airborne, a wheel has nothing but its own rotational inertia to resist the motor, so left unclamped it would accelerate towards an arbitrary top speed almost instantly and land spinning far faster than the car is actually moving, burning torque on wheel spin instead of quickly regaining grip. Each wheel mounts to the chassis through its upright: a PrismaticJoint (see the Prismatic Joint demo) constrains the upright to slide only vertically relative to the chassis, a RevoluteJoint (see the Revolute Joint demo) pins the wheel's position to that upright while leaving its rotation completely free to spin, and a LinearSpring/LinearDamper pair (see the Linear Spring and Damper demo) along that same axis supplies the suspension's force. Wiring either joint straight to the wheel wouldn't work - a RevoluteJoint would pin it rigidly in place with no suspension travel, and a PrismaticJoint locks relative rotation, which would lock the wheel's spin to the chassis - so the upright exists specifically to give the wheel a rotation-free attachment point. Unlike a spring-only mount, both joints are hard, iteratively-solved constraints with no lateral give, so the wheel only ever travels along that one axis, no swinging or sideways slop. Because the mount's *force* still comes from a soft spring rather than a rigid frame, accelerating and braking visibly pitches the chassis backward and forward, the 'leaning' feel the genre is named for; a light demo-only ChassisStabilizerEcsComponent nudges the chassis back level once nothing else is actively tipping it, without fighting a deliberate acceleration or brake lean. Each wheel mount's PrismaticJoint axis is also tilted outward rather than straight up/down, so the line from each chassis anchor to its wheel splays into a trapezoid instead of a rectangle, like a monster truck's lifted suspension - besides the visual, this gives an impact that's perpendicular to the chassis (hitting a ledge face-on) a component along the suspension axis for the spring to absorb, rather than landing entirely on the joint's hard, unsprung constraint. The terrain is a single static TerrainCollider following a procedurally generated height profile (see the Terrain guide), and a demo-only camera-follow system (the engine's built-in camera only supports input-driven pan/zoom) keeps the car in view as it drives across a course much wider than the canvas. ChassisStabilizerEcsComponent and AirControlEcsComponent, which both live on the chassis rather than either wheel, hold direct references to both wheels' GroundContactEcsComponent objects to check grounded state - ChassisStabilizerEcsSystem stays out of the way entirely while both wheels are airborne, and in that same situation AirControlEcsSystem instead drives the chassis's angular velocity towards a target proportional to the throttle input (the same targetVelocity/maxTorque approach AngularVelocityMotorEcsComponent uses for the wheels) - gas targets a nose-up-and-back spin, brake targets nose-down-and-forward, and releasing the input targets zero rotation, actively cancelling existing spin rather than just coasting on momentum, the classic mid-air control for lining up a landing. Letting go of the throttle on the ground also drops the driven wheel's motor torque budget to zero rather than braking it to a stop, so the car coasts and freewheels downhill under gravity instead of being held by an invisible parking brake. The hills get steep further along the course - if the car flips or gets stuck, press R to restart.