00What it looks like
Before the how, the what. Every picture here is a real frame from the game, drawn by the graphics card at full HD (1920×1080). They come from the camera shots my trailer tool records (section 14), with no colour correction or retouching. You can watch the finished trailer at the end of section 14. Click any picture to see it full size.


















01What the game is made of
VoxelStrike is an arcade war game. You can fly helicopters and jets, drive tanks and boats, pilot giant walking robots, and land on an aircraft carrier, where the deck crew rearms you before you take off again. You fight a computer-controlled army across open country, cities and sea, through a twelve-mission campaign with a story and a voiced cast, or play against friends over a home network.
It is written in Python, a programming language known for being easy to write and slow to run. A few free libraries do the heavy lifting: pygame opens the window and reads the keyboard, moderngl talks to the graphics card, and numba translates the busiest parts of the code into fast machine code, close to the speed of C, a classic fast language. A one-click launcher sets everything up on a new PC.
There's no game engine underneath. Most games are built on Unity or Unreal, which supply the drawing, physics and sound. Here, all of that is written for this game: 88 files and 154,488 lines. The biggest pieces are the code that builds the vehicles (31,404 lines), the code that runs a game session (22,000) and the graphics code (12,862). There are 80 vehicles you can use (66 machines plus 14 giant robots), 89 weapons and 60 kinds of enemy.
Because Python is slow, I designed the whole thing around one rule: Python makes the decisions, and the heavy, repetitive work goes to fast compiled code or to the graphics card. Drawing the ground, building the world, moving thousands of bullets, checking what can see what, choosing targets and the helicopters' rotor physics all run as compiled code (section 17 explains how Python gets compiled while the game runs). Everything drawn in bulk (ground, trees, vehicles, smoke, power lines) goes to the graphics card in about 25 batches per frame. Python keeps the parts that need thinking: the rules, the enemy's tactics, the on-screen display and the missions.
I started the project on 31 May 2026. By 27 October it had 2,482 saved changes ("commits") across 92 working days. I build it on my own, working with an AI coding assistant, and that way of working gets as much space here as the game itself (section 12).
Frame: one picture on screen. Games draw 60 or more a second, so each frame has about 16.7 milliseconds (ms). A frame that takes much longer is a visible stutter.
CPU and GPU: the computer's main processor, and the graphics card. The GPU is very fast at doing the same simple job for millions of pixels at once.
Height map: a grid of numbers, one height per square of ground, like a topographic map. A colour map is the matching grid of colours.
"99th percentile" (p99): the frame time that 99 frames out of 100 beat. It measures the bad frames, not the typical ones.
02How the ground is drawn: the 1992 trick
Most 3D games build their landscapes from millions of small triangles. VoxelStrike doesn't. Its ground is just two images: a height map (how high each spot is) and a colour map (what colour it is). The 1992 game Comanche turned those two images into a 3D view with a clever shortcut called Voxel Space.
Here is the idea. Take one vertical strip of the screen, one pixel wide. Walk outward from the camera across the map in that direction, a step at a time. At each step, look up the ground's height, work out where that height would appear on screen, and paint the strip up to there in the ground's colour. Because you walk from near to far, anything closer has already been painted, so a hill automatically hides whatever is behind it. You only paint the part that sticks up above what's already there. Once the strip is full to the top, you stop. Do that for every strip across the screen, and you have a landscape.
My first renderer did exactly this on the main processor, and it's still in the game as a backup and for the automatic tests. Each screen strip is independent, so every processor core takes its own strips at the same time. The steps start small, so the ground right under a low tank is drawn in fine detail, and grow longer with distance, where detail doesn't show. Far away, the program switches to a lower-detail copy of the map and blends the two, so there's no visible line where the detail changes.
Most of what makes it look good came from chasing down specific things I noticed while playing:
- Buildings "shape-shifted" as you approached. At a sharp edge, like a cliff or a wall, the program picked whichever map square was closest, and that choice flipped back and forth as the camera crept forward. Now it always uses the tallest square, so edges stay still.
- The ground seemed to crawl under a slow tank. Colours snapped from one square to the next. Now smooth ground blends the four nearest colours, and only sharp edges stay crisp.
- Looking down from high up looked warped. The original 1992 method assumes the camera looks straight ahead. I extended the maths to handle tilting the view down, without changing the picture when you fly level.
- Haze and fog thicken with distance the way real air does, and low mist pools over water and in valleys. The graphics-card version uses the exact same formula, so both versions agree on the weather.
The limit of the trick is that each spot on the map has just one height. So there can be no overhangs, no bridges you fly under, and no caves. Buildings are simply tall spots on the map with their own colours. Anything that must be a true 3D object, like vehicles, trees and flying debris, is drawn separately on top.
03Moving it to the graphics card
Painting every pixel of the landscape on the main processor and then shipping the finished picture to the screen was never going to be fast enough. The graphics card is built for exactly this kind of work: the same small job for every pixel, millions of times, all at once. I started the move on 6 June. Four days later the ground was being drawn by a shader, a small program the graphics card runs once for every pixel.
Main processor · the original method
Graphics card · today's methodThe same world, helicopter and camera angle, drawn by each version. The ground and scenery match. The graphics-card version adds finer ground detail, properly shaded vehicles and a soft glow around bright spots.
How the graphics card finds the ground
Instead of one walk per screen strip, the graphics card now does one walk per pixel. Each pixel sends an imaginary line out from the camera and steps along it until it hits the ground. The trick that keeps this fast: if the line is, say, 100 metres above the ground, it can safely jump ahead a good distance, because it can't hit anything sooner. So it takes big leaps through open air and small careful steps near the ground. A special check near walls stops it jumping straight through the side of a skyscraper; before that fix, distant towers had strange blue slices cut out of them.
Each pixel is allowed up to 1,200 steps. In practice a jet at height uses about 45 steps per pixel on average, and a helicopter flying low about 146. No pixel has ever needed the full 1,200.
The graphics card also records how far away each pixel of ground is. That lets it draw vehicles, trees, smoke and power lines afterwards and automatically hide any part that's behind a hill. Section 11 comes back to this.
A standard technique called "hi-Z" keeps a simplified copy of the map to help the lines skip empty space faster. I built it, measured it, and it made drawing slower in every test, by 2% to 27%. The big leaps through open air were already doing the same job, so the extra copy only added work. It's switched off, and I wrote it down so I never turn it on again expecting a gain.
Only send what changed
My first version of the endless world sent the entire map, about 112 MB, to the graphics card every frame. The frame rate fell from 40–50 frames per second to 1–4. Now only the new strip of land is sent as it arrives, and an explosion sends only the small patch it changed. A later bug brought the old problem back in disguise: once you had flown far enough from the start, the game thought every new crater crossed the edge of the map, and sent the whole 112 MB map again for each one. Fixing that made those frames about three times faster.
04Where the time goes in each frame
Before speeding anything up, I needed to know where the time actually went. So I built a measuring tool that plays the real game and times each step the graphics card takes to draw a frame. Here is what it found on 16 September, in milliseconds per frame (remember that 60 frames a second allows about 16.7 ms in total):
| What the graphics card draws | Big fight, full HD | Battle, full HD | Jet, full HD | Big fight, 4K |
|---|---|---|---|---|
| the ground and sky | 3.41 | 1.49 | 0.99 | 7.67 |
| the on-screen display, sent as a full-screen image | 1.13 | 1.13 | 1.10 | 4.38 |
| clouds and sun, sent as a full-screen image | – | 1.14 | 1.13 | – |
| all vehicles, trees, smoke and power lines | under 0.05 | under 0.02 | under 0.02 | under 0.05 |
| total | 4.7 | 3.8 | 3.3 | 12.1 |
Three things stand out. First, the graphics card wasn't the problem at full HD: it needed 3–5 ms, while the main processor needed 10–13 ms for its share. Second, all the vehicles, trees and smoke together cost almost nothing, because each kind is sent to the graphics card as one batch. Third, a surprising share of the graphics card's time went on full-screen images the main processor painted and sent over every frame. At 4K, those cost more than drawing the whole landscape.
When the armies get big
Those figures were for everyday fights. To see how the game copes as a battle grows, I set up a scaling test that plays the same fight with more and more units and splits each frame into the game's logic (every unit thinking, moving and shooting) and drawing (the main processor preparing the picture for the graphics card). On my desktop, before and after a day of work on 30 September:
| Units in the fight | Frame, before | Frame, after | Logic, after | Drawing, after |
|---|---|---|---|---|
| 54 | 13.1 ms · 76 fps | 10.1 ms · 99 fps | 3.9 ms | 6.0 ms |
| 108 | 23.4 ms · 43 fps | 16.5 ms · 60 fps | 7.2 ms | 9.1 ms |
| 212 | 42.1 ms · 24 fps | 31.5 ms · 32 fps | 16.0 ms | 15.2 ms |
The typical frame as the game plays it (the graphics card draws one frame while the processor prepares the next), "balanced" preset, full HD, measured with the game's benchmark tool. Single runs vary by about 1.5 ms.
The graphics card still isn't the limit: at 212 units it needs about 10.5 ms, 6.4 of it for the ground, and all 97 vehicles together take under 2 ms. The time goes on the main processor, split roughly half and half between the logic and preparing the picture, and both halves grow with the number of units.
Compiling the busiest Python, one piece at a time
I never planned a rewrite. Instead, timers find the single most expensive piece of Python in a big fight, and I translate just that one piece to compiled code. The new version must give exactly the same answers as the Python it replaces, down to the last digit, checked on hundreds of thousands of inputs and on whole recorded fights. Only then does it take over. (How the compiler itself works, and what it costs, is in section 17.) What the timers found, in order:
- Choosing targets. Every unit weighed every enemy unit to pick the most dangerous one it could reach, about 20,000 comparisons a frame at 200 units, and the biggest single cost in the logic. The catch: units move one at a time, so each one sees the others where they've already moved to this frame. The compiled version keeps its table of positions up to date as each unit moves, and picks exactly the same targets.
- The enemy's helicopter physics. The same rotor model the player flies, so every flight had to come out identical: 320 test flights (80 vehicle designs, each flown four different ways) matched the old code on every number, every frame.
- Ground lookups, tyre marks, finding units that touch, posing the walking robots. Smaller pieces, each a straight swap.
Just as useful was what not to compile. The robots' walking code costs about 25 microseconds per robot, but spread over 150 lines of decisions (when to jump, where to step round an obstacle, when to play a footstep). Compiling it would mean rewriting how the robots walk to save about a millisecond, so I measured it and deliberately left it alone.
Taking the full-screen images off the main processor
An explosion's flash, a storm darkening the sky, lightning and the red flash when you're hit were each a full-screen picture that the main processor painted pixel by pixel, at 3.75 ms each at full HD. Now they're simple settings the graphics card applies as it draws. There was a bug hiding here too: the sun had been painted onto a see-through layer in a way that left it completely invisible, so there had never been a visible sun in the graphics-card version. It's drawn properly now, and clouds sit correctly behind mountains. The on-screen display is only re-sent where it changed.
The result in a thunderstorm: drawing time fell from 16–20 ms to about 10 ms at full HD, and from 38–56 ms to 10–14 ms at 4K. Data sent to the graphics card each frame fell from 8–17 MB to 1–2 MB, and stuttering frames (over 33 ms) fell from 214 to 42.
Lower resolution that still looks sharp
Since the ground's cost depends on the number of pixels, a slower graphics card can draw at a lower resolution and scale the picture up. The game uses FSR 1, AMD's free upscaler, which keeps far more fine detail than simple stretching and costs under half a millisecond at full HD. A built-in manager now watches how busy the graphics card is and lowers the resolution only when the graphics card is actually the bottleneck. My first version didn't check that and lowered the resolution in a battle where the main processor was the slow part, which made the picture worse for no gain.
Once the graphics card had less work, it started saving power by slowing itself down, and the same drawing step measured 3.2 ms at one moment and over 7 ms at another. A graphics card that's resting can look like a slow one. Any automatic quality setting has to tell the difference.
Why not DirectX or Vulkan?
Newer graphics systems like DirectX 12 and Vulkan mainly reduce the cost of sending each batch of drawing work. This game sends only about 25 batches a frame, so there's little to save. The real costs, drawing the ground's pixels and running Python, would be the same. Switching would mean rewriting all the graphics code for almost no speed gain, so I kept the game on OpenGL, which runs on nearly every PC.
05A world that doesn't end
In the streamed worlds, you can fly in any direction for hundreds of kilometres without reaching an edge or seeing the land repeat. The ground is made from layered random noise, the standard way games create natural-looking terrain, with extra shaping for sharp ridges and stepped mesas. Slow-changing "dials" across the world decide where it's flat, mountainous, forested or cut into mesas, in regions about 9 km across, so a long flight crosses real changes of scenery.
The game only keeps the land around you in memory, in a window that moves with you. That window wraps around at its edges (leaving on the right brings you back in on the left), which makes it cheap to slide along. Vehicles and soldiers, though, must not wrap. Until I fixed that, a unit placed far out could be "teleported to the far side of the world".
Why helpers, and not threads
Python has a well-known limitation: within one program, only one piece of Python code can run at a time. So building land in a background "thread" (a second line of work inside the same program) was hopeless: one strip of land took 4.8 seconds there, against 0.14 seconds when the game did it itself. So I moved the land-building into a separate helper program running alongside the game, and the two share a block of memory. An earlier version of mine passed data through a channel where each side waited for the other; once, both waited at the same time and the game froze for 97 seconds. With the helper, a jet flying at full speed got 135 of 136 new strips of land ready in advance, and picking one up cost less than a fifth of a millisecond. Before, the same flight had frozen for 1.3 seconds at a time.
The horizon is real
Beyond the detailed area around you, a low-detail copy of the same land stretches out to the horizon. It uses only 7.3 MB, and its outline differs from the real mountains by less than a third of a pixel. Reaching that far with full detail would have needed 793 MB and about 45 seconds to build. So the mountains in the distance aren't a painted backdrop: they're the actual mountains you're about to fly over.
The fixed maps








Eight of the 26 world settings, seen from directly above. These are the actual colour maps the game draws the ground from. The grey circles are cities.
Alongside the endless worlds there are fixed maps like these (alps, dunes, Mars, the Moon, an alien planet and more), each one built from a recipe: random noise, shaped into islands, then water, landscape types by height and slope, forests and cities. They also get a smoothing pass that wears down spiky peaks, a bit like erosion. In multiplayer the map itself is never sent over the network. Each player's computer receives the recipe and builds the identical world itself.
On 19 August, a change let the land be built in the background with a speed limit of about 4 strips per frame. A fast jet needs about 27. So the jet flew off the edge of the built land, and my report after that flight was "I couldn't even see a mountain". Yet all 348 automatic tests passed. Every test checked that the land was built correctly and quickly; none actually flew a jet and asked "is there ground in front of me?". That test exists now, and I ran it against the broken version to prove it catches the problem.
06Hunting down stutters
A game can average 60 frames a second and still feel awful, because what you notice is the hitch. Most frames in a jet were fine. The trouble came every time the jet crossed into new land: frames of 63–111 ms where 17 ms was normal, 103 of them in one flight. So I built a tool that plays the game at a steady 60 frames a second, records every slow frame and lists exactly what the game was doing during it. It found eleven separate causes:
| What was happening during the slow frame | ms | How it was fixed |
|---|---|---|
| the list of all 19,105 visible trees thrown away and rebuilt from scratch | 26–50 | add and remove only the trees that changed |
| building the trees, houses and roads of new land | 17–55 | moved to a helper program, 2 seconds ahead |
| extending the horizon, then re-sending all 7.3 MB of it | 5–15 + 3–5.5 | built by the helper; only new parts sent |
| shading new land | 3–6.4 | one fast compiled step |
| traffic looking up nearby roads | 8–12 | looked up once per area, in advance |
| filling a town with people | up to 14 | at most 64 new people per frame |
| drawing the picture of a new kind of tree or prop the first time it appears | 7–17 | prepared while the land is on its way |
| the collection of those pictures outgrowing its space and being rebuilt | 25 + 125 | new pages added; nothing is rebuilt |
| building a 3D model the first time it's seen | 28.7 | built on the loading screen |
| creating an enemy robot's footstep sound the first time it walks | ~50 | created on the loading screen |
| compiled code being loaded the first time it's used | 7.4–159 | loaded on the loading screen |
Two scenes, each run twice before the fixes and twice after (900 frames a run, full HD):
| Scene | 1 frame in 100 is slower than (ms) | slowest frame (ms) | frames over 33 ms | over 50 ms |
|---|---|---|---|---|
| jet, before | 74.4 / 72.0 | 334.9 / 328.3 | 45 / 41 | 32 / 29 |
| jet, after | 21.9 / 22.0 | 44.0 / 27.2 | 2 / 0 | 0 / 0 |
| battle, before | 70.7 / 66.6 | 915.4 / 176.9 | 64 / 63 | 21 / 20 |
| battle, after | 34.3 / 34.0 | 62.6 / 57.6 | 11 / 11 | 2 / 1 |
Not every idea worked. I tried one well-known trick for sending images to the graphics card faster, measured it as slower (22.5 ms per frame instead of 17.3), and took it out. An idea that sounds right still has to prove it.
Waiting less before you play
Loading time counts as waiting too. Starting the game took about 5 seconds and the first mission about 39. Three changes brought that to about 1.5 seconds to start and about 13 for the first mission. The vehicle models and the sound effects, built from code, are now kept on disk after the first time and rebuilt automatically when the code that makes them changes. The land of a new map is filled in on several processor cores at once. And the compiled code is saved to disk too; that needed a trick of its own, because a common way of calling a C function from compiled code quietly stops it being saved, which would have added two seconds to every start.
Python can hand out a quick ID number for any object, but once an object is deleted, a new one can get the same number. A test counting missiles by those IDs reported 15 alarms for 2 missiles, when really 8 missiles had been merged into 2. The same mistake later nearly made 12,791 trees draw with the wrong pictures. Everything now gets its own permanent serial number.
07Vehicles built from boxes




A tank, a helicopter, a carrier and a giant robot, rendered from the game's current models. Each is built from code every time the game starts.
Every vehicle is made of small boxes, a bit like LEGO, and each one is described by a short piece of code rather than a model file. The model code holds 475 of these recipes, from aircraft carriers down to fire hydrants and phone booths. A recipe lays out the main panels; the game then joins them up, splits them into smaller boxes and tidies up thin slivers, up to a limit per vehicle type (about 1,400 boxes for most vehicles, up to 6,000 for big ships and 9,000 for the giant robots). A final pass adds the small details each kind of vehicle is recognised by: tank tracks, rails, canopy frames, navigation lights. A Falcon jet goes from 84 boxes as written to 1,400 in the game.
The graphics card draws all of a vehicle's boxes in one go. At first each box was drawn as a flat square facing the camera, which looked like a cardboard cut-out up close. Now each box is drawn as a real box with three lit sides, which still costs only a fifth of a millisecond for 54 vehicles. Moving parts like rotors, propellers, wheels, tracks, radar dishes and recoiling gun barrels are moved by telling the graphics card a few numbers each frame, not by rebuilding the model. Skipping numbers that hadn't changed cut that traffic from 240 updates to 29.
An obvious next step would be to draw every tank of one type in a single go. I counted before building it, and the count showed why it doesn't work yet: in a 207-unit fight there are 84 vehicle drawings a frame, but only 12 share a model. A tank turns its turret by keeping its own adjusted copy of the model, and each walking robot is posed into its own copy, both prepared by the main processor. Batching needs that turret and leg movement done by the graphics card first. For now, a posed robot sends the graphics card only the 6 numbers per box that a pose changes (where each box is and which way it faces), not all 17.
08Damage you can see
This one started as a feeling. What I asked for, word for word, was: "when we're hitting other vehicles … there should be some more impact type feel to it … I want to have a little more oomph". "More oomph" can't be tested, so I turned it into something that could: film a hit, and count how many pixels it changed on the target.
| Weapon | Pixels changed on the target, before | After |
|---|---|---|
| rifle | 0 of 30,905 | light rounds: 6.2% of the target, 150 pixels of debris |
| autocannon | 105 (0.3%) | |
| tank shell | 1,381 (4.5%) | 19.3%, 1,125 pixels of debris |
| armour-piercing dart | 1,547 (5.0%) | 19.3%, 1,855 pixels of debris |
So a rifle hit used to leave no visible mark at all. Now each vehicle remembers up to six hits. At each one the paint is scorched off, the plate is dented, a hole is punched through, and the vehicle rocks on its springs: 1.5° for a light round, 3.9° for a tank shell. Before this, damage was a single number that darkened the whole vehicle evenly. The giant robots can lose arms and legs as they take damage. Each lost limb used to be built the moment it was blown off, a 2–7 ms hitch, and a big fight had 28 of them in ten seconds. Now every limb a robot on the field could lose is built once, on the loading screen, and shared by all robots of that type.
Two days later, I put the measurement itself to the test. Part of the change counted in the table was debris flying off, not the scar on the vehicle. With the debris switched off, the scar alone accounted for 160 pixels. The lesson: to measure something partly see-through, like smoke or a scorch mark, compare the picture with it and without it.
Destruction that stays
Explosions dig craters into the ground, set the trees and buildings on them to match, and write down what happened, so the damage is still there when you fly away and come back. A bug once brought a destroyed hangar back to life: after you left and returned, it stood whole on top of its own rubble and could be blown up "again, and again". The game had remembered it by one of Python's reusable ID numbers. Now it's remembered by what it is and exactly where it stood. My first guess, duplicated buildings, was wrong, and I checked before acting on it: 2,506 buildings, 2,506 different positions.
09Flying, driving, floating, and the enemy
A helicopter, a jet, a tank and a destroyer should feel nothing alike in your hands, so each kind of vehicle moves by its own rules:
- Helicopters use a real rotor model, built from the same published formulas engineers use. It covers the extra lift near the ground, the dangerous "vortex ring" state when you descend too fast into your own downwash, and autorotation, where a helicopter with a dead engine glides down on its freely spinning rotor. It gives the same result at 30 or 480 frames a second; the model it replaced drifted 34 units apart over 10 seconds depending on the frame rate.
- Planes generate lift from their angle to the airflow and stall when too slow. The slower you are, the harder the nose drops.
- Ground vehicles sit on four springy corners, so they pitch when braking, roll in turns, bottom out over bumps, and grip differently on grass, sand, rock and road.
- Ships float on the same three sets of waves that are drawn on the water. A long ship averages out short waves, so a destroyer barely notices chop that throws a small patrol boat around.
- Giant robots walk, and can fold up into vehicles.
Players disagree about which way is up. Some want a real flight stick, where pulling back raises the nose; others want "up" to mean up. One setting (the F4 key flips it in flight) now covers jets and helicopters alike, and building it revealed that the two had disagreed all along: in a helicopter, "up" had been lowering the nose.
Most bugs here were mix-ups of units, not bad physics. A boat's acceleration was accidentally tied to its speed, and it topped out at 58% of its real top speed. A landing rocket's firing height was written as a speed, so when gravity was changed, the rocket fired at the wrong height. That kind of mix-up happened five times before I made it a written rule. And a gunship once started the game 80 units inside a mountain and flipped over on the very first frame.
The October trailer caught a quieter kind of bug: tanks that sank into the ground, soldiers pressed into walls, a supply convoy bouncing along its road. The cause was that each unit tested the ground at its centre only, so a hull could ride straight up onto a block narrower than its own stride, or rest on one point of ground rougher than a flat plane. Now a hull settles on its high points, soldiers and robots test a ring around their feet, and the convoy follows the road closely instead of cutting corners through the hillside. On one battle mission the worst sinking went from 38.9 units to 1.4, and on another the soldiers stopped sinking altogether.
How the enemy thinks
When I reviewed the enemy's behaviour on 3 September, I started by measuring it: 2 of 18 ground vehicles were permanently stuck. Each had exactly one direction it could choose, and if that pointed up a steep hill, it pushed against the hill forever. Giant robots stood up once and never folded back down. Nothing decided to flank, hold a ridge or retreat; every unit simply drove at its target.
Now each enemy has a plan: advance, flank, hold, withdraw, regroup or reposition. It rethinks that plan every half a second to a second and a half, and units take turns so they don't all think on the same frame. When moving, a unit tries nine directions and takes the first good one. Afterwards, stuck units fell from 5 of 53 to 1 of 53, and units switched plans more often (1.89 different plans each on average, up from 1).
The enemy also plays fair. Every unit, on both sides, can only shoot what it could really see: you must be in front of it, not behind a hill, and in range, for long enough for it to react. So there are no shots through mountains and no eyes in the back of its head. The difficulty levels change how sharp the enemy is, not just how hard it hits: an easy enemy rethinks every 1.6 seconds and takes 1.4 seconds to react, a hard one every 0.6 seconds and 0.5 seconds. Badly damaged, 40% of easy enemies retreat, and none of the hard ones do.
Seeing the enemy matters as much as the enemy seeing you. Turrets, missile sites and guns were placed at the height of the ground under their centre and drawn perfectly level, so on a hillside any rise in the ground hid part of them. On hilly maps about 1 in 4 was hard to see, and a flat artillery piece could show a quarter of itself. They now tilt with the slope, sit clear of the highest ground under their whole base, and move to flatter ground nearby rather than hang off a cliff edge.
A war with a story
By October the game had 26 good missions sitting in one flat list, all unlocked, with nothing carried from one to the next. On 9 October I asked for something better: missions you work your way through, "like an actual game that has a story". The result is a campaign, the war for a place called the Halvard Reach: twelve missions in three acts, from holding a river crossing at first light to the assault on the Spire, a giant rail-gun battery the enemy is building in the mountains above the bay. A cast of characters talks to you over the radio, and they react to what you do, when you're hit, when you beat a missile, when it's gone quiet for too long. What happens carries forward. If your wingman Sparrow is shot down, the ninth mission becomes a rescue; if she's still flying, it's a raid on a railway instead. The original 26 missions are still there, as stand-alone operations.
All those voices are made by a text-to-speech model running on one of the spare PCs, rendered once, ahead of time, and shipped as recordings. Twelve missions with many stages each are also a lot to test by hand, so the menu has a test missions screen: start any mission, at any stage, in any vehicle, with the story set up as if you'd played the missions before it, and skip ahead a stage with one key. A test run saves nothing, so it can never touch a real campaign save.
10Sound made from scratch
Close your eyes in a fight and almost everything you hear was generated by code when the game started, not played from a recording: every vehicle's engine, every weapon according to its size, and every hit according to what was hit, whether the shot bounced off or went through, and how big the gun was. The rumble of tyres and tracks changes with the ground, and footsteps change with weight. The one exception is speech: 1,048 voice lines (briefings, radio calls and the campaign's cast) were generated with text-to-speech ahead of time and ship as recordings.
Two sound bugs changed the rules for the whole project. First, every engine sounded the same. The code meant to keep only the deep, low sounds couldn't actually reach low enough, so every "rumble" came out as a mid-range hum. I replaced it with a proper filter. Second, loading the music changed the world. The music and the world builder shared one random number generator, so building music at the same time as land changed the land: the same world setting produced 2,250, 1,906 and 2,070 particles on three runs. Now anything that needs repeatable randomness has its own generator.
That rule bit again in October, from the other end. A campaign mission replayed with the same inputs drifted apart from its earlier run after a while, which makes a test of it useless. The culprit was the radio: it chose which voice line to play with the game's shared dice, and it lives across missions, so whatever it had said in the last war changed how many throws it used in this one. It now has dice of its own, reset for every mission.
11Let the graphics card decide what's hidden
Here's a mistake I carried over from the old renderer. The original version had to work out for itself whether a vehicle was hidden behind a hill before drawing it, by tracing a line from the camera to the vehicle. I kept that check when drawing moved to the graphics card, even though the graphics card already knew exactly how far away every pixel of ground was. Keeping it wasn't just wasted effort; it was wrong:
- Vehicles blinked in and out as they came over hills. The single line was aimed at the middle of the vehicle. So a tank whose top was clearly visible over a ridge was skipped on 121 of 660 frames, flickering six times in eleven seconds.
- Power lines disappeared behind trees that were behind them. Cables were painted first and every tree was painted over them, near or far. Drawn by the graphics card with proper distance checks, 20–60% more cable correctly shows, and it takes 0.2 ms instead of 3.
The rule now: when the graphics card already knows what's in front, let it decide what's hidden. My sister project, the block-building game VoxelBlox, came to the same conclusion and explains it from its side.
12How the work gets done
The code is only half of this project. The other half is how I work with an AI coding assistant. I describe what I want in my own words, and the assistant writes the code. That sounds simple, and at first it went wrong in all the simple ways: fixes that passed their tests but never reached the game, "done" that wasn't. So I wrote down a way of working, one rule per mistake, and it's the reason everything else on this page has numbers.
Keep the request in my own words
Every request goes into a to-do list exactly as I said it, and stays there until it works when I play the game, not just until a test passes. It isn't rewritten into a formal specification, because that rewriting is where meaning gets lost. Instead, it's turned into something that can be measured, like "more oomph" becoming pixel counts in section 8. Keeping the exact words has another benefit: if the same request comes back word for word, it's obvious the earlier fix never made it into the game. The list currently has 245 entries waiting for me to try them, which says as much about how fast things get built as about how slowly I get round to playing them.
Prove every test can fail
A test that has never failed might not be testing anything. So every test gets checked by deliberately breaking the thing it protects, running it, confirming it fails, and then putting everything back exactly as it was. I call this "witnessing" a test, and a small tool does it in one step. It found real problems:
- A test that checked things were drawn in the right order passed even on a version that never sorted anything, because they happened to come out in the right order anyway.
- A test for dust kicked up by walking robots still passed after the dust was switched off, because the dust it saw was coming from somewhere else.
Suspect the measurement first
Again and again, a surprising number turned out to be a mistake in how it was measured, not a problem in the game. A test track had its bumps laid in the wrong direction, so the vehicle drove along the top of a ridge. A test ramp drove a truck underwater. A "three times slower" result was just the computer being busy with something else. A reported worst frame was a single frame out of 540 (44, 18 and 14 ms on three identical runs). A profiler, a tool that times every part of the code, lumped two different pieces of code with the same name together and blamed the ground for 31.4 ms, when the ground was really 9.1 ms. So now I reproduce any dramatic number before acting on it.
Test the way a player plays
Tests press real keys, run the real game loop and check what actually appears on screen, by reading back the pixels, not by asking the code whether it thinks it did its job. This rule came from experience. Three times, I reported "the restart menu doesn't appear", and each time the assistant tested the code in a way that skipped the real game loop and said it worked. Another time, a menu counted four options as "shown" that were actually placed below the bottom of the window.
Slow code is usually repeated work
Nearly every speed-up in the table below came from finding work being done more often than needed, like rebuilding a list that hadn't changed. That's why most fixes produced exactly the same picture, just faster. One lesson went the other way: splitting a task across all the processor cores made that task twice as fast and the whole frame 3.8 ms slower, because the idle cores kept spinning while waiting. Only comparing whole frames, back to back, revealed it. The game now chooses how many cores to use from how many the PC has.
And the last step of every change is writing down what it taught me: an entry in the engineering log with the measurements, and one line in the list of lessons pointing back at it. Before I touch a part of the game, I search that list first. It's cheaper to read a lesson than to learn it twice.
13The tools
Every question I kept asking by hand eventually became a tool. There are now about 80 of them, each answering one kind of question and saving the answer where I can find it later. The main ones:
| Tool | What it's for |
|---|---|
| The test suite | About 1,400 automatic checks covering every part of the game, spread over the spare PCs in my house (below). You can re-run just the ones that failed last time, or just the ones a change could affect. |
| The "prove it fails" tool | Breaks one thing on purpose, runs one test, and puts everything back exactly as it was. |
| The measuring kit | Shared building blocks for every measurement, so two tools can't measure the same thing differently. |
| Graphics and stutter labs | How long each drawing step takes on the graphics card, and what was happening during the slowest frames of a flight. |
| The frame-log reader | Reads the timing log the game writes while I play, and shows what the slow frames have in common. |
| The mission launcher | Drops me into any campaign mission, at any stage, in any vehicle, with nothing saved: the quickest way to look at one moment of one level. |
| Status and code map | Summaries worked out automatically from the code itself, so they can't go out of date. |
| Trailer and website builders | Film the trailer and render the showcase site's pictures from the real game. |
Each tool exists because of a mistake
- A test once quietly saved a lower view distance into my own game settings. Now no tool can change the player's settings.
- A "random" test map was different every time, so a test gave three different answers on three identical runs. Now test maps are fixed.
- A test that moved a robot by calling the code directly skipped the controls, and reported the robot fine while the player was actually still getting the jet's controls. Now tests press real keys.
- Screenshots read back from the graphics card come out upside down, and once "proved" that the world was being drawn upside down. It wasn't. Now every captured picture is flipped the right way before anyone sees it.
Making the tests faster
On 22 September I profiled the test suite itself. Just two tests took 544 seconds between them. One built an entire game session for each of 80 vehicles just to listen to an engine sound; it went from 282 seconds to 66. The other used a very slow method to check whether an engine sound repeated noticeably; it went from 262 seconds to under one. Total test time fell by 22%. Separately, some tests failed only when run in parallel. The cause was one test leaving the game's settings changed for whichever test ran next (for example, allowing 350 particles instead of 2,800). A tool that records what each test leaves behind found it, and after the fix the whole parallel run passed cleanly for the first time.
A test lab made of the spare PCs in my house
By October the full set of checks added up to 27,544 seconds, over seven and a half hours, if one computer ran them one after another. So my desktop stopped running them at all. It now acts as the commander: it sends the checks out over the home network to whichever other machines are idle (a Legion Go handheld, a Steam Deck, mini PCs and older gaming PCs). Each check runs with no window on screen, and the commander packs them onto the machines by how long each one took last time, so they all finish together. If someone picks up a handheld to play on it, its share moves to another machine.
Two shortcuts keep everyday work quick. The fast set leaves out the 36 slowest checks (whole campaign missions played start to finish), which are only 2.7% of the checks but 47% of the time. The affected set runs only the checks that a change could actually reach, worked out from a recorded map of which checks touch which code. The full set still has the last word before a change joins the main version of the game. The "prove it fails" tool runs on the lab machines too.
My own logs beat the test lab
While you play, the game writes down how long each frame took. On 22 September, my own game was running at 16 frames a second while the test lab said 49–60. The lab had never used my settings. Once it did, the cause showed up: the shock rings drawn around bomb blasts were costing 4–12 ms per frame. Moved to the graphics card, they cost at most 0.7 ms.
14Filming the trailer inside the game
The trailer isn't a screen recording, and I didn't fly it. My trailer tool films it from inside the game: each shot is the real game running on the graphics card, on a fixed map, at full HD and 60 frames a second, and every frame is captured straight from the card.
- All camera angles come from one run. Running the same shot twice doesn't give the same result, because new land arrives from the helper programs at slightly different moments. Enemies ended up in different places within half a second. So every camera (chase, cockpit, orbit, fly-by, wingtip, target) films the same run at once. Slow motion comes from running the game in smaller time steps, not from repeating frames.
- Computer "pilots" press the real keys. The canyon and low-flying shots are flown through the same controls a player uses, low enough to crash if they get it wrong.
- The game's sound is recorded as instructions. Instead of recording audio, the tool notes every sound the game plays and when, then mixes it all afterwards, so slow-motion shots get correctly slowed sound.
- The music is generated too, and the edit is cut to its beat, with each shot's most exciting moment found automatically from how much is happening on screen.
In October the trailer became my quality inspector. Version 13, The First Day, runs 3 minutes 29 seconds over 40 shots in a wide cinema frame, with a narrator voice made by a speech model on one of my PCs. Because every frame of it is a real frame of the game, the tool also scans each one for problems (a burst of pure white, a black frame, a unit floating or sunk into the ground, a sudden jump) and accepts a shot only when every frame passes. What it caught started the polishing work in section 16, and I filmed the trailer again on the polished game, more than once, as each round of fixes landed.
15Which PCs it runs on
A game that only runs well on the machine it was made on isn't finished. I measured most figures in this article on my desktop, which is a fast machine. But I don't only play there: I also play on a laptop with Intel Arc graphics and on a mini PC with an AMD Ryzen 7 7730U. Both have their graphics built into the processor rather than on a separate card.
To replace "it runs fine" with numbers, I added a benchmark anyone can run: double-click
bench.bat in the game's folder. It records the machine (processor, memory, graphics and driver, whether a
laptop is on battery), asks the game which quality setting it would choose there, then plays three scenes with the real
game loop at full HD: a battle, a jet at full speed over new land, and 36 units fighting on one map. Every machine runs
the game's default settings with the same "balanced" preset, so the results compare directly.
| Machine | Processor | Graphics | Battle | Jet over new land | 36-unit fight |
|---|---|---|---|---|---|
| My desktop | AMD Ryzen 9 5950X (16 cores) | NVIDIA RTX 3090 card | 60 fps (the cap) | 60 fps (the cap) | 13.5 ms · 74 fps |
| Laptop, plugged in | Intel Core Ultra 7 258V (8 cores) | Intel Arc 140V, built in | 60 fps (the cap) | 60 fps (the cap) | 11.6 ms · 86 fps |
| Mini PC | AMD Ryzen 7 7730U (8 cores) | AMD Radeon, built in | plays fine when I play on it · benchmark to come | ||
The typical frame as the game plays it, "balanced" preset, 1920×1080, default settings, measured 30 September 2026 by the same benchmark. The battle and the jet run at the game's usual 60 frames a second, so a machine that keeps up shows 60; the 36-unit fight runs flat out. In the battle, 1 frame in 100 was slower than 17.0 ms on the laptop and 24.0 ms on the desktop.
The thin laptop keeps up with the big desktop, and beats it in the big fight. On both machines it's the processor that sets the pace: the game's logic and preparing each frame take about 11.3 ms on the laptop's new Core Ultra and 13.4 ms on the desktop's older Ryzen, and the frame times follow. The graphics, whether a big card or built into the processor, finish their part within that time.
First, my benchmark waited for the graphics to finish every frame before starting the next. That adds the processor's time and the graphics time together, while the real game overlaps them: the graphics draw one frame while the processor prepares the next. It made every machine look slower than it is.
Second, that waiting also left the graphics idle for part of every frame, and graphics chips slow themselves down to save power when they're idle. On the laptop, it read as 23–27 ms of graphics work per frame, and as the laptop being "held back by its graphics". Measured as the game really runs, the laptop finishes a whole frame in 11.6 ms, so its graphics can't be taking 27. Three rounds of graphics-setting tests I ran on it, which found that no setting made a real difference, were measuring that power saving too.
The benchmark now reports the frame as the game plays it. I kept the earlier figures in the project's log with this explanation, rather than quietly replacing them.
A fourth machine, an older laptop with Intel Iris Xe graphics (a 12th-generation Core i5), played at 35–40 frames a second when I tried it; its benchmark is still to come. The scaling test in section 4, which grows the fight to 212 units, has so far only been run on the desktop; the laptop and mini PC are next.
Power matters a lot on a laptop. On battery, the same laptop took about twice as long per frame in the big fight (measured the old way: 58 ms on battery against 32 plugged in), so the game now notices a laptop on battery. It also recognises what kind of graphics it's running on, built into the processor or a separate card, and shows it in the Options screen. Built-in graphics never start above the "balanced" preset, because their quick start-up test swings with power saving (the laptop's read 12.7, 21.5 and 4.8 ms on three launches). If the game folder moves to a different PC, or a laptop switches between its built-in graphics and a card, or settings were first tuned on battery and it's now plugged in, the game re-tunes on the next launch. And on a laptop with a separate card sitting idle, it says so and explains how to switch.
October: the built-in graphics, measured
Two machines in the test lab have graphics built into the processor, like the mini PC: another mini PC with AMD's Radeon 680M and the Legion Go handheld with the Radeon 780M. Playing the real game at full HD on the default settings, at a steady 60 frames a second (16.7 ms is a perfect frame):
| Scene | Radeon 680M: frame · graphics work | Radeon 780M: frame · graphics work |
|---|---|---|
| jet low over the plains | 16.7 ms · 18.9 ms | 16.7 ms · 21.3 ms |
| tank down a city avenue | 16.7 ms · 30.9 ms | 16.7 ms · 19.0 ms |
| battle, seven units a side | 30.2 ms · 20.6 ms | 34.0 ms · 24.9 ms |
The typical frame (half the frames are faster) and the graphics chip's own time for one frame, 300 frames after a warm-up, 16 October 2026. The graphics chip can take longer than a frame because it works on one frame while the processor prepares the next.
On the desktop the processor sets the pace; on these small machines, at the default settings, it's the graphics. That's why the game picks a lighter preset by itself on built-in graphics, and why these numbers are the worst case. The same review found two stutters, both "the same work, done earlier or only once": the first civilian train built its five carriages on the frame it appeared (47.7 ms, now built on the loading screen), and a bridge was rebuilt every time its piece of the world came back into range (up to 47.6 ms, now remembered: 0.03 ms). A second pass chased a smaller, more frequent hitch: the little picture of each kind of tree and prop, drawn the first time you fly past one, cost about 5 ms for a tree on the 680M, and 1 time in 20 nearly 24 ms. Every one of them is now drawn on the loading screen instead, which adds 2.4 seconds to the load and takes that work out of flight entirely.
16October: polishing what the camera caught
A trailer is a harsh critic. Forty shots, every frame looked at closely, and a list of things that looked wrong: white flashes, blobs of dust, striped cliffs, a gun barrel in pieces. Each one went through the same routine. Find it in the game, not just the film. Measure it with a number. Fix the cause. Write a test that fails on the old code and passes on the new.
A few of those stories show the routine best.
The rocket that whited out the screen

Seen: fire a salvo of rockets and the launcher disappears behind a white ball. Cause: on the graphics card, each puff of a rocket's smoke trail was drawn as light, added on top of whatever was behind it. One puff is harmless, but a salvo lays hundreds, and added light only ever gets brighter, until it's pure white. Fix: the trail is drawn as smoke again, which covers what's behind it instead of brightening it, so a stack of puffs can never get lighter than smoke. The rocket's flame stays bright. Number: pure-white pixels added by six rockets went from 2.9% of the screen to 0.00%. Then I witnessed the test: put the old line back and it fails.
The explosion that was a white disc
The same mistake was hiding in the biggest effect of all. A bomb's fireball is about 190 blobs of fire, and every one was drawn as pure added light, so for its first half second the stack summed to a flat white disc, and as it cooled the edges turned lavender against the blue sky. Now a fireball blob also covers what's behind it, a little brighter to make up for it, so the stack settles on the colour of fire, and it cools the way a real flame does: white, yellow, orange, red, then soot. White on screen at that moment fell from 2.2% to 0.03%, and the share that actually looks like fire rose from 0.3% to 2.8%. Two more fakes went in the same round: sparks had been drawn as little balls, and are now thin streaks along their path; and a Hellfire missile, which carries a shaped explosive charge, had been going off like a solid armour-piercing round, a spray of dots with no fireball. The cost on the graphics card didn't move: 0.13 ms before and after.
Dust that hid the tank

Each puff of dust a tank throws up was reasonable on its own. But a track throws up to six a frame into the same few metres, so one second of driving stacked about 250 of them: a solid tan wall. The fix gave each puff its own thickness, half that of smoke. The share of the screen the dust made solid fell from 11.3% to 1.8%. I tried a third of smoke's thickness too, and rejected it: at 0.05% it no longer looked like dust at all. The number of puffs and how they move didn't change.
A gun barrel in pieces

Vehicles are made of boxes (section 7). To draw one, the graphics card first marks out a flat rectangle on the screen, then works out the box inside it pixel by pixel. That rectangle was sized from the box's centre. For a long box pointing almost at the camera, like a gun barrel, the near end sticks out past the rectangle and gets cut off. Now, only for long boxes leaning toward the camera, the rectangle is drawn around all eight corners. Pixels missing from the barrel, counted over four camera angles: 376 before, 8 after. Because the fix applies only to long boxes, the cost stayed tiny: drawing all the vehicles went from 0.35 to 0.36 ms on the Legion Go.
Hunting flicker
Flicker in a game like this usually means z-fighting: two surfaces at exactly the same depth, so the graphics card can't decide which is in front and swaps between them from frame to frame. To find it, I drew each model twice from four cameras, once with its boxes in their normal order and once reversed. Any pixel that changes is a tie. Across 41 ground vehicles, the worst had 76 such pixels out of 705,600, none visible. The hunt also caught the polish itself: the new camouflage gave two touching, flat plates different colours, which made 65 tie pixels on the flak tank where there had been 8. Plates that touch like that now always get one colour.
Paint, dirt and soot


The models themselves got a dozen rounds of finishing, ranked by how long each one is on screen. The first fix was the one seen most: every helicopter's rotor had been a flat plank in one colour on a black cube, and now has a proper hub and painted tips. Next came the ground vehicles: the ten machines the enemy uses most had each worn a single factory coat, and now have camouflage, road dirt, soot where the guns and exhausts are, and the kit crews strap on. The last round did all 25 remaining aircraft in one shared pass rather than 25 hand edits: the game finds each aircraft's skin colour itself and applies one of ten paint schemes.
Two rules held throughout. No model changed shape where it matters: outlines, hit areas and the number of boxes stayed exactly the same, checked by comparing every model before and after, so the polish costs the graphics card nothing. And I timed the cliffs, clouds and dust I fixed on screen too: the new layered rock and smoother clouds added about 0.9 ms to the ground's drawing on the Radeon 680M, about 6%, and I've written down a cheaper setting in case that machine ever needs the time back.

I rewrote the check for whether a line of sight passes through trees to sort the trees into tables first. In a dense made-up forest it was 2.8 times faster and gave exactly the same answers. In the real game it was slower (0.84 ms against 0.61), because a real patch of ground holds only about 25 objects, and building the tables cost more than the loop they replaced. I deleted it. A speed-up has to be measured where the player is.
17Compiling on the fly: how a Python game gets fast
Python is famous for being easy to write and slow to run. Yet this game draws a landscape column by column, moves a thousand bullets and flies two dozen helicopters, sixty times a second, in Python. The trick that makes that possible has a name: just-in-time compilation, or JIT. Here is what it is, how I use it, and what it taught me, written for anyone curious enough to try it.
The idea, in one picture
Imagine handing a recipe to a cook who doesn't speak your language, with an interpreter standing beside them. For every step the interpreter reads the line, works out what you meant, and translates it. It works, but each step waits on the interpreter. Now imagine the cook asks for the whole recipe to be translated once, the first time they make it. From then on they just cook.
That is the whole difference. Plain Python is the interpreter: it reads your program one small step at a time, checking as it goes what kind of value each thing is (a whole number? text? a list?). That checking is what makes Python friendly, and slow. A compiler translates a function into the processor's own language, machine code, before it runs. A just-in-time compiler waits until the very first time the function is called, sees exactly what kinds of numbers it was given, and translates it then. Every call after that runs the fast version.
How I use it
The game runs on Python 3.13 and uses Numba (version 0.65), a free JIT compiler for
Python that installs like any other package (pip install numba). You don't rewrite anything in another
language. You put one line above a function:
from numba import njit @njit(cache=True) # compile this function, and save the result to disk def hill_in_the_way(heights, x0, y0, x1, y1): ... # ordinary Python loops over a NumPy array of numbers
The first time the game calls hill_in_the_way, Numba translates it into machine code for the exact
processor it's running on; every later call skips Python entirely. About forty functions in the game are marked this
way: the backup renderer that draws the ground column by column, bullet movement, lighting, collisions, the
helicopters' rotor physics.
How much faster? One honest measurement
That line-of-sight check, the one that asks "is a hill between these two points?", was timed both ways on a small mini PC: the same function, the same 2,000 sight lines, each about 1.2 km long.
| The same line-of-sight check | Time per sight line |
|---|---|
| as plain Python | 318 µs |
| compiled by Numba | 2.7 µs: about 116 times faster |
| the one-off cost of translating it, on first use | about 0.3 s |
Radeon 680M mini PC, Python 3.12, Numba 0.65.1, 27 October 2026. A µs is a millionth of a second.
A hundred times faster is normal for this kind of code: a tight loop doing arithmetic. For a game it's the difference between a renderer that works and one that doesn't.
The graphics card does it too
The version you play draws the ground on the graphics card, with a shader: a small program that runs once for every pixel on the screen. The game ships that program as plain text, and the graphics driver translates it into the card's own instructions when the game starts, because every make of graphics chip speaks its own language. That is just-in-time compilation as well, and every 3D game you have played relies on it.
Four things I learned the hard way
- The first call pays. Translation happens on first use, so a function used for the first time in the middle of a fight caused a hitch of 7 to 159 ms, when a whole frame only gets 16.7. The fix: the loading screen now calls every compiled function once, with the same kinds of numbers play uses, and a test fails if anything is still being translated during play.
- Save the translation, and check it stays saved.
cache=Truekeeps the compiled code on disk so the next launch doesn't redo the work. But one way of calling C code quietly switched the saving off, and the game spent 2.75 seconds re-translating at every start until a workaround brought it to 0.32. - Don't trust the first timing. A menu preview once measured about 400 ms and nearly had a whole loading system built around it. The real cost was about 1 ms; the rest was the one-off translation.
- Using every core isn't free. Numba can split a loop across all the processor's cores, but its helpers keep spinning for a moment after each job, waiting for the next. One tiny job per frame kept 6 to 8 cores busy and, on a small PC whose processor and graphics share one power budget, slowed a graphics step from 25 ms to 82. Small per-frame jobs now run on one core.
Where it doesn't help
JIT shines on loops over plain numbers. It does very little for code that juggles objects and makes decisions. A bullet in flight cost about 4.9 µs of Python per frame, and only 0.37 µs of that was arithmetic; the rest was looking things up and calling other functions. Compiling it as it stood would have changed almost nothing.
So the rule became: change the shape of the data first. Put every unit's position into one table of numbers, let a compiled function answer one question about the whole table, and keep the decision-making in ordinary Python. That is how picking targets went from 12.7 to 9.0 ms. And some code I measured and deliberately left alone: the giant robots' walking logic is 150 lines of decisions worth about a millisecond, not worth the effort.
What about Python's own JIT?
Python has been experimenting with a JIT built into the language itself. In Python 3.13, the version this game uses, it only exists if you build Python yourself with a special option; the standard download doesn't include it (I checked the game's own copy, 3.13.13). Its gains for everyday code have so far been a few percent, and it doesn't touch the parts Numba already compiles or the shaders. So whatever happens to it, this game doesn't depend on it: Numba is a separate, mature tool that works on the Python you already have.
If you want to try this yourself
- Measure first. Find the one function where the time actually goes. Most of the speed in this project came from simply not repeating work, before any compiler was involved.
- Make it a loop over numbers. Put the data in a NumPy array, keep the function to arithmetic and simple loops, no objects.
- Add
@njit(cache=True), call it once at start-up to warm it, and time it again. - Check the answers match the plain Python version exactly, then move on to the next hot spot.
That's the same habit that runs through this whole write-up: measure, change one thing, prove it. JIT isn't magic and it isn't the whole answer, but for the number-crunching heart of a game it's what lets Python keep up.
18All the numbers
If you skipped straight here, I don't blame you. These are the before-and-after figures from the sections above, in one place.
| What was measured | Before | After | What changed |
|---|---|---|---|
| Slowest frame, jet flying over new land | 334.9 / 328.3 ms | 44.0 / 27.2 ms | eleven causes of stutter moved to helpers or the loading screen |
| Slowest frame, battle | 915.4 / 176.9 ms | 62.6 / 57.6 ms | the same |
| Map data sent to the graphics card | ~112 MB every frame | only new strips | send only what changed |
| Frame rate while that happened | 40–50 → 1–4 fps | back to normal | the same |
| Building land for a fast jet | freezes up to 1.3 s | 0.18 ms typical | a separate helper program |
| Drawing a thunderstorm at 4K | 38–56 ms | 10–14 ms | flashes and weather done by the graphics card |
| Stuttering frames in a storm | 214 | 42 | the same |
| Preparing each finished frame for the screen | 9.02 ms | 0.01 ms | no more copying the picture |
| Typical frame in a big fight | 29.6 ms | 18.9 / 17.5 ms | the same |
| Drawing soft smoke puffs | 16.4 ms | 1.8 ms | grouped, instead of 14,170 separate drawings |
| Stutter when a new kind of tree first appears | 221.8 ms | 16.9 ms | no longer rebuilds every tree picture |
| Frames with a new crater | 14.8 ms | 4.2 ms | no longer re-sends the whole map |
| Anti-aircraft fire into a forest | 16.3 ms | 2.2 ms | update only the damaged trees (was rebuilding 145 times in 20 s) |
| Power lines | 3.0 ms | 0.2 ms | drawn by the graphics card |
| Bullets in flight | 2.26 ms | 1.13 ms | 172 visibility checks done as one batch |
| Bomb shock rings, on my own settings | +4 to +12 ms | at most +0.7 ms | drawn by the graphics card |
| "Hi-Z" speed-up technique | – | 2% to 27% slower | measured, and left switched off |
| Visible mark from a rifle hit | 0 of 30,905 pixels | 6.2% | scorch, dent, hole and rock |
| Enemy ground units permanently stuck | 5 of 53 | 1 of 53 | plans, and nine directions to try |
| Typical frame, 212-unit fight | 42.1 ms · 24 fps | 31.5 ms · 32 fps | busiest Python compiled, repeated work removed |
| Game logic, 212-unit fight | 23.9 ms | 16.0 ms | the same |
| Enemy thinking, about 200 units | 12.7 ms | 9.0 ms | choosing targets compiled, same picks |
| Finding units that touch | 475 µs | 44 µs | compiled, no temporary tables |
| Minimap in a big fight | 2.2 ms | 1.0 ms | all range rings on one layer |
| A robot losing a limb | 2–7 ms hitch | 0.1 ms | limbs built once, on the loading screen |
| Starting the game | ~5 s | ~1.5 s | models, sounds and compiled code kept on disk |
| Loading the first mission | 39 s | ~13 s | the same, and land built on several cores |
| Turrets and guns hard to see on hilly maps | about 1 in 4 | none | seated on the slope, never in it |
| Screen whited out by a rocket salvo | 2.9% | 0.00% | trail drawn as smoke, not light |
| Screen whited out by a bomb's fireball | 2.2% | 0.03% | fire covers as well as glows |
| The first civilian train appearing (Radeon 680M) | 47.7 ms | 0.003 ms | built on the loading screen |
| Total time of the test suite | 9,423 s | 7,312 s | two slow tests: 544 s → 67 s |
| Tests passing when run in parallel | 1,012 of 1,019 | 1,019 of 1,019 | tests no longer leave settings changed |
Measured on my development PC (RTX 3090 graphics card) at full HD unless the row says otherwise; some of the October effects figures come from the test lab's machines. Every figure is recorded in the project's engineering log. Where two numbers are given, the test was run twice.
19Should you copy this?
My honest advice for anyone starting a game like this: copy the techniques and the habits, and think carefully before copying the choice of language.
| Idea | Verdict | Why |
|---|---|---|
| Drawing the ground from a height map on the graphics card | KEEP | For flying and fighting over big landscapes it's hard to beat. The cost depends on screen size, not world size, and a crater is just a change to one image. |
| Building the world in separate helper programs | KEEP | In Python it's the only way that works: a background thread was 35 times slower. Never make two programs wait on each other. |
| A low-detail copy of the real land for the horizon | KEEP | 7.3 MB gives a real horizon about 4.5 km away, accurate to a third of a pixel. |
| Vehicles made of boxes, drawn in one batch each | KEEP | Hundreds of vehicles for well under a millisecond. Damage and moving parts cost a few numbers each. |
| Everything generated from code | KEEP | No separate art workflow, and any model or sound can be changed by editing code. Build them on the loading screen, not mid-game. |
| Proving tests fail, reading the player's own logs | KEEP | The habit that paid off most. Passing tests that were never shown to fail missed real problems several times. |
| A just-in-time compiler (Numba) for the number crunching | KEEP | About 100 times faster on loops over numbers, with no build tools on the player's PC. Warm it on the loading screen and reshape the data into tables first (section 17). |
| Python as the language | ONLY IF | It works here because the heavy work is compiled or on the graphics card. The rest (enemy AI, rules, display) costs far more than it would in C#, Rust or C++. Choose it to learn, or because you love Python. |
| A single height for every spot of ground | IMPROVE | No overhangs, bridges or caves, and buildings are just tall spots. Real 3D buildings would improve cities most. |
| The "hi-Z" speed-up | SKIP | Measured slower here; the big leaps through open air already do its job. |
| Checking what's hidden on the main processor | SKIP | When the graphics card already knows, it's both wasted work and wrong: vehicles blink. |
20What's missing
A project like this is never finished, only further along. Here's what's on my list.
- The other machines' big-fight numbers. The scaling test (section 4) has only run on the desktop; the Arc laptop, the Ryzen mini PC and the Iris Xe laptop come next, and their results decide what to work on after that.
- The next big speed-ups are design choices. The piece-by-piece compiling has done its job: what's left of the logic is spread thin, no single piece worth more than about a millisecond. Two bigger changes remain. One is drawing every vehicle of a type in one go, which first needs turrets and robot legs to be moved by the graphics card (section 7). The other is letting far-away units think and move less often, the biggest win for large armies, but one that changes how the game plays, so it waits until I decide I want that. If something ever needs more than today's compiler can give, the next step is Rust, delivered ready-built so players never compile anything. I'm still not planning a full rewrite.
- Cheaper ground drawing. Drawing the ground is still most of the graphics work on every machine, and I've measured two ideas for it but not built them. One sets a step limit for each quality setting. The other starts each pixel's search from where it found the ground last frame; that could skip 41–66% of the work, but isn't yet safe when things move or appear suddenly.
- Real 3D buildings. This is the honest limit of the 1992 trick. Cities would benefit most.
- Erosion in the endless worlds. The fixed maps smooth their peaks; the endless ones don't, and valleys would look more natural carved by water.
- Online play. Multiplayer sends each player's position about 20 times a second in small messages of about 48 bytes, and every computer builds the same world from the same recipe. That's fine on a home network, but not yet built for the open internet.
21Talking points
Questions this project raises and I haven't settled. They're here to argue about.
- Is a 1992 trick still the right tool? It makes huge landscapes cheap and craters easy, but it can't draw a bridge you fly under. For a flying-and-fighting game, is that a good trade or a ceiling hit early?
- Can you really build a game engine in Python? Here it works because the heavy lifting is handed off. Even so, the main processor, not the graphics card, set the pace. Where would you draw the line?
- Is all this measuring the product or the overhead? A quarter of the first 900 lessons were about testing and measuring, and the test file is the biggest file in the project. It also caught passing tests hiding missing mountains. How much of it would a team keep?
- Would you trust a test you've never seen fail? Checking every test this way found several that passed no matter what. Most projects never check even once.
- When an AI writes the code, where does the design live? Here, in my exact words, kept until I've played the result, and in a 36,700-line log of what was measured and why. Is that enough, or does something get lost?
- How many "standard" speed-ups are actually measured? Here, a well-known technique made drawing slower, spreading work across all cores made the game slower, and a common upload trick made uploads slower.
22Lessons learned
I keep a list of 1,627 one-line lessons, each linked to the measurement that taught it. These are the ones I'd take to almost any project:
- Passing tests mean nothing if none of them checks what the user actually sees. 348 of 348 passed while the jet flew toward a mountain that wasn't there.
- A test you've never seen fail may not be testing anything. Break the thing on purpose and watch the test catch it.
- When a number looks dramatic, suspect the measurement first. Reproduce it before acting on it.
- Don't make the same decision in two places. When the graphics card already knows what's hidden, a second check on the main processor is slower and wrong.
- Slowness is usually repeated work, not a bad method. Look for work done more often than anything changes.
- In Python, real parallel work needs separate programs, and speeding up one part can slow down the whole. Always time the whole thing.
- Python's quick ID numbers get reused. They merged missiles, raised a destroyed hangar and swapped tree pictures. Use permanent serial numbers.
- Say exactly what you mean. Store a height as a height, not as a speed that happens to produce it, and check that something has actually arrived safely, not just that it was placed.
- To measure something see-through, compare with and without it.
- A resting graphics card can look like a slow one.
- Keep requests in the asker's own words. A request repeated word for word is the clearest sign a fix didn't land.
- Delete abandoned code; don't just switch it off. Switched-off code with confident comments invites someone to make the same mistake again.