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.
Mental model
Section titled “Mental model”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— existingpublishcall 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)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 events arrive as local bus topics:kernel.Bus:subscribe("Round.*", function(topic, ...) updateRoundHud(topic, ...)end)
-- Upward publish rides the intent pipeline:kernel.Bus:publishRemote("Emote.Play", 4)Server → client
Section titled “Server → client”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.
Client → server: the security model
Section titled “Client → 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 hostile client firingAdmin.GrantCoinsdoesn’t get rejected — there is nothing to fire at, and nothing reaches the bus (spec-verified: no leak, andNet.Stats.IntentsRejectedstays 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 noClientTopicsat all, nothing goes up, period. - Typed schema. Every upward topic must have a
ClientSchemasentry —attacherrors immediately if one is missing. Payloads decode through the declared wire types; malformed payloads never reach your code. - 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
RateLimitper second (default 20) — flooding a topic throttles that player on that topic, publishesNet.RateLimited, and costs the server almost nothing. - The hook chain.
Intent.BusPublish.<Topic>fires before the publish, fail-closed — a validator returningfalseor crashing rejects the message. This is where game rules live. - 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 endend)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, 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) |
Client
Section titled “Client”| 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 |
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 whitelisted |
Bus:setNetBridge(bridge) |
The slot attach fills — you don’t call this yourself |
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 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) |