Part 2 of 5

Four Engines, Fifteen Years

Twenty-Four Projects: Decomposing an Engine

The previous post in this series ended with the problem: an engine whose 603 globals meant nothing could be reused, tested, or safely changed in isolation. Manic Engine, which followed it around 2012, was the answer to that problem, and it answered it hard.

One program became twenty-four projects. Average file size fell from 475 lines to 73. About 40% more code, spread across more than nine times as many files.

It also introduced sixteen manager singletons, which I'd do differently, and a particle system I'd still defend.

The Dependency Graph Is the Design

The projects aren't a flat pile. They form a graph with a direction, and the direction is the actual architecture:

Game projects (DungeonCrawler, PixelPets, RTS)
  └── ManicEngine
        ├── ManicGL → ManicAssets → ManicFoundation
        │             ManicMath   → ManicFoundation
        ├── ManicParticleSystem
        ├── ManicGUI
        ├── ManicNet   → ManicFoundation
        ├── ManicDB    → ManicFoundation
        ├── ManicInput → ManicFoundation
        └── ManicAL    → ManicFoundation

ManicFoundation sits at the bottom and depends on nothing. Everything else depends downward. Games sit at the top and depend on ManicEngine, which composes the rest.

The important property isn't the diagram, it's that the linker enforces it. In the previous engine, the module boundaries were filename prefixes, which meant they were a convention and conventions decay. Here, ManicMath cannot call into ManicGL, because ManicMath doesn't link against it and the build fails if anyone tries.

That's the whole lesson of this engine compressed into one sentence. A boundary that the build system enforces is a boundary. A boundary that exists in the naming is a wish.

The Graph Arrived in Three Moves

The dependency graph above is the finished shape. It arrived in stages.

The project starts on 2011-11-08 with two directories, ManicEngine and ManicGame. One library and one game, which is the previous engine's shape with better names.

The first split is a single commit on 2012-01-05, eight weeks later. ManicAssets, ManicFoundation, ManicGL and ManicMath all appear at once, and 90 files move into them out of ManicEngine. Thirty-eight of those files change by not one character, and most of the rest change by an include path. The commit reports 116 files and 1,169 added lines, and nearly all of those added lines are new Visual Studio project files. No code was rewritten. The decomposition was a move.

Then the same thing happens again. On 2012-12-11, eleven months later, 56 files move out of ManicGL into two new libraries, 42 of them into ManicGUI and 14 into ManicParticleSystem.

So the twenty-four projects accreted over seventeen months, and the two structural events in that span are one operation performed twice. A library is doing several jobs, the files move, and the linker starts enforcing what the filenames had been claiming all along.

The tools predate the libraries they edit. The GUI editor exists from 2011-11-30 and the particle editor from 2011-12-15, but ManicGUI and ManicParticleSystem are the last two libraries to appear, a year later. Each editor spent that year working on content whose code was still inside a general graphics library.

What Decomposition Actually Bought

The file size number is the visible effect: 29,030 lines across 397 files, averaging 73 lines each, against the previous engine's 475. Both counts exclude blank lines, comments and third-party code. By the same rule the previous engine is 20,421 lines across 43 files, so the code grew by about 40% while the number of files grew more than ninefold.

Files got smaller because they had to. A library that only sees the layer below it can't reach for arbitrary state, so functions grew parameters, and functions with parameters have natural places to split. The size drop is a consequence of the boundaries rather than a separate act of tidying.

The reuse arrived too, and it's the thing the previous engine completely failed at. Four separate game projects sit on top of this engine in the same solution, plus a client and a server for a networked prototype. That was the goal, and it worked.

The less obvious benefit is that a graph makes the missing pieces visible. When every subsystem is a library with a declared position, a new subsystem has to be placed somewhere, and placing it forces the question of what it's allowed to depend on. That question has no natural home in a single-project codebase.

The Streamer Gets an Owner

The previous post described a texture streamer whose state lived in twenty globals, and called those globals the frame of a coroutine that had nowhere to live. ManicMeshManager is what happened to that design once there was somewhere.

The class declares its states as methods:

void idle();
void seek();
void header();
void read();
void upload();

and drives them from one dispatch:

if      (state == IDLE)   idle();
else if (state == SEEK)   seek();
else if (state == HEADER) header();
else if (state == READ)   read();
else if (state == UPLOAD) upload();

offset, size, bytesRead, and meshData are private members. The worklist is a queue<string> with a companion map<string,bool> acting as a membership index, so the same asset can't be queued twice without a linear scan to find out.

Nothing about the algorithm changed. It's the same incremental seek, header parse, read, and upload, advanced a bounded amount per frame. What changed is that the states have names, the fields belong to something, and a second streaming operation is now a second object rather than an impossibility.

That's the same design one engine apart, with and without an owner. The earlier version wasn't worse, it had no container available to express it. The moment a class existed, the implicit states became five named methods, and the tangle of if (streamingTextureSeeking) branches became a dispatch anyone can read.

The best-fit atlas allocator made the same journey. It's still recognizably the code from the previous engine, still tracking the smallest wasted area with an early return on an exact fit, but now it's ManicTextureAtlas::findEmptyChunk operating on its own chunks member, and it has gained a one-pixel border to stop neighboring textures bleeding into each other.

Where the Real Engineering Went

Decomposition is the structural story, and it isn't where the interesting code is. As with the previous engine, the sophistication concentrated wherever something actually hurt, and here that was particles.

ManicParticleStateTransition holds a start state and an end state, each carrying a color, a size, and a value. The obvious implementation interpolates between them per particle, per frame. With a few thousand particles that's four color lerps and a size lerp every frame for every one of them, and all of them are computing the same curve.

So it precomputes the curve into a table instead:

lookupSize = transitionTime / interval;
lookupTable = new ManicParticleState[lookupSize];
for (int i = 0; i < lookupSize; i++) {
    float interpOffset = (float)i / (float)lookupSize;
    lookupTable[i].color.r = linearInterpolation(start.color.r, end.color.r, interpOffset);
    // ...and g, b, a, size
}

A particle's per-frame cost then becomes an integer index and a clamp:

int index = offset * lookupSize;
index = min(index, lookupSize - 1);
return &lookupTable[index];

Parts of it are better than they need to be.

The interval is fixed at a sixtieth of a second and deliberately decoupled from the frame rate, with a comment saying so. The table's resolution doesn't change because a machine is running at 30 or 144 frames per second, so a particle effect looks the same everywhere rather than subtly different per machine.

The table invalidates itself. The transition keeps backup copies of its inputs and compares them on update, rebuilding only when the start state, end state, or lifetime actually changed. That exists because of the editor: a designer dragging a color in the particle tool changes the start state, and the transition notices on the next frame and rebuilds. Live editing with automatic cache invalidation, in a hobby engine, in 2012.

And the particles themselves never allocate. ManicParticleBuffer is a ring over a preallocated array. push() hands back a pointer into it, advances, wraps, and when the ring is full it pops the oldest to make room:

out = buffer + end++;
if (end >= bufferLength) end = 0;
if (end == start) pop();

That can't fail and can't fragment. Running out of particles degrades by dropping the oldest one rather than by allocating or refusing, which is the correct failure for something visual.

Put together, the particle system is a shared precomputed curve, an allocation-free pool, and an invalidation scheme that exists to make a tool feel immediate. That's a coherent design with a stated rationale, and I'd still be pleased with it.

It's also the code I submitted as my portfolio when I applied to EA in 2013, and I got the job and worked there for a decade. Of everything in twenty-four projects it's the right choice, because the reasoning is visible from the outside. The lookup table explains why it exists, the dirty check explains what it's for, and the ring buffer explains what happens when it runs out. A reviewer can see the thinking without being walked through it.

The renderer has its own quiet competence. ManicFrustum does point, sphere, and cube culling tests, and the mesh manager holds its draw submissions in a priority_queue with a custom comparator rather than a flat list, so what reaches the GPU is ordered rather than whatever order the scene happened to iterate in.

Two other things in the tree belong here for a different reason. ManicNet implements reliable delivery over UDP, with sequence numbers that handle wrap-around, a 32-bit ack bitfield, round-trip-time estimation, and sent and acked bandwidth measured over a sliding second. And the dungeon game links Valve's MIT-licensed Event Tracing for Windows helpers, so frames, input, and scoped work all emit markers that Windows Performance Analyzer can read.

Neither of those is original work. The reliability layer follows a well-known published design closely enough that the naming still shows it, and the tracing code carries Valve's copyright header. What they show is different: knowing that reliable UDP is a solved problem with a correct answer, and profiling a game with a real system-wide tracer rather than with print statements. Adopting the right existing solution is its own skill, and it's more useful than the instinct to write everything.

The Manager Singleton Trap

Sixteen classes in this engine end in Manager. Camera, font, texture, material, mesh, shader, model, framebuffer, particle, GUI, input, socket, connection, sound, asset, and mesh instance.

Each one owns a category of resource and is reachable from anywhere. Which means the state that used to live in globals.h now lives behind sixteen accessors, and the property that made globals painful is intact.

What improved is real, and it isn't everything. The state is grouped by concern, so a texture question has one place to go. The lifetime is explicit, because a manager gets initialized and torn down in a known order. And the library boundaries mean a manager can only be reached by code above it in the graph, so the reachability is bounded in a way a global's never was.

But "who can modify this and when" is still unanswerable locally, and constructing a subsystem for a test still means standing up the managers it consults. There's no test project in this engine either, and the managers are a large part of why.

Manager singletons were a real improvement over globals and a stopping point I mistook for a destination. They fix the organization problem and leave the ownership problem alone, and because they fix something visible, they hide the thing they don't fix.

Tools as First-Class Projects

Five of the twenty-four projects are editors: an animation editor, a GUI editor, a material editor, a particle editor, and a dungeon designer. There's also an asset packager.

I'd repeat this part of Manic Engine without hesitation. In the previous engine, content was defined in code or in files hand-edited by whoever made them. Here, each content type got a tool, and the tools live in the same solution as the engine, built by the same build.

That placement matters more than it sounds. A tool in the same solution shares the engine's types directly, so a particle system edited in the editor is the same struct the runtime consumes, with no export format in between to drift. When the engine's particle definition changes, the editor fails to compile, which is exactly when a person should find out.

The cost is that the solution takes longer to build and a tool can break the build for the game. My opinion is that's a good trade at this scale, and the alternative, tools maintained separately against a serialized format, is how content pipelines rot.

Platform Layer as a Translation Boundary

The engine sits on SDL2 for windowing, context creation, input, and gamepads. What's interesting is that it doesn't expose SDL upward.

ManicWindow owns the window and GL context, and translates SDL events into the engine's own message codes. Games never see a platform event. They see the engine's KEY_DOWN and mouse callbacks, and the GUI system consumes those same codes.

The key codes and gamepad constants keep the numeric values that Win32 and XInput originally used, but the engine defines them itself and fills them from SDL. That's a small decision with a specific payoff: existing key bindings stayed valid across a platform layer swap, because the numbers didn't move even though the source of them did.

That's what a good platform abstraction looks like, and it's the first one in this series that qualifies. Not a wrapper that renames the platform's concepts, but a translation into concepts the engine already had, so the platform can be replaced without the layers above noticing.

What This Engine Got Wrong

It's 32-bit only, on the Win32 platform toolset. That was a defensible default in 2012 and became a constraint that outlived its reason, which is what happens to every default nobody revisits.

Global using namespace std; throughout the codebase. It causes exactly the collision anyone would predict, and the repository has documented workarounds for it, including needing a specific define before including a Windows socket header because std::byte clashes with a Windows type. A decision made for typing convenience in the first week produced a note in the build documentation a decade later.

And there are no tests, which by this point had become a choice rather than a limitation. The library boundaries existed. The managers were what made testing impractical, and I didn't see it at the time because the decomposition felt like the hard part had been done.

Where It Left Me

Manic Engine is the one where the structural instincts arrived: enforce boundaries with the build, give every content type a tool, translate the platform rather than exposing it. Those all survive into what I write now.

What it didn't solve was ownership. State was organized, and it was still reachable from anywhere by anything above it. The next engine attacked that directly, and it did it by moving most of the game out of C++ entirely.