Bus
The Bus is the kernel’s shared topic space — the quest system reacts to Combat.Kill without ever importing the combat system. Where a Signal is an event owned by one object, the Bus is a topic space shared by every system that cares to subscribe. It’s built directly on Signal: every topic and every wildcard family is backed by a lazily created Signal, so publishing inherits Signal’s defining decision — thread reuse — for free, with zero coroutine allocation per event in the steady state. A Bus can’t cancel anything either; announcing facts is the Bus’s whole job, and asking permission belongs to Hooks.
Mental model
Section titled “Mental model”A Bus is two maps of lazily created Signals:
Topics— one Signal per exact topic name, created on first subscribe.Wildcards— one Signal per wildcard family. A subscription topic ending in.*is a wildcard;"Combat.*"stores a Signal under the prefix"Combat.".
publish(topic, ...) fires the exact-topic Signal first (if any), then every wildcard Signal whose prefix matches the start of the topic. Handlers always receive (topic, ...) — the concrete topic name is prepended, which is what makes wildcard subscribers useful: they know which member of the family fired.
Wildcard matching is prefix string comparison, cached per concrete topic:
- The first publish of
"Combat.Hit"scans the wildcard families once and caches the matched set. - Topics that match nothing share one frozen empty table and are never cached — unbounded dynamic topic names (
"Player.12345.Update") cannot bloat the cache. - The cache caps at 512 entries; hitting the cap clears it wholesale. Subscribing the first handler of a new wildcard family also clears it, so existing topics re-match against the new family.
Because "Combat.*" is stored as the prefix "Combat.", it matches "Combat.Hit" and "Combat.Hit.Critical" — the whole subtree — but not "Combat" itself and not "CombatX.Hit".
-- src/Server/Bootstrap.luaureturn function(kernel) -- The quest system doesn't import the combat system: kernel.Bus:subscribe("Combat.Kill", function(topic, killerSession, victimSession) advanceQuest(killerSession, "KillEnemies") end)
-- Analytics sees a whole family without knowing anyone: kernel.Bus:subscribe("Combat.*", function(topic, ...) track(topic, ...) end)
-- Somewhere in combat code: kernel.Bus:publish("Combat.Kill", killerSession, victimSession)
-- Unsubscribe is the Signal connection: local Subscription = kernel.Bus:subscribe("Tick", onTick) Subscription:disconnect()endPublish ordering, spec-verified: exact-topic subscribers run before wildcard subscribers for the same publish, and a wildcard family receives its topics in publish order. Publishing a topic nobody subscribes to costs a failed table lookup — announcing facts is free even when nobody listens, so publish liberally and let listeners come later.
Crossing the network
Section titled “Crossing the network”publishRemote(topic, ...) publishes locally and forwards through an attached NetBridge — a slot the kernel Bus knows nothing about beyond the publish method shape. BusBridge fulfills it: attach it and marked topic families cross the wire as ordinary bus publishes on the other side, with the full intent pipeline (whitelist, schemas, rate limits, hook chains) guarding the upward direction.
kernel.Bus:publishRemote("Server.Announcement", "Double XP weekend")Without a bridge attached, publishRemote is just publish.
| Member | Description |
|---|---|
Bus.new() → Bus |
|
bus:subscribe(topic, fn) → Connection |
topic ending in .* subscribes the family; handlers receive (topic, ...) |
bus:publish(topic, ...) |
Fires exact subscribers, then matching wildcard families |
bus:publishRemote(topic, ...) |
publish plus forward through the NetBridge, if one is attached |
bus:setNetBridge(bridge?) |
Installs/removes the bridge ({ publish = function(self, topic, ...) end }); BusBridge calls this |
bus:destroy() |
Destroys every underlying Signal and clears the wildcard cache |
Performance
Section titled “Performance”From the kernel benchmarks (Bench.runAll on the hot paths):
| Operation | Throughput | p50/op |
|---|---|---|
Bus:publish (exact topic) |
5.0M ops/s | 198ns |
The wildcard cache is why publish stays flat: after the first publish of a topic, the wildcard scan is one cached table lookup regardless of how many families exist. The number includes the thread-reuse path inherited from the underlying Signal — no coroutine allocation per event.
Bus vs Signal vs Hooks
Section titled “Bus vs Signal vs Hooks”| Question | Primitive | Why |
|---|---|---|
| “This object’s thing happened” | Signal | Owned, discoverable on the object, cheapest, typed by convention at the source |
| “Something happened, whoever cares” | Bus | No imports between systems; wildcard families; one topic space shared by framework and game (see the built-in topics) |
| “May this happen?” | Hooks | Ordered, cancellable, shared mutable context, fail-closed — none of which events provide |
The framework follows its own rule: subsystems expose Signals for their own lifecycles (OnError, OnExit), publish facts on the Bus (Kernel.SessionStart, Net.RateLimited, Combat-style kit topics), and ask permission exclusively through hook chains. Game code that keeps the same split stays decoupled for free.