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.
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.
Feature it could activate. Each implementation could own its JSON shape.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.
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.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.
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.
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.
Rage worked. So I gave the seam the next real thing I thought might break it.
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.
The test supplied the answer. We did not know to ask who was supposed to produce it.
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.
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.
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.
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 →