NetDriver
The server half of the channel layer: typed intents, states, requests and unreliable streams, a fail-closed gate on every inbound message, audits and counters.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Net - Since
- v0.1.0
- Source
- Net.luau
NetDriver is the server half of the channel layer. It defines typed Intents, States, Requests and Unreliable streams over
the Packet wire, and runs every inbound message through a session check, a
per-player token bucket and a fail-closed hook chain before any handler executes. Fail-closed includes absence: a
client-to-server channel with no validator rejects every payload until one is registered or the channel is marked
Open = true. The client half is NetClient, and the Registry defines
both from one table.
The driver lives in ServerScriptService.ChloeKernelServer.Net, so its rate limits and validation cannot be read or
patched out of replicated code. The kernel builds it on first use:
local Net = Kernel:net() -- lazy; a game that never touches networking never builds itGame code declares its channels in ReplicatedStorage.Game.Channels through the Registry, which calls this API (see
Channels). Kits and framework services call the driver directly.
Mental model
Section titled “Mental model”The driver keeps a map per kind, keyed by channel name. Each definition creates a Packet channel under a prefixed wire name, so an intent and a state with the same name never collide:
| Kind | Wire channel | Extra wire channels |
|---|---|---|
| Intent | CKI_<name> |
|
| PredictedIntent | CKI_<name>, with a leading NumberU16 sequence in the schema |
CKACK_<name> with schema NumberU16, Boolean8 |
| State | CKS_<name> |
|
| Request | CKR_<name> plus a response schema |
|
| Unreliable | an UnreliableRemoteEvent named <name> under ReplicatedStorage.ChloeKernelUnreliable |
Every Packet channel (Intents, States, Requests, acks, and the framework’s own channels such as CKRep2_*) counts
against Packet’s 256-id limit. Unreliable remotes do not.
An inbound intent or request passes Packet’s transport limits and schema decode first. Then the driver’s gate runs in this order:
- Destroyed driver: dropped.
- Session: the sender must have an active session. Verdict
NoSession. This runs before bucket creation, so a message deferred pastPlayerRemovingcannot key a new bucket to a departed player. - Transaction lock: a session whose profile is inside a durable transaction
(
TransactionOwnerset) or blocked after a failed settle (TransactionBlocked) is refused. VerdictTransactionLocked. No client handler or validator may change a profile the journal is writing. - Token bucket: one per player per channel. Verdict
RateLimited. - Fail-closed absence check: no validator and not
Open. VerdictUnguarded. - The
Intent.<name>orRequest.<name>hook chain. VerdictRejected. - Recheck: validators can yield, so the session must still be active, still be the player’s session, and still be free of a transaction lock. Otherwise the message is rejected.
- The handler, under
pcall. A throw counts as a rejection and never reaches the client. VerdictError.
Every channel’s bucket for a player is dropped on Kernel.SessionEnd, along with that player’s unreliable sequence
state.
Intents
Section titled “Intents”“The player wants to do X.” Schema, rate limit, validators, handler.
local Net = Kernel:net()
Net:defineIntent("UseItem", { "NumberU16" }, { RateLimit = 5 }) -- 5 per second per player
-- Validator 1: argument legality. Priority 10 runs before 20Kernel.Hooks:on("Intent.UseItem", function(context) if ItemCatalog[context.Args[1]] == nil then return false -- unknown id: rejected, the handler never runs end return nilend, 10)
-- Validator 2: server-side state, never a client claimKernel.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 return nilend, 20)
-- Runs only after every validator passedNet:onIntent("UseItem", function(session, itemId: number) 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, Seq } (Seq only on predicted intents) and may
rewrite Args, for example to clamp a speed, before the handler sees them. Returning false rejects. Returning nothing
passes and lets later validators run. A crashing validator rejects too. An accepted intent publishes Intent.<name> on
the Bus with (session, ...args) after the handler, using the args as validators left them. A channel
with a validator and no handler still accepts and publishes, which is how a service consumes an intent through the Bus
alone.
Channel options
Section titled “Channel options”defineIntent, definePredictedIntent and defineRequest take the same options table:
| Option | Type | Default | Meaning |
|---|---|---|---|
RateLimit |
number |
30 |
Token refill in messages per second, per player per channel. Must be finite and greater than 0, or define* errors |
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. Same as calling onIntent or onRequest later |
RejectValue |
any? |
nil |
Requests only: the value every failure answers with. Must encode under the Response schema |
Open |
boolean? |
nil |
Declares 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 sequence, and
the driver acks each sequence on CKACK_<name> so the client can keep or roll back its optimistic effect. The client
half is Prediction, or .predict on a Registry handle.
Net:definePredictedIntent("Dash", { "NumberU8" }, { RateLimit = 4, Handler = function(session, direction: number) 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 -- acks a rollback: the client undoes its dash end context.Session.Data.DashCharges = Charges - 1 return nilend)Ack rules:
- The sequence is stripped from
Argsand exposed ascontext.Seq. Validators and the handler never see it as an argument. - The ack goes out after the handler, so an accepted ack means the handler ran. A throwing handler acks
false. - An unguarded channel, a validator rejection and a failed recheck after validation ack
false. - A destroyed driver, a missing session, a transaction lock and an empty bucket send no ack at all. The client’s prediction times out and rolls back.
States
Section titled “States”Server to client, fire and forget, no gate. The server is trusted.
Net:defineState("KillFeed", { "String", "String" }) -- killer, victimNet:broadcastState("KillFeed", killerName, victimName) -- every clientNet:sendState("KillFeed", somePlayer, killerName, victimName) -- one playerNet:onState("KillFeed", { "String", "String" }, function(killer: string, victim: string) addFeedLine(`{killer} eliminated {victim}`)end)States are for events. For values that change over time (scores, health, timers), use a replica:
it snapshots for late joiners and sends only changes. A state frame that reaches a client before anything listens is
decoded and dropped when the client already constructed the channel (as Registry.client does), and parked for up to 10
seconds when it did not. See frame parking.
Unreliable states
Section titled “Unreliable states”High-frequency cosmetic data where a lost update is better than a late one. Payloads are Serde struct
buffers (a string-keyed field-to-type schema, not a positional array) 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)Every datagram ends in a 2-byte little-endian sequence footer after the struct body. The high bit marks the stream: set
for per-player sends (sendUnreliableState), clear for broadcasts. The low 15 bits count within that stream, wrapping at
32,768. Each recipient has its own per-player counter, so a player sent to rarely never sees a jump. The client keeps
only the latest datagram per stream and drops one that is a duplicate of, or older than, the last one it applied. A
stream quiet for 1 second takes its next datagram whatever the sequence, so a recipient the governor shed for minutes
recovers. Decoders ignore the trailing bytes.
A channel defined with Ordered = false skips that filter on the client (the remote carries an Unordered attribute).
Use it for independent events where an older datagram still matters, such as NetGovernor’s probes.
The encoded datagram, footer included, may be at most 980 bytes: a 20-byte margin under the engine’s 1,000-byte
UnreliableRemoteEvent limit. That leaves 978 bytes for the struct body. A larger send warns once per channel and is
dropped.
Priority decides what happens to Poor links when a NetGovernor is attached. "Low", the
default, sheds sends to players graded Poor; "High" always sends. A Game.Channels definition sets it with the same
field, which Registry.server passes through since 0.9.1. A broadcast goes out as one FireAllClients when no
governor is attached, when the channel is High, or while no tracked player is Poor. Otherwise it is sent per player to
everyone the governor does not shed.
Requests
Section titled “Requests”The client asks and the server answers, or answers RejectValue. Every failure path answers the same value: a
destroyed driver, no session, a transaction lock, an empty bucket, an unguarded channel, a validator rejection, a failed
recheck, a missing handler and a throwing handler. A probing client learns nothing about which gate fired.
Net:defineRequest("GetShopStock", { "NumberU16" }, { "NumberU8" }, { RejectValue = 0, RateLimit = 10, Handler = function(_session, itemId: number) 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 return nilend)local GetStock = Net:request("GetShopStock", { "NumberU16" }, { "NumberU8" }, { RejectValue = 0 })local Count = GetStock.invoke(itemId) -- yields; 0 on rejection or timeoutdefineRequest checks at definition that RejectValue encodes under the Response schema, with
Packet:SerializeResponse. A typed Response such as { "NumberU8" }
needs a matching RejectValue; { "Any" } accepts nil. A mismatch errors at boot with
request channel "<name>" RejectValue must encode under its Response schema. Before 0.9.0 the mismatch surfaced only at
run time: every rejection errored the server’s reply write and the client waited out its full timeout.
A handler that returns a value the Response schema cannot encode gets the same treatment at run time from Packet: that reply is dropped with one warning per channel, and the client resumes with its timeout value.
The response timeout is 10 seconds, or the ResponseTimeoutSeconds attribute on the Packet ModuleScript. A client that
passes RejectValue receives it on timeout too, so rejection and timeout share one failure encoding. See
NetClient for the invoke side.
The fail-closed absence rule
Section titled “The fail-closed absence rule”A client-to-server channel is unguarded when it is not marked Open and its hook point (Intent.<name> or
Request.<name>) has no registered handlers. Whether the channel has a handler does not matter. An unguarded channel
rejects every payload:
- Intents: dropped,
IntentsRejectedincremented,Net.IntentRejectedpublished, predicted sequences ackedfalse. Nothing reaches the handler or theIntent.<name>Bus topic. - Requests: answered with
RejectValue,RequestsRejectedincremented.
Before 0.9.0 the rule applied only to channels with a handler, so a channel consumed through its Intent.<name> Bus
topic alone delivered unvalidated payloads to subscribers. It now covers every channel.
The first rejection logs one warning through the NetDriver logger scope. It names the channel, the hook point to add a
validator on, and { Open = true } as the alternative:
[NetDriver] intent "Buy" has no validator and is not marked Open: rejecting every payload (fail-closed). Add a validator via Hooks:on("Intent.Buy", ...), or pass { Open = true } if any client may safely drive it.The rejection also shows as the Unguarded verdict in the debug panel’s NET window. Three
things open a channel:
- A validator: any
Kernel.Hooks:on("Intent.<name>", fn)at any priority. - Registry
Validaterules, which install as priority-5 validators. An emptyValidate = {}installs nothing and leaves the channel closed. Open = true, the explicit, searchable statement that any client may drive the channel (a payload-less ready-up, an idempotent toggle).
auditValidators()
Section titled “auditValidators()”Returns the channels currently rejecting under this rule, each { Name, Kind } with Kind "Intent" or "Request".
The server kernel’s start() runs it once after services start and logs one line per channel, such as
Intent "Buy" has no validator and is not Open; it will reject every payload. Channels defined after start() need
their own check:
for _, Finding in Net:auditValidators() do warn(`unguarded channel: {Finding.Kind} {Finding.Name}`)endRegistering a validator later removes the channel from the list at once. Channels can be defined and guarded at any time.
Security audits
Section titled “Security audits”Net:securityAudit() sweeps the whole surface before a ship. It returns { Severity, Kind, Name, Detail } records,
most severe first:
| Finding kind | Severity | Fires when |
|---|---|---|
UnguardedChannel |
High | No validator and not Open, with or without a handler. Every payload rejects |
FailOpenGate |
High | A hook point in the Intent. or Request. namespace was set FailOpen, so a crashing validator would pass the payload |
OpenChannel |
Medium | Open = true with a handler. Any client payload reaches the handler unvalidated; confirm it is safe with hostile input |
DefaultRateLimit |
Info | A channel with a handler rides the default 30 per second |
Fail-open points outside the gate namespaces (Net.RateLimited, AntiExploit.*) are informational by design and not
flagged. kernel:securityAudit({ Silent? }) runs the same sweep, prints a summary unless Silent, and works before the
driver exists (it then checks hook points only). The summary lists one finding per line as
[Severity] Kind (Name): Detail, where Name is intent <name>, request <name> or hook <point>:
[ChloeKernelServer] security audit: 1 finding(s) [High] UnguardedChannel (intent Grant): has no validator and is not Open: every payload rejects; add a validator or audit OpenFramework channels show up too: BusBridge client topics and NetGovernor’s intents are Open by design and appear as
OpenChannel. Pair the audit with the fuzzer, which drives hostile payloads through every
channel’s validators.
Transport flood reports
Section titled “Transport flood reports”NetDriver.new connects Packet’s RateLimitExceeded event (waiting up to 30 seconds for it) unless SkipFloodWiring is
set. Every Packet report adds 1 to TransportFloods and its dropped bytes to TransportFloodBytes. The hook and Bus
topic are coalesced: the driver collects reports and, on the next Scheduler step at High priority, fires one
Net.FloodDetected per player and reason with:
bytes: the largest window total among the collected reports.payloadBytes: the bytes dropped across them.budgetBytes: the byte budget in force.reason:ByteLimit,MessageLimit,DuplicateRequestorMalformed. See Inbound limits.count: how many reports were folded in.
Count strikes with count if a policy relied on one event per dropped payload. Rate limiting
has a complete strike policy.
Destroying the driver
Section titled “Destroying the driver”Net:destroy() disconnects the driver’s listeners, cancels a pending debug-mirror flush and flood report, clears every
handler and bucket it owns, and destroys its unreliable remotes. A validator suspended across the destroy cannot resume
into a handler. Sends become no-ops, inbound intents drop, requests answer RejectValue, and a new define* call
errors. Packet stays alive because other services share it.
API reference
Section titled “API reference”| Member | Description |
|---|---|
NetDriver.new(kernel, options?) -> NetDriver |
Direct construction. options holds test seams: PacketFactory, SkipFloodWiring, and GetPlayers (the roster for the debug mirror and shed-aware broadcasts). Game code uses kernel:net() |
Net:defineIntent(name, schema, options?) |
Typed client-to-server channel. Duplicate names and invalid rate options error at definition |
Net:definePredictedIntent(name, schema, options?) |
defineIntent plus a leading NumberU16 sequence and a CKACK_<name> ack channel |
Net:onIntent(name, fn(session, ...)) |
Attaches or replaces the handler. Errors on an unknown channel |
Net:defineState(name, schema) |
Server-to-client channel with no gate |
Net:sendState(name, player, ...) |
Sends to one player. Errors on an unknown channel |
Net:broadcastState(name, ...) |
Sends to every client. Errors on an unknown channel |
Net:defineUnreliableState(name, structSchema, { Priority?, Ordered? }?) |
Serde struct over an UnreliableRemoteEvent. Priority "Low" (default) or "High"; Ordered = false turns off the client’s latest-wins filter |
Net:sendUnreliableState(name, player, data) |
Encodes with the player’s stream footer. Shed for a Poor player on a Low channel. Over 980 bytes warns once and drops |
Net:broadcastUnreliableState(name, data) |
Encodes once with the broadcast footer. One FireAllClients unless a Low channel must skip a Poor player |
Net:defineRequest(name, schema, responseSchema, options?) |
Request channel. Every failure answers RejectValue, which must encode under responseSchema |
Net:onRequest(name, fn(session, ...) -> ...) |
Attaches or replaces the request handler |
Net:auditValidators() -> { { Name, Kind } } |
Channels currently rejecting under the absence rule |
Net:securityAudit() -> { AuditFinding } |
Full surface sweep, most severe first |
Net:markTapDirty() |
Arms the debug mirror’s next flush. Modules that record their own NetTap entries (ReplicaService) call it; it does nothing when the mirror is off |
Net:destroy() |
Stops the driver’s listeners, handlers, buckets and unreliable remotes. Idempotent |
Net.Stats |
Live counter table. See Counters |
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, Reason, Count |
fail-open | Once per player and reason per Scheduler step while Packet drops traffic |
Bus topics published:
| Topic | Payload | When |
|---|---|---|
Intent.<name> |
(session, ...args) |
After an accepted intent’s handler ran, with the args as validators left them |
Net.IntentRejected |
(player, channelName) |
Unguarded rejection, validator rejection, failed recheck, or a throwing handler |
Net.RateLimited |
(player, channelName) |
Mirror of the hook, for rate-limit policy |
Net.FloodDetected |
(player, bytes, payloadBytes, budgetBytes, reason, count) |
Mirror of the hook |
Counters
Section titled “Counters”Net.Stats is a plain table of monotonic counters. kernel:stats().Net returns a snapshot copy:
| Counter | Counts |
|---|---|
IntentsAccepted |
Intents that passed every gate and whose handler did not throw |
IntentsRejected |
Unguarded rejections, validator rejections, failed rechecks and throwing intent handlers |
IntentsRateLimited |
Bucket-dry drops, shared by the intent and request gates |
RequestsRejected |
Every request failure path |
TransportFloods |
Packet drop reports, each counted once (before coalescing) |
TransportFloodBytes |
Bytes those reports dropped |
A climbing IntentsRejected on one channel is either an exploit probe or a client bug sending bad arguments. Both are
worth knowing about.