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
- I built a kart racer inside VoxelBlox, my Python voxel world, and wanted it playable in a browser without it becoming a different game.
- Rather than rewrite it all, I let the Python game bake everything it computes once into files, and ported only the per-tick rules to JavaScript.
- Then I recorded the real game and made the browser replay it, comparing every field on every tick — 7,200 of 7,200 ticks match.
- The hard parts were not where I expected: phones, a 25 MiB file limit, and one stray
//that silenced ten engines. - Best surprise: proving the browser right found bugs in the original.
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.
- Eleven cars, from a go-kart to a monster truck, balanced against each other to within 6%.
- Twelve voiced rivals with personalities and battle habits — snipers, trappers, bullies — and a live announcer with three takes of every call, so it repeats itself less than you'd fear.
- Twenty-nine power-ups, a battle mode, and an arena where the last car standing wins.
- Spectacle on schedule: herds, trains, geysers, weather, and from the second lap three big moments per race — a breach, a giant, a wrecking ball.
- Racing with friends, over the home network in the desktop game and peer to peer in the browser.
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-24First racetracka loop of road in the open world
- 2026-10-17Battle racingpower-ups, roles and finally a reason to check the mirror
- 2026-10-18Courses built near youpress P anywhere; twelve seconds became one
- 2026-10-21The web port, in one daya boundary, a spike, car, race and sound parity, then every course live
- 2026-10-22The racers kitnew courses, powers and events from a checklist
- 2026-10-25Arena on the websix arena themes, proven like the races
- 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 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
- Record every piece of state from day one. One unrecorded wall-slide field kept car checks failing on most courses for days.
- Put a phone in front of it before the desktop looks good. The whole-course load worked on every desktop and crashed phones; streaming tiles came after.
- Make refusals name their cases from the first version. "429 of 471" cost days; the named spots found the buried dirt in minutes.
- Check every engine, not one. The audio check listened to a single V8, which is how a stray
//slipped through. - Fetch into an empty folder from the very first release. Fetching over old results shipped 16 courses stale before I learned it.