Start here
It is a plain browser game: JavaScript modules and Three.js (r180, loaded from a CDN). There is no build step and no bundler. Edit a file, refresh the page.
git clone https://github.com/gunpowderstudios/car-racer.git
cd car-racer
python3 -m http.server 8080 # or: npm start
# open http://localhost:8080/
npm test # node --test tests/*.test.mjs (Node 20 or newer)
Every number on this page comes from the source and is the value at version 17.258. Where a value lives in code, its file is named so you can change it.
The clock
The whole game runs a little faster than real time. Handling, jumps and tracks are unchanged; races just finish quicker. The speedometer and lap clock convert back to real mph and seconds.
| Setting | Value | Where |
|---|---|---|
| Game speed | 1.1 | GAME_SPEED in src/gameSpeed.js |
| Longest frame the game will believe | 0.05 s | Math.min(0.05, rawDt) in src/main.js |
| Physics steps per frame, at most | 6 | stepPhysics in src/main.js |
| Physics step | 1/120 s (1/60 on Low) | physicsHz in src/quality.js |
The clock is the same on every device. A phone at 60 fps plays at exactly the speed of a desktop. At 30 fps (Low Power Mode) it plays at the same speed but looks choppier, and only below about 20 fps does the game actually slow down, because of the 0.05 s frame cap and the 6-step limit.
Frame time is smoothed by interpolating the drawn car between the last two physics steps, so a 120 Hz physics step on a 60 fps screen still looks smooth.
Graphics tiers
Three tiers live in one table, TIERS in src/quality.js. Pick one in the menu (Graphics), force one with ?q=low, ?q=medium or ?q=high, or leave it on Auto.
| Setting | Low | Medium | High |
|---|---|---|---|
| Pixel ratio cap | 1 | 1.5 | 2 |
| Anti-aliasing (MSAA) | off | off | on |
| Shadows | off | 1024 map | 2048 map, soft |
| Physics rate | 60 Hz | 120 Hz | 120 Hz |
| Rivals by default | 3 | 5 | 6 |
| Dents a car can show | 0 | 6 | 8 |
| Flying debris pieces | 0 | 48 | 96 |
| Smoke sprites at once | 60 | 110 | 180 |
| Mirror redraw | every 3rd frame | every 2nd | every frame |
| Ground texture sharpness (anisotropy) | 2 | 4 | 8 |
| Lowest adaptive resolution | 0.55 | 0.60 | 0.75 |
Adaptive resolution
While you drive, the game watches the frame rate in one-second windows (AdaptiveRes in src/quality.js):
- Two slow windows in a row (under 45 fps) lower the render resolution by 0.15.
- A run of smooth windows (57 fps or more) raises it by 0.1. It waits 5 windows the first time and doubles the wait after every drop (up to 60), so it settles instead of flapping.
- At the floor and still slow it gives things up in order: thin the smoke, then turn shadows off, then turn the mirror off.
- Pausing the game or leaving driving resets it.
Add ?perf to the address for a live overlay: tier, fps, resolution scale, physics Hz, draw calls, triangles, geometries and textures.
Phones
Which tier a device starts on
detectTier in src/quality.js, in this order. A forced tier (?q= or the menu) always wins.
- Not a touch screen (desktop): High.
- Data-saver on: Low.
- Reports 3 GB of memory or less: Low.
- An old graphics chip (Adreno 2xx–5xx, Mali-4xx and T-series, PowerVR SGX/GE8xxx): Low.
- 4 cores or fewer, and not an Apple chip: Low. (Safari reports few cores even on a fast iPhone, so Apple chips are not judged by that.)
- Anything else, which means a phone from the last few years: High. The adaptive resolution protects the slower ones.
What we measured
| Device | Result |
|---|---|
| Desktop (Mac, Chrome), High | 60 fps, about 680k triangles and 109 draw calls |
| iPhone 13, plugged in, High | 60 fps, 150–165k triangles solo, 330–410k with a caravan |
| iPhone 13, Low Power Mode | 30 fps: iOS caps the screen at 30. Same game speed, choppier look. |
Nothing here has been measured on Android phones yet. If you have one, open ?perf and tell us what it says.
The tricks that made phones work
- Lighter model copies. Every touch screen (and any device not on High) loads
cars/lite/copies of the vehicles: about 30k triangles for the body and 4k per wheel, under 1.1 MB each.LITE_MODELSinsrc/vehicleChoice.jslists them.?lite=1or?lite=0forces it. - Textures are the big memory cost. A vehicle exported with the Shrink Racer preset is 1024 / 1024 / 512 pixel WebP in one material, about 13 MB of graphics memory. The old four-image 2048 exports were about 92 MB with a caravan, which made Chrome on a phone quit.
- The caravan on phones. Allowed except on Low-tier devices.
?caravan=1forces it on and?caravan=0off, for testing (src/caravanPhone.js). - Phone menu. On touch screens the action buttons (Restart, Back on road, Camera, Reset view, Mirror, Pause, Explode, Share, Menu) sit inside the cog menu, so the screen stays clear.
- Low Power Mode hint. A phone held at a steady 30 fps for about 8 seconds gets a one-off toast suggesting it.
Vehicles
The catalogue is VEHICLES in src/vehicleChoice.js (look, size, toughness). The handling for each is in src/vehiclePhysicsProfiles.js, kept apart on purpose so desktop, phone, solo and multiplayer all share one set of numbers.
| Name | Id | Model file | Length m | Mass kg | Toughness × | Caravan | Bonnet cam |
|---|---|---|---|---|---|---|---|
| Mr Muscle | car | dodge-shrink.glb | 4.89 | 1450 | 0.95 | yes | yes |
| 70s Saloon | escort | escort-shrink.glb | 4.05 | 920 | 1.15 | yes | yes |
| German Beema | bmw | bmw-SHRINK.glb | 4.33 | 1080 | 1.00 | yes | yes |
| Mini Roundabout | mini | mini-SHRINK.glb | 3.05 | 700 | 1.20 | yes | yes |
| French Cheese | french-cheese | french-cheese-SHRINK.glb | 3.83 | 640 | 1.25 | yes | yes |
| Cuddly Panda | panda | panda-SHRINK.glb | 3.54 | 860 | 1.15 | yes | yes |
| Mobile Chariot | mobility-car | mobility-car-SHRINK.glb | 2.90 | 380 | 1.40 | yes | yes |
| Swedish Massage | volvo | volvo-SHRINK.glb | 4.79 | 1420 | 0.90 | yes | yes |
| WASAABI | saab | saab-SHRINK.glb | 4.66 | 1250 | 0.95 | yes | yes |
| Classic Oil Burner | v8-pilot | v8-pilot-SHRINK.glb | 4.44 | 1510 | 0.85 | yes | yes |
| Happy Camper | campervan | campervan-shrink.glb | 4.50 | 1280 | 1.10 | no | no |
| Rootmaster | bus | bus-SHRINK.glb | 6.40 | 4300 | 0.70 | no | no |
| Motorhome | motor-home | motorhome-SHRINK.glb | 4.85 | 2800 | 0.80 | no | no |
Toughness is a multiplier on the damage a vehicle takes, so below 1 is tougher and above 1 is weaker. A tuk-tuk has a physics profile (520 kg) and a tow ball ready, but no model yet.
Adding a vehicle
- Make the model (see Making models) and put it in
cars/. - Add a physics profile in
src/vehiclePhysicsProfiles.js. - Add an entry in
VEHICLESthat points at the profile and the file, with its length and toughness. - For a phone copy, add the lite file in
cars/lite/and list it inLITE_MODELS. - To let it tow, add a tow-ball position in
HITCHinsrc/caravan.js.
Damage
Every car has five zones: front, back, left, right and roof. A hit lands on the zone nearest the contact. When any zone reaches zero the car is wrecked and explodes, so where you are hit matters as much as how hard. The numbers are in src/damage.js and the game rules in src/derby.js.
| Hit points | Front | Back | Left | Right | Roof |
|---|---|---|---|---|---|
| Your car | 256 | 128 | 144 | 144 | 120 |
| A rival | 76 | 68 | 68 | 68 | 60 |
These are the base figures times TOUGHNESS (0.8; the table above is already scaled), and then each vehicle's own toughness multiplier. Your front end takes half of every hit (the plate welded on).
- Crash damage grows with the energy of the closing speed:
0.65hit points per kilojoule above a 3 m/s shrug-off. One crash can never take more than 110 from one zone, so a single hit from full health never kills you. - A quarter of every hit also crumples the two neighbouring zones.
- Walls hurt far less than cars (×0.12); the ground even less (×0.04).
- Barrel blast: 90 at point blank, reaching 10 m. A wrecked car going up: 60, reaching 8 m. You take 0.4 of a blast.
- A big single hit (14% of a zone for you, 28% for a rival) stalls the engine for a couple of seconds.
- Race mode: a wrecked player smoulders for 3 s and is put back on the road with 3 s of protection. Demolition: last one standing wins.
- A car on its roof burns at 16 hit points a second. At most 4 wrecks stay on the track before the oldest fades.
Visible dents
Dents are done in the vertex shader (src/dents.js): the body mesh is pushed in around the hit, so there is no extra geometry. The wheels are left out. A body needs enough triangles to dent smoothly (about 20–35k is good; a 7k body looks lumpy). How many dents show at once is the tier setting above; the caravan always shows at least 6.
Caravan
The Hastings 2000 is a separate body on a drawbar joint, with its own tyres, weight, damage and sway. Numbers are in CARAVAN in src/caravan.js; the look is in src/caravanView.js.
| Setting | Value | What it does |
|---|---|---|
mass | 450 kg | about half an Escort |
hp | 100 | battering it takes before it is wrecked (was 220, then 60) |
wallMul / carMul | 14 / 5 | its thin skin takes more from the same knock than a car does |
cog | 0.5 m | weight sits ahead of the axle, so the tail does not wag the car |
tyreMu / tyreStiff | 1.5 / 38 | sideways grip (raised so the caravan stays behind the car instead of sliding out) |
jointK / jointC / jointMax | 380000 / 11000 / 38000 | drawbar spring, damping and the most force it can pull |
yawDamp | 1.5 | extra damping of the swing |
The body also rocks a little on its springs when cornering (sway, looks only, at most about 4.5°). The picture lifts the caravan by about what the low side drops, so the wheels never sink into the road (src/caravanView.js, update).
When the caravan is wrecked its wheels fly off and the car drags it along, scraping, with the drag capped at what the towing car can pull. Dents go on the body only.
Hills
Cars have a hill assist so they do not slide backwards on steep slopes: on a climb with the throttle down, the drive force is raised to at least mass × g × slope × 1.4 (fading out above about 9 m/s), and driven tyres get extra grip in proportion to the slope (up to about double on a very steep climb). This holds a car up to about 33°; steeper than about 38° it still slides back.
Cameras and controls
Press C to cycle: Chase (desktop default), High (phone default), Bumper, Bonnet. The chase camera drops back and up when you tow. R (or 0) resets the view.
The bonnet camera
src/bonnetCam.js works out where to put it from the model itself, so it needs no per-car setting. It lines up the highest surface along the middle of the car and walks back from the nose: a bonnet is a low stretch followed by the windscreen climbing at least 0.28 m. The camera sits 0.1 m in front of that step and 0.3 m above the bonnet, tilted so the nose of the bonnet fills about an eighth of the screen. A bus, campervan or motorhome has a flat or steeply sloped nose, so it is skipped and C goes straight on.
Keys
| Key | Does | Key | Does |
|---|---|---|---|
| W S | Throttle, brake (and reverse) | B | Back on the road |
| A D | Steer | Backspace / Delete | Restart the race |
| Space | Boost | C | Camera |
| Shift | Drift (handbrake) | V | Rear-view mirror |
| H | Horn | P | Pause (single player) |
| X | Blow up your own car (single player). Handy when you are stuck. | M | Music |
| E | Edit this track (desktop) | Esc | Menu |
Gamepad and touch controls work too; the key handling is in src/input.js.
Links and switches
Address options
| Add to the address | Does |
|---|---|
?perf | Live frame-rate and memory overlay |
?q=low / medium / high | Force a graphics tier |
?lite=1 / ?lite=0 | Force the light phone models on or off |
?caravan=1 / ?caravan=0 | Force caravan towing allowed or blocked on a phone |
?room=CODE | Join a multiplayer room |
?track=NAME (with &edit=1 or &drive=0) | Open or drive a built-in track |
Sharing a race
The share button (top of the screen on desktop, in the cog menu on a phone) makes one link holding the car, whether the caravan is hitched, and the whole track as it is now, edits included:
https://bangersandsmash.co.uk/?car=car&van=1#t=~<packed track>
The track is packed into the address itself: a compact binary form, deflate-compressed, then base64, so there is no server and no account (src/trackLink.js). A link is never trusted: the packed text and the unpacked size are both capped. Your friend sees a box with the car and track, and Drive it! switches them to your car (a reload) and starts straight away.
What the game remembers (localStorage)
cr.opts (all menu options, including the saved graphics choice), cr.vehicle (chosen car), cr.tracks (your saved tracks), cr.draft (the track in the editor), cr.best (best laps), cr.mpname, cr.mptrack, cr.sfx, cr.music, cr.story.
Making models
Every vehicle is a .glb exported from Shrink, our own browser tool, using its Racer preset. The recipe:
- Textures: WebP at 1024 / 1024 / 512 pixels, one material. About 13 MB of graphics memory per vehicle.
- Wheels: separate nodes named
Wheel_FL,Wheel_FR,Wheel_RL,Wheel_RR(a three-wheeler usesWheel_F,Wheel_RL,Wheel_RR; the caravan has left and right). Each node's origin is the hub, and carriesextrasof{ wheel: true, axle: 'z', radius, width }. - Mesh compression: meshopt with quantization. Do not let a later tool re-quantize it, which shifts node transforms and moves the hub off-centre.
- Wheel centring: even a Shrink wheel can have its pivot a few percent of the radius off the real hub, so the game fits a circle to the tyre rim and re-pivots on it when the model loads. A wheel that is not round will still wobble, so keep tyres round and left and right wheels the same size.
- Lite copy for phones: about 30k triangles for the body and 4k per wheel, put in
cars/lite/under the same name. - Dentable body: aim for 20–35k triangles; very low-poly bodies dent lumpily.
tools/optimize-car.mjs is an older command-line way to shrink a textured glTF; tools/make-vehicle-thumbs.py makes the menu thumbnails.
bmw-SHRINK.glb and bmw-shrink.glb are different files. The name in VEHICLES must match the file exactly, including the capitals. We keep SHRINK in capitals for new files.House rules and gotchas
Every change
- Bump the version in
src/version.js(VERSIONand a one-lineNOTE) andsrc/assetVersion.js. A test checks they match. The asset version is added to model URLs so a replaced model is not served from an old cache. - Run
npm testand read the summary line. Do not chain it into a command that hides a failure. - Commit, then pull with rebase and push.
Gotchas
- The service worker (
sw.js) is network-first for code and.glbfiles, so an update shows on the next reload. Sounds and images come from cache first. Three.js is pinned to an exact version, so it is cached for good. - Saved settings beat Auto. If a phone reads Medium when you expected High, it is probably a saved Graphics choice in the menu. Set it back to Auto.
- The body mesh for dents is the one with the most vertices that is not a wheel. If a wheel has more vertices than the body, it must be excluded by name or the wheel gets dented.
- One multiplayer test is occasionally flaky (a dent-relay timing test). If it fails alone, run the whole suite again before assuming a real fault.
- You cannot test drawing without a browser. Physics and logic can be checked headless; anything visual needs a real device.
Tracks and game modes
The home page lists the built-in tracks under three headings that open and close (renderMenu in src/main.js; the tracks themselves are in RAW in src/templates.js).
| Heading | Tracks |
|---|---|
| Wacky Racing | Speedway, Technical, Croydon Overpass, Random circuit |
| Stunt and Shunts | The Oval (Demolition Derby), The hills have highs, The Twister, Bluebell Woods, Thin and Crispy |
| Sunday Driving | Coming soon |
Thin and Crispy is a 12 m wide road with no walls, up to about 32 m in the air, with two jumps, banked bends, barrels and one huge dip to a flooded valley floor (pools of water across the whole road) and back up. The heights are TC_Y in templates.js. Pools of water can now be put on any track, not only dirt stages.
Game mode names in the cog menu: Wacky Racing (every rival a different vehicle), Send in the clones (same vehicle, different colours) and Demolition (last one standing). Laps default to 1 everywhere.
Replay and Make my movie
The game quietly records where every car is about 20 times a second (src/replay.js); Replay plays that back from TV-style cameras (src/replayMode.js). Replay and the movie button are for computers: a phone is for quick racing, so the movie button is hidden when the pointer is coarse.
- One tap: Make my movie picks the liveliest 18 seconds (wrecks and big hits score most,
src/bestBit.js), cuts between the TV, Chase and Top cameras every few seconds, and puts it in the 1970s TV set (tv/tv-frame.webp) with the TV logo. - Fast: frames are drawn as fast as the computer can manage and written with the browser's own video encoder as a proper 30 fps H.264 mp4 under about 14 MB, so WhatsApp takes it (
src/mp4Save.js, using the vendoredmp4-muxer). The picture is stamped with the replay's own clock, so it can never run fast or jolt. - Sound: the game music is laid on afterwards and only if the music is switched on; engine and crash sounds are not recorded (that is what keeps it fast).
- Needs a browser with WebCodecs (recent Chrome, Edge or Safari).
Source map
| File | What is in it |
|---|---|
src/main.js | The game loop, menus, modes, glue between everything |
src/vehicle.js, vehiclePhysicsProfiles.js | Car physics and each vehicle's handling numbers |
src/vehicleChoice.js | Vehicle catalogue, gallery, lite models, multiplayer vehicle sync |
src/carVisual.js, dents.js, wheelDebris.js | Drawing a car, wheel pivots, cameras, dents, flying wheels |
src/damage.js, derby.js, ai.js | Zones and hit points, the race and demolition rules, rival drivers |
src/replay.js, replayMode.js, bestBit.js, mp4Save.js | Recording and playing back a race, picking the best bit, writing the movie |
src/caravan.js, caravanView.js, caravanPhone.js | Towing physics, look, phone rule |
src/quality.js | Tiers, device detection, adaptive resolution, 30 fps hint |
src/bonnetCam.js, cameraChoice.js, cameraOrbit.js | Bonnet camera, camera cycle, drag-to-orbit |
src/track.js, trackGeometry.js, templates.js, editor.js | Tracks, built-in tracks, the editor |
src/trackLink.js | Tracks as links |
src/multiplayer.js and mp*.js | Peer-to-peer multiplayer, chat, rejoining, caravan contacts |
src/input.js, hud.js, audio.js | Keys, pad and touch; the on-screen display; sound |
tests/ | Headless tests (npm test) |