Skip to content

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 it

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

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:

  1. Destroyed driver: dropped.
  2. Session: the sender must have an active session. Verdict NoSession. This runs before bucket creation, so a message deferred past PlayerRemoving cannot key a new bucket to a departed player.
  3. Transaction lock: a session whose profile is inside a durable transaction (TransactionOwner set) or blocked after a failed settle (TransactionBlocked) is refused. Verdict TransactionLocked. No client handler or validator may change a profile the journal is writing.
  4. Token bucket: one per player per channel. Verdict RateLimited.
  5. Fail-closed absence check: no validator and not Open. Verdict Unguarded.
  6. The Intent.<name> or Request.<name> hook chain. Verdict Rejected.
  7. 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.
  8. The handler, under pcall. A throw counts as a rejection and never reaches the client. Verdict Error.

Every channel’s bucket for a player is dropped on Kernel.SessionEnd, along with that player’s unreliable sequence state.

“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 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
return nil
end, 10)
-- Validator 2: server-side state, never a client claim
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
return nil
end, 20)
-- Runs only after every validator passed
Net:onIntent("UseItem", function(session, itemId: number)
session.Data.LastUse = os.clock()
applyItem(session, itemId)
end)

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.

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

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

Ack rules:

  • The sequence is stripped from Args and exposed as context.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.

Server to client, fire and forget, no gate. The server is trusted.

Net:defineState("KillFeed", { "String", "String" }) -- killer, victim
Net:broadcastState("KillFeed", killerName, victimName) -- every client
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 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.

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)

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.

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

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

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, IntentsRejected incremented, Net.IntentRejected published, predicted sequences acked false. Nothing reaches the handler or the Intent.<name> Bus topic.
  • Requests: answered with RejectValue, RequestsRejected incremented.

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:

  1. A validator: any Kernel.Hooks:on("Intent.<name>", fn) at any priority.
  2. Registry Validate rules, which install as priority-5 validators. An empty Validate = {} installs nothing and leaves the channel closed.
  3. Open = true, the explicit, searchable statement that any client may drive the channel (a payload-less ready-up, an idempotent toggle).

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

Registering a validator later removes the channel from the list at once. Channels can be defined and guarded at any time.

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 Open

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

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, DuplicateRequest or Malformed. 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.

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.

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

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

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.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.