Networking overview
How ChloeKernel moves data between client and server: five channel kinds over one RemoteEvent, a fail-closed inbound pipeline, and where each module lives.
All reliable channel traffic between client and server rides one RemoteEvent carrying schema-packed buffers.
Unreliable streams ride their own UnreliableRemoteEvents. On top of that wire sits a channel layer built around one decision: inbound
traffic is rejected unless something explicitly accepts it. A client payload reaches a handler only after it passes the
transport limits, a session gate, a per-channel token bucket and a fail-closed validation chain. A channel with no
validator rejects every payload until one is registered or the channel is marked Open = true.
The client renders and asks. It sends intents (“use item 3”), never state (“my coins are now 9999”). The server validates every intent and is the only thing that changes state. Client code is always readable by exploiters, so the defense is never trusting it.
A game declares its channels in one module, ReplicatedStorage.Game.Channels, and the Channels
guide walks through that workflow.
The layered picture
Section titled “The layered picture” Client Server Registry.client(Channels) Registry.server(kernel, Channels) fire / invoke / on / predict on / handle / send / broadcast | | v v NetClient NetDriver typed handles, no validation session gate, token bucket, fail-closed hook chain, handler | | v v Packet <----------------- one RemoteEvent -----------------> Packet schema-packed buffers batched per heartbeat, U8 channel ids, varint frame lengths server to client, byte and message budgets on the serverEach layer has one job:
- Packet is the wire. It packs arguments into buffers by typed schema
(
"NumberU16"costs exactly 2 bytes), batches every channel into one flush per heartbeat, and multiplexes them over one RemoteEvent with U8 channel ids. On the server it enforces per-player byte and message budgets before anything decodes. - NetDriver on the server and NetClient on the client are the channel layer: named, typed channels with a direction, and on the server the gate every inbound message passes.
- The Registry is the definition layer. One shared module declares every channel for both
sides, so schemas cannot drift, and its
Validaterules compile into the validation chain.
Around them sit Prediction for optimistic client actions, Replica for replicated state, NetGovernor for per-client link grading, BusBridge for Bus topics across the boundary and MessageDriver for events between servers.
Where each piece lives
Section titled “Where each piece lives”Code placement is attack surface. Everything that decides runs on the server; the client holds only what it executes.
| Piece | Module | Replicates to clients |
|---|---|---|
| NetDriver (gates, rate limits, audits) | ServerScriptService.ChloeKernelServer.Net |
Never |
| ReplicaService, NetGovernor, BusBridge, MessageDriver | ServerScriptService.ChloeKernelServer.* |
Never |
| NetClient, ReplicaClient, GovernorClient, BusBridgeClient | ReplicatedStorage.ChloeKernel.Net.* |
Yes |
| Registry | ReplicatedStorage.ChloeKernel.Net.Registry |
Yes. Definitions are shared by design |
| Packet and TransportGuard | ReplicatedStorage.ChloeKernel.Net.Packet |
Yes. The limits are checked in server memory |
| TokenBucket | ReplicatedStorage.ChloeKernel.Net.TokenBucket |
Yes. The buckets themselves are created and checked in server memory |
| Prediction | ReplicatedStorage.ChloeKernel.Net.Prediction |
Yes. Rollback is a client concern |
| Game channel definitions | ReplicatedStorage.Game.Channels |
Yes |
An exploiter can read the TokenBucket module and the Registry rules. The instances that enforce them live in server state and cannot be changed from a client.
Channel kinds
Section titled “Channel kinds”Five kinds, each a different answer to who talks and what happens on failure:
| Kind | Direction | Delivery | Gate | Use it for |
|---|---|---|---|---|
| Intent | client to server | reliable | full inbound pipeline | Player actions. Fire and forget |
| PredictedIntent | client to server, ack back | reliable | full inbound pipeline; the ack follows the handler | Actions the client shows at once and rolls back on rejection |
| Request | client to server to client | reliable, yields | full inbound pipeline; every failure answers RejectValue |
Questions with answers (shop stock, eligibility) |
| State | server to client | reliable | none; the server is trusted | One-shot events (kill feed, toasts, round events) |
| Unreliable | server to client | unreliable, latest wins per stream, at most 980 bytes | none | High-frequency cosmetic data where a lost update beats a late one |
Choosing a channel kind
Section titled “Choosing a channel kind”| Goal | Use | Notes |
|---|---|---|
| Send a player action to the server | Intent | The default. See NetDriver |
| Make an ability feel instant and keep the server authoritative | PredictedIntent | The client applies the effect at once; the server acks or it rolls back. See Prediction |
| Ask the server a question and wait for the answer | Request | Yields. Rejection and timeout both resume with RejectValue |
| Tell clients something happened | State | One-shot events. Values that evolve belong in a replica |
| Show clients a value that changes over time | A replica | Joiners get a snapshot; changes go out as field deltas |
| Stream 20-60 Hz cosmetic data that tolerates loss | Unreliable | Serde structs over UnreliableRemoteEvent. Stale datagrams drop unless Ordered = false |
| Mirror Bus topics across the network | BusBridge | Server topics broadcast down; client topics come up as whitelisted intents |
| Send events to other servers | MessageDriver | Uses MessagingService; carries no player traffic |
The inbound pipeline
Section titled “The inbound pipeline”Every client-to-server message walks these stages in order. Failing any stage stops it; the handler is the last thing to run.
| # | Stage | Lives in | On failure |
|---|---|---|---|
| 1 | Byte budget: 8,000 bytes per player per heartbeat window, each call counted as at least 800 | Packet | The whole payload drops before any decode. Reported as ByteLimit |
| 2 | Call shape: the payload is a buffer and the instances argument a table or nil | Packet | The payload drops after paying its 800 bytes. Reported as Malformed |
| 3 | Message budget: one token per frame, 240 per second, burst 128 | Packet | The rest of the payload drops before its next frame decodes. Reported as MessageLimit |
| 4 | Frame decode by the channel’s schema, with Instance slots checked and typed arrays capped at 8,192 elements per message | Packet | A decode error drops the rest of the payload. A duplicate of a pending request is skipped and reported as DuplicateRequest |
| 5 | Session gate: the sender has an active session whose profile is not locked by a transaction | NetDriver | Dropped. Runs before bucket creation, so a message deferred past PlayerRemoving cannot key a bucket to a departed player |
| 6 | Token bucket: RateLimit per second and Burst capacity, per player per channel |
NetDriver | Dropped; Net.RateLimited hook and Bus topic. Predicted intents get no ack and roll back on timeout |
| 7 | Fail-closed absence check: no validator and no Open = true |
NetDriver | Everything rejects; one warning names the channel |
| 8 | Registry Validate rules (Range, OneOf, MaxLength) at priority 5 |
Hooks | Rejected before game code runs |
| 9 | Game validators: Hooks:on("Intent.<name>", fn) in priority order (default 100) |
Hooks | false rejects, and so does a crashing validator. Validators may rewrite Args, and the handler receives the result |
| 10 | Recheck: the session is still active, still the player’s, and still unlocked after validators that yielded | NetDriver | Rejected |
| 11 | Handler | NetDriver | Runs under pcall. An error counts as a rejection and never reaches the client |
Stages 1 to 4 are covered on the Packet page and reported through
Net.FloodDetected. For Requests, every failure from stage 5 on answers the channel’s RejectValue, never an error
message or a hint about which gate fired. A request dropped at stages 1 to 4 never reaches the driver; the client waits
out its timeout and resumes with RejectValue all the same.
Fail-closed, including by absence
Section titled “Fail-closed, including by absence”Fail-closed usually means a crashing validator rejects. ChloeKernel extends it to absence: a missing validator is not permission.
- A crashing validator rejects, unless the hook point was set
FailOpen, andsecurityAuditreports such a gate as a High finding. - An Intent or Request with no validator registered rejects every payload, whether a handler or only an
Intent.<name>Bus subscriber consumes it. This closes the unguardedGrantCoinsintent whose handler trusts a client-supplied amount, and since 0.9.0 the same channel consumed only through the Bus. - A channel that needs no authorization (a payload-less ready-up, an idempotent toggle) says so with
Open = true, a searchable, auditable declaration. - Registry
Validaterules count as a validator. - Rejections are silent to the sender: intents do not happen, requests answer
RejectValue, predicted intents ack a rollback. A probing client learns nothing about which gate stopped it.
Net:auditValidators() lists every channel still missing both, and Net:securityAudit() sweeps the whole surface
(unguarded channels, Open channels, fail-open gates, defaulted rate limits). Details are on the
NetDriver page; the fuzzing side is Fuzz and audit.
A complete round trip
Section titled “A complete round trip”One intent, defined once, gated by a declarative rule and one game validator. The Game layer injects the handles as
self.Net. List the service in src/Server/Game/Registry.luau and the controller in src/Client/Game/Registry.luau
(see Services and controllers).
--!strictlocal ReplicatedStorage = game:GetService("ReplicatedStorage")
local Registry = require(ReplicatedStorage.ChloeKernel.Net.Registry)
return Registry.define({ UseItem = { Kind = "Intent", Schema = { "NumberU16" }, RateLimit = 5, Validate = { [1] = { Range = { 1, 999 } } } },})--!strict-- ItemService: applies item uses after a per-player cooldown.
local ItemService = { Name = "ItemService", Dependencies = {},}
local CooldownSeconds = 0.5
function ItemService.init(self: any, kernel: any) self.Kernel = kernel
-- Game validator: server-side state, never a client claim kernel.Hooks:on("Intent.UseItem", function(context) if os.clock() - (context.Session.Data.LastItemUse or 0) < CooldownSeconds then return false end return nil end)
self.Net.UseItem.on(function(session, itemId: number) session.Data.LastItemUse = os.clock() print(`{session.Player.Name} used item {itemId}`) end)end
function ItemService.start(_self: any) end
function ItemService.stop(_self: any) end
return ItemService--!strict-- ItemController: sends item uses from the hotbar.
local ItemController = { Name = "ItemController", Dependencies = {},}
function ItemController.init(self: any, kernel: any) self.Kernel = kernelend
function ItemController.start(_self: any) end
function ItemController.useItem(self: any, itemId: number) self.Net.UseItem.fire(itemId)end
function ItemController.stop(_self: any) end
return ItemControllerA rejected intent costs the client nothing and the server one pass through the chain, so validating everything has no performance excuse.
Observability
Section titled “Observability”The driver counts everything it does. kernel:stats().Net exposes IntentsAccepted, IntentsRejected,
IntentsRateLimited, RequestsRejected, TransportFloods and TransportFloodBytes. The Net.RateLimited and
Net.FloodDetected hook points and Bus topics feed the anti-exploit layer (see
Rate limiting). The debug panel’s NET window shows decoded
messages on both sides of the wire with their verdict: OK, Rejected, RateLimited, Unguarded, NoSession,
TransactionLocked, Error, Dropped or Shed. Capture runs only while the window is open on a client, and on the
server only while an allowlisted debugger is connected.