The idea
Every browser game I admired had the same hidden cost: a pipeline. Someone models the kart in Blender, someone bakes the textures, someone records the engine, and the repository fills up with binaries that have nothing to do with the code. I wanted to know whether the pipeline was the hard part or the craft was — so I set a rule and refused to break it:
Not one art asset. No models, no textures, no fonts, no audio files, no images. If the player sees it or hears it, code made it at load time.
Kart Mirage is the result: a Mario Kart-style racer on a sun-drenched Amalfi coastal circuit, running at 60fps in a browser tab, out of a repository whose assets folder does not exist. The name is the thesis — the whole world is a mirage, an image with nothing behind it but arithmetic.
What it does
Sunset Bay Circuit: three laps, eight karts, drift-to-boost with a mini-turbo
ladder, eight items (shells, bananas, bombs, mushrooms, star, bolt), and a
field of AI drivers that brake before corners instead of during them. There is
a title screen, character select with six karts on real stat curves
(AZZURA, KOMOTO, ORBITA, SUNBOLT, VELOCE), a results board, a replay
recorder that saves a .webm, and four mobile touch-control schemes with saved
preferences. Add it to a phone home screen and it runs fullscreen in landscape.
How I built it
Three.js + TypeScript + Vite, about 67,000 lines across eight directories
with one concern each, all coding against a single interface file (types.ts)
that nothing is allowed to edit as a side effect.
The world is a curvature schedule, not a spline. The circuit is authored as a table of legs — each a length and a heading change — then box-blurred, which turns every constant-radius corner into a clothoid-like transition. That is the same trick road engineers use, and it is why the track has no curvature pops when you drive it. The leg angles were solved offline with a min-norm Newton step on the two closure equations so the loop shuts to under 5 cm, and the curvature integral is renormalised so the heading closes exactly:
$$\oint \kappa(s)\,ds = -2\pi$$
Everything downstream — width, banking, shoulder surface, wall type, terrain — is baked into per-station typed arrays, so a physics probe is a table lookup and two lerps.
The handling is a real vehicle model. Four-corner raycast suspension where roll and pitch are genuine degrees of freedom: each spring generates torque, the inertial couple at the centre-of-mass height pushes back, dampers resist the rate. Weight actually transfers to the outside wheels, and the tyre model reads that as load. Lateral grip is a cheap stand-in for Pacejka — a quarter- sine rise to a peak, then a smoothstep decay to a lower sliding plateau:
$$\mu(\alpha)=\begin{cases} \mu_p\,\sin!\left(\dfrac{\pi}{2}\cdot\dfrac{\alpha}{\alpha_p}\right) & \alpha \le \alpha_p\[8pt] \mu_p-(\mu_p-\mu_s)\,S!\left(\dfrac{\alpha-\alpha_p}{\alpha_s-\alpha_p}\right) & \alpha > \alpha_p \end{cases}$$
The falling tail past the peak is the entire drift mechanic. Without it the rear snaps back to grip; with it the rear breaks away and stays away, which is what makes a slide something you steer rather than something that happens to you.
The sky lights the game. A raymarched atmosphere is rendered once into a
cube and PMREM'd into an environment probe, so every metal flake and clearcoat
highlight is reflecting the exact sky the player can see. Aerial perspective is
a height-attenuated Beer fog replacing three's FogExp2 chunk, sampled per
view azimuth so it meets the real sky instead of an average of it:
$$f = 1-e^{-\beta(y)\,d}, \qquad \beta(y)=\beta_0\,e^{-y/H}$$
The light rig is a warm key, a cool anti-solar fill, two bounces and a rim term. The number I tuned against was measured on a grey card laid flat on the road, pre-tonemap: 4.37:1 lit-to-shadow (2.13 stops) with a 4.3× warm/cool hue split across the terminator.
Audio is an oscillator graph. No samples anywhere — oscillators, noise buffers, biquads, a tanh waveshaper, and a convolution reverb fed by a procedurally generated impulse response. Two duck gains let a boost pull the engine and music down for 150 ms without fighting the levels the engine voices write every frame, and the mini-turbo tone has its own never-ducked path with a 2.4 kHz presence bell so it stays legible over a full-throttle engine.
What I learned
Your instrument lies before your game does. I wrote 12,000 lines of headless Puppeteer harnesses — frame pacing, fill rate, drift payoff, tear detection, AI wall contact, touch latency, memory soak, WebGL context loss — and the hardest lesson was that two of them produced confident, precise, entirely fictional numbers before anyone checked them. Validate the ruler.
At 60Hz there is no such thing as a 20ms frame. vsync only ever hands back ~16.7ms or ~33.4ms. So the median frame time of a build running at 48fps is exactly 16.70 and reads as a perfect pass, and "percentage of frames over 16.7ms" sits near 45% on a flawless run purely from jitter. Gate on the mean and on two-vsync frames instead.
A screenshot cannot find a gameplay bug. Inverted steering, unusable mobile controls, and a pause menu that permanently ended your race all survived three full rounds of scoring beautiful stills.
Draw calls are not proof of rendering. Every black-screen failure mode I hit still issued a full frame's worth of draws into a canvas that was never painted. I ended up sampling the luma spread of the presented image instead.
Challenges
- MSAA silently fights ambient occlusion. The AO pass samples the
composer's buffer as a texture, which a multisampled target cannot resolve
to.
multisampling = 2produced black frames on 7.6% of frames;0produced 0.2%. - Shader pre-warm has to bind a render target. Three reads
outputColorSpaceandtoneMappingoff the currently bound target, so compiling with nothing bound builds default-framebuffer variants the composer never uses — and every real shader still stalls on first sight. - A single global constant deleted every reflection in the game.
envMapIntensityapplied uniformly flattened all metal and clearcoat. It is now per-material. - Legibility is a mipmap problem, not a font problem. A 4-metre trackside board at 40 metres is about 40 screen pixels wide. No filter recovers a letterform from that, so every sign is six glyphs or fewer, with generous tracking and a hard contrast ring, chosen for luminance contrast rather than hue — hue is the first thing a mip average throws away.
- The adaptive scaler ruins measurements. It spends resolution to protect frame rate, so a genuine 2ms saving lets it hold a higher rung, draw more pixels, and report the same fps. Unpinned run-to-run spread was 8.65ms; pinned, 2.04ms.
- Mobile is a memory budget, not a perf budget. Every texture passes through one cap before its first upload, because an iOS tab dies past roughly 80MB of texture memory.
Built With
- canvas-api
- css3
- game-development
- glsl
- html5
- javascript
- n8ao
- node.js
- pbr
- physics-simulation
- pointer-events
- postprocessing
- procedural-generation
- puppeteer
- pwa
- shaders
- simplex-noise
- smaa
- ssao
- three.js
- typescript
- vite
- web-audio-api
- webgl

Log in or sign up for Devpost to join the conversation.