The received wisdom for a game like this is to data-drive everything: push behaviour out of the binary into scripts so content can change without a rebuild. Embed Lua, expose the world to it, and let spells be script files.
The call, made on 7 July 2026 and recorded in docs/magic system.md, went the other way: no Lua, a class hierarchy instead. Every spell became a C++ class, one file pair each, and spells.cat was demoted from definition to numeric overrides.
That is the unfashionable answer, so it is worth being precise about what it buys and what it costs — and about where the line actually falls, because "put it in code" is not the rule. The rule is narrower:
Data can tune, never redefine.
Where the line falls
A closed set of behaviours lives in code. Membership of that set, and every number attached to it, lives in data. Four subsystems, one shape:
| Subsystem | In C++ (identity + behaviour) | In .cat (numbers + presentation) |
|---|---|---|
| Spells | a class per spell; the symbol recipe; Cast() |
name, power, mana, speed, range, push, duration |
| Effects | an EffectKind per effect; tick, resist, react |
display name, icon, stacking policy |
| Attacks | a 10-entry table of id + damage type | damage, accuracy, speed, stamina multipliers |
| Monsters | a closed Archetype enum the AI dispatches on |
model, texture, hp, damage, IQ, aggro, threat shaping |
A spell's recipe — which runes in which order — is class identity, not a field. A monster's behaviour is one of six archetypes the AI knows how to run; the catalog picks which, and supplies everything else. In both cases the catalog decides which and how much, never what happens.
The two worked examples are worth spelling out because the second was built on the first deliberately, seventeen days later, and the shape held even though the constraints were not the same: an effect has to apply to monsters and party alike, survive a save, and tick every frame, none of which a spell does.
Spells. Spell is the base: id, loc keys, the symbol recipe, mana, base power, and a pure virtual Cast(CastContext&). Shared forms are intermediate classes — BoltSpell flies a projectile, WardSpell lands a school-keyed ward — and each of the eighteen concrete spells is its own file pair, constructed with its numbers, free to override Cast() the day it grows unique behaviour. Flame igniting wall sconces lives in Flame::Cast, which is exactly where you would look for it.
A Cast() reaches the world only through CastServices — spawn a bolt, log a line — wired once by the host. So the magic module knows nothing about the map, monsters, HUD, or audio, the same way the AI module doesn't.
Effects. Status effects use the codebase's usual flyweight: one shared EffectKind carrying behaviour, and a small POD Inst per combatant carrying the numbers. A combatant with no effects costs nothing; applying one is a push_back; the per-frame tick walks plain values. effects.cat overrides display name, icon, and stacking policy — and its own header says so: an entry naming no class is a warning, never a new effect.
How the rule is enforced
Not by convention. The load-time merge is deliberately one-directional, and it says so out loud when data tries to overstep:
- The class registry is built first. It is the authority on what exists.
- Catalog entries are then matched by id and their numbers laid on top.
- A
.catentry naming no class is ignored, with a warning — it cannot bring a spell or effect into being. - A
symbolsfield disagreeing with the class recipe is warned about, and the class wins — data cannot redefine identity even when it looks like it is trying to.
Attacks work the same way from the other direction: the ten-entry identity table (stab/Pierce, chop/Slash, bash/Bash, …) is seeded in Balance's constructor as the closed list from the design doc, and attacks.cat carries only the four multipliers per verb.
The complement to that strictness is that catalogs are generous about what they don't understand: unknown fields survive a load → save round trip verbatim, so a newer build can add a field without an older file losing it, and the editor can rewrite a catalog it only partly understands.
What it cost
Adding a spell is a developer action, not a designer one. A file pair, a line in AllSpells.cpp, a line in CMakeLists.txt, and a rebuild. There is no path where someone adds genuinely new behaviour without a compiler. That is the whole trade, stated plainly.
Two registries have to agree. The class list and the .cat file are separate places that both name the same ids, which is why the mismatch warnings exist at all. They are load-time log lines, not build failures — you have to be looking.
No mod story. Nothing here can be extended by a third party without the source. For a solo project that is free; for a game meant to be modded it would be the wrong call, and I would reach for the scripting layer I just argued against.
The restraint is easy to get backwards. Two decisions in docs/effects.md look contradictory. One says the ModifyStat/ModifySpeed hook ships unused in phase 1, because "retrofitting a query hook once read sites exist means touching all of them." The other says DamageEvent and ITarget were deliberately not invented before their callers existed, because "inventing them before their callers exist is how you design them wrong." The same document also notes that a power field was dropped from the design before it shipped, on the grounds that "a dead field is worse than a rename later."
They only look contradictory. The cost of adding a call site later is spread across every reader, so pay it early; the cost of getting a type's shape wrong is that you design it blind, so pay it late. Knowing which of the two you are looking at is most of the skill, and I do not think I would have articulated the distinction without having got it wrong somewhere first.
What it bought
- Behaviour is debuggable. A spell that misbehaves gets a breakpoint in a named method, a call stack, and a type error at compile time — not a runtime stack trace through a bound interpreter.
- The numbers are still free. The entire combat model — every knob in the attack formula, every attack multiplier, every spell's power and cost — is per-project data, editable live in the editor's Balance dialog, no rebuild. That was a hard requirement, and code-side identity did not compromise it.
- A malformed catalog degrades to a warning. It cannot crash the game and, more importantly, it cannot silently conjure a half-formed entity that fails much later somewhere confusing.
- No binding layer. No embedded runtime, no sandbox, no marshalling, no separate debugging story, no second language for a solo developer to maintain fluency in.
- The editor still authors types. Because catalogs are free-form key/value with documented fields, one schema-driven
TypeEditorDialogrenders every category from a field table — so creating and editing content types in-game works fine, within the closed behaviour sets. Adding a field to a category is one table row.
How it's proved
Weakly, and in the same way as the rest of the project: by warnings you have to read.
The merge logs every disagreement, the spellbook validates that each class obeys the one-school rule at construction, and the editor round-trips catalogs without dropping fields it does not know. There is no test asserting the class registry and the shipped .cat files agree on ids — that agreement is currently checked by a human noticing a line in the log.
Still open
- A build-time consistency check. Comparing the class registry against the project's catalogs and failing on an orphan either way would turn four warnings into an invariant. It is a small piece of work and the obvious next one.
- Adding a spell touches three files. A registry macro or a small codegen step would make it one. Not painful at eighteen spells; it will be at eighty.
- The line has never been tested by an outsider. Every judgement about what belongs in code has been made by the person who can also change the code. That is exactly the condition under which this rule is easiest to get wrong and hardest to notice.