Bus
Kernel-wide topic pub/sub with trailing-wildcard families and a bounded match cache, so systems react to each other without importing each other.
- Realm
- Shared
- Require
ReplicatedStorage.ChloeKernel .IPC .Bus - Since
- v0.1.0
- Source
- IPC/Bus.luau
The Bus is the kernel’s shared topic space. The quest system reacts to Combat.Kill without importing the combat
system, and analytics sees the whole Combat.* family without knowing either. Where a Signal is an
event owned by one object, the Bus is shared by every system that subscribes. It is built on Signal: every topic and
every wildcard family is a Signal created on first subscribe, so publishing inherits Signal’s thread reuse and
allocates no coroutines in the steady state. The Bus cannot cancel anything. It announces facts; asking permission
belongs to Hooks. Milestones builds replay of once-per-session moments on top
of it, and BusBridge carries marked topics across the network.
Mental model
Section titled “Mental model”A Bus holds two maps of Signals:
Topics: one Signal per exact topic name.Wildcards: one Signal per wildcard family. A subscription topic ending in.*is a wildcard, and"Combat.*"is stored under the prefix"Combat.".
publish(topic, ...) fires the exact-topic Signal first, if there is one, then every wildcard Signal whose prefix
matches the start of the topic. Handlers always receive (topic, ...). The concrete topic comes first so a wildcard
subscriber knows which member of the family fired.
Publishing is synchronous: each subscriber starts inside the publish call, through the Signal’s task.spawn, and runs
to completion before publish returns unless it yields. A subscriber that throws is reported on its own thread and never
reaches the publisher.
Wildcard matching is a prefix comparison, cached per concrete topic:
- The first publish of
"Combat.Hit"scans the wildcard families once and caches the matched set. - A topic that matches no family shares one frozen empty table and is never cached, so unbounded dynamic topic names
(
"Player.12345.Update") cannot grow the cache. - The cache holds at most 512 topics and is cleared whole when it fills. Creating a new wildcard family also clears it, so existing topics are matched again against the new family.
Because "Combat.*" is stored as "Combat.", it matches "Combat.Hit" and "Combat.Hit.Critical" (the whole
subtree) but not "Combat" itself and not "CombatX.Hit". When several families match one topic, the order between
families is not defined.
--!strict-- QuestService: advances kill quests from combat events.
local QuestService = { Name = "QuestService",}
function QuestService.init(self: any, kernel: any) self.Kernel = kernel self.Kills = {} :: { [any]: number }end
function QuestService.start(self: any) local Bus = self.Kernel.Bus -- The quest system never imports the combat system self.KillConnection = Bus:subscribe("Combat.Kill", function(_topic, killerSession, _victimSession) self.Kills[killerSession] = (self.Kills[killerSession] or 0) + 1 end) -- A whole family, with the concrete topic as the first argument self.AuditConnection = Bus:subscribe("Combat.*", function(topic: string, ...) print("combat event", topic, select("#", ...)) end)end
function QuestService.stop(self: any) if self.KillConnection then self.KillConnection:disconnect() end if self.AuditConnection then self.AuditConnection:disconnect() endend
return QuestServiceSomewhere in combat code:
kernel.Bus:publish("Combat.Kill", KillerSession, VictimSession)Unsubscribing is disconnecting the returned Signal connection. Exact-topic subscribers run before wildcard subscribers for the same publish. Publishing a topic nobody subscribes to costs a failed table lookup plus a prefix check against each wildcard family (topics that match nothing are not cached), so announce facts freely and let listeners arrive later. A listener that arrives after a once-per-session moment has missed it; publish those through Milestones instead.
Crossing the network
Section titled “Crossing the network”publishRemote(topic, ...) publishes locally, then forwards through an attached net bridge: any table with a
publish(self, topic, ...) method. BusBridge is that bridge. 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 client-to-server direction.
kernel.Bus:publishRemote("Server.Announcement", "Double XP weekend")Without a bridge attached, publishRemote is publish.
API reference
Section titled “API reference”| Member | Description |
|---|---|
Bus.new(): Bus |
An empty Bus. The kernel creates one at boot as kernel.Bus. |
bus:subscribe(topic, fn): Connection |
A topic ending in .* subscribes the family. Handlers receive (topic, ...). |
bus:publish(topic, ...) |
Fires exact subscribers, then every matching wildcard family. |
bus:publishRemote(topic, ...) |
publish, then forwards through the net bridge if one is attached. |
bus:setNetBridge(bridge?) |
Installs or removes the bridge ({ publish = function(self, topic, ...) end }). BusBridge calls this. |
bus:destroy() |
Destroys every Signal, clears the wildcard cache and drops the bridge. kernel:shutdown() calls it. |
Kernel topics
Section titled “Kernel topics”The kernel publishes its own lifecycle on the Bus: Kernel.ServiceRegistered, Kernel.SessionStart,
Kernel.SessionEnd, Kernel.Closing, Kernel.Stopped, Kernel.Overload and, from DataDriver, Kernel.ProfileLoaded.
They are described on Kernel & Boot and
Sessions. Every topic the framework publishes is listed in the
hooks and bus topics reference.
Performance
Section titled “Performance”From the kernel benchmarks (Bench.runAll on the hot paths):
| Operation | Throughput | p50 per op |
|---|---|---|
Bus:publish (exact topic) |
5.0M ops/s | 198 ns |
The wildcard cache keeps publish flat: after the first publish of a topic that matches a family, the family scan is one cached table lookup however many families exist. A topic that matches no family is checked against every family on each publish, which costs little while families are few. The figure includes the thread-reuse path inherited from Signal.
Bus, Signal or Hooks
Section titled “Bus, Signal or Hooks”| Question | Primitive | Why |
|---|---|---|
| “This object’s thing happened” | Signal | Owned by the object, easy to find on it, the cheapest. |
| “Something happened, whoever cares” | Bus | No imports between systems, wildcard families, one topic space shared by framework and game. |
| “May this happen?” | Hooks | Ordered, cancellable, a shared mutable context and fail-closed errors, none of which events provide. |
The framework follows its own rule. Subsystems expose Signals for their own lifecycles (OnError, OnExit, OnEnd),
publish facts on the Bus (Kernel.SessionStart, Net.RateLimited, kit topics) and ask permission only through hook
chains. Game code that keeps the same split stays decoupled.