Voxel Racers · engineering notes

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

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

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

The short version
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 is made by code. Press P and the game surveys the land around you — 72 candidate spots, and the flattest one clear of towns wins — then 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, and a couple of courses out in orbit that later taught me space is not rock.

02How it grew

The first racetrack landed on 2026-09-24; the web port began on 2026-10-21 and had every course live the same afternoon. That's 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 arrived in one burst — not heroics, just a method that 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 finally a reason to check 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?

There are three obvious ways to get a Python game into a browser, and each fails in an instructive way. 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 gives you a game that feels about the same — my first spike cheerfully drove a car at 151 km/h in a game that tops out at 108. "About right" is a feeling, not a test anyone can run.

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

04Bake, rewrite, prove

The whole method is three verbs, in this order.

Bake

Anything the game computes once, the game computes itself and writes 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; it only unpacks.

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; the numbers arrive as data.

Prove

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

The toolchain page walks through all thirteen steps →

05Architecture at a glance

Everything flows one way: out of the Python game, never back into it. That single decision decides the shape of the rest.

The originalVoxelBlox and its racing modules, in Python. It is the specification, and nothing in it changes to suit the web.
The boundaryNamed extension points where racing meets the engine. The same list tells the browser runtime exactly what it has to provide.
The bakeThe game builds each course in a throwaway world and writes down meshes, textures, collision, rule constants and sounds. The browser never generates anything.
The rules port~9k lines of JavaScript: only the per-tick rules, one file per Python module, with no numbers of their own.
The proofRecordings of the real game, replayed stepwise — every field, every tick. It is the contract between the two halves.
Packs and the fleetEach course is built, checked and packed on fleet machines, cut to fit the host and versioned, so a returning player re-downloads nothing.

The trade-off is deliberate. Baking moves the weight into the download — which, as you'll see, became the hard part — but the browser cannot disagree with the game about anything it never had to compute. Only the rules can drift, so only the rules need proving.

06Proving 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, and the worst difference I've seen is 2.7e-15.

There is also a free run, where both games simply race and I watch how long chaos takes 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 always wins eventually — a butterfly flaps, a kart takes a different line. On most courses the free run holds for 9 to 46 seconds before a rounding difference becomes a different line through a corner.

07Making 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. I was very pleased with myself. Briefly.

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. Then a twist: culling just moved the stutter, because meshes were uploaded on their first visible frame. Now they upload when built.

08Shipping it

Each course is a pack of 23–50 MB gzipped. The host caps any file at 25 MiB, and it means it, 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.

09Keeping two games in step

The desktop game keeps moving — that is rather the point of it — so a small tool lists every racing module changed since the last time the two were in sync. A changed constant rides along with the next release automatically; a changed rule needs a hand port, and the tool says which. It prints after every ship, so the gap can't grow quietly.

10Eighteen lessons

Each of these is a real mistake or surprise, with what I do now and the takeaway that travels to other projects.

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 monster; 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.

estimate a job by the part you can't automate, not the part that makes a good demo.

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.

the cheapest geometry is the geometry you never ship.

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.

if two things must agree, derive them from one source — and check the measuring stick before believing the measurement.

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.

a check that fails precisely is a guide, not an obstacle; fix one difference at a time.

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. A rounding error with ambitions.

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

in anything chaotic, compare one step at a time; a whole-run comparison only tells you how long luck lasts.

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. Nothing in the source said so; the waveform did.

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

when a port is off by a strange amount, check the types the original really used, not the ones you assumed.

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 happily. 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.

a check that samples one case only checks that case; widen it until the bug you just had would fail it.

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.

an assumption that holds on the ground deserves a second look the moment your game leaves it.

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.

a comparison can only see what the recording captured; record first, compare second.

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.

try the weakest device you mean to support early; desktops forgive what phones won't.

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.

watch the worst frame, not the average; players feel the hitch.

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.

learn your platform's hard limits before your files grow into them.

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. Humbling, twice.

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

a command that succeeded hasn't proven its output is current; read the result back.

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. Only the very patient got to race that day.

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

validate before you publish, never after.

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.

an architectural boundary nobody counts slowly stops being one.

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.

make every failure name the cases that failed; a bare count costs days.

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 — a generous podium.

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

a second implementation is a second opinion; when they disagree, either side can be 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 entirely innocent.

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

read the evidence the system already gives you before forming a theory.

11What I'd do differently

12What 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.