Voxel Racers · engineering notes

I built a racing game inside my voxel world, carried it to the browser, and proved the two identical

Press P anywhere in my open-world voxel game and a race course is built onto the land around you: 29 courses, eleven cars, twelve voiced rivals, an announcer, power-ups and a lot of explosions. Then I wanted anyone to be able to play it with one click — no install, no Python — without it becoming a different game. These notes are about how.

Play it in your browser ↗  ·  Watch the trailer ↗  ·  How the conversion works

7,200 / 7,200physics ticks identical between the Python game and the browser, on every course's car check
< 1e-6the most a full eight-car race drifts over 5,400 ticks — worst seen 2.7e-15
~9k linesported to JavaScript; everything else is baked by the game itself
865rule constants read straight out of the Python code, so the browser has no numbers of its own
29courses, each built, checked eight ways and packed on a fleet of machines
5e-8worst error across all 34 engine sounds, synthesised live in the browser

01The game

Voxel Racers lives inside VoxelBlox, an open world I built in Python where every texture, model and sound comes from code. Press P and the game looks at the land around you — 72 candidate spots, the flattest one clear of towns wins — and builds a course there in about a second. Neon Night, Frozen Lake, a sandworm desert, a pinball machine, the inside of a computer, a moon base on the actual Moon.

02How it grew

The first racetrack landed on 2026-09-24; the web port began on 2026-10-21 and went live with every course the same afternoon. 176 commits to the racing game and 77 to its web port:

2026-09-24: 22026-09-24: 02026-09-25: 32026-09-25: 02026-09-26: 02026-09-26: 02026-09-27: 02026-09-27: 02026-09-28: 02026-09-28: 02026-09-29: 22026-09-29: 02026-09-30: 62026-09-30: 02026-10-01: 12026-10-01: 02026-10-02: 02026-10-02: 02026-10-03: 02026-10-03: 02026-10-04: 02026-10-04: 02026-10-05: 02026-10-05: 02026-10-06: 22026-10-06: 02026-10-07: 02026-10-07: 02026-10-08: 32026-10-08: 02026-10-09: 42026-10-09: 02026-10-10: 12026-10-10: 02026-10-11: 02026-10-11: 02026-10-12: 22026-10-12: 02026-10-13: 02026-10-13: 02026-10-14: 02026-10-14: 02026-10-15: 02026-10-15: 02026-10-16: 02026-10-16: 02026-10-17: 122026-10-17: 02026-10-18: 212026-10-18: 02026-10-19: 392026-10-19: 02026-10-20: 282026-10-20: 02026-10-21: 82026-10-21: 202026-10-22: 92026-10-22: 152026-10-23: 62026-10-23: 102026-10-24: 42026-10-24: 62026-10-25: 132026-10-25: 122026-10-26: 72026-10-26: 92026-10-27: 32026-10-27: 52026-09-242026-10-27
Commits per day: the racing game (first bar) and the web port (second bar, accented). The port was built in a burst, because the method below made most of it mechanical.
  1. 2026-09-24First racetracka loop of road in the open world
  2. 2026-10-17Battle racingpower-ups, roles and a reason to look in the mirror
  3. 2026-10-18Courses built near youpress P anywhere; twelve seconds became one
  4. 2026-10-21The web port, in one daya boundary, a spike, car, race and sound parity, then every course live
  5. 2026-10-22The racers kitnew courses, powers and events from a checklist
  6. 2026-10-25Arena on the websix arena themes, proven like the races
  7. 2026-10-27Space is not rockevery course's car check passing, including the ones in orbit

03Why not just rewrite it?

The obvious ways to get a Python game into a browser all fail in instructive ways. Automatic conversion chokes on numpy and on a game whose objects reach into a whole engine. Running Python in the browser would mean shipping the entire engine without the compiler that makes it fast. And a clean rewrite produces a game that feels about the same — my first spike drove a car at 151 km/h that the real game tops out at 108. "About right" is not a test anyone can run.

So the Python game stays the single source of truth, and is never changed to suit the web.

04Bake, rewrite, prove

Bake

Everything the game computes once is computed by the game itself and written to files: the course mesh in the GPU's own vertex format, textures, collision cells, 865 rule constants, the race's starting grid, every sound. No generator runs in the browser.

Rewrite

Only the rules that run every tick are written in JavaScript — car physics, rivals, battles, shows, audio mixing. Each file is named after the Python it mirrors and holds no numbers of its own.

Prove

The Python game is recorded racing; the JavaScript replays the recording and every field is compared on every tick. A check is trusted only after I have watched it fail on a planted bug.

The toolchain page walks through all thirteen steps →

05Proving it

The gate is stepwise: each tick, the JavaScript is set to the Python game's exact state, steps once, and every field is compared — position, speed, drift, gear, every rival, every sound event. A whole course passes at 7,200 of 7,200 ticks; a full eight-car race stays within a millionth over 5,400 ticks, with the worst difference seen at 2.7e-15.

There is also a free run, where both games simply race and I watch how long it takes chaos to pull them apart. It is information, not a gate: two languages' atan2 differ in the last bit, and in a chaotic system the last bit eventually wins. On most courses the free run holds for 9 to 46 seconds before a rounding difference becomes a different line through a corner.

06Making it fit a phone

The first spike drew a 1.62-million-face canyon in 7 draw calls at about 57 frames a second on my desk, and the whole bake took 13.6 seconds. The download was the hard part, and phones were harder: loading a whole course at once needed about 227 MB of graphics memory, and phones simply lost the GPU. Streaming tiles near the camera with packed vertices brought that to 61 MB, and culling later drew 44–61% fewer faces. One more surprise: culling moved the stutter, because meshes were uploaded on their first visible frame. Now they upload when built.

07Shipping it

Each course is a pack of 23–50 MB gzipped. The host caps any file at 25 MiB, so packs are cut into 24 MiB parts and a course's sounds travel in one binary with an index. Content-versioned file names plus a long cache header turned a repeat visit from about 190 MB of re-downloading into almost nothing. One command bakes every course across three fleet machines, fetches results into an empty folder, reads every pack back, and refuses to deploy anything stale.

08Keeping two games in step

The desktop game keeps changing, so a small tool lists every racing module changed since the last time the two were in sync. A change to a constant travels with the next release automatically; a change to a rule needs a hand port, and the tool says which. It prints after every ship, so the gap never grows quietly.

09Eighteen lessons

01The bake was the cheap part

The first course baked in 13.6 seconds and drew at 57 fps. I had braced for the bake to be the problem; it was the runtime — the thousands of lines of rules — that needed the planning.

Now: size a port from the code that has to be rewritten, not from what can be baked.

02Caves nobody drives

Dropping faces lying 24 blocks or more below the road cut a course from 2.4 million faces to 1.62 million. Nobody races through bedrock.

Now: bake only what a driver can see.

03Two floors from two numbers

The drawn floor and the collision floor came from different constants, so up to 27 m of ground you could stand on was never drawn. My first measurement of it was wrong too: it counted flat tops only and called one course 312 of 500 drawn when it was 452.

Now: one floor, and a bake refuses to ship if under 99% of the road is drawn.

04What the replay found, in order

The first race check failed, then failed differently, four times: my car's race spec, a missing slipstream branch, a part of the course the recorder had not loaded, and a coin's 0.25-second kick hidden in state the check did not compare.

Now: 5,400 ticks, all within a millionth. Each failure was a real difference, and each pointed at exactly one.

05atan2's last bit

The free run held together for 23 seconds with eight cars, then parted. Python and the browser disagreed by 2.2e-16 in one atan2, a second in.

Now: stepwise comparison is the gate; the free run only measures how long chaos takes. The track's trigonometry is exported rather than recomputed.

06The compiler's secret integer

The engine sound came out 14% wrong. The Python compiler had quietly typed a seed as a 64-bit unsigned integer, and converting a float to 32 bits behaves differently above 263.

Now: the seed is carried as two 32-bit halves. Worst error over 81,920 samples: 5e-8.

07The comment that silenced ten engines

A // ate the end of a line in the audio code. The check only ever listened to one course's V8, so it passed. Later another stray // cut a function's return short, and every fleet check still passed.

Now: all 34 engines are checked, the old line fails 10 of them, and a drive test asserts every field the page reads.

08Space is not rock

The browser treated everything below the road's band as solid rock. On the orbit and Jupiter courses the car fell 130 m onto "rock" that was really grass, or air: 225 and 2,383 bad ticks.

Now: each column carries its two hollows and their floors. 0 of 7,200 bad.

09The wall-slide was in the trace

Car checks failed on most courses for days. One field was never recorded, so the browser charged the full cost of grazing a wall where the game charged a fifth.

Now: a new piece of state goes into the recording the day it is created.

10Phones lose the context

A whole course loaded at once — 1.9 million faces, about 227 MB on the GPU — worked on every desktop and crashed phones' graphics outright.

Now: stream tiles near the camera with packed vertices: 61 MB.

11Culling moved the stutter

Drawing fewer faces made frames faster on average and gave 45–190 ms hitches mid-race, because each tile uploaded to the GPU the first time it became visible.

Now: upload a mesh whole when it is built; cull it afterwards.

1225 MiB is a law

The host refuses any file over 25 MiB, and course packs grew to 23–51 MB.

Now: packs are cut into 24 MiB parts and sounds are bundled into one indexed file.

13Exit code zero is not done

A release fetched fleet results into folders that already held old ones, and 16 courses shipped stale. A second attempt threw away the fetch's output and left 10 stale.

Now: fetch into an empty folder and read back every pack before deploying.

14Check, then write

A bundle that did not parse was deployed anyway, and the live game sat on its loading screen for half an hour.

Now: the bundle is parsed before it is written anywhere a player can reach.

15149 becomes 76

Racing touched the engine in 149 places across 19 modules. Turning that boundary into named extension points brought it to 76 places in 2 modules — and the list of points doubled as the browser runtime's interface. The first run of the counter found two stale exceptions and one point nothing called.

Now: the boundary is a number a tool counts, and it fails if it grows.

16A count is not a diagnosis

Three courses refused to bake for days with nothing more than "429 of 471". Once the refusal named the failing spots, the cause took minutes: the road reader was picking up buried dirt.

Now: a refusal names its cases. All three bake clean.

17The port debugged the original

Proving the browser right found bugs in the desktop game: shortcuts under the main road counted as falls, and the finish board sorted finished cars slowest first.

Now: a disagreement is investigated on both sides. Sometimes the original is the one that is wrong.

18The thumbstick that turned the screen blue

An analog stick value of 1.86 indexed past the end of a table and a phone's screen went blue. My first fix went after graphics memory, which was innocent.

Now: read the device's own error report before guessing.

10What I do now

The original is the specification. Never change the game to suit the port.
Bake, don't port. Anything computed once is computed by the original and shipped as data.
Prove by recording. Stepwise against the original, every field, every tick.
Witness every check fail on a planted bug before trusting it to pass.
Port changes while they are small. A tool says which rules have drifted after every ship.