01
It started as a road on a canvas
The first playable build was a single-file pseudo-3D road drawn on a 2D canvas: projected road segments, parallax scenery and a hand-drawn HUD. No 3D engine yet, just math and a drivable kart.
From there it was rebuilt on Three.js with a real 3D track (a spline with hills and elevation) and a chase camera, then textured, then given a grid of rivals. Each stage is still visible in the captures I kept along the way.
02
Six stages to a 3D racer
The montage is the short version. Real 3D with procedural surfaces came first, then photoreal CC0 textures under filmic tone mapping, then a nine-car grid with AI rivals whose personalities (how cautious in corners, which lane, how aggressive, when they boost) are seeded from their names, so a rival drives the same way every race.
The cars went from faceted karts to individually modelled road cars, lofted from cross-sections in code. Drift charges nitro, and that became the mechanic the whole game is built around.
03
Twelve places to drive
Each world has its own palette, elevation and landmarks: a coastal causeway at sunset, a snowbound mountain pass, eight lanes of new tarmac across a desert, canyon switchbacks above a grid of city lights. The skies are shaders, the tracks are splines with real elevation, and the engine, tyres, wind and music are synthesised live with the Web Audio API. Only a handful of crash, nitro and impact effects are recordings (free CC0 sounds).
Four modes sit on top: Circuit (nine rivals, a standing grid), Neon Run (an endless wet highway), Grand Prix (three rounds, points) and Space Wars (an anti-gravity tube with live ordnance). There's also a daily challenge (the same world, car and laps for everyone) and a replay of your lap from the results screen.
04
Making it feel like a game
Looking like a game and feeling like one turned out to be different jobs. Props stopped being scenery you ghost through: hit a tree and you're shoved clear, your speed scrubs and sparks fly. Ram a rival and it spins out. Going off-track gives you a grace window, then a clean respawn where you left the road.
The AI was retuned into an actual race. The leading rival matches your top speed and the field strings out by personality, rubber-banded so one bad lap doesn't end your race, but capped at your nitro ceiling so nobody rockets away uncatchably. The music lifts with your speed and nitro, and call-outs land for the moments that matter: an overtake, the final lap.
05
It looked finished. It didn't feel smooth.
The obvious suspects were the GPU and the physics. Instead of guessing, I profiled the running engine. The physics cost about 0.05 ms a frame. The whole cost was rendering, and it was bound by draw calls: the scene was about 12,500 separate meshes but only 800k triangles, which a GPU handles easily. Dropping the resolution did nothing, because the cost was per-object overhead on the CPU.
Two fixes did most of it. The sun's shadow pass was drawing thousands of distant objects into a shadow map for shadows you couldn't see; tightening it culled them, and the same shadow map over a smaller area came out sharper. Then each prop's handful of sub-meshes was merged into one mesh per material, in the prop's own space rather than the world's, because the wind shader sways foliage based on height within the tree. A world-space merge would have made every tree flail.
The physics cost about 0.05 ms a frame. The whole cost was rendering.
| Forest race | Before | After |
|---|---|---|
| Meshes / draw calls | 12,474 | 5,627 |
| Render time | ~24–30 ms | 11.75 ms |
| Frame rate | ~33 fps | ~85 fps |
Same forest, the same look.
- The cars were 65% of the frame's draw calls but 0.5% of its triangles. Merging each wheel internally halved the meshes per car, and I spent the savings on rounder tyres and bodies.
- The neon city was 21,151 meshes, mostly plain boxes on its towers. Merging static parts cut it to 4,971 meshes and 1,838 draw calls to 843, with triangle counts unchanged.
- Static art was being revalidated on every visit (30 round trips at about 112 ms each) until it got proper cache headers.
06
One rule for the architecture
Game logic never imports React, and React never touches in-race state. React owns the menus, the engine owns the race, and they meet at a single callback. That kept the per-frame path free of re-renders and made the engine testable on its own.
07
Shipping it, and keeping it honest
Getting it ready for other people was its own list: a crash boundary with a way back to the menu, a WebGL check that tells old devices what they're missing, a tap-for-sound fallback for iOS, an offline service worker with a test in CI, and saved data that's migrated rather than dropped when the format changes. Memory was measured across 29 races in a row with no leak.
Render and bundle-size budgets run in CI, so a change that makes it slower or heavier fails the build instead of quietly shipping. And when a claim in the docs turned out wrong, the fix went in the record. The car picker once said ten cars while the header counted fourteen, because one number was typed and the other was read from the code. Now both are counted.