BusBridge
Carries Bus topics across the client and server boundary: server topics broadcast to every client, and client topics from an exact-name whitelist come up as rate-limited intents.
- Realm
- Server + Client
- Require
ServerScriptService.ChloeKernelServer .BusBridge - Since
- v0.3.0
- Source
- BusBridge.luauNet/BusBridgeClient.luau
The Bus has a NetBridge slot, and BusBridge fills it. Attach it once on each side and marked topics
cross the wire as ordinary Bus publishes, with no channel code per event. The two directions are deliberately
different. Downward (server to client) is a broadcast of whatever the server chooses to say. Upward (client to server)
is an intent like any other. Only whitelisted topic names get a channel, each payload decodes against a schema, each
topic has a per-player rate limit, and the fail-closed hook chain runs before the Bus sees anything. Nothing a client
publishes reaches the server Bus except through the full NetDriver intent pipeline.
Mental model
Section titled “Mental model”Two independent paths share one attach call.
Every forwarded server topic rides one state channel, BusBridge (CKS_BusBridge on the wire), with schema
String, Any: the topic name and the packed argument table. Two things forward a topic:
Kernel.Bus:publishRemote(topic, ...): explicit, any topic, per call site.- A topic matching
ServerTopics: existingpublishcall sites cross with no code change. Trailing wildcards ("Round.*") work here, as they do in Bus subscriptions.
The arguments are packed with table.pack, so the count travels with them and a nil argument no longer drops the
arguments after it on the client (fixed in 0.9.0). Each client republishes the topic on its own Bus, so client code
subscribes with kernel.Bus:subscribe and cannot tell a bridged topic from a local one.
Each entry in ClientTopics defines its own intent channel, 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 sender’s session prepended. The session comes from the server’s own
session table, so nothing in the payload can name a different sender.
The reflection guard
Section titled “The reflection guard”A client publish delivered on the server Bus could match a ServerTopics pattern and forward
straight back down, sending the session object and attacker-chosen arguments to every client. While an upward publish
is being delivered, the bridge suppresses the downward forward of that same topic. Only the reflected topic is
blocked: a server topic that a subscriber publishes synchronously in response (a reply on another topic) still goes
down. Before 0.9.0 the guard blocked every downward forward during delivery, so those replies were lost. The guard saves
and restores the previous topic around the publish, so nested deliveries unwind correctly, and it resets even when the
publish throws.
local ServerScriptService = game:GetService("ServerScriptService")
local BusBridge = require(ServerScriptService.ChloeKernelServer.BusBridge)
BusBridge.attach(Kernel, { ServerTopics = { "Round.*", "Combat.Kill" }, -- forward to every client ClientTopics = { "Emote.Play" }, -- exact names clients may publish upward ClientSchemas = { ["Emote.Play"] = { "NumberU8" } }, RateLimit = 20, -- per player, per upward topic, per second})
-- Any topic, explicitly, without listing it in ServerTopics:Kernel.Bus:publishRemote("Server.Announcement", "Double XP weekend")
-- Client publishes arrive validated, with the session first:Kernel.Bus:subscribe("Emote.Play", function(_, session, emoteId: number) playEmoteFor(session.Player, emoteId)end)local ReplicatedStorage = game:GetService("ReplicatedStorage")
local BusBridgeClient = require(ReplicatedStorage.ChloeKernel.Net.BusBridgeClient)
BusBridgeClient.attach(Kernel, { ClientSchemas = { ["Emote.Play"] = { "NumberU8" } }, -- must match the server's schema})
-- Server topics arrive as local Bus topics:Kernel.Bus:subscribe("Round.*", function(topic: string, ...) updateRoundHud(topic, ...)end)
-- The upward publish rides the intent pipeline:Kernel.Bus:publishRemote("Emote.Play", 4)Server to client
Section titled “Server to client”Downward forwards go to every client through broadcastState. The bridge has no per-topic interest scoping. Client
subscribers receive (topic, ...args) exactly as a local publish would deliver them.
Order is preserved. All downward topics share one reliable channel, so clients see bridged publishes in the server’s
publish order, across topics. A client that attaches late loses nothing recent:
parked frames for CKS_BusBridge replay once
BusBridgeClient.attach constructs the channel, up to 64 frames and 10 seconds.
Client to server: the security model
Section titled “Client to server: the security model”An upward publish passes every gate an ordinary intent passes, in order:
- Exact-name whitelist, fail-closed. Only topics listed in
ClientTopicshave a channel at all. A client firingAdmin.GrantCoinsis not rejected; there is nothing to fire at, and nothing reaches the Bus. With noClientTopics, nothing goes up. - Channel-name rules.
BusPublish.<Topic>must pass the Registry name check: letters, digits and. / - _, at most 94 characters in total (so a topic of at most 83), never starting withRBX.attacherrors on a topic that breaks them, for exampleBusBridge client topic "Combat.*" (channel "BusPublish.Combat.*"): names use only letters, digits and . / - _. An upward wildcard is therefore an attach error. Before 0.9.0 it defined a channel no published topic could match. - Typed schema. Every upward topic needs a
ClientSchemasentry, orattacherrors withBusBridge client topic "<Topic>" needs a ClientSchemas entry. Payloads decode through the declared wire types. - Session gate and rate limit. A sender with no active session, or whose profile is locked by a
transaction, is dropped. Each topic then has a per-player token bucket at
RateLimitper second (default 20). Flooding a topic throttles that player on that topic and publishesNet.RateLimited. - The hook chain.
Intent.BusPublish.<Topic>fires before the publish, fail-closed. A validator returningfalseor crashing rejects the message. Game rules go here. - The Bus. Only then does
kernel.Bus:publish(topic, session, ...)run on the server.
A validator reads the decoded arguments and the server’s own session:
Kernel.Hooks:on("Intent.BusPublish.Emote.Play", function(context) local Owned = OwnedEmotes[context.Session.Player.UserId] if Owned == nil or not Owned[context.Args[1]] then return false -- not owned: rejected, never reaches the Bus end return nilend)API reference
Section titled “API reference”Server
Section titled “Server”| Member | Description |
|---|---|
BusBridge.attach(kernel, options?) |
Defines the downward state channel, installs the Bus NetBridge, subscribes the ServerTopics forwards and defines one intent per ClientTopics entry. Call once, in the server bootstrap |
attach options:
| Field | Type | Description |
|---|---|---|
ServerTopics |
{ string }? |
Topic patterns that forward on an ordinary publish. Trailing wildcards allowed |
ClientTopics |
{ string }? |
Exact topic names clients may publish upward. Each BusPublish.<Topic> must pass the channel-name rules. Absent means nothing goes up |
ClientSchemas |
{ [string]: { any } }? |
Topic to wire-type array. Required for every ClientTopics entry |
RateLimit |
number?, default 20 |
Per-player, per-topic upward rate, messages per second |
Client
Section titled “Client”| Member | Description |
|---|---|
BusBridgeClient.attach(kernel, netClient?, options?) |
Listens on the downward channel, builds an intent handle per ClientSchemas topic and installs the client Bus NetBridge. netClient defaults to a new NetClient; passing the options table in its place also works |
attach options:
| Field | Type | Description |
|---|---|---|
ClientSchemas |
{ [string]: { any } }? |
Topic to wire-type array for upward publishes. Must match the server’s ClientSchemas |
Bus surface
Section titled “Bus surface”| Member | Description |
|---|---|
Bus:publishRemote(topic, ...) |
Publishes locally, then hands the topic to the bridge. Server: broadcast down. Client: fire the upward intent if the topic has a schema |
Bus:setNetBridge(bridge) |
The slot attach fills. Game code does not call it |
Hooks & bus topics
Section titled “Hooks & bus topics”| 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, delivered as (topic, session, ...args) |
Intent.BusPublish.<Topic> (server Bus) |
Bus topic | NetDriver’s publish after the underlying intent was accepted, (topic, session, ...args) |
Net.IntentRejected |
Bus topic | (player, channelName): an upward publish failed validation |
Net.RateLimited |
Bus topic and fail-open hook | (player, channelName): an upward publish exceeded the topic’s rate |
<Topic> (client Bus) |
Bus topic | Downward forwards, republished locally as (topic, ...args) |