Make a drivable car
Two crossing streets at sunset, lined with buildings, lamps and trees, and a car you can drive into all of them. The car and its handling come straight from City Drive; wgpu3d packages them as a prefab and a controller, so the whole game fits on one screen of code.
The whole program
import { createApp, createCar, createGround, createRoad, createBuilding, createTree, createLampPost, VehicleController, createRandom } from "wgpu3d";
const app = await createApp({ canvas: document.querySelector("#drive-demo"), environment: "sunset" });
const random = createRandom(7);
app.add(createGround({ size: 260, tiles: 52 }));
app.add(createRoad({ length: 240, width: 9 }));
app.add(createRoad({ length: 240, width: 9, yaw: Math.PI / 2, height: 0.04 }));
for (let block = -5; block <= 5; block++) {
if (block === 0) continue;
for (const side of [-1, 1]) {
const along = block * 20;
const height = random.range(8, 26);
app.add(createBuilding({ seed: random.integer(1, 9999), width: 12, depth: 12, height, position: [side * 14, 0, along], litShare: 0.25 }));
app.add(createBuilding({ seed: random.integer(1, 9999), width: 12, depth: 12, height: random.range(8, 26), position: [along, 0, side * 14], litShare: 0.25 }));
app.add(createLampPost({ position: [side * 6, 0, along + 10] }));
}
}
for (let index = 0; index < 60; index++) {
const x = random.range(-120, 120), z = random.range(-120, 120);
if (Math.abs(x) < 24 || Math.abs(z) < 24) continue;
app.add(createTree({ seed: index, height: random.range(4, 8), kind: random() < 0.4 ? "pine" : "round", position: [x, 0, z] }));
}
const car = createCar({ paint: "#1d6fd8" });
car.addBehavior(new VehicleController({ bounds: { minX: -125, maxX: 125, minZ: -125, maxZ: 125 } }));
app.add(car);
app.follow(car, { speedFieldOfView: 12 });
app.start();
Laying out the world
The world is built from prefabs, functions that return a ready-made SceneObject. createRoad gives an asphalt strip with lane markings; the second road is rotated a quarter turn with yaw and raised with height so it sits cleanly on top of the first where they cross. Buildings get a random height and a litShare, the fraction of windows that glow, which is what makes the street look lived-in once the sun is low.
All the randomness comes from createRandom(7), a seeded generator. The same seed always lays out the same street, so a bug you find can be reproduced, and changing one number gives you a new town. The trees loop skips spots within 24 metres of either road so nothing grows in the traffic lanes.
The car
createCar builds the City Drive car: a body lofted from twelve cross-sections, a glass cabin, trim, headlights and four wheels. The wheels and body are separate child objects listed in car.parts, so a controller can spin and steer the wheels and tilt the body without rebuilding any geometry. All four wheels share one mesh.
VehicleController is a behavior that turns key presses into motion, using the same model as City Drive's car physics:
- The throttle pushes harder at low speed and fades towards
maxSpeed. Letting go coasts, with a little drag. - Steering eases towards the key you hold, and the turn rate shrinks as speed rises, so the car is nimble at walking pace and stable on a straight.
- The handbrake scrubs speed and adds steering, enough to swing the tail round a corner.
- A head-on hit bounces the car back at a fifth of its speed; glancing hits just scrape it along the wall.
- For feel, the body squats under acceleration, dives under braking and leans out of turns, and the brake lights glow when you slow down.
Every one of those numbers is an option. A heavier truck is new VehicleController({ maxSpeed: 20, acceleration: 9, steering: 1.2 }).
Collisions
Buildings, lamp posts and trees come with box colliders, rotated with the object. Each frame the controller treats the car as a circle, finds any colliders it overlaps and pushes it out along the shortest way. That is cheaper and steadier than a physics engine, and it is exactly what a driving game needs. bounds keeps the car on the map.
The chase camera
app.follow(car) puts a chase camera behind the car. It aims at a point ahead of the car rather than the car itself, so you see where you are going, and it follows with springy smoothing so turns feel weighty. speedFieldOfView: 12 widens the view by up to 12 degrees as you speed up, an old racing-game trick for a sense of speed.
Things to try
- Change
environmentto"night": the lit windows and lamps carry the scene. - Add a second car with no controller and park it in the street. It is solid, so you can bump into it.
- Read
car.speedinapp.onUpdateand write it into a speedometer element over the canvas.