Part 4 of 5
Four Engines, Fifteen Years
Generating the Game Instead of Writing It
The engine from the previous post is still in use. Carbon Monoxide shipped a mobile puzzle game in 2014, and it currently runs a dungeon crawler that looks nothing like that game and is built in a way the 2014 codebase would not recognize.
Three things changed, and all three point the same direction: away from writing C++ for every new piece of content.
The Engine Finally Left the Building
The change that mattered most is the least interesting to look at. The engine is a git submodule now. The game repository contains game code, and the engine lives in its own repository underneath it.
Every engine in this series has had a prefix marking the boundary. engine_ in the first, Manic in the second, cm in the third. All three were conventions inside one tree, which meant the boundary held exactly as long as nobody was in a hurry.
A submodule is the same boundary with a version number attached. The game pins an engine commit, the engine can't reach into the game at all, and a change that would have been a two-line convenience across the boundary is now a change to a different repository with its own history. That friction is the feature. It's the same lesson as the linker enforcing the dependency graph in the second engine, applied one level up.
Content Is Data, and the Code Comes From It
There are 35 JSON files describing schemas and data, and they produce 9,315 lines of C++ across 96 generated files.
A schema is unremarkable to look at. It names a class and lists typed members:
{ "Classes": [ { "Name": "AdventuringGear",
"Members": [ { "Name": "name", "Typename": "string" },
{ "Name": "cost", "Typename": "int" },
{ "Name": "weight","Typename": "float" } ] } ] }The generator turns that into the C++ class and the data source that loads it. Adding a new content type means editing JSON and running the generator, not writing a class, a loader, a validator, and the code that wires them together.
The ratio is the argument. Thirty-five files of declaration produce nine thousand lines of implementation, and none of those nine thousand lines can drift from the declaration, because they don't survive the next generation run. The generated directories carry an instruction not to hand-edit them, which is the one place this approach reliably goes wrong.
What this really buys isn't typing speed. It's that the data has exactly one definition. In the first engine, a content type existed as a struct, a loader, an editor's understanding of it, and a file format, and those four could disagree. Here there's one source and everything else is derived.
How the Generator Arrived
Two dates set the sequence.
The engine became a submodule on 2019-09-28, in a commit called "Add gitignore, gitmodules, and jenkinsfile", which is the migration from Subversion to git. That commit is a translation rather than a decision. The engine was already a separate repository mounted into the game through svn:externals, set up on 2018-12-15 in a commit called "dungeoneer extern".
The evidence for that survives the conversion by accident. git-svn drops externals, so in the pre-2019 history there is a CarbonMonoxideBase.sln referring to carbonmonoxide\carbonmonoxide and no directory of that name anywhere in the tree. The solution file is pointing at something the version control system used to supply and the conversion threw away.
So the separation is a year older than the submodule, and the intent is older than that again. The boundary starts in 2014 as a cm prefix on filenames, becomes a mounted external in 2018, and becomes a submodule in 2019. Each step hands the same rule to something that enforces it a little harder, and none of them changed a line of engine code.
The generator took longer and went through a dead end. Serialisation starts as macros on 2021-09-29, in a commit adding "new smart enum macros" and "new json serialisation macros". On 2021-10-04 it becomes real code generation, and the commit says exactly how: "Adding flatbuffers lib & setting up a schema for cmVector3 (they run as a custom build step before compile to produce the serialise/deserialise code)". There is no FlatBuffers in the tree today. Six days later comes "make packing json less macroish", which is the macro layer being unwound rather than extended.
So the arrangement I described, one declaration producing everything derived from it, is the third answer rather than the first. Macros were the second, and they are the interesting failure. A macro puts the generation at the point of use and keeps it inside the compiler, which feels lighter than a build step and a tool. What it cannot do is produce a file a person can open and read, which turns out to be most of why generated code is tolerable to work with.
What settled is the schema tools, and they are a submodule of the engine, which is itself a submodule of the game. Three repositories, each pinned to a commit, the generator held at 533af64. The commits updating it are all called "Taking latest schema tools", and they are identical because a batch file in the repository writes the message.
One seam is still open. run_code_gen.bat calls into tools/schemer-tools/bin/Release/, which is a build output and is not in version control. A fresh clone gets the generator's C# source and no generator, and nothing in the game's build compiles it. That gap stays small right until somebody new tries to add a content type.
Components Classified at Compile Time
The entity system is a C++20 template ECS, and it uses concepts to sort components rather than inheritance.
template <typename C>
concept base_component = requires {
requires std::is_same_v<std::remove_cv_t<decltype(C::Config)>, ComponentConfig>;
};
template <typename C>
concept component = base_component<C> && C::Config.Type == ComponentType::Entity;
template <typename C>
concept singleton_component = base_component<C> && C::Config.Type == ComponentType::Singleton;A component declares a static Config, and the concepts read it to decide what kind of component it is. Whether something is a per-entity component or a singleton is answered during compilation, from a value the type carries, with no base class and no runtime check.
The contrast with the second engine is the whole point of this series in one comparison. There, a subsystem was a manager: a singleton object, reachable from anywhere, with lifetime managed by convention. Here, a singleton component is a compile-time classification that the storage layer acts on. Both express "there is one of these." Only one of them is checkable before the program runs.
The engine also compiles with warnings as errors under the latest C++ standard, which is the same instinct in a different place. A rule that the build enforces is a rule.
There Is a Pong in the Dungeon Crawler
Alongside the monsters and the tile editor sits a complete implementation of Pong: its own components, physics, state, and systems, in its own namespace, built on the same DungeonEcs.h the dungeon game uses.
It isn't an easter egg. It's a proving harness. An entity system written for one game will quietly grow assumptions about that game until it can only express that game, and the assumptions are invisible from inside because everything they'd block is something nobody tried. A second game that shares nothing with the first, a paddle and a ball instead of monsters and dungeons, makes them visible immediately.
Pong is a good choice for it too, because it's small enough to keep working and different enough to be a real test. If the ECS can express it without a special case, the ECS is about entities rather than about dungeons.
That's a discipline the earlier engines had no equivalent of. Manic Engine had four games on it, which is the same idea arrived at by accident: the second and third game are what find the places the first one shaped. Doing it deliberately, with a game small enough to live inside the repository, is cheaper than discovering it a year later.
A Language for Entity Behavior
Entity behavior lives in script files rather than C++. A monster looks like this:
function OnInit()
{
player = FindEntityByName('player')
aggroRadius = 3
roamRadius = 1
speedNormal = 1
speedAggro = 2
homePosition = GetPosition(this)
aggro = false
}
The scripting system has more in it than that suggests. Scripts support includes, control flow, typed function bindings, and wait states, so a behavior can say wait two seconds or wait until a condition holds and resume where it left off. Execution contexts are scoped as system, game, entity, trigger, or UI, with child scopes inheriting from parents.
Wait states are the feature that justifies the language. Expressing "walk over there, pause, then turn around" in C++ means a state machine, an accumulator, and a switch that runs every frame. In a language with coroutine-like waits, it's four sequential lines. Behavior code is overwhelmingly sequential in intent and awkwardly parallel in implementation, and closing that gap is worth a lot.
Whether Owning a Language Is Defensible
Writing a scripting language is the decision in this series most likely to be a mistake.
Owning a language means owning its parser, its error messages, its scoping rules, and its debugging story forever. Lua exists. Embedding it is a solved problem with documentation, editor support, and people who already know it. Every hour spent on a bespoke parser is an hour not spent on the game, and the resulting language will be worse than Lua at everything except the two or three things it was shaped for.
My opinion is that it paid off here and wouldn't on a team. The things it's shaped for, wait states and scoped contexts that match the engine's own entity model, are exactly the friction points of embedding a general-purpose language, and the audience is one person who already knows every rule. On a project with five programmers, the calculation inverts completely: the cost of everyone learning an undocumented language exceeds the cost of adapting to Lua's model.
It's a defensible choice for a specific and narrow set of circumstances, and an indefensible default.
What the Progression Actually Was
Four engines, and the direction is consistent once the numbers are laid next to each other.
The first kept its state in 603 globals and averaged 475 lines a file. The second enforced boundaries with the linker and got to 73. The third let a constrained platform dictate structure. This one moves most of the game out of C++ entirely: content into JSON that generates code, behavior into scripts, component classification into the type system.
Each step moved something from convention into enforcement. Prefixes became libraries, libraries became a submodule, manager singletons became compile-time concepts, hand-written content became generated code. That's one idea applied at four different levels over about fifteen years.
The last post in this series is what I'd tell someone starting an engine now, including the part where I argue they probably shouldn't.