For the first month of this project you looked at the battlefield from straight above, and a unit was one sprite drawn at a pixel offset from its tile. In April that stopped being the only way to see it. The camera gained a pitch and an orbit, so you can drop it toward the ground and look along a valley instead of down into it. Units became models that turn continuously toward wherever they are walking rather than snapping between eight drawn facings. Buildings started throwing shadows from a sun that moves.
The brief was not a specification: the game should be in 3D, and the version people were already playing had to keep working. Three weeks of build with nothing shippable in the middle, then both.
Since this is the first post in a series about that move, two names in it are worth fixing now. WebGL is the browser's interface to the graphics card: the same class of API as OpenGL or Direct3D, reachable from JavaScript, and the only way to draw hardware-accelerated 3D on a web page. It is also famously low-level — it deals in buffers, shaders and draw calls, not in models and lights.
three.js is the library almost everyone puts on top of it. It supplies the vocabulary you actually want (a scene, a camera, a mesh, a material, a light), loads standard model formats, and generates the shader code for you. It is not a game engine: there is no gameplay, no physics, no asset pipeline and no editor. It draws things. Everything in this game that decides what to draw is written here, which is exactly why the swap below was survivable.
![]()
The simulation did not move at all
The part people expect to be expensive was untouched.
Tiles are 32 by 32 pixels. Ground units occupy one tile, flying and naval units take two by two, and buildings run from one by one up to four by four. Three separate maps track which tiles are occupied on the ground, on the water and in the air. Pathfinding runs over a grid of walkable tiles, the game updates at a fixed twenty ticks per second, and terrain is one number per tile. None of that is a rendering concern, so none of it moved. A dwarf standing on tile 37, 42 is standing on tile 37, 42 in both renderers, takes the same damage from the same crossbow bolt, and arrives by the same route.
That separation is what made the whole thing survivable, and it is the thing to get right before any of the graphics. If your simulation reads a pixel offset, a sprite width or a screen position anywhere, a renderer swap is a rewrite of your game rules. If it reads tiles and ticks, a renderer swap is a renderer swap.
What did have to move is every assumption about what a unit looks like. In 2D a unit is one downward-facing sprite, flipped horizontally when it walks right. In 3D it is a mesh with a heading, a size, a scale correction, a rotation to fix the model's idea of which way is up, a shadow, a fog state and a chain of progressively cheaper versions of itself for when it is far away. The sprite pipeline did not become useless. It became one of several ways to draw the same unit, which is why a renderer that draws sprites as flat cards inside the 3D scene was written on day two rather than month two.
The first thing that broke was being able to see what was wrong
Three days in, the thing holding everything up was not frame time and it was not model quality. It was that when something looked wrong there was no way to find out why, because a model that renders at the wrong scale, the wrong rotation or around the wrong pivot point looks identical to a model whose source art is simply bad.
So the third day is mostly instrumentation: three developer-only screens for looking at one thing on its own, a detector that works out what the player's machine can handle, and a tool that counts polygons in the imported models. That ordering became a standing habit and eventually the reason this project has so many labs. The problem of what to draw at the far end of the map got its first answer in the same batch, and that machinery ran for four months until real geometry took it over.
Buildings first, units five days later
Model generation for buildings landed on the second day. Model generation for units landed five days after that, and the ordering was not scheduling luck.

A building is a static mesh. Generate it, import it, put it on a footprint, and it never moves again. A unit needs a skeleton and an idle, a walk, an attack, a death and a gather, all named so the game can find them, and it has to face the direction it is travelling. So the easy half went first on purpose, and the pipeline for animated units followed once the static one was known to work end to end.
A canvas draw owns nothing. A 3D scene owns everything
Four days after the first model appeared, the loudest problem in the project was memory. A whole day that week went on nothing but releasing GPU resources across the model cache, the renderer, the particle system, the event system and the sound manager.
This is the one thing to expect when moving from a 2D canvas to WebGL, and it is entirely mechanical. Draw a sprite to a canvas and the browser owns everything you touched. Build a mesh in a 3D scene and you now hold a geometry, a material and one or more textures, all of which sit in video memory until something explicitly hands them back. Removing the mesh from the scene does not do it. Dropping your last reference to it does not do it either.
The helper below covers the discipline, and it works in any three.js project:
// Release everything a subtree owns. Removing an object from the scene frees
// NOTHING: geometries, materials and textures live in GPU memory until
// dispose() is called on each of them.
function disposeSubtree(root) {
const seenMaterials = new Set();
const seenTextures = new Set();
root.traverse((obj) => {
obj.geometry?.dispose();
const materials = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const material of materials) {
if (!material || seenMaterials.has(material)) continue;
seenMaterials.add(material);
// Every texture-valued property, whatever it is called. Walking the
// material's own keys catches map, normalMap, alphaMap and any custom
// texture a patched material added, which a hard-coded list will not.
for (const value of Object.values(material)) {
if (value?.isTexture && !seenTextures.has(value)) {
seenTextures.add(value);
value.dispose();
}
}
material.dispose();
}
});
root.removeFromParent();
return { materials: seenMaterials.size, textures: seenTextures.size };
}Two details in there are the ones that catch people out. Shared materials and
textures have to be de-duplicated, or you will release the same texture once per
copy of the model and, worse, release a texture another live object is still
using. And walking the material's own properties rather than a fixed list of
known names is what makes it survive a custom shader, because a patched
material's extra textures are not called map.
The portable check is one line: log the renderer's own memory counters on a timer while you load and unload content, and if the geometry or texture count only ever goes up, you have this bug. It does not look like a leak from the inside. It looks like the game getting slower the longer it is open.
Both renderers still ship
Four months on, the render mode is still a choice between 2D and 3D, and the game picks 2D for the weakest machines and 3D for everything above. Both paths are maintained, which is why, when the ground was rebuilt to blend materials along contours rather than tile them, both renderers were made to read the same data rather than one being retired.
That decision has held up better than expected. Keeping the cheap renderer alive means the game runs on a laptop with no usable GPU, and it means every ground feature since has had to be expressible twice, which turns out to be a good test of whether a feature is really about the world or really about the shader. The next thing that ground had to answer was elevation, and that is where a flat plane stopped being enough.





