Skip to content

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.

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.

src/Server/Game/Services/QuestService.luau
--!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()
end
end
return QuestService

Somewhere 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.

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.

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.

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.

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.

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.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.