Layer 1 · Before the event bus

When Configuration Becomes a Programming Language

I love configuration-driven systems. I thought an open D&D database could drive character creation all the way through. The Monk taught me where configuration ends, interpretation begins, and writing the rule can be simpler than building a language for describing it.

Why this existsWhere Everything Meets →
Startborrow the D&D data
Turncode what Fighter means
Next problemmake Rage true
The borrowed foundation

Good enough to begin

dnd5eapi.co is an open-source D&D 5e database with a hosted API. The Mastodon adventure bot already used it. The hosted service had aggressive rate limits, but the project could also run locally. It was good enough for that bot, which made it good enough for the Discord bot I wanted to build.

The open D&D data source gives two bots a starting point OPEN-SOURCE PROJECT dnd5eapi.co classes · races · equipment character-creation choices hosted API run locally hosted rate limits were aggressive REFERENCE DATA PROVEN START Mastodon bot poll → choice → story already depended on the API GOOD ENOUGH DISCORD BOT forms arrive make a character the first real excitement
The API was not the product. It was enough borrowed ground to discover that Discord forms could turn an adventure poll into a character somebody owned.
The beginning: use what already works and find out where it stops.
The interpreter appears

When configuration becomes a programming language

The Monk made the hidden cost visible. One tool-proficiency prompt was a choice whose options were themselves choices, each opening another options array. One weapon prompt was either a counted reference or a nested choice backed by an equipment-category lookup. The data was valid. A generic character creator still had to execute its control flow.

The Monk choice data is recursive control flow REAL SHAPE 01 · CHOOSE ONE TOOL PROFICIENCY choose: 1options[] option_type:choicenot a selectable value yet choice: artisan toolschoose: 1another options array reference[]brewer · smith · cookand sixteen more choice: instrumentsanother reference[] REAL SHAPE 02 · CHOOSE A STARTING WEAPON choose: 1options[] counted_reference1 × shortsworda concrete answer or option_type: choiceany simple weaponanother decision option_set:equipment_categorylookup required
The source was designed to describe D&D choices. Driving an interactive form and validating every recursive branch was a different job.
I thought I could conquer it. I still do not know whether the model was wrong for what I wanted or whether I fell short of finding the right interpreter. I know how much easier the work became when I stopped trying.
Configuration-driven choices are overrated. I say that as someone who loves configuration.
The imagined boundaries

Where did I think the pieces belonged?

The toolkit repository began as one README and an ambitious map: shared core/, generic systems/, and game-specific games/. It called the project TypeScript-first in prose, but the commit contained no implementation. The broad dream existed. The boundaries did not.

The first architecture existed only in a README README ARCHITECTURE · IMAGINARY FOUNDATIONcore/entities · state · eventsstorage GENERIC RPG MECHANICSsystems/combat · inventory · worlddialogue · quests GAME-SPECIFICgames/d20/dnd5e · pathfinderpbta · fate · custom 1 README 0 Go packages 0 working rulebooks
The folders named a hope: some RPG concepts would be shared and each game would own its rules. Almost everything about how those layers connected was still unknown.
At this point: architecture was nouns connected by hope.
The first architecture

Where does any of this get saved?

The next day, eight storage and persistence documents landed across five commits, with two early versions already replaced. The strongest idea was repository injection. A module declared the access pattern it needed; Redis, Mongo, memory, or files could implement it. The backing store should not matter. Nobody yet knew where those repositories ultimately belonged.

Repository injection answers datastore choice but not ownership EIGHT DOCUMENTS · FIVE COMMITS · TWO EARLY VERSIONS REPLACED THE SAME DAY 01comparison 02middleware 03Go v1 04Go v2 05repository 06Redis 07minimal 08simplified MODULE DECLARES NEEDRoom generatorSave(ctx, room)FindByID(ctx, id) INJECT ACCESS PATTERNRoomRepositoryinterface, not datastore Redis Mongo memory files But where do the repositories belong?
The repository pattern answered how storage technology could vary. It did not yet answer which layer should own the load and save lifecycle.
First storage principle: depend on the access pattern, not the datastore.
Give up the interpreter

What if I just code what Fighter gets?

I still do not know whether the source model was wrong for what I wanted or whether I fell short of finding the right interpreter. I honestly thought I could conquer it. It got too messy. The breakthrough was not a better recursive parser. It was giving the D&D rulebook permission to state what a Fighter grants and which decisions creating one requires.

Fighter meaning moves from a generic interpreter into the D&D rulebook GENERIC INTERPRETERswitch option_typerecurse choiceresolve categoryvalidate selectionremember source instead RULEBOOK · DND5EFighter owns the meaning GRANTS · AUTOMATICarmor proficienciesweapon proficienciesSecond Windwhat the class gives REQUIREMENTS · DECISIONSchoose skillschoose fighting stylechoose equipmentmanual, explicit options
Manual options did not make the system less extensible. They removed an accidental language and put D&D meaning in D&D code.
The relief: the simpler system was the one that admitted it knew D&D.
The first working promise

A character, finally

Character creation stopped executing a generic choices language. It asked a class for requirements, collected named selections, validated them where the rules lived, and compiled automatic grants into a live character. Fighter worked. Barbarian worked. The project could finally create the person at the center of the game.

Explicit rulebook choices produce live characters THE CHARACTER-CREATION FLOW 01 ASKrequirements 02 COLLECTnamed selections 03 VALIDATEin rulebook 04 COMPILEgrants + character LIVE RESULTFighterstyle · gear · Second Wind LIVE RESULTBarbarianfeature: Rage
The class now supplied meaning directly. The same explicit flow could create different characters without executing a universal external-data language.
The breakthrough: we could create a character.
The persistence breakthrough

ToData changed everything

The API already had the live character because it was using it. The rulebook did not need a repository. It only needed a clean way to turn the character into plain data and reconstruct it later. That separation made loading and saving almost boring.

Load: get CharacterData from the repository and call LoadFromData. Now the API has a fully functioning character. Save: call ToData on that character and hand the result back to the repository.

ToData and LoadFromData separate repository storage from live rulebook behavior REPOSITORYstores data Get(characterID)returns CharacterData Save(data)stores CharacterData RPG-APIuses the character data := repo.Get(id) character := LoadFromData(data) play with it repo.Save(character.ToData()) DND5E RULEBOOKowns reconstruction LoadFromData(data)restores rulebook behavior LIVE RESULTCharacter DATA LOAD NO DATABASE CONNECTION OR REPOSITORY ENTERS THE RULEBOOK
The access pattern stayed in the repository. The behavior stayed in the rulebook. The API held the live object only while doing the work.
Loading your character? Get the data. Call LoadFromData. Boom, you have a fully functioning character. Saving it? Call ToData and send the result to the repository.
The separation: a repository stores data. A rulebook restores behavior. The API moves between them.
The victory contains the next problem

Well, Rage is part of that data, right?

ToData solved the character round trip. But a Barbarian carries Rage. If LoadFromData returns a fully functioning character, Rage has to come back fully functioning too. It cannot return as a name in an array. It has behavior spread across resources, attacks, damage, time, and its own ending.

The character round trip raises the unresolved Rage behavior problem CHARACTERDATAclass: barbarianfeatures: [rage]plain data looks complete LOADFROMDATA LIVE BARBARIANfully functioning?Rage THE UNSAVED HALFbehaviorfunctions are not fieldshow does Rage return? MAKING RAGE TRUE spend a use add damage Strength advantagechecks and saves reduce damagephysical resistance track attacks track being hit end after inactivitydecide for itself remove itselfleave nothing behind
The character’s known shape could cross storage. Rage introduced the polymorphic behavior problem that the next field note will unpack through refs, ToJSON, and the first event bus.

We had solved how to create and save a Barbarian. Rage asked what “fully functioning” actually meant.

Next field note The Nervous System →