Skip to content

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.

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.

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.

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)
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

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 bucketRateLimit 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 validatorsHooks: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 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 — and securityAudit flags 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 GrantCoins intent 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 Validate rules 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.

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 claims
Kernel.Hooks:on("Intent.UseItem", function(context)
if not ownsItem(context.Session, context.Args[1]) then
return false
end
end)
Net.UseItem.on(function(session, itemId)
applyItem(session, itemId)
end)

Rejected intents cost the client nothing and the server roughly 150ns through the chain — cheap enough that “validate everything” has no performance excuse.

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).