Skip to content

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 it

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.

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
end
end, 10)
-- Validator 2: state gate — server-side state, never client claims
Kernel.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
end
end, 20)
-- Handler: runs only after every validator passed
Net:onIntent("UseItem", function(session, itemId)
session.Data.LastUse = os.clock()
applyItem(session, itemId)
end)

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.

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

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

Ack semantics, exactly:

  • Validators never see the sequence number in Args — it is stripped and exposed separately as context.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.

Server → client fire-and-forget. No gate — the server is trusted.

Net:defineState("KillFeed", { "String", "String" }) -- killer, victim
Net:broadcastState("KillFeed", killerName, victimName) -- everyone
Net:sendState("KillFeed", somePlayer, killerName, victimName) -- one player

States are for events. For values that change over time (scores, health, timers), use a replica — it handles late joiners and only sends changes.

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)

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

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

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, IntentsRejected incremented, Net.IntentRejected published, predicted sequences acked as rollbacks.
  • Requests: answered with RejectValue, RequestsRejected incremented.

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:

  1. Register a validator — any Kernel.Hooks:on("Intent.<name>", fn) handler counts, at any priority.
  2. Declare Validate rules in the Registry — they install as priority-5 hook handlers, so they count.
  3. Mark it Open = true — the explicit, grep-able acknowledgement that any client may safely drive it (a payload-less ready-up, an idempotent toggle).

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}`)
end

Registering a validator later removes the channel from the list immediately — channels are define-anytime, guard-anytime.

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.

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

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

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.