Skip to content

BusBridge

The Bus carries a NetBridge slot; BusBridge fulfills it. Attach it once in each Bootstrap and marked topics cross the wire as ordinary bus publishes — no channel boilerplate per event family. The defining decision is asymmetry: downward (server → client) is a broadcast of whatever the server chooses to say, while upward (client → server) is an intent like any other — exact-name whitelisted, schema-typed, rate-limited, and run through the fail-closed hook chain. The bridge must never become an unguarded channel, so nothing a client publishes reaches the server bus except through the full NetDriver intent pipeline.

Two independent paths share one attach call:

Down. All forwarded server topics ride a single state channel, BusBridge (CKS_BusBridge on the wire), with schema String, Any — the topic name plus the packed argument array. Two things trigger a forward:

  • Kernel.Bus:publishRemote(topic, ...) — explicit, any topic, per call site.
  • A topic matching ServerTopics — existing publish call sites cross with no code changes. Trailing wildcards ("Round.*") work here, same as bus subscriptions.

On each client the bridge republishes the topic locally, so client code subscribes with plain kernel.Bus:subscribe and cannot tell a networked topic from a local one.

Up. Each entry in ClientTopics defines its own intent channel named BusPublish.<Topic> (CKI_BusPublish.<Topic> on the wire) with the schema from ClientSchemas and the bridge’s rate limit. A client publishRemote fires that intent; the server republishes the topic on its bus with the session prepended — subscribers always know exactly who sent it, from the server’s own session table, not from anything the client claimed.

The reflection guard. A client publish landing on the server bus could match a ServerTopics pattern and auto-forward straight back down — sending the session object and attacker-chosen arguments to every client. A ForwardingUp flag blocks this: while an upward publish is being delivered, the downward forwarder is inert. Bus publishes are synchronous, so a plain flag suffices, and the flag is restored even when a subscriber crashes (spec-verified) — one bad handler cannot wedge the bridge shut.

local ServerScriptService = game:GetService("ServerScriptService")
local BusBridge = require(ServerScriptService.ChloeKernelServer.BusBridge)
BusBridge.attach(Kernel, {
ServerTopics = { "Round.*", "Combat.Kill" }, -- auto-forward to every client
ClientTopics = { "Emote.Play" }, -- EXACT names clients may publish upward
ClientSchemas = { ["Emote.Play"] = { "NumberU8" } },
RateLimit = 20, -- per-player upward budget, per topic
})
-- Any topic, explicitly, without listing it in ServerTopics:
Kernel.Bus:publishRemote("Server.Announcement", "Double XP weekend")
-- Client publishes arrive validated, with the session prepended:
Kernel.Bus:subscribe("Emote.Play", function(_, session, emoteId)
playEmoteFor(session.Player, emoteId)
end)

Downward forwards go to every client via broadcastState — there is no per-topic interest scoping on the bridge. Client subscribers receive (topic, ...args) exactly as a local publish would deliver them.

Ordering is preserved: all downward topics share one reliable channel, so clients observe bridged publishes in server publish order, across topics.

An upward publish passes every gate an ordinary intent passes, in order:

  1. Exact-name whitelist, fail-closed. Only topics listed in ClientTopics have a channel at all. A hostile client firing Admin.GrantCoins doesn’t get rejected — there is nothing to fire at, and nothing reaches the bus (spec-verified: no leak, and Net.Stats.IntentsRejected stays 0 because no channel ever saw it). Wildcards do not work upward: the whitelist is exact names only, so an entry like "Combat.*" is a channel no real topic name ever matches. With no ClientTopics at all, nothing goes up, period.
  2. Typed schema. Every upward topic must have a ClientSchemas entry — attach errors immediately if one is missing. Payloads decode through the declared wire types; malformed payloads never reach your code.
  3. Session gate and per-player rate limit. A sender with no live session (not joined yet, already leaving) is dropped outright. Past that, each topic keeps a per-player token bucket at RateLimit per second (default 20) — flooding a topic throttles that player on that topic, publishes Net.RateLimited, and costs the server almost nothing.
  4. The hook chain. Intent.BusPublish.<Topic> fires before the publish, fail-closed — a validator returning false or crashing rejects the message. This is where game rules live.
  5. The bus. Only after all of that does kernel.Bus:publish(topic, session, ...) run on the server, with the server-derived session as the first argument.

A validator on the hook chain reads the decoded args and the server’s own session:

Kernel.Hooks:on("Intent.BusPublish.Emote.Play", function(context)
local EmoteId = context.Args[1]
if not OwnedEmotes[context.Session.Player.UserId][EmoteId] then
return false -- doesn't own it: rejected, never reaches the bus
end
end)
Member Description
BusBridge.attach(kernel, options?) Defines the downward state channel, installs the Bus NetBridge, wires ServerTopics forwards and ClientTopics intents. Call once, in the server Bootstrap

attach options:

Field Type Description
ServerTopics { string }? Topic patterns that auto-forward on ordinary publish. Trailing wildcards allowed
ClientTopics { string }? Exact topic names clients may publish upward. No wildcards. Absent = nothing goes up
ClientSchemas table? Topic → wire-type array. Required for every ClientTopics entry; attach errors otherwise
RateLimit number? = 20 Per-player, per-topic upward budget (messages/second)
Member Description
BusBridgeClient.attach(kernel, netClient?, options?) Subscribes the downward channel, builds an intent handle per ClientSchemas topic, installs the client Bus NetBridge. netClient defaults to a fresh NetClient; passing the options table in its position also works

attach options:

Field Type Description
ClientSchemas table? Topic → wire-type array for upward publishes. Must match the server’s ClientSchemas exactly
Member Description
Bus:publishRemote(topic, ...) Publishes locally, then hands the topic to the bridge. Server: broadcast down. Client: fire the upward intent if whitelisted
Bus:setNetBridge(bridge) The slot attach fills — you don’t call this yourself
Point Kind Description
Intent.BusPublish.<Topic> hook chain, fail-closed Game validators for an upward topic; context carries Name, Player, Session, Args
<Topic> (server bus) bus topic The bridged publish itself, delivered as (topic, session, ...args)
Intent.BusPublish.<Topic> (server bus) bus topic NetDriver’s post-accept publish for the underlying intent channel
Net.IntentRejected bus topic (player, channelName) — an upward publish failed validation or its subscriber crashed
Net.RateLimited bus topic + fail-open hook (player, channelName) — an upward publish exceeded the topic’s budget
<Topic> (client bus) bus topic Downward forwards, republished locally as (topic, ...args)