Skip to content

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.

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.luau
return 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()
end

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

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

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.

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.