VoxelBlox · notes from the workbench

Building a block world, one witnessed bug at a time

An endless block world where every texture, sound and building is made by code, written in Python of all things. This isn't a sales page (there's nothing to buy). It's a look over the shoulder: what we're making, how we work, the bugs that taught us something, and how the engine keeps up.

Last updated 30 September 2026

New since last time (30 September 2026)Games played in the world (bowling, mini golf, golf, basketball, darts, pool) with markers so you can find them; every screen game with a proper rules page; blackjack and roulette with real casino rules; the screen games redrawn; towns with staff, visitors and more variety; banks and a stock market; smooth train tracks; and eight new stories below.
1
draw call for all visible solid terrain (GL 4.3 multi-draw indirect; water is a second)
2.69 ms
median frame in the reference smoke world (was 3.88)
228 → 1
draw calls for distant terrain at 4 km
0 of 57,600
pixels differ between the fast path and the fallback

00What it looks like

Screenshots from the game itself, taken through the real GPU path at 1280×720 (the on-screen guide and HUD left in). Click any one for full size.

The games, lately

The newest pictures: games you play in the world, and the little screen games at the places round town, freshly redrawn. Same GPU path, same 1280×720.

01What we're making, and why it's fun to make

VoxelBlox is an open-world block game: dig, build, drive, fly, go to space. The twist that makes it interesting to build is that nothing is drawn by hand. Every texture, model, sound and piece of music is generated by code when the game starts. A town is a small program. So is a cruise ship, a bowling alley and a planetarium. If a building looks wrong, you don't open a paint program: you read its code, fix it, and every town in every world gets the fix.

It's also written in Python, which is a slightly silly choice for a 3D engine. Python is lovely to write and slow to run, so the whole project is an exercise in keeping Python out of the hot loops (the engine section below is about exactly that). The rest of this page is about the part we find more interesting: how we build it, and the bugs that taught us something.

Lately the world has filled up. Towns have staff and visitors: the barista stands at the counter, patients sit in the waiting room, a cruise ship has cabins with walls. Parked cars vary (sometimes electric, sometimes sporty). There's a bank on the ground floor of some towers and an investment bank near the top, with a little stock market. And there are games everywhere: bowling, mini golf, golf, basketball, darts and pool played in the world with real physics, plus eighteen small screen games at the places they belong (air traffic control in a control tower, triage at a hospital, a crane at the port).

02How we work

This is a one-developer project, and a big one. What keeps it from collapsing under its own weight is a handful of habits, followed every time. None of them are clever. Together they're the whole trick.

  1. Every request is written down in the developer's own words in a file called OPEN.md, and it stays there until it works in the game: not "the code is written", not "the tests pass". “When I leave the alley, I don't have my bowling ball anymore” stays open until someone has walked out of an alley and back.
  2. A check isn't a check until it has failed. Every automated check gets witnessed: a tool reverts the one line the check protects, runs the check, and it must go red. A surprising number of checks pass for the wrong reason, and this is how you find them.
  3. Test through the player's path. Checks press real keys and click real buttons through the same event handler the game uses, and read back what's on screen, not some internal flag.
  4. Every job leaves a tool behind. When something was hard to see, we build the thing that shows it and keep it. There are now tools that play every screen game with bots and tune their difficulty, tour the slowest places in the world and time them, drive every vehicle's jobs to the end, and count every building type over 500 generated towns.
  5. The evidence goes in a log, the lesson in one line. Every decision gets an entry in an engineering log (with the numbers), and every lesson gets one line in a lessons file. Before working in an area, you read its lessons. Most of the stories below came straight out of those two files.
  6. The graphics card belongs to its owner. The development PC's GPU also runs a desktop, a language model and a text-to-speech server, so anything that draws asks first, runs one at a time, and reports how many driver faults happened during the run. (There's a story behind that rule. It's below.)
The shape of a dayThe developer asks for something in plain words (“make the train tracks go down like the roads, so they're smooth”). It becomes a row in OPEN.md. It gets built on its own branch, with a check that's been seen failing. It's looked at through the real game. Then a log entry, a lesson, a merge, and the row waits for the developer to try it.

03Stories from the workbench

The fun part. Each of these is a real bug or surprise, what caught it, and what we changed so it can't happen again.

The crash that only a picture could find

We added floating name tags over every game spot, so you can see where the bowling lane or the dartboard is. Every check passed. Then we took screenshots, and every single one crashed on its first frame. One line in the drawing code asked the settings for get("play_markers", True), with a default value, and the settings object only takes a name. None of the checks ever drew a frame in play, so none of them ran that line.

What we changed: a check that reads the source code itself and flags any settings lookup with the wrong number of arguments, and a habit: after touching the drawing path, take a picture.

The bowling ball that walked through the pins

Bowling is played in the world: you pick up a ball and roll it down a real lane. The first perfect roll into the pocket knocked over two pins. At 8 m/s the ball moves 40 cm per physics tick, and a pin is 13 cm wide, so the ball simply stepped over most of them without ever touching.

What we changed: split each tick into small steps so nothing fast moves more than 4 cm at a time. Now a pocket roll takes nine pins and just off the head pin is a strike.

The stock market that never moved

The investment bank's share prices were generated from a hash of the time, summing four random draws. They barely moved. The hash (CRC32) is linear, so the four “random” numbers were related and summed to almost the same total every time.

What we changed: a proper random number generator, seeded once per block of time. Prices now swing between 0.3× and 2.4× over a long run, and still come out identical for everyone in the same world.

The planetarium nobody ever built

We added six new buildings to towns. A tool that plans 500 settlements and counts what appears found that the planetarium lost every weighted draw it was in, and the garden centre never fit on a town lot. Both existed in the code and never in any world.

What we changed: their weights and sizes, and the census became part of the routine: a building that never appears is a bug even if nothing crashes.

The home run derby was two white posts

Asked to polish eighteen screen games, we didn't want to guess what they looked like, and we didn't want to use the graphics card for every look. So we built a tool that plays each game with a bot and draws its screen in software. It showed the home run derby as two white rectangles and a dot, hockey as a blue box in front of a white box, and the film quiz's clips empty at most moments.

The first redrawn ballpark was built out of dots and weighed 475 shapes per frame. Lines and fields are polygons now: 64 shapes. A check keeps every game screen under 160.

What we changed: look before you polish, even if the look is rough. And put a ceiling on what a screen may cost.

Blackjack was a toy, and the robot couldn't count to “and”

The old blackjack dealt from an endless deck, paid you for ties and never charged you for losing. Now it's played as a casino plays it: a six-deck shoe, 10 chips a hand, 3 to 2 for a blackjack, doubling down, the dealer checking for blackjack first. Over 300 rounds, a bot playing textbook basic strategy ends down about 3 chips. That's the house edge, just like the real thing.

Each game also has a “learning player”, a small model that teaches itself the game and makes sure it's winnable. It kept losing 10 chips a round. It had been given the raw numbers (my total, the dealer's card) but a linear scorer can't learn “hit on 12 to 16 and the dealer shows 7 or more”. Given that idea as one input, it plays level with basic strategy.

What we changed: real rules, real payouts, and a lesson for the learners: give them the concepts, not just the numbers.

The font can't draw a heart

Proper cards need suits. The game's font had no ♥ ♦ ♣ ♠, and asking for them silently drew five identical empty boxes (we checked the font's measurements: all five were the same box). So hearts are two dots and three stacked bars, clubs are three dots and a stem, and a diamond is a single four-sided polygon. In the real GPU screenshot the stacked-bar diamond looked like a plus sign, which is why it's a polygon now.

The graphics card that kept resetting

For a few days the development PC's graphics driver kept crashing and restarting, taking everything on the card with it. Windows' event log showed 1,334 driver errors in one day, all during test runs. The cause: up to seven test runs at once, their helpers each creating and destroying 30 to 50 OpenGL contexts, beside a language model already holding 7 GB of video memory. The card's context-switching engine gave up.

What we changed: one OpenGL context per process, at most four drawing helpers, a machine-wide lock so only one run draws at a time, and every run now ends by reporting how many driver faults happened while it ran. Today's runs: GPU: 0 driver faults.

04The engine, for the curious

The rest is the technical story: how an endless block world gets drawn in a few milliseconds a frame from Python. Skip ahead to lessons from the neighbours if you're here for the stories.

05The stack, and why Python

VoxelBlox is Python 3.13 with pygame for the window and input, moderngl for OpenGL (3.3 core, 4.3 where the card has it), and numba compiling the heavy loops over numpy arrays. There are no asset files: every texture, model, sound and piece of music is generated in code at load.

Python is slow per call, so the whole design follows one rule: Python touches single blocks; numba touches many. Generation, lighting, meshing, raycasts over many rays and bulk queries are compiled kernels over arrays. Python orchestrates, handles the player's own edit, and issues as few GL calls as possible. A browser port was researched and declined; Windows + Python + numba was a deliberate choice.

06The world's data

The world is a grid of columns, 16×16 blocks wide and 384 tall (y from −128 to 255: under y 0 lie 128 blocks of deep rock, magma and mantle). A column is keyed by integer cells, (x >> 4, z >> 4), never by Python object identity (Python reuses ids, and a sister project once collapsed 8 missiles into 2 that way).

At render distance 32 the raw columns would be 1,114 MB. A ColumnStore keeps columns beyond the near ring zlib-packed (blocks about 30×, meshes about 3×) and unpacks them on read: 181 MB.

07Generation and streaming off the main thread

The terrain kernel, the light flood and the mesher are all compiled nogil, so they run truly in parallel on a thread pool (cores − 2, at most 8) while the main thread gathers inputs and integrates results. Output is byte-identical to a single thread. Loading at render distance 16 went from 1.97 s to 0.78 s.

Worker threads (nogil) terrain kernel light flood column mesher nearest ground first Main thread feature passes the player's own edit: re-meshed the same frame upload only what changed cull columns (numpy) build the command list GPU one arena buffer MultiDraw ElementsIndirect depth test does the occlusion
Where the work runs. The main thread never generates: the compiled kernels do it on the workers.

Two lessons shaped the scheduling:

08Meshing: 12 bytes a vertex

The mesher takes a column padded by one cell on every side (258 × 18 × 18), so faces on the border cull against the neighbours and ambient occlusion and smooth light see across the seam. It emits 4 vertices a visible face, each six int16:

// 6 x int16 = 12 bytes a vertex
x, y, z      column-local corner, in 1/16 block (shaped blocks need the sixteenths)
layer        texture layer in a sampler2DArray (every texture generated at load)
info         face:3 | ao:2 | sky:4 | flow:4 | glow:1 | fluid-surface:1
block light  0-15, flooded by the light kernel
Not built yetGreedy merging. The shader's UVs are already world-space and tile with fract(), so merged quads would drop in unchanged, but so far vertex count hasn't been what limits a frame.

09The terrain in one draw call

In Python, every GL call costs real CPU time, so the aim is to keep terrain to a handful of calls. All column meshes live in one arena buffer, sub-allocated first-fit in spans of 64 quads and doubled on the GPU when full. Each frame, numpy culls the columns against the view (six planes against every column's box at once), sorts them near-first, and writes one indirect command per visible column:

# one row per visible column: (count, instances, first index, base vertex, base instance)
cmd = np.zeros((n, 5), dtype=np.uint32)
cmd[:, 0] = (quads - wet) * 6        # the dry quads' indices
cmd[:, 1] = 1
cmd[:, 3] = base                     # where this column's vertices start in the arena
cmd[:, 4] = np.arange(n)             # baseInstance -> this column's camera-relative offset
vao.render_indirect(commands, moderngl.TRIANGLES, count=len(cmd))

Each column's camera-relative offset is a per-draw attribute read through baseInstance, so the floating origin survives without one uniform per column. The whole terrain is one glMultiDrawElementsIndirect; water is a second, sorted far-first for blending, from the wet quads kept at the end of each column's mesh.

One arena buffer free free Indirect commands (visible columns, near first) cmd 0 → col A cmd 1 → col D cmd 2 → col F one call draws them all
Column meshes sub-allocated in one buffer; culled columns simply get no command.

The per-column path (GL 3.3: one buffer and one draw per column) stays as the fallback, and a check requires the two to render identical pixels (0 of 57,600 differ). Paired smoke runs: terrain draw 2.01 / 2.17 ms per column → 1.78 / 1.77 ms multi-draw, and the p99 frame 11.0 / 8.8 ms → 5.7 / 5.9 ms.

Upload only what changed. A sister project once re-uploaded a 112 MB terrain every frame while streaming and fell from 40–50 FPS to 1–4. Here a new mesh is written into its span with a byte offset, and nothing else moves.

10The far land, and the holes a jet outruns

Beyond the meshed columns, a far land of coarse height tiles (a region each) runs out to the horizon, built on the workers from the same generation rule the columns follow. Towns, cities and megastructures appear on it: each tile samples the heights of its structures' own voxel programs, coloured by their top blocks.

columns meshed not meshed yet: filled by the holes pass far land tiles (one indirect call) flying fast, the ring ahead isn't meshed yet: the far tiles cover it, so there's no sky hole.
The far land fills exactly where the near columns aren't ready, and nowhere else. A check requires every pixel on a drawn column to be identical with the fill on and off.

11Occlusion: letting the depth buffer do it

Eniko Fox's excellent software-rendered occlusion culling in Block Game rasterises nearby opaque blocks into a small CPU depth buffer and skips hidden chunks, cutting a lot of chunks (50–60% above ground, up to 95% in caves). It's a great fit for an engine that pays per chunk it draws.

VoxelBlox made the opposite call, for two reasons:

  1. The draw calls are already gone. All visible terrain is one indirect call, so skipping a column saves only GPU vertex work, not CPU time per chunk. The CPU cost of drawing doesn't grow with the number of columns in view.
  2. A CPU occlusion test where the GPU writes depth was a bug as well as a cost. In the sister project, VoxelStrike, machines blinked as they crested hills: the CPU's answer lagged or disagreed with what the GPU drew. The rule since: where the GPU writes depth, the CPU never tests occlusion.
Where the article's idea still pointsTwo things would fit the rule if a GPU measurement ever shows terrain vertices are the bound: cave culling by connectivity (flood each section's air at mesh time, record which faces see each other, and walk only open air from the camera: conservative, on the workers, with no depth and so no blinking), and GPU occlusion (a compute pass testing boxes against a depth pyramid of the GPU's own depth, writing the indirect command list directly). Neither is built: measure first.

12Frame pacing and the CPU side

The first windowed profile had p50 16.6 ms but p99 35–57 ms, while the logged work summed to ~6 ms. Every spike was in flip: the driver's windowed vsync stalled 2–4 refreshes. With vsync off, p99 was 5.6 ms. So the game paces itself: sleep, then spin the last 1.5 ms, at the monitor's rate, and the result was 15.15 ms intervals with 0.00 jitter over 1,125 frames.

On the CPU, profiling ranked things nobody would guess:

Net result on the reference smoke world (render distance 8): frame p50 3.88 → 2.69 ms, draw 2.43 → 1.33 ms.

13The method: measure through the player's path

The engine is less interesting than the habits that built it. A few rules that did most of the work:

The tools are part of the codebase: perf_tour.py drives one world through the real loop to the costly places (a metropolis, a spaceport mid-launch, a jet low over mountains) and reports percentiles, the part that dominates slow frames, and CPU by function. Spike reports capture what any frame over 250 ms was doing. Seeded fuzzing plays through the input path, and a crash leaves a replayable recording. perf_tour once took the city's worst frame from 224 ms to 18 ms and the metropolis's from 667 ms to 20 ms by finding one pure-Python search per building every 2 seconds.

14All the numbers

WhatBeforeAfterHow
Frame p50, smoke world3.88 ms2.69 msmulti-draw, batched posing, resting creatures, cached HUD
Frame p99, paired smoke runs11.0 / 8.8 ms5.7 / 5.9 msterrain in one indirect call
Windowed p9935–57 ms5.6 msown frame pacer instead of driver vsync
HUD draw4.2 ms1.1 msflat array, cached layouts
Distant terrain calls, 4 km2281tiles in an arena, one indirect call
Holes in view, jet at 18 chunks4.8%0.01%the holes pass (u_cover)
Ground drawn ahead of the jet (p50)165–178 m1,800 mthe holes pass
A dig, deep underground148 ms8 msre-mesh only what the edit dirtied; deep zone
Load, render distance 161.97 s0.78 snogil kernels on a thread pool
Memory, render distance 321,114 MB181 MBfar columns zlib-packed
City worst frame224 ms18 msperf_tour found a per-building search
Metropolis worst frame667 ms20 msthe same, compiled and spread out

All figures measured on the development machine and recorded in the project's engineering log with the run that produced them.

15Lessons from the neighbours

VoxelBlox has sister projects, and some of the best lessons here were paid for over there first.

16Should you copy this?

Honestly: copy the techniques and the habits; think hard before copying the language. Here's each piece graded for someone starting their own voxel game.

PieceVerdictWhy
One buffer + multi-draw indirectKEEPThe standard modern answer to draw-call cost, in any language. The single biggest win here.
Kernels off the main threadKEEPGeneration, light and meshing as pure functions over arrays, run on workers, nearest ground first. It's language-independent and it removes most hitches.
Pure-hash generation, journal saveKEEPAny chunk regenerates identically in any order, and a save stores only what the player changed. That makes streaming, saves and multiplayer simple.
Floating originKEEPCheap, and it removes a whole class of far-from-origin jitter bugs for good.
Your own frame pacerKEEPWindowed vsync stalled 2–4 refreshes here. Measure your flip before you blame your code.
The far land + holes passKEEPA cheap horizon, and the fill trick means fast travel never shows sky through the ground.
Witnessed checks, perf toursKEEPThe habit that paid most: every fix was measured through the player's path and every check shown to fail.
Python + numba as the engineONLY IFIt works because the heavy loops are compiled and the draw calls are gone. But everything left in Python (creatures, UI, feature passes) costs many times what it would in C#, Rust or C++, first-run compiles are slow, and shipping is heavier. Choose it if you love Python or want to learn the ideas. For a commercial game, Godot, Unity, Bevy or a C++ engine will get you there with less effort.
Columns as the draw and cull unitIMPROVEA 16×384 column is simple, but it's coarse. 16³ sections cull caves and empty sky separately and enable connectivity culling.
12-byte vertices, no greedy mergeIMPROVEFine so far, but greedy meshing or vertex pulling with packed 4–8-byte faces would cut memory and vertex work several times.
CPU occlusion cullingSKIP HEREIt's a big win when you pay per chunk. With one indirect call you don't, and a CPU answer that disagrees with the GPU's depth makes things blink. Prefer connectivity culling or GPU-side tests.

17What's missing, and what I'd do differently

18What's next: faster, then finer

Two research tracks, each item with a measurement to take first and a condition that stops it. Nothing here ships until it beats the current path on more than one machine and matches its pixels.

Faster: first inside Python, then out of it

  1. Measure better first. Build a GPU lab tool (per-pass GPU time, quads, overdraw, arena use for every screenshot scene), get a second, weaker baseline machine, and gate frame budgets per system so a regression fails a check instead of being noticed by feel.
  2. Sections and cave culling, then greedy meshing and packed vertices, then GPU-driven culling (a compute pass writing the indirect list), each only if the GPU is shown to be the bound.
  3. The known CPU cost: the per-column feature passes still on the main thread while streaming, and the per-object Python work (creatures, people, vehicle tools) that grows with the scene.
  4. Leaving Python, piece by piece. Replace the numba kernels one at a time with native ones (Rust or C++, called from Python), each behind a setting beside its twin and required to produce byte-identical output: the mesher first, since its reference oracle already exists. Only when the kernels are native, profile again and decide whether the renderer and streaming core should follow. A full port to an engine would be the last resort, because it throws away the tools and checks that made this work.
  5. A second graphics API (wgpu: Vulkan, Metal, DX12) behind the same renderer interface, if macOS or GPU culling ever needs it.

Finer: less blockiness, built on what already works

19Lessons learned

  1. The instrument is wrong more often than the engine. A CPU meter read the game at 0% (a truncated process handle); a stall blamed on the code was the driver's vsync. Check the tool before believing its number.
  2. The cost is almost always redundant work, not a bad algorithm. Parked vehicles re-posed on every column streamed in, the deep re-meshed for the whole view, a whole terrain re-uploaded every frame. Look for work done twice before rewriting anything.
  3. A green test suite proves nothing unless a check asks the observable question. Read back pixels and positions, not internal flags.
  4. A check never shown to fail isn't a check. Revert the line it protects and watch it go red. Never derive the bar from the setting it checks.
  5. Ship a new render path off until an on-device frame rate says it's fine, and keep the old path as a pixel-identical oracle.
  6. Python pays per call. Batch everything that repeats: one indirect draw, one numpy pass for all creatures, a flat array('f') for the HUD instead of 800,000 tuples.
  7. Never key anything by object identity, and never draw from a global random stream during play. Both produced bugs that only showed up much later.
  8. Where you put a player, ask for room and for ground. "Not inside a block" alone once put a driver on top of the hill above the tunnel they'd just dug.
  9. If a line only runs while drawing, only a picture tests it. Take a screenshot after touching the draw path, and have a check read the code for the mistakes pictures find.
  10. Look before you polish. A rough software drawing of a screen showed more in a minute than a day of reading its code.
  11. Physics needs small steps. Anything fast moves further in one tick than the thing it should hit is wide. Split the step.
  12. “It exists in the code” isn't “it exists in the world”. Count what actually gets generated.
  13. Give a learner the concepts, not just the numbers. A simple scorer can't discover “this and that” on its own.