Layer 2 · Polymorphic rules and events

The Nervous System

Saving a character restored its shape. Making Rage true meant restoring behavior, then giving every independent rule a way to hear what happened, respond without permission, and clean up after itself.

Startrestore the rule
Stress testcast Bless
Limitwait for a person
new seamnext real case
Layer 1 left something inside the data

Well, Rage is part of that data, right?

ToData and LoadFromData worked when a value had one known Go struct. A character’s name, level, and hit points fit that model. Features were deliberately different. Feature was an interface, and every feature only had to satisfy that shared contract. Character did not need to know whether it held Rage, Second Wind, or something supplied by another module. It only needed to activate it.

That was the abstraction working. Persistence should not make Character learn the concrete types again. Each Feature could own ToJSON and its reverse path; CharacterData could hold Features []json.RawMessage. Conditions could use the same pattern.

The Feature interface hides concrete runtime types while JSON preserves their data KNOWN STRUCT FIELDS CHARACTER name · level · hit points one known shape ROUND TRIP ToData ↔ LoadFromData typed in both directions CHARACTERDATA Name string · Level int one struct can receive it DYNAMIC INTERFACE FIELDS CHARACTER Features []Feature Rage · Second Wind · ... Conditions []Condition Blessed · Poisoned · ... shared interfaces, different shapes RUNTIME CONTRACT Feature interface Activate(input) · all Character needs SMALLER SEAM ToJSON → json.RawMessage Features[] · Conditions[] LOAD THE BLOBS features.LoadJSON(blob) conditions.LoadJSON(blob) WHICH CONCRETE VALUE? the family is known; the member is not
The interface intentionally hid each concrete type at runtime. JSON preserved that separation across storage, but it left each family loader staring at an arbitrary blob.
The runtime contract was enough: Character only needed a Feature it could activate. Each implementation could own its JSON shape.
The routing question: the character knew to ask the Features package. How did that loader know the blob meant Rage?
Give every shape a route home

The little Ref told the loader what to build

The blob carried one small route: module : type : value. In this design those parts meant package, loader location, and concrete ID. features.LoadJSON or conditions.LoadJSON could peek at the core.Ref, dispatch to the owning module, and return the shared interface. Rage could finally load into the bus that was already waiting.

Then came the next test: could the same route reach a class the toolkit had never heard of? homebrew:classes:artificer proved it could.

A ref routes opaque JSON to the module that owns its loader TOJSON OUTPUT · REF + OWNED PAYLOAD { "ref": "homebrew:classes:artificer", "data": { ... module-owned shape ... } } PEEK REF CORE.REF · THE ROUTE module: homebrew type: classes value: artificer package · loader location · ID choose registered implementation HOMEBREW PACKAGE Artificer loader owns JSON shape restores behavior returns core.Class THE FAMILY LOADER PEEKS AT THE REF features.LoadJSON → dnd5e:feature:rage → Rage conditions.LoadJSON → dnd5e:condition:blessed → Blessed HARD BOUNDARY code not linked? a string cannot load it NO
ToJSON wrote the route into the blob; the family loader followed it home. Rage and Blessed fell out of the same design. Artificer proved that design could also reach a linked and registered external package.
The law: the JSON carries an address, not an implementation.

This was where core.Ref became valuable. Character did not learn Rage, Blessed, or Artificer. Each loader only needed a stable route to the package and concrete ID that it already knew how to restore.

Now Rage can arrive

Once Rage could load, the bus could make it true

The event bus was already waiting. Polymorphic loading removed the blocker to using it with a real condition. Rage could return from data, subscribe to the moments it cared about, add outgoing damage, reduce incoming damage, track activity, and hear when it should end.

The initial bus was surprisingly capable: synchronous publication, source and target, cancellation, modifiers, and ordered handlers. It even copied subscribers before invoking them, so its registry lock was not held over Rage’s code.

The existing event bus gives loaded Rage a runtime nervous system RUNTIME MOMENT Publish(event) does not name Rage EVENT type: "attack" · "damage" · "turn" source · target · timestamp Context: map[string]any Modifier[]: source · type · value SAFE DISPATCH copy subscribers release lock THEN INVOKE IN PRIORITY ORDER OUTGOING DAMAGE Rage adds damage INCOMING DAMAGE Rage reduces damage ACTIVITY Rage decides to end PRESSURE "attack" value.(Entity) priority: 50 STRINGS + MAPS + RUNTIME ASSERTIONS + MAGIC ORDER
Loading restored Rage as a participant. The bus gave that participant moments to react to without putting Rage-specific branches into combat.
The seam: combat could publish a moment without naming Rage. Rage could own what that moment meant to it.
The pressure: strings, maps, runtime assertions, and numeric priorities were becoming rules nobody could see in one place.
One working effect is not enough

Rage worked. Naturally, I tried Bless.

Rage proved that one loaded effect could own its event behavior. Bless asked whether the same seam could support several condition instances created by one cast. Combat still should not switch on Blessed. Each condition should filter for its own grantee and roll a fresh d4 for every qualifying attack.

Blessed conditions react independently to attack events CASTER Cleric casts Bless CONDITION · FIGHTER Blessed Apply() → subscribe Remove() → unsubscribe CONDITION · ROGUE Blessed Apply() → subscribe Remove() → unsubscribe EVENT BUS "before_attack" Fighter? roll fresh 1d4 Rogue? ignore this attack
The condition supplied the rule. The combat code supplied the moment. The d4 was rolled when the qualifying event happened, not stored as an old result.
The promise: behavior belonged to the condition. Combat did not need to know Bless existed.

Rage worked. So I gave the seam the next real thing I thought might break it.

The graph leaves working memory

One spell was already a relationship

Bless was not two isolated bonuses. One caster owned one concentration relationship connecting every condition created by that cast. The cascade worked: breaking that relationship removed every Blessed condition and every subscription. Looking backward, one actor is missing from the graph. At the time, we could not see the question yet.

Bless creates a relationship graph and the demo skips the concentration save SOURCE Cleric RELATIONSHIP MANAGER concentration · one cast GRANTEE · FIGHTER Blessed condition GRANTEE · ROGUE Blessed condition owns attack subscription owns attack subscription Cleric takes damage THE DEMO PRINTS "fails concentration save" BreakAllRelationships REMOVE both conditions INVISIBLE AT THE TIME who rolls the save? who can answer? we did not know to ask
The cascade was real. The test supplied the failed save as a fact, so nobody noticed that the architecture did not know who was supposed to produce that fact.

The test supplied the answer. We did not know to ask who was supposed to produce it.

What it proved: one relationship could cleanly remove every dependent condition created by the cast.
The unknown unknown: who fires the save, where does the answer come from, and what waits while a person decides?
Pressure, then relief

Rage found the lock. AttackChain.On(bus) let me sleep.

The ref-based rewrite made runtime-loaded rules possible and introduced a regression the first bus had avoided. It held a read lock while invoking handlers. Rage heard that it should end and tried to unsubscribe, which needed the write lock. Dispatch waited for Rage; Rage waited for dispatch.

Deferred intent made self-removal safe again. But the larger graph still relied on priority arithmetic to decide how independent rules changed one calculation. AttackChain.On(bus) was the moment discovery and rules order finally became separate ideas.

Rage exposes the lock regression before AttackChain makes discovery and order explicit PRESSURE · RAGE REMOVES ITSELF BUS DISPATCH holds RLock RAGE END HANDLER Unsubscribe() UNSUBSCRIBE needs write Lock DEADLOCK · EACH WAITS FOR THE OTHER SAFE AGAIN snapshot release lock then invoke RELIEF · DISCOVERY AND RULES ORDER SEPARATE RUNTIME-LOADED RULES Rage · Blessed · gear THE CONNECTION AttackChain.On(bus) GATHER FIRST execute once BASE weapon die FEATURES Rage CONDITIONS Blessed EQUIPMENT magic weapon FINAL explainable result EVENTS DISCOVER WHO PARTICIPATES ATTACKCHAIN DEFINES HOW THEY RUN
Loaded rules could still discover the attack through the bus. The chain gave their named transformations a rulebook-defined place and produced one explainable result.

AttackChain.On(bus) was the thing that let me go to sleep that night. When I saw it, I knew we were going to be okay.

The distinction: events solved who gets to react. AttackChain solved how all those reactions become one deterministic result.
The person who is not in memory

Every bus still lived on one call stack

The missing actor in the Bless demo stayed invisible until much later. Concentration code could hear damage and publish concentration.check. Another handler could answer immediately. But a Discord player might answer minutes or hours later, through another request handled by another process.

Nested publication was still one synchronous call stack. Deferred operations still ran after dispatch in the same process. Chained topics still gathered live Go functions from current subscriptions. None of them could turn an unfinished interaction into something storage could hold.

The event bus cannot wait across a process and time boundary ONE PROCESS · NOW DAMAGE EVENT Cleric loses HP NESTED PUBLISH concentration.check CURRENT SUBSCRIBERS must return now ONE CALL STACK publish → invoke → return → continue RPC + TIME + PROCESS BOUNDARY ANOTHER PROCESS · LATER player replies minutes or hours later A CALL STACK CANNOT REPRESENT pause nowand return persistunfinished work waitwithout memory rehydratesomewhere else resumethe exact step THE UNFINISHED INTERACTION WOULD HAVE TO BECOME DATA
The missing subscriber was not the real problem. The architecture had no durable form for a question whose answer would arrive after the process returned.

That was the unknown unknown: not how to calculate a save, but how to wait for the person who had to answer. We would slay that dragon much, much later.

The event bus could ask every rule in memory. It could not wait for a person who was not there yet.

Next field note Rage Is Not Raging →