Networking Overview
Every byte between client and server rides a single RemoteEvent carrying schema-packed buffers. On top of that wire sits a channel layer with one defining decision: inbound traffic is guilty until proven innocent. A client payload reaches your handler only after it survives a transport flood cap, a session gate, a per-channel token bucket, and a fail-closed validation chain — and a channel that has a handler but no validator rejects everything until you add one or explicitly mark the channel Open.
The client is a renderer with opinions. It sends intents (“I want to use item 3”), never state (“my coins are now 9999”). The server validates every intent and is the only thing that mutates state. Client code is always readable by exploiters — the defense is never trusting it, not hiding it.
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 │ │ ▼ ▼ NetClient (shared) NetDriver (server-only) typed channel handles, session gate → token bucket → zero validation by design fail-closed hook chain → handler │ │ ▼ ▼ Packet ◄──────────── one RemoteEvent ─────────────► Packet schema-packed buffers, batched per heartbeat, U8 channel ids, per-player inbound flood cap (server side)Three layers, one job each:
- Packet is the wire. It packs arguments into buffers by typed schema (
"NumberU16"costs exactly 2 bytes, every time), batches everything into one flush per heartbeat, and multiplexes all channels over a single RemoteEvent with U8 channel ids. It also enforces the transport-level flood cap before anything is decoded. - NetDriver (server) and NetClient (client) are the channel layer: named, typed channels with direction, rate limits, and — on the server — the fail-closed gate every inbound message passes.
- Registry is the definition layer: one shared module declares every channel for both sides, so schemas cannot drift and declarative argument rules compile straight into the validation chain.
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 must execute.
| Piece | Module | Replicates to clients? |
|---|---|---|
| NetDriver (channel gates, rate limits, audits) | ServerScriptService.ChloeKernelServer.Net |
Never |
| NetClient | ReplicatedStorage.ChloeKernel.Net.Client |
Yes |
| Registry | ReplicatedStorage.ChloeKernel.Net.Registry |
Yes (definitions are shared by design) |
| Packet wire transport | ReplicatedStorage.ChloeKernel.Net.Packet |
Yes |
| TokenBucket | ReplicatedStorage.ChloeKernel.Net.TokenBucket |
Yes — but the buckets themselves are created and checked in server memory |
| Prediction | ReplicatedStorage.ChloeKernel.Net.Prediction |
Yes (rollback is a client concern) |
An exploiter can read the TokenBucket module and the Registry rules all day; the instances that enforce them live in ServerScriptService state and cannot be patched 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 | Reliability | Gate | Use it for |
|---|---|---|---|---|
| Intent | client → server | reliable | full inbound pipeline | “Player wants to do X” — fire-and-forget actions |
| PredictedIntent | client → server, ack back | reliable | full inbound pipeline; ack after the handler | intents whose effect the client shows optimistically and rolls back on rejection |
| Request | client → server → client | reliable, yields | full inbound pipeline; every failure answers RejectValue |
questions with answers (shop stock, eligibility) |
| State | server → client | reliable | none — the server is trusted | fire-and-forget pushes (kill feed, toasts, round events) |
| Unreliable | server → client | unreliable, ≤ 900 bytes | none | high-frequency cosmetic data where lost beats late (aim angles, cosmetic positions) |
Which channel kind do I want?
Section titled “Which channel kind do I want?”| I want to… | Use | Notes |
|---|---|---|
| Send a player action to the server | Intent | The default. See NetDriver |
| Make an ability feel instant, but stay server-authoritative | PredictedIntent | Client applies the effect immediately, server acks or rolls back — Prediction |
| Ask the server a question and wait for the answer | Request | Yields; rejection and timeout both resume with RejectValue |
| Notify clients that something happened | State | One-shot events, not evolving values |
| Show clients a value that changes over time | Neither — use a replica | Late joiners get snapshots, changes go out as field deltas |
| Stream 20–60Hz cosmetic data that tolerates loss | Unreliable | Serde-packed structs over UnreliableRemoteEvent, hard 900-byte cap |
| Mirror bus topics across the network | BusBridge | Whitelisted, typed, rate-limited topic forwarding |
| Send events to other servers | MessageDriver | MessagingService, not player networking |
The inbound pipeline
Section titled “The inbound pipeline”Every client → server message walks these stages in order. Failing any stage stops it dead — the handler is the last thing to run, never the first.
| # | Stage | Lives in | On failure |
|---|---|---|---|
| 1 | Transport flood cap — per-player inbound byte budget per flush window (default 8,000 bytes; every payload counts at least 800) | Packet | Whole payload dropped before any decode work; Net.FloodDetected hook + bus, TransportFloods counter |
| 2 | Schema decode — the buffer is read positionally by the channel’s wire schema | Packet | A malformed or desynced buffer errors; the receive loop is pcall-isolated so the rest of the frame drops silently (Studio warns) |
| 3 | Session gate — the sender must have a live session | NetDriver | Dropped. Runs before bucket creation so a message deferred past PlayerRemoving can’t key a fresh bucket to a departed Player |
| 4 | Per-channel token bucket — RateLimit tokens/s, Burst capacity, per player per channel |
NetDriver | Dropped; Net.RateLimited hook + bus. Predicted intents get no ack — the client times out and rolls back |
| 5 | Fail-closed absence check — a handler with no validator and no Open = true |
NetDriver | Everything rejects; a one-time [NetDriver] … fail-closed warning names the channel |
| 6 | Declarative rules — Registry Validate rules (Range / OneOf / MaxLength), installed at priority 5 |
Hooks | Rejected before any game code runs |
| 7 | Game validators — Hooks:on("Intent.<name>", fn) in priority order (default 100) |
Hooks | Return false to reject; a crashing validator also rejects (fail-closed). Validators may rewrite Args — clamp, normalize — and the handler receives the mutated copy |
| 8 | Handler — your game code | NetDriver | The handler is pcall-wrapped; an error counts as a rejection and never leaks to the client |
For Requests, every failure from stage 3 onward answers the client with the channel’s RejectValue — never an error message, never a hint about which gate fired.
Fail-closed, including by absence
Section titled “Fail-closed, including by absence”Fail-closed is usually read as “a crashing validator rejects the action”. ChloeKernel extends it to absence: a missing validator is not permission.
- A crashing validator rejects (unless the hook point is explicitly
FailOpen— andsecurityAuditflags any gate configured that way as its highest-severity finding). - An Intent or Request whose handler has no validator registered rejects every payload. This closes the classic footgun: the unguarded
GrantCoinsintent whose handler trusts a client-supplied amount. - If a channel genuinely needs no authorization (a payload-less ready-up, an idempotent toggle), you say so explicitly with
Open = true— a grep-able, auditable declaration. - Declaring
Validaterules in the Registry counts as a validator. - Rejections are silent to the sender: intents just don’t happen, requests answer
RejectValue, predicted intents ack a rollback. Attackers probing the surface learn nothing about which gate stopped them.
Net:auditValidators() lists every handler-bearing channel still missing both, and Net:securityAudit() sweeps the whole surface (unguarded channels, Open escape hatches, fail-open gates, defaulted rate limits). Details on the NetDriver page; the fuzzing side lives in Fuzz & Audit.
A complete round trip
Section titled “A complete round trip”The shortest useful example — one intent, defined once, gated by a declarative rule plus one game validator:
-- ReplicatedStorage.Game.Channels (shared by both sides)local 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 } } }, },})-- Bootstrap (server). Kernel is the booted server kernel.local ReplicatedStorage = game:GetService("ReplicatedStorage")local Channels = require(ReplicatedStorage.Game.Channels)local Registry = require(ReplicatedStorage.ChloeKernel.Net.Registry)
local Net = Registry.server(Kernel, Channels)
-- Game validator: server-side state, never client claimsKernel.Hooks:on("Intent.UseItem", function(context) if not ownsItem(context.Session, context.Args[1]) then return false endend)
Net.UseItem.on(function(session, itemId) applyItem(session, itemId)end)-- Client Bootstraplocal ReplicatedStorage = game:GetService("ReplicatedStorage")local Channels = require(ReplicatedStorage.Game.Channels)local Registry = require(ReplicatedStorage.ChloeKernel.Net.Registry)
local Net = Registry.client(Channels)Net.UseItem.fire(3)Rejected intents cost the client nothing and the server roughly 150ns through the chain — cheap enough that “validate 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); and the debug panel’s NET window shows every decoded message on both wire sides with its verdict (OK, Rejected, RateLimited, Unguarded, NoSession, Error, Dropped).