NetDriver
NetDriver is the server half of the channel layer: typed Intents, States, Requests, and Unreliable streams over the Packet wire. Its defining decision is that every inbound message runs a session check, a per-player token bucket, and a fail-closed hook chain before any handler executes — and “fail-closed” includes absence: a channel with a handler but no validator rejects every payload until you register one or explicitly mark the channel Open.
Rate limits and validation live server-side, in ServerScriptService.ChloeKernelServer.Net, so they can never be read or patched out of replicated code. You normally never construct the driver yourself:
local Net = Kernel:net() -- lazy singleton; games that never touch networking never build itMental model
Section titled “Mental model”The driver keeps three maps — Intents, States, Requests — keyed by channel name. Each definition creates a Packet channel with a prefixed wire name, so channel ids (a single U8 on the wire, 256 max) never collide across kinds:
| Kind | Wire channel | Extra wire channels |
|---|---|---|
| Intent | CKI_<name> |
— |
| PredictedIntent | CKI_<name> (schema gains a leading NumberU16 sequence) |
CKACK_<name> with schema NumberU16, Boolean8 |
| State | CKS_<name> |
— |
| Request | CKR_<name> plus a response schema |
— |
| Unreliable | an UnreliableRemoteEvent named <name> under ReplicatedStorage.ChloeKernelUnreliable |
— |
Inbound messages walk the pipeline in order: transport flood cap, schema decode (both in Packet), then the driver’s gate — session check, per-player-per-channel token bucket, the fail-closed absence check, the Intent.<name> / Request.<name> hook chain, and finally the handler, pcall-wrapped so its errors count as rejections and never leak to the client.
Bucket hygiene is automatic: the session check runs before bucket creation (a message deferred past PlayerRemoving cannot key a fresh bucket to a departed Player), and every channel’s bucket for a player is dropped when the kernel publishes Kernel.SessionEnd.
NetDriver.new also wires Packet’s transport-level flood drops into the kernel: every dropped payload increments TransportFloods / TransportFloodBytes and fires the Net.FloodDetected hook and bus topic, so the anti-exploit layer can observe (and act on) flooding clients before they cost decode work.
Intents
Section titled “Intents”The workhorse: “player wants to do X”. Schema, rate limit, validators, handler.
local Net = Kernel:net()
Net:defineIntent("UseItem", { "NumberU16" }, { RateLimit = 5 }) -- ≤5 sends/sec/player
-- Validator 1: argument legality (priority 10 runs before 20)Kernel.Hooks:on("Intent.UseItem", function(context) if ItemCatalog[context.Args[1]] == nil then return false -- unknown id: rejected, the handler never runs endend, 10)
-- Validator 2: state gate — server-side state, never client claimsKernel.Hooks:on("Intent.UseItem", function(context) local LastUse = context.Session.Data.LastUse or 0 if os.clock() - LastUse < ItemCatalog[context.Args[1]].Cooldown then return false endend, 20)
-- Handler: runs only after every validator passedNet:onIntent("UseItem", function(session, itemId) session.Data.LastUse = os.clock() applyItem(session, itemId)end)local ReplicatedStorage = game:GetService("ReplicatedStorage")local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)
local Net = NetClient.new()local UseItem = Net:intent("UseItem", { "NumberU16" })UseItem.fire(3)Validators receive a mutable context Name, Player, Session, Args (plus Seq for predicted intents) and may rewrite Args — clamp a speed, normalize a vector — before the handler sees them. Returning false rejects; returning nothing passes and lets later validators run. An accepted intent also publishes Intent.<name> on the bus with (session, ...args) after the handler, so other services can react without owning the channel.
Channel options
Section titled “Channel options”Both defineIntent and defineRequest (and their variants) take the same options table:
| Option | Type | Default | Meaning |
|---|---|---|---|
RateLimit |
number |
30 |
Token-bucket refill in messages/second, per player per channel. Must be finite and greater than 0 — a non-positive or non-finite rate would mean a bucket that never refills, so define* errors at definition time |
Burst |
number? |
math.max(1, math.ceil(RateLimit)) |
Bucket capacity (how many messages can land back-to-back). Must be finite and at least 1 |
Handler |
function? |
nil |
(session, ...) -> () for intents, (session, ...) -> ... for requests. Equivalent to registering later via onIntent / onRequest |
RejectValue |
any? |
nil |
Requests only — the value every failure path answers with. Intents ignore it |
Open |
boolean? |
nil |
Explicit acknowledgement that any client may drive this channel with no validator. Never set it on a channel that grants, sells, or sets a stat |
Predicted intents
Section titled “Predicted intents”definePredictedIntent is defineIntent plus reconciliation: the wire schema gains a leading NumberU16 client sequence number, and the driver acks each sequence on CKACK_<name> so the optimistic client can keep or roll back its local effect. The client half is Prediction (or .predict on a Registry handle).
Net:definePredictedIntent("Dash", { "NumberU8" }, { RateLimit = 4, Handler = function(session, direction) applyDash(session, direction) end,})
Kernel.Hooks:on("Intent.Dash", function(context) local Charges = context.Session.Data.DashCharges or 0 if Charges < 1 then return false -- rollback ack: the client undoes its dash end context.Session.Data.DashCharges = Charges - 1end)Ack semantics, exactly:
- Validators never see the sequence number in
Args— it is stripped and exposed separately ascontext.Seq. - The ack is sent after the handler, so an accepted ack means the handler actually ran. A throwing handler acks a rollback to keep the optimistic client in sync.
- Rate-limited and sessionless sends get no ack at all. The client’s prediction times out (2s default) and rolls back — deliberate silence, not an oversight.
States
Section titled “States”Server → client fire-and-forget. No gate — the server is trusted.
Net:defineState("KillFeed", { "String", "String" }) -- killer, victimNet:broadcastState("KillFeed", killerName, victimName) -- everyoneNet:sendState("KillFeed", somePlayer, killerName, victimName) -- one playerNet:onState("KillFeed", { "String", "String" }, function(killer, victim) addFeedLine(`{killer} eliminated {victim}`)end)States are for events. For values that change over time (scores, health, timers), use a replica — it handles late joiners and only sends changes.
Unreliable states
Section titled “Unreliable states”High-frequency cosmetic data where lost beats late. Payloads are Serde struct buffers (string-keyed field → type schemas, not positional arrays) over an UnreliableRemoteEvent.
Net:defineUnreliableState("BossAim", { Pitch = "NumberF16", Yaw = "NumberF16" })
Kernel.Scheduler:every(1 / 20, function() Net:broadcastUnreliableState("BossAim", { Pitch = Boss.Pitch, Yaw = Boss.Yaw })end, Kernel.Priority.Low)Net:onUnreliableState("BossAim", { Pitch = "NumberF16", Yaw = "NumberF16" }, function(data) aimBossHead(data.Pitch, data.Yaw)end)Requests
Section titled “Requests”Client asks, server answers — or answers RejectValue. Every failure path (no session, rate limited, unguarded, validator rejection, missing handler, throwing handler) returns the same RejectValue, so a probing client learns nothing about which gate fired and errors never leak.
Net:defineRequest("GetShopStock", { "NumberU16" }, { "NumberU8" }, { RejectValue = 0, RateLimit = 10, Handler = function(session, itemId) return Stock[itemId] or 0 end,})
Kernel.Hooks:on("Request.GetShopStock", function(context) if context.Args[1] > MaxItemId then return false -- answered with 0, no explanation endend)local GetStock = Net:request("GetShopStock", { "NumberU16" }, { "NumberU8" }, { RejectValue = 0 })local Count = GetStock.invoke(itemId) -- yields; 0 on rejection or timeoutThe wire’s response timeout is 10 seconds (Packet’s default); a client that passes RejectValue receives it on timeout too, so rejection and timeout share one failure encoding. See NetClient for the invoke semantics.
The fail-closed absence rule
Section titled “The fail-closed absence rule”A channel is unguarded when all three hold: it has a Handler, it is not marked Open, and its hook point (Intent.<name> or Request.<name>) has zero registered handlers. An unguarded channel rejects every payload:
- Intents: dropped,
IntentsRejectedincremented,Net.IntentRejectedpublished, predicted sequences acked as rollbacks. - Requests: answered with
RejectValue,RequestsRejectedincremented.
The first rejection prints a one-time warning naming the channel and the fix:
[NetDriver] intent "Grant" has a handler but no validator and is not marked Open —rejecting every payload (fail-closed). Add a validator via Hooks:on("Intent.Grant", ...),or pass { Open = true } if any client may safely drive it.The same rejection shows as the Unguarded verdict in the debug panel’s NET window. Three things open a channel:
- Register a validator — any
Kernel.Hooks:on("Intent.<name>", fn)handler counts, at any priority. - Declare
Validaterules in the Registry — they install as priority-5 hook handlers, so they count. - Mark it
Open = true— the explicit, grep-able acknowledgement that any client may safely drive it (a payload-less ready-up, an idempotent toggle).
auditValidators()
Section titled “auditValidators()”Returns the channels currently rejecting for this reason — each entry a table with Name and Kind ("Intent" or "Request"). Call it after boot for a proactive sweep instead of waiting for the first rejected payload:
for _, Finding in Net:auditValidators() do warn(`unguarded channel: {Finding.Kind} {Finding.Name}`)endRegistering a validator later removes the channel from the list immediately — channels are define-anytime, guard-anytime.
Security audits
Section titled “Security audits”Net:securityAudit() is the pre-ship sweep across the whole surface. It returns AuditFinding records — Severity, Kind, Name, Detail — sorted most severe first:
| Finding kind | Severity | Fires when |
|---|---|---|
UnguardedChannel |
High | Handler, no validator, not Open — every payload rejects |
FailOpenGate |
High | A hook point in the Intent. / Request. namespaces was flipped FailOpen — a crashing validator would PASS the payload. Gates must stay fail-closed |
OpenChannel |
Medium | Open = true with a handler — any client payload reaches the handler unvalidated; confirm it is safe with hostile input |
DefaultRateLimit |
Info | A handler-bearing channel rides the default 30/s — set RateLimit to what the gameplay actually needs |
Fail-open points off the gate namespaces (Net.RateLimited, AntiExploit.*, …) are informational by design and not flagged. The kernel-level wrapper kernel:securityAudit({ Silent? }) runs the same sweep, prints a summary unless Silent, and works even before the driver exists (it still checks hook points). Pair it with the fuzzer, which drives hostile payloads through every channel to prove the gates hold under fire.
API reference
Section titled “API reference”| Member | Description |
|---|---|
NetDriver.new(kernel, { PacketFactory?, SkipFloodWiring? }?) → NetDriver |
Direct construction — tests inject a fake PacketFactory and skip flood wiring. Game code uses kernel:net() |
Net:defineIntent(name, schema, { RateLimit? = 30, Burst?, Handler?, Open? }?) |
Typed client → server channel. Duplicate names error; invalid rate options error at define time |
Net:definePredictedIntent(name, schema, options?) |
defineIntent plus a leading NumberU16 sequence and a CKACK_<name> ack channel (NumberU16, Boolean8) |
Net:onIntent(name, fn(session, ...)) |
Attach or replace the handler. Errors on unknown channels |
Net:defineState(name, schema) |
Server → client channel, no gate |
Net:sendState(name, player, ...) · Net:broadcastState(name, ...) |
One player / all players. Error on unknown channels |
Net:defineUnreliableState(name, structSchema) |
Serde struct over UnreliableRemoteEvent; string-keyed schema |
Net:sendUnreliableState(name, player, data) · Net:broadcastUnreliableState(name, data) |
Encodes once; payloads over 900 bytes warn once and drop |
Net:defineRequest(name, schema, responseSchema, { RateLimit? = 30, Burst?, Handler?, RejectValue?, Open? }?) |
Request/response channel; all failures answer RejectValue |
Net:onRequest(name, fn(session, ...) → ...) |
Attach or replace the request handler |
Net:auditValidators() → { { Name, Kind } } |
Channels currently rejecting under the absence rule |
Net:securityAudit() → { { Severity, Kind, Name, Detail } } |
Full surface sweep, most severe first |
Net.Stats |
Live counter table — see below |
Hooks & bus topics
Section titled “Hooks & bus topics”Hook points the driver defines and fires:
| Hook point | Context | Failure mode | Fires |
|---|---|---|---|
Intent.<name> |
Name, Player, Session, Args, Seq? |
fail-closed | Before every intent handler; validators may rewrite Args |
Request.<name> |
Name, Player, Session, Args |
fail-closed | Before every request handler; rejection answers RejectValue |
Net.RateLimited |
Player, Channel |
fail-open | A player’s bucket for a channel ran dry |
Net.FloodDetected |
Player, Bytes, PayloadBytes, BudgetBytes |
fail-open | Packet dropped a payload at the transport byte budget |
Bus topics published:
| Topic | Payload | When |
|---|---|---|
Intent.<name> |
(session, ...args) |
After an accepted intent’s handler ran — args as mutated by validators |
Net.IntentRejected |
(player, channelName) |
Validator rejection, unguarded rejection, or a throwing handler |
Net.RateLimited |
(player, channelName) |
Mirror of the hook, for rate-limit punishment wiring |
Net.FloodDetected |
(player, bytes, payloadBytes, budgetBytes) |
Mirror of the hook |
Counters
Section titled “Counters”Net.Stats is a plain table of monotonic counters; kernel:stats().Net returns a snapshot clone of it:
| Counter | Counts |
|---|---|
IntentsAccepted |
Intents that passed every gate and whose handler did not throw |
IntentsRejected |
Validator rejections + unguarded rejections + throwing intent handlers |
IntentsRateLimited |
Bucket-dry drops — shared by the intent and request gate |
RequestsRejected |
Every request failure path (gate, unguarded, rejection, missing or throwing handler) |
TransportFloods |
Payloads dropped at Packet’s byte budget |
TransportFloodBytes |
Bytes across those drops |
Watch them per-interval in live servers: a climbing IntentsRejected on one channel is either an exploit probe or a client bug shipping bad args — both worth knowing about.