VoxelBlox · architecture and engineering notes

Engineering a procedural voxel world in Python

A case study in architecture and engineering practice: an open-world voxel sandbox with an endless editable world, no hand-made assets, peer-to-peer multiplayer and a mod API, built and run by one developer. It covers the system's shape, the decisions and their trade-offs, the verification strategy that keeps a large codebase honest, the incidents that shaped it, and how the rendering engine meets its frame budget from Python.

Last updated 20 October 2026 · Watch the seven-minute film

A game within a game: Voxel Racers Arcade racing built inside the world -- 21 tracks laid onto the land, 11 cars, 8 voiced rivals, a live announcer. Watch the trailer → · Play it in your browser →

Recent changes (30 September 2026)In-world physics games (bowling, golf, basketball, darts, pool) with discoverable play spots; eighteen screen games with rules pages and casino-accurate blackjack and roulette; a post-round coach in every game; learning by doing (topics taught by playing, every building and person teaching something); procedural audio for every game, venue and job; populated venues; banking and a simulated stock market; smooth rail gradients; a static check class for draw-path-only code; nine incident reviews.
622
automated checks in the gate, each witnessed failing on a revert
~94k
lines of Python across 141 engine and 92 content modules
0
asset files: every texture, model, sound and building is generated
1
draw call for all visible solid terrain (GL 4.3 multi-draw indirect)

00What it looks like

Seven and a half minutes of the game, filmed through its own renderer with the HUD hidden: towns and rides, landforms, creatures, digging and building, battles and bosses, dragons, the sea; then a tour of its places -- villages, farms, a monastery, a port, stadiums, a fair, a mall, a museum, a library, a factory, a dam spilling, sea stacks, karst, caves of crystal and lava, a drone show -- and a launch to the Moon. Music made for it.

The film is the game played offline: film.py drives a scene's world (a dragon's fire, a fight, a building put up course by course) and pipes each frame from the GPU to an encoder. It is the one place the frame budget is waived, and that exposed what the budget normally hides. In play, columns and distant terrain arrive over several frames, nearest first, and a moving camera sometimes sees the ground fill in. A film frame instead waits until everything within the draw distance is generated, meshed and uploaded (the stream's synchronous mode, used by the checks) and the distant land's ring is complete. A frame can take seconds, and nothing pops in.

The game has a racing game inside it, Voxel Racers, with its own trailer. That trailer also carries the game's own sound, recorded on the game's clock rather than the sound card's.

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.

Recent work: games in the world

The latest captures: physics games played in the world itself, and the screen games at the venues that host them. Same GPU path, 1280×720.

01The system at a glance

VoxelBlox is an open-world voxel sandbox: dig, build, drive, fly, travel to orbit. Three constraints shape everything else. The world is endless and fully editable. Content is procedural and code-defined: there are no asset files, so a texture, a sound effect, a town and a cruise ship are all programs. And the codebase is maintained by one developer, so the architecture has to keep a large surface area safe to change without a team's worth of review.

Content as code blocks, items, structures, creatures, games, sounds: registered through a mod API Registries and world options append-only ids, saves store names; generation changes gated per world World model deterministic generation, one edit API, journal = save Simulation (session) fixed tick, host-authoritative, player intents as named actions Rendering numba kernels on workers, GPU-driven terrain, GL quads UI Networking WebRTC peer-to-peer or LAN, world deltas from the edit journal Tooling and verification 622 checks 41 tools 364 GPU scenes
The layers. The verification layer is drawn alongside rather than beneath: it exercises every layer through the same entry points the player uses.

Much of the design is a single-developer answer to problems a studio would solve with process and headcount. Where a team relies on code review to catch a broken invariant, this codebase encodes the invariant in one function or one registry and gates it with a check. Where a team would keep architecture knowledge in people's heads, it is kept in an engineering log and a lessons file that the gate itself keeps in step with the code.

02Architecture decisions and trade-offs

The significant decisions, in the spirit of lightweight architecture decision records: what was chosen, what it costs, and the alternative it was weighed against. Each has a dated entry in the engineering log with the measurements behind it.

DecisionWhyCost / alternative
Python + numba, not an engineFast iteration and one language end to end; the hot loops compile to native code and release the GIL, so they run truly parallel on worker threads.Everything left in Python (creatures, UI, feature passes) costs several times what it would in C#, Rust or C++. Godot, Unity or Bevy would have been the conventional choice. A browser build was researched and declined.
Content as code, through registriesNo asset pipeline; every building is a small program over a builder API, and mods register content the same way core does. Registries are append-only and saves store names, so content can grow and be renamed without breaking a save.Content changes are code changes, with code's review cost. Visual iteration needs tooling (a design preview, a gallery), which had to be built.
Deterministic generation, the journal as the saveGeneration is a pure hash of coordinates, so any region regenerates identically in any order. A save stores only the cells a player changed.Every generation change would silently alter existing worlds, so each one ships behind a per-world option: on for new worlds, off for old saves. In effect, feature flags with a backward-compatibility policy.
One edit API for the worldEvery change goes through one call that journals it, supports undo and produces the multiplayer delta. Networking and persistence can't drift from each other.No shortcut writes, even in tools. Bulk generation is the single sanctioned exception.
Player intents as named actionsAnything that changes state (a purchase, a bank deposit, a game button) is a registered action the host executes. It's the command pattern, and it makes multiplayer safe by construction.Slightly more ceremony for small UI features.
One rule, one functionA rule the UI displays and the model enforces lives in one function: layout, recipes, stats, and now each game's rules page. The drawing and the checks call the same code.More indirection in UI code, in exchange for UI and model never disagreeing.
Explanation as a system, not copyRules, scoring, real-world context and coaching live in registries that every surface reads: the rules page, the in-world rules card, the Almanac, the Learn tab. A game records what happened (notes); a coach function turns that into feedback after the round, stored with the player.Every new game needs its coach and notes written; a gate requires them, and requires the coach to say different things for careful and careless play.
GPU-driven terrain, no CPU occlusionOne multi-draw-indirect call removes per-chunk CPU cost, which is the thing Python can least afford. The depth buffer does occlusion.Vertex work isn't culled early; connectivity or compute-based culling are ready options if a measurement ever shows the GPU is the bound.

Search as an index built from the content. With 1,500 items, a search that matched only the words of an item's id stopped being enough: a player looks for "something to sit on", not bar_stool. The item search is now BM25F over fields every item already has -- its name and the names people use for it, its kind and catalog section, what its tooltip says it does, its real-world fact, what it is made from -- each weighted (a name over a mention), with stemming, stop words, prefix matching for the word still being typed and edit-distance matching for typos. Because every field comes from the item's own data, an item added later is indexed with no work, and a gated check asks that every item is found by its own name and that a table of plain questions is answered. Two tunings were measured rather than guessed: with the usual saturation constant (k1 = 1.2) a field's weight barely survived saturation, so a toaster "made from wire" scored a quarter of the wire itself; and an exact-name bonus applied before the relevance cut-off hid every related result. The index builds in 0.2 s on first use; a search over every item takes under a millisecond.

03Verification strategy

With one developer and a fast-moving codebase, the test suite has to catch what review would. Its central idea is borrowed from mutation testing and applied to every assertion rather than sampled across the codebase.

Why this matters more than coverageLine coverage says a line ran. A witnessed check says a specific wrong version of that line would have been caught. For a solo codebase that changes daily, the second is the property that lets you refactor without fear.

Designing for learning

The game is meant to teach how things work, so learning is engineered like any other feature: measured, gated and fed by the game's own data.

04Observability and performance engineering

05Delivery model: from request to done

The workflow is deliberately lightweight, but it has the traceability of a larger process:

  1. Requirements in the stakeholder's words. Each request becomes a row in an open-items file, quoted verbatim, with its status, the checks that prove it and how to try it. A row closes when the feature is confirmed in the product, not when tests pass: definition of done as acceptance, not as a build status.
  2. A branch per change, trunk kept releasable. Merges require the targeted checks, the documentation check and the code-map check to pass. The full suite runs in parallel workers on demand.
  3. An engineering log as the decision record. Every change ends with a dated entry holding the evidence (the measurements, what was tried, what was rejected), and a one-line lesson, tagged by area, in a lessons file that's read before touching that area again.
  4. Every job leaves a tool. A question that was hard to answer once becomes a tool that answers it always: 41 so far, all on one result protocol with JSON output, saved baselines and comparisons, and one local web page that runs them.

06Incident reviews

Blameless, short, and focused on the detection gap: why the existing checks didn't catch it, and what now does.

A draw-path-only crash that every check missed

Impact: the game would have crashed on the first frame of play. It was caught by pre-release screenshots before reaching a player. Root cause: new code in the draw path called the settings lookup with a default value; the lookup takes only a name. Detection gap: all checks for the feature exercised its logic directly, and no check draws a frame during play, so that line never ran under test.

Remediation: a static check parses the source and fails on any settings lookup with the wrong arity, and a practice of capturing a GPU screenshot after any change to the draw path.

GPU driver resets during test runs

Impact: repeated graphics driver resets on the development machine, taking down every program using the GPU. Root cause: the Windows event log showed 1,334 driver errors in one day, all during test runs: up to seven concurrent runs, their workers each creating and destroying 30 to 50 OpenGL contexts, beside an inference server holding 7 GB of video memory. The driver's context-switching engine failed. Detection gap: the dying workers looked like crashes in whatever check they happened to be running, so each failure first read as a different bug.

Remediation: one GL context per process, at most four rendering workers, a machine-wide lock, workers bound to their parent's lifetime, and a driver-fault count printed at the end of every run.

Collision tunnelling in the bowling physics

Impact: a perfect pocket roll knocked down two pins. Root cause: at 8 m/s the ball advanced 40 cm per tick against a pin 13 cm wide, so most pins were stepped over. Detection gap: none. The first end-to-end check caught it before merge, which is the point of writing that check first.

Remediation: adaptive substeps capping any fast body's movement at 4 cm (up to 24 per tick). A pocket roll now takes nine pins, and just off the head pin is a strike.

A market with no volatility

Impact: simulated share prices barely moved. Root cause: prices summed four draws from CRC32 of the time; CRC32 is linear, so the draws were correlated and their sum nearly constant. Detection gap: the check asserted determinism (the same prices for everyone), not the distribution.

Remediation: a seeded PRNG per block of 256 ticks, which is deterministic across clients and swings 0.3–2.4× over a long run. The check now covers the range as well.

Content that existed in code and never in a world

Impact: two new building types never generated. Root cause: the planetarium lost every weighted draw because of a bias in the hash on the lot grid, and the garden centre never fit a town lot. Detection gap: unit checks built each building directly; nothing measured the population.

Remediation: adjusted weights and footprints, and a census tool that plans 500+ settlements and reports types that never appear, and why. A type that never generates is treated as a defect.

Eighteen screens nobody had looked at

Impact: screen games shipped with placeholder visuals: the home run derby was two white rectangles and a dot. Root cause: the games were verified by bots and checks, which don't judge appearance. Detection gap: looking required the GPU, which is rationed.

Remediation: a software renderer for the games' view description, which draws any game's screen without a GPU. The first redraw cost 475 shapes a frame; lines and fields became polygons (64 shapes), and a gated budget now caps every game screen at 160.

A learning agent that couldn't express the policy

Impact: after blackjack moved to real casino rules (six-deck shoe, 3:2 blackjack, doubling, dealer peek), the learned player lost 10 chips a round, and the balancing gate failed. Root cause: a linear scorer over raw features can't represent a conjunction such as “12–16 and the dealer shows 7+”. Detection gap: none. The gate did its job.

Remediation: the domain concepts as features. The agent now plays level with textbook basic strategy, which over 300 rounds ends about 3 chips down: the house edge, as in the real game.

A font without the glyphs

Impact: card suits would have rendered as empty boxes. Root cause: the UI font atlas covers ASCII only; the default font's metrics for ♥ ♦ ♣ ♠ were one identical box. Detection gap: caught by measuring before building.

Remediation: suits composed from the view's own primitives (discs, rectangles, one polygon for the diamond, after a GPU screenshot showed stacked bars reading as a plus sign).

Two thirds of the multiplayer events, lost

Impact: a guest missed most of what the host's world told it: toasts, chat lines, explosions, mode changes. Root cause: the host forwarded each frame's events after the gate that limits snapshots to 20 a second, and the frame cleared its events whether they had gone or not. At 60 frames a second, two frames in three returned before the forwarding loop. Detection gap: state that matters also travels in every snapshot (the mode, positions), so the visible symptoms were rare and self-healing; the event checks happened to fire on a snapshot's frame.

Remediation: events are forwarded every frame, before the gate. The check now pins the press to a frame that is not a snapshot's, so it can't pass by timing luck. Moving the events forward exposed a second path: the mode event now always arrived before the snapshot, and only the snapshot's handler grounded a flying guest.

An interface argument that stopped at the class

Impact: walking on any ship's deck raised an exception. Root cause: letting players vault a low fence added a flag to the world's collision_boxes, and physics began passing it. A ship's frame stands in for the world while you're aboard, and its two copies of the method kept the old signature. Detection gap: the ship checks draw, and the GPU is rationed; they hadn't run since the change.

Remediation: the stand-ins take the flag. When a duck-typed method changes, search for its name, not its class.

A balance check inside its own noise

Impact: the race fairness check failed: a scripted driver at 97% of the car's limit finished second. Root cause: rivals were fitted so the fastest matched a 95% driver on an empty track. Over three laps that left the 97% driver 0.4 s in hand, and starting third on the grid costs about a second in traffic. A cheaper replay on the CPU won by 2.6 s; only the full app path reproduced the loss. Refitting the rivals (the tooling's first suspect, since the cars had changed) moved nothing, because the scripted driver had sped up by as much. Detection gap: the claim held only on a clean race, and the check had passed on clean races.

Remediation: the field is fitted to 78, 83 and 89% drivers; the 97% driver now wins by more than three seconds and the 80% driver still finishes third. The bar did not move; the knob did. A balance claim needs a margin larger than the variance of the thing it races in.

Every figure facing the wrong way

Impact: the drone show's new 3D figures (a hero swinging on a web line, a dragon breathing fire) were drawn from behind and mirrored: the dragon breathed to the left, and the hero's eyes faced away. Root cause: the figures are modelled facing +z; the show turned them by atan2(fx, fz), which points +z along the direction from the audience. Detection gap: the earlier formations (spheres, rings, hearts) are symmetric, so the error was invisible until the figures had a front.

Remediation: atan2(fx, -fz), and a check that measures, for each of the four ways a console can face, that the figure's eyes are nearer the audience than the back of its head and its outstretched hand is on the audience's right. The figures also lean back 0.55 rad: seen from 70 m below and 45 m away, an upright figure foreshortened to a line.

A display that padded over a missing value

Impact: none shipped; a check guarded less than it claimed. Root cause: the darts card shows a turn as three slots, padding unthrown darts with dots. The check asserted “three marks”, which still held with every mark deleted. Detection gap: the rule that every assertion must be shown to fail at least once. A revert that removed the marks passed, which flagged the check, not the code.

Remediation: the check counts real marks against the darts the model says were thrown. In the same pass, a caddie line read “about 18%, about 18%, about 18%…” because "%d%%" % x * 5 repeats the string five times (% and * share a precedence level). The check now matches the whole line, not a substring.

A resolution governor that assumed pixels cost time

Impact: none shipped; a new optimisation would have blurred the picture for no gain. Root cause: the new “Auto” render resolution draws the world at a fraction of the window and stretches it whenever GPU timer queries show the GPU, not the CPU, holding the frame up. It assumed that fewer pixels mean less GPU time. The first GPU check asked exactly that and failed. On the development card, 70% took longer than 100% (0.139 vs 0.123 ms), because the stretch pass cost more than the pixels saved. A probe confirmed the timer was right: sky time grows with the window, 0.026 to 0.222 ms from 400×225 to 3200×1800. The workload, though, was not pixel-bound. A real world at 1600×900 cost 0.57 ms on the GPU at full resolution and 0.53 at 70%. Detection gap: the governor's logic was checked against simulated frames whose GPU time was proportional to pixels by construction. The model encoded the assumption the check should have tested.

Remediation: the governor now measures each step down it takes. If the GPU's time didn't fall below 90% of what it was, the step is undone and not tried again for a minute. The clock's check asks the observable question, more pixels reading more time, and fails with the clock frozen at a constant. The feature ships at 100%. It is headroom for integrated GPUs, and on a discrete card it now declines to act.

A Wi-Fi blink that split the game in two

Impact: latent; a guest whose connection dropped for a moment would leave the shared game and start hosting a copy of their own while everyone else played on. Root cause: the client had one closed flag for every way a link can end. Its only answer was the one meant for "the host left": hand the game to the first player by name. Over TCP, the host closed its sockets without a word, so a deliberate exit and a lost packet looked the same. A link that died silently (a laptop asleep) was never noticed at all, and the host kept the player's name reserved, so a reconnect would have been refused. Detection gap: the checks covered the host leaving, not the link failing.

Remediation: leaving now has a message of its own (bye); any other ending is a drop. A guest that drops reconnects the way it came, under the same name, and the host hands the name over to the same machine. A heartbeat runs on a thread each way, so a host frozen on a load still counts as alive, and six seconds of silence counts as a dead link. The new check found a second path on its first run: a failed send set the flag on its own. On Windows a reader blocked on a socket that is shut down isn't woken, so the send fails first. Two of its assertions passed when the code they guard was reverted, which showed the check was too weak: one needed a drop that only breaks sending, the other a deadline shorter than the timeout it replaced.

A teleport the physics counted as a fall

Impact: none to players; a check of town quests failed only inside the full suite, after a new feature that has nothing to do with quests (one recurring character per town) was added. Root cause: the check moved the player by writing their position directly. The physics still remembered the highest point of the current "fall", so a jump from a delivery in a hill town down to a dungeon entrance landed as a fatal fall, and a dead player's quest never advances. Whether it happened depended on which quest came first. The new characters spawn as people, and that shifted the order of the random rolls just enough to put the delivery first. Detection gap: run alone, the check always drew the safe order. Its failure message reported where the player stood, not whether they were alive.

Remediation: a test-kit helper, place_player, moves a player the way the game's own travel does: the fall forgotten, the velocity cleared. Checks use it instead of writing the position. The general rule: when a change elsewhere breaks a check, look for what the check takes for granted before suspecting the change. Here a new spawn did nothing wrong; it only changed which hidden assumption got exercised.

Thirty checks that had never run

Impact: about thirty checks that draw (handling, water, rockets, riding, the formula car among them) reported failures on every run on a shared test machine, and never once tested anything there. Root cause: two measuring tools set a "CPU only, no graphics" switch in the environment when they were imported, not when they were run. A check that confirms every tool loads imports all of them, so every check that came after it in the same worker process was refused a window. Eight more tools changed the graphics driver settings the same way. Detection gap: the failures said "no window in a CPU-only run", which reads like a configuration choice rather than a defect, and the order of checks in a worker differs between machines.

Remediation: a tool sets its own environment only when it runs, never when it is imported. The check that guards this imports every tool in a fresh process and compares the environment before and after. The first version ran inside the test process, where the variable was already set, so it could never fail. Once the checks ran, they found real defects: the formula car ran off the end of its test course while braking, and the test machine had been hiding that.

Sea in the engine room, painted by the minimap

Impact: none to players. Three checks reported rendering defects on one machine and not another: sea showing through a tanker's hull (3% of the view) and through a submarine's, and a scene 30 million blocks from the origin drawing 4,204 pixels differently. Root cause: the checks counted pixels on the whole screen, HUD included. The blue was the minimap's ocean in a corner, and the far scene's difference was the minimap showing that place's own land. The scenes themselves were identical. Detection gap: a pixel count with no picture kept, so each failure read as a game defect.

Remediation: pixel checks draw the world alone, without the HUD, and a check run on a remote machine can keep its pictures for a person to look at. One picture answered what three rounds of guessing had not. The same audit found a second instrument fault through a missing file. The remote test machines receive only the files under version control, so they lacked the local file that switches example mods off. A world started with an explicit "no mods" handed its worker processes "the defaults" instead (an empty list tested as false). The workers then booted a mod the game hadn't loaded, and every column they generated was refused. The fix passes on what the parent actually booted, and the check now pins its content so it fails the same way everywhere.

A fix through a path that was off by default

Impact: dragging the map at its widest zoom showed holes in 231 to 300 of 300 frames: missing tiles that filled in a second or two later. Root cause: each map tile needs nearby places looked up, about 196 blocks of the directory at the widest zoom. The first fix sent those lookups to the world-generation worker processes. Those processes are off by default (the default generates on the game thread), so on almost every machine the lookups still ran inside the frames, at 15.6 ms a step. Detection gap: the drag check failed before and after, and its message said only that there were holes. Once it was made to say what each hole was waiting on, the answer was "processes: False".

Remediation: the map has a small pool of its own (up to four processes, the machine's cores less two), started the first time the map opens and checked for the same content as the game. Two processes were not enough (188 frames with holes); four brought it to 0-12 of 300, against 300 with the pool switched off. The lesson generalises: a fix delivered through an optional path only helps the people who have that option on. Ask which path the default takes.

A door drawn black, then drawn somewhere else

Impact: a forged house's new door rendered almost black beside a lit stone wall. Root cause: a shaped block (a door, a stair, a chair) lit every face from its own cell, and the sky light is a heightmap model: any opaque block above a column shades it, so a storey jutting out twenty blocks over the doorstep put the door in full shadow. Cube faces average four columns at each corner, so the wall beside it was lit. Second fault: the first fix reused three loop-variable names that the mesh kernel already used for the cell's origin. The compiled kernel raised nothing; it drew the door's faces two blocks away, so the doorway looked empty. A CPU repro that meshed the one column and listed the door's quads with their coordinates found both.

Remediation: a shaped face on its cell's edge is lit by the cell it faces and the open cells round that, as a cube's is. A gated check meshes a door under an overhang and reads its front's light and its single handle; it was shown to fail with either part of the fix reverted.

Tables in the air

Impact: furniture in forged buildings could hang a block above the floor, or stand outside a round tower's walls. Root cause: three faults, found one after another by measuring each piece's footing in the final voxels. Furniture spots came from the bounding box, which lies outside a star or round floor plan. Walls and floors were one unit thick in the building's own coordinates, and a building generated at 0.6 scale has voxels wider than that, so the slabs fell between voxel centres. Finally, each floor was clipped to its storey's volume, which begins at the floor's centre line, so floors were half a slab: a ground floor missed its row of voxels. Detection gap: the first measure undercounted, because its own sampling window was a unit wide. The instrument shared the bug it was looking for.

Remediation: every thickness is now at least one voxel at any scale. A floor is its own slab, and furniture is placed only where its room's field encloses it, clear of the stairwell. The check counts pieces standing on floors against pieces in the air over sixty buildings, and it fails on the old half-slab floor.

An inventory fixed twice, and still broken

Impact: the third report of the same inventory fault. A stack taken from the creative catalog vanished when the player scrolled the backpack to find room for it: the scrolled-to rows never appeared, so there were no empty slots to see. Root cause: the screen's draw built its own layout of the backpack, always the first six rows, while the clicks read the application's layout, which had scrolled. The stack went into a real, empty slot hidden under a drawn full one. Detection gap: both earlier fixes were proven by a check that asked the application's layout which slots were shown. That is the model the clicks use, and it was right. Nothing asked what the screen drew.

Remediation: the draw now takes the same shown rows as the clicks, from one place. The check reproduces the player's path through the mouse on a GPU and asks the screen two things: which slot the drawn backpack starts at, and whether the slot the stack went into changed in pixels. It fails without the fix. The other scrolled lists on that screen were audited the same way.

Thirteen seconds to rejoin

Impact: a player whose connection dropped took about 13 seconds to rejoin through the online room, against the documented "about a second" (which was true only on a local network). Root cause: measured, not assumed: every invitation took 5.0 seconds to prepare. The WebRTC library asks a STUN server for the public address from every local network adapter and waits for all of them, and on a PC with a VPN or WSL adapter that has no route out, that wait is the full 5-second timeout. On top of that, the host checked the meeting point every 1.2 seconds, and a guest that had already answered looked at its own link only every 2.4 seconds. Detection gap: the reconnect had been rehearsed on one PC, where the local route connects at once.

Remediation: STUN is asked only from the adapter that holds the route out, while every address is still offered, so friends on the same network or VPN still link directly. Preparation fell from 5.02 to 0.05 s here and from 5.3 to 0.3 s on a Linux machine. The host polls every 0.3 s while a seat is mid-handshake, and the guest watches its own link every 0.1 s. Rejoin went from 13.1 s to 6.3-6.5 s between two real machines, and a first join to 3.8 s. The remaining 3 seconds or so is WebRTC's own connectivity checks and encryption handshake. Restarting ICE on the existing connection would avoid them, but the library doesn't support it yet.

07The Seed Forge: a pile of things as a seed

The Seed Forge is a crafting station with no recipes. A player throws anything onto it, in any amounts, and lights it. Something that never existed builds itself beside it out of the pile's own materials: a tracked tank, a balloon with a basket and burner, a many-legged grove, a sailing cottage. The design constraint came from the product side. The same pile must always make the same thing, in any order, in any world, for any player. Any other pile must make something visibly its own, in its design, its parts and how it drives.

The pile is the seed. Items and counts are sorted and hashed (SHA-256), so throwing order never matters. Each of the game's 1,455 items carries traits (metal, wood, wing, wheel, power, fire, water, gem and so on), derived from its name, the block it places, that block's material, and its kind and tier. Every item has at least one trait, so nothing thrown on is ignored. A trait counts by its rarity across all items, inverse frequency to the 0.7 power, because most items are tools, armour and blocks. Without that weighting a random pile was a golem or a tank half the time and four plans were never reached.

Every item votes in every slot. The traits choose one of 18 body plans; a close runner-up lends its way of getting about, which gives a cottage on legs or a tree that sails. Each slot of the plan is then decided by vote: the body's primitive, its feet, head, top, flanks, tail, pattern and palette. Every item has a stable gene per slot, the hash of its name and the slot's name. It votes for one option, weighted by the square root of its count, and the pile's seed breaks ties. So each item moves something, and changing any one item changes something else.

Fields, not boxes. The first generations stacked boxes, and even with generated parts they read as assemblies. The current engine treats every part as a signed distance field over the lot's voxels, evaluated with vectorised numpy:

A gate against garbage. Procedural generation's failure mode is noise that technically differs. Every forging must pass the same checks:

A forging that fails is retried as a variant: every vote's slot carries an attempt number, so the retry is deterministic too. The first measurement of the gate found real defects:

All 400 random piles now pass, 391 at the first attempt. How many of each item was thrown in leans how many of a thing it gets (axles, spines, shards, crown points) without fixing it. Each forging is also named from its own syllables, so a player gets something like "Kelobtooth the Smouldering Floating Engine".

Pieces, turned and bolted on. On top of the plan's body, each item in the pile grows a piece of its own, built from its own numbers (a hash of the item, so a brick always makes a brick's sort of piece). A piece is two to five primitives joined, smoothed, carved out of each other or intersected. The pile then sets it: turned a quarter at a time, flipped upside down, mirrored, tilted, scaled by how many of that item went in, and either bolted on hard or melted in smoothly. The pieces of a forging agree on one move (taller, longer, wider or lower) so that they change its outline together rather than cluttering it. A forging may also borrow a part from another plan's grammar, have portholes, arches or slots carved through it, and be twisted, bent, sheared or tapered as a whole.

More parts is not more variety. A shape signature measured the layer: a pair-distance histogram of the voxels, the bounding proportions, the fill and the block mix, compared between random forgings. Its first version made forgings more alike (the mean distance fell from 1.67 to 1.33): many small pieces on every forging were a shared texture that averaged the outlines out. Fewer, bigger pieces that push one silhouette, a body with wider proportions, and pieces on about two forgings in three brought it back to 1.53, with every one of 2,000 random piles different and all 300 sampled forgings through the gate. The number guards the variety; the sheet shows whether it is interesting.

Places as well as machines. Six more plans make the forge build places and things to look at, not only things that move: buildings (storeys of hollow walls on a square, round, octagonal or star footprint, jogged, turned or narrowing as they rise, glazed, with a door and a gable, cone, dome, onion or parapet roof), treehouses (a trunk with splayed roots, a deck with its railing, a hut, branches to a canopy, a ladder), pools (a superformula basin with a diving board, a spiral slide, an island with its palm, a fountain or steps), bowls (a scalloped or round shell on a foot, holding water, fruit, gems, soup or a garden), saucers and discs (stacked, lit and domed, or stood on edge as a gong), and shrines (stepped platforms, rings of round, square, twisted or tapered pillars, henges with lintels, arches, domes, cones and pagoda roofs over an altar). About one forging in eight takes a body that isn't its plan's at all, which is where a walking pool or a bowl on wheels comes from.

Things built into things. A forging may carry a second one, smaller and turned: its runner-up's body, or any plan's, built on its back, beside it with a walk between them, so a rover carries a cottage, a boat a pool, a tank a shrine. Landscapes joined the plans (a mesa of strata, a rock arch, a cliff with its waterfall, an island on a stone stalk, each rough with its own noise and grassed, with trees), and a piece may be built of the very item that brought it, so odd blocks turn up in odd shapes. Both again taught the measure's lesson: fused into three forgings in four, everything became stuff piled on stuff (the spread fell from 1.53 to 1.37), and the places, all built to the lot's full footprint, looked alike to it. Combinations are now occasional, and every place is built in a frame of its own stretch (narrow, long, tall or squat), which brought the spread back to 1.52 with seven more kinds of thing in it.

Its own size. Every forging is drawn in its own units and mapped to voxels at a scale of its own: more things thrown in lean it bigger, never exactly, and now and then a pile makes a tabletop trinket or a towering monument (0.42 to 1.45 times the size its plan is drawn at, in a lot grown to 31 by 31 by 40). The thinnest part a forging holds is kept a voxel thick at any scale, the size gate scales with it, and a forging is set down two blocks from the forge whatever its size (what drives, a road's length further, since it is built facing the player). Random forgings now run from 27 to 3,524 blocks; the check asks that the 90th percentile be 6.5 times the 10th (9.2 with the scale, 4.3 without it, so it fails if the scale is lost).

Laid out again, and a bias found. A forging may now be laid out as a ring of itself (three to six petals or spokes about its middle), a row of touching copies tied by a rail (a convoy, a terrace, a colonnade), or an hourglass, mirrored upside down beneath itself. Each is a fold of the field's coordinates, so every part it gets is laid out the same way. Its body may also be painted in layers through the pile's own blocks, speckled with them, or checkered. Counting how often the ring appeared found a deeper fault: none in 300. Every number a forging draws was an average of its items' genes, and an average of several uniform numbers bunches at the middle, so a big pile never reached a range's ends: a one-in-eight chance fired a few times in a hundred, a two-in-three chance nearly always. Each draw is now the items' standard score mixed with the seed's and put back through the normal curve, so it is even again (a 12% chance fires 12.7% of the time) while more long-voting items still make it longer. The spread of sizes doubled from that alone. A forged contraption also keeps its own cells in the save now, so a later change to the forge no longer leaves the world's parked ones too broken to board.

Balance, ships, insides, discoveries. Measuring the plans showed some never won: a spire wants much what a crystal wants, so a crystal always scored higher. Each plan has a weight fitted until every plan wins its share of random piles. The fit is damped (the shares jump, and an undamped fit oscillates), bounded, and holds a set of reference piles as anchors. Unanchored, it turned the reference copter pile into a skyship. Boats can be galleons, paddle steamers or submarines, and a name says how a forging turned out ("Colossal", "a Ring", "a Convoy"). Buildings have stairs and furniture, and a measure confirms every piece stands on a floor in the final voxels (see the incident below). Six hand-made discoveries pin their shape's votes so they always come out as themselves, with riddles for the ones not yet found. As you throw items on, the forge says what the pile leans toward, so a player can steer it before lighting.

Measured, not eyeballed. Hand-picked examples looked varied. A spread tool (forgeshow.py) forges 2,000 random piles drawn from every item and counts what came out different and how the plans spread. It showed plans skewed toward golems and tanks with four never reached, and 0.5% of piles reproducing another's exact blocks. Each forging also carries a seal: its eight lowest front blocks follow its seed through 6,561 patterns. The gate holds the stronger property without it: with the seal removed, every one of 2,000 random piles must still differ in its parts. That check fails if the continuous parameters are flattened to their midpoints, or if only the surface details are removed.

Forged things that move. A rover, tank, beast, crawler, serpent, golem, skyship, copter, balloon or boat can be boarded. Its blocks leave the world and it becomes one driven creature, drawn as a per-instance model of its own boxes: tyres turn, legs swing. Its handling comes from its traits plus a seeded temper of up to 15% either way. It is kept outside the vehicle registry, so none of the fleet-wide checks (feel bars, jobs, parking) apply to a vehicle that did not exist at boot. Getting out writes the blocks back where it stopped, turned to the nearest quarter, never over anything built. Multiplayer friends rebuild its model from the pile itself, because forging is deterministic.

Trade-off: votes from every item make the result sensitive to the whole pile, which is what makes it unique, but also hard to steer: a player can't easily aim for one design. Discoveries of named piles, shared like recipes, are the planned answer: the determinism that guarantees uniqueness also makes a good pile repeatable.

08The rendering engine

The engine's problem statement: draw an endless, editable voxel world at a few milliseconds a frame when the host language pays heavily per call. The answer follows the direction the industry has taken since the mid-2010s: compute on workers, GPU-driven submission, and as few API calls per frame as possible.

09The 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.

10The 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.

11Generation 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:

12Meshing: 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.

13The 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.

14The 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.

A later request asked for racing to "render further" and then return to normal. The obvious change, a larger draw distance, would have done little: the developer already plays at the maximum of 32 chunks. What a racer actually loses is the course ahead arriving late at speed. A circuit, unlike open terrain, is finite and known in advance, so the race view widens the floor rather than the ceiling: from the grid to the flag, at least 24 chunks and 2 km of distant land, and a larger streaming budget during the five-light countdown, when the car is stationary and a frame can afford it. The whole lap is meshed before the start. It is application state, never written to the player's settings file, and it switches off at the chequered flag.

15Occlusion: 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.

16Frame 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.

A later pass on the racing mode found the same shape again, and each fix was removing repeated work, not a better algorithm. The first race on a new course froze for 6.7 s while the course was built. Three costs made up most of it:

The freeze is now 1.2 s, and a hash of every block the build lays matches the old one on four courses. During the race itself, the renderer's table of visible columns was "rebuilt only when a column changes". At racing speed a column changes every frame, so it was rebuilt every frame: 1.4 ms. It now keeps one row per column, and the draw's CPU time fell from 8.1 to 6.8 ms. The check for that table first passed against a deliberately broken version, because every freed row had been reused by the end of the test. It now ends on releases.

17All 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
Earth in orbit, sky pass GPU*4.24 ms2.68 msa noise that returns its own slope
Map drag, frames with holes*241–300 of 3000–12the map's own worker processes
Multiplayer invite prepared5.02 s0.05 sSTUN from the route out only
Rejoin after a drop, two machines13.1 s6.3–6.5 sthe above, and faster handshake polls

All figures measured on the development machine, or (*) on a shared test machine with an integrated GPU, and recorded in the project's engineering log with the run that produced them.

18Patterns from adjacent projects

Several of the rules here were paid for first in sister projects. Each one generalises well beyond games.

19Should 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.

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

21What'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

22Principles

  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.
  14. Code that only runs while drawing needs its own detection. Either render in the test or read the source for the failure shape. Neither happens by default.
  15. Test the population, not only the unit. A generator whose every part passes its checks can still never produce a given output. Measure what comes out.
  16. Budget what you can't see. A render cost cap in the gate stopped a 7× regression that no functional check would have noticed.
  17. Discrete simulation needs step control. Anything that moves further in a tick than the thing it should hit is wide will tunnel. Substep adaptively.
  18. Give models the domain concepts. Feature engineering beat more training for a simple learned player.
  19. Per-frame work doesn't belong behind a per-snapshot gate. Events sent after the gate lost two in three.
  20. A balance claim needs more margin than its noise. Move the knob, never the bar.
  21. Write down which way a model faces. A symmetric test shape can't show a mirror.
  22. Change a duck-typed method everywhere it's duck-typed. Search for the name, not the class.