Skip to content

Signals & Bus

The kernel ships two event primitives, and the split is the point. A Signal is an event owned by one objectScheduler.OnError, Process.OnExit, a replica’s OnChange. The Bus is a shared topic space — the quest system reacts to Combat.Kill without ever importing the combat system. Both are built on the same defining decision: thread reuse. Firing N handlers allocates zero coroutines in the steady state, so events are cheap enough to be the default way systems talk.

Neither can cancel anything. Announcing facts is the Bus’s job; asking permission belongs to Hooks.

A Signal is a singly linked list of connections:

  • connect prepends a node at the head — O(1), no arrays, no sorting.
  • disconnect flips the node’s Connected flag and relinks the list — safe at any time, including mid-fire.
  • fire walks the list and runs each connected handler via task.spawn on a shared reusable thread. The runner thread parks itself in a module-level slot between fires; as long as handlers don’t yield, the same thread is reused forever and firing allocates nothing.
  • If a handler yields, it keeps the thread; the next handler (or next fire) creates a fresh runner. Yielding handlers work — they just cost one coroutine allocation each time.
  • If a handler errors, the engine reports it on the handler’s thread; the fire loop, the other handlers, and the caller of fire are unaffected. The reusable thread is lost and replaced on the next fire.

Two mid-fire semantics fall out of the list walk and are safe to rely on:

  • A handler connected during a fire does not run in that fire (it prepends ahead of the walk’s start).
  • A handler disconnected during a fire is skipped if it hasn’t run yet (the Connected flag is checked per node).
local Signal = require(game:GetService("ReplicatedStorage").ChloeKernel.IPC.Signal)
local Damaged = Signal.new()
local Conn = Damaged:connect(function(amount, source)
updateHealthBar(amount, source)
end)
Damaged:once(function(amount)
playFirstHitTutorial()
end)
task.spawn(function()
local Amount = Damaged:wait() -- parks this thread until the next fire
print("first damage after spawn:", Amount)
end)
Damaged:fire(25, "Falling")
Conn:disconnect()
Damaged:destroy()

once disconnects before invoking its handler, so a handler that fires the same signal re-entrantly cannot run itself twice. destroy disconnects everything and — spec-verified — resumes any wait()-parked threads (with no values) instead of leaking them suspended.

Member Description
Signal.new() → Signal
signal:connect(fn) → Connection Prepends; handlers receive exactly the arguments passed to fire
signal:once(fn) → Connection Self-disconnecting connection; disconnects before the handler runs
signal:wait() → ...any Yields the calling thread until the next fire; resumes with the fired values
signal:fire(...) Runs every connected handler on the reusable thread
signal:destroy() Disconnects all, resumes parked waiters, clears the list
connection.Connected false after disconnect
connection:disconnect() O(1) flag flip plus list relink; idempotent

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
Signal:fire (1 handler) 4.5M ops/s 203ns
Bus:publish (exact topic) 5.0M ops/s 198ns

The Bus’s 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. Both numbers include the thread-reuse path — 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.