Channels
Every game network channel is declared once in src/Shared/Game/Channels.luau with Registry.define, then reaches systems as typed handles on self.Net.
A game declares every network channel it uses in one module, src/Shared/Game/Channels.luau, built with Registry.define. The server and every client require that same module, so a schema cannot change on one side only. Game/init.luau turns the definitions into typed handles and injects them into every system as self.Net. Kits declare their own channels (SH_Buy, WK_Fire and so on) and are extended through their hooks and Bus topics, never through new channels. The Registry page documents the module itself; the transport sits in the NetDriver and NetClient. Games never create RemoteEvents, RemoteFunctions or BindableEvents (directive CK007).
The file
Section titled “The file”The skeleton ships an empty definition:
--!strict-- Every network channel the game uses. Server and client both require this one-- module, so schemas cannot drift. Client-to-server kinds (Intent, Request,-- PredictedIntent) need RateLimit plus Validate rules or Open = true.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Registry = require(ReplicatedStorage.ChloeKernel.Net.Registry)
return Registry.define({})A filled one, for a game with a map vote between rounds:
--!strict-- Every network channel the game uses. Server and client both require this one-- module, so schemas cannot drift. Client-to-server kinds (Intent, Request,-- PredictedIntent) need RateLimit plus Validate rules or Open = true.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Maps = require(script.Parent.Config.Maps)local Registry = require(ReplicatedStorage.ChloeKernel.Net.Registry)
return Registry.define({ -- Vote for a map by its index in Config/Maps. Intent.VoteMap hooks add the round rules. VoteMap = { Kind = "Intent", Schema = { "NumberU8" }, RateLimit = 2, Validate = { [1] = { Range = { 1, #Maps } } } }, Emote = { Kind = "Intent", Schema = { "String" }, RateLimit = 1, Validate = { [1] = { OneOf = { "Wave", "Cheer", "Dance" } } }, }, -- The caller's own vote. 0 means none, and every rejection or timeout also answers 0. GetMyVote = { Kind = "Request", Schema = {}, Response = { "NumberU8" }, RejectValue = 0, RateLimit = 2, Open = true }, VoteDecided = { Kind = "State", Schema = { "String" } }, Aim = { Kind = "Unreliable", Schema = { Pitch = "NumberF16", Yaw = "NumberF16" } },})Config/Maps is a client-visible list of { Name = "Harbor" } entries. Requiring it from Channels.luau keeps the Range rule and the list in step.
What Registry.define checks
Section titled “What Registry.define checks”Registry.define runs when the module is first required, on the server and on every client. It errors at the definition (error level 2, so the message points at Channels.luau) when:
| Check | Error |
|---|---|
| The name breaks the naming rules below | channel "<name>": names use only letters, digits and . / - _ (or the length or RBX message) |
Kind is not one of the five kinds |
channel "<name>": unknown Kind "<kind>" |
Schema is not a table |
channel "<name>": Schema must be a table |
A Request has no Response table |
channel "<name>": Request channels need a Response schema |
Priority is set on a channel that is not Unreliable, or is not "Low" or "High" |
channel "<name>": Priority is "Low" or "High" and only applies to Unreliable channels |
Validate is set on a State or Unreliable channel |
channel "<name>": Validate only applies to client->server kinds |
A Validate key is not an argument position |
channel "<name>": Validate keys are argument positions |
A Range is not a two-element table |
channel "<name>": Range must be {min, max} |
It then freezes the table and returns it. It does not check RateLimit, Validate or Open presence; directive CK012 does that statically.
Channel kinds
Section titled “Channel kinds”| Kind | Direction | Server handle | Client handle |
|---|---|---|---|
Intent |
Client to server | on(fn(session, ...)) |
fire(...) |
PredictedIntent |
Client to server, acknowledged | on(fn(session, ...)) |
predict(options?) returns a handle with fire(...) -> seq and OnResolved |
State |
Server to client, reliable and ordered | send(player, ...), broadcast(...) |
on(fn(...)) returns a connection |
Unreliable |
Server to client, latest wins | send(player, data), broadcast(data) |
on(fn(data)) returns a connection |
Request |
Client asks, server answers | handle(fn(session, ...) -> ...) |
invoke(...) yields and returns the answer |
Schemas are lists of Packet type names (NumberU8, NumberU16, NumberF32, String, Boolean8, Vector3F24, Instance, Any and the rest on Packet and wire types). Integer types round and saturate on write: a NumberU8 holding 300 arrives as 255. Saturation is part of encoding; bounds a handler relies on belong in Validate rules.
Kind-specific rules:
PredictedIntentprepends aNumberU16sequence number and adds an ack channel namedCKACK_<Name>. The server acks after the handler: accepted when the handler returned, rejected when a validator refused or the handler threw. A message dropped earlier by the session check or the rate limit gets no ack at all. The client’spredict({ Predict = fn })runsPredictat once and calls the rollback it returns on a rejection or on a timeout ofmax(2, 3 x round trip + 0.5)seconds. The Prediction page has the details. A secondpredictfor the same channel returns the first handle and ignores its options.Unreliabletakes a table of named fields ({ Pitch = "NumberF16", Yaw = "NumberF16" }), andsendandbroadcasttake one table in that shape. Each datagram carries a 2-byte sequence footer and the client drops any datagram older than the last one it applied.Ordered = falsekeeps every datagram, for independent events.Priority = "Low"(the default) lets a NetGovernor skip sends to players it grades Poor;Priority = "High"always sends, for a stream every client needs. A payload over 980 bytes (body plus footer) warns once and is dropped.Requestneeds aResponseschema and, for any typed response, aRejectValue. See Typed requests and RejectValue.
Naming rules
Section titled “Naming rules”Channel names become attribute names behind a kernel prefix of up to six characters (CKACK_), and Unreliable names become UnreliableRemoteEvent names. So a name:
- uses only letters, digits and
.,/,-,_; - is at most 94 characters (the 100-character attribute limit minus the prefix);
- does not start with
RBX.
Registry.define enforces this at require time through Registry.validateName, and CK012 enforces it statically, so a bad name fails python tools/check.py before Studio ever loads it. Names are also unique per kind across the whole server: when a game channel reuses a kit’s channel name, the NetDriver errors at boot with intent channel "<name>" is already defined (or the matching message for the other kinds).
Rate limits
Section titled “Rate limits”RateLimit is messages per second, per player, per channel, enforced on the server by a token bucket. The bucket holds Burst tokens (default: RateLimit rounded up, at least 1) and refills continuously at RateLimit per second. A message that finds the bucket empty is dropped: the server fires the Net.RateLimited hook and publishes Net.RateLimited(player, channelName) on the Bus, and a Request answers its RejectValue. See Rate limiting.
A channel without RateLimit runs at 30 per second, and once it has a handler the security audit lists it as an Info finding. CK012 requires the field on every client-to-server channel anyway, because the right number depends on the gameplay: a vote needs 2, a movement intent may need 20. RateLimit must be a finite number above 0 and Burst a finite number of at least 1; anything else errors when the channel is defined.
The server gate
Section titled “The server gate”Every Intent and Request message passes the same gate before a handler sees it:
- Session check. No session, or a session that is ending, drops the message.
- Transaction lock. A profile held by a trade, or blocked after a trade that needs recovery, drops the message.
- The token bucket for this player and channel.
- The fail-closed check (next section).
- The hook chain for
Intent.<Name>(orRequest.<Name>). TheValidaterules fromChannels.luaurun first at priority 5. Game hooks registered at a higher number run after them. - A re-check that the session is still the player’s active session, in case a hook yielded.
- The handler, under
pcall, with(session, ...). - For an Intent, a Bus publish of
Intent.<Name>with(session, ...).
A Request answers RejectValue whenever it fails any step. For an Intent, steps 1 and 2 drop the message quietly, step 3 publishes Net.RateLimited, and a rejection from step 4 on publishes Net.IntentRejected(player, channelName) on the Bus. A handler that throws is logged as intent "<name>" handler failed (or request "<name>" handler failed) and counts as a rejection. Errors never reach the client.
Validate rules
Section titled “Validate rules”Validate maps argument positions to rules. All rules for a position must pass:
| Rule | Passes when |
|---|---|
Range = { min, max } |
The value is a number, not NaN, and min <= value <= max |
OneOf = { ... } |
The value is one of the listed values (compiled to a set once) |
MaxLength = n |
The value is a string of at most n bytes |
A passing rule set returns nothing, so later hooks still run. The rules cover what the wire schema cannot. Anything that needs server state (does the player own the item, is the round in intermission, is the target in reach) goes in a hook.
Fail-closed channels
Section titled “Fail-closed channels”A client-to-server channel with no validator and without Open = true rejects every payload. “No validator” means no handler on its Intent.<Name> or Request.<Name> hook point: no Validate rules and no game hook. Such a channel:
- warns through the NetDriver’s Logger on its first message, naming the hook point to add a validator to;
- is listed when the server kernel starts, with the warning
Intent "<name>" has no validator and is not Open; it will reject every payload; - is a High finding in
securityAudit, which fails the Studio spec gate’s audit step.
Before 0.9.0 a channel with no validator and no handler still reached Intent.<Name> Bus subscribers unvalidated. It now rejects like any other, so a game that consumes an intent only through the Bus topic still needs rules or a hook.
Open = true declares that any client may drive the channel with any payload. Use it for reads and cosmetic requests (GetMyVote above only reads the caller’s own state). Never use it on a channel that grants, sells or sets a stat. The audit reports every Open channel that has a handler as a Medium finding, so each one is reviewed.
Game rules in Intent hooks
Section titled “Game rules in Intent hooks”Game rules run as hooks on the channel’s hook point. A hook receives a mutable context and cancels by returning false:
-- Priority 10 runs after the Range rule from Channels.luau (priority 5)self.Scope:add(self.Kernel.Hooks:on("Intent.VoteMap", function(_context: any): boolean? if not self.IsOpen then return false end return nilend, 10))
self.Net.VoteMap.on(function(session: any, mapIndex: number) self.Votes[session] = mapIndexend)The context holds Name, Player, Session, Args (the decoded arguments, in order) and, for a PredictedIntent, Seq. A hook may rewrite context.Args; the handler receives the rewritten values. Hooks:on returns a function that removes the hook, which a Scope can own.
Hook points for channels are fail-closed: a hook that throws cancels the chain like a false, and the Studio fuzz gate fails when a validator crashes on hostile input. Write hooks that reject bad input instead of indexing into it. A Request’s hook point is Request.<Name>, with the same context minus Seq.
Typed requests and RejectValue
Section titled “Typed requests and RejectValue”A Request answers its RejectValue for every failure: rate limit, no session, a rejected hook, no handler bound, a throwing handler. The client’s invoke also resumes with RejectValue when the response times out. The client therefore cannot tell a rejection from an answer that equals RejectValue, so pick a value that means “nothing” to the caller, such as 0 for “no vote”.
The server checks at definition time that RejectValue encodes under the Response schema and errors with request channel "<name>" RejectValue must encode under its Response schema when it does not. Before that check, a bad RejectValue left every rejected client waiting out the full timeout. A { "Any" } response accepts a nil RejectValue.
Using the handles
Section titled “Using the handles”On the server, bind handlers in a service’s start. Each channel holds one handler; binding again replaces it.
-- In a service's startself.Net.GetMyVote.handle(function(session: any): number return self.Votes[session] or 0end)-- An accepted Intent is published on the Bus as Intent.<Name>, handler or notself.Scope:add(self.Kernel.Bus:subscribe("Intent.Emote", function(_topic: string, session: any, emote: string) self.Kernel.Bus:publish("Player.Emoted", session.Player, emote)end))
-- Any time after startself.Net.VoteDecided.broadcast("Harbor") -- every clientself.Net.VoteDecided.send(player, "Harbor") -- one clientself.Net.Aim.broadcast({ Pitch = 0.2, Yaw = 1.4 })On the client, from a controller or a screen:
self.Net.VoteMap.fire(2)self.Net.Emote.fire("Wave")self.Scope:add(self.Net.VoteDecided.on(function(mapName: string) print(mapName)end))-- invoke yields for the round trip; keep it off the start pathtask.spawn(function() local MyVote = self.Net.GetMyVote.invoke() if not self.Scope.Destroyed then self.MyVote = MyVote endend)Client handles never error at construction. fire and invoke wait up to 10 seconds for the server’s channel id on first use, then error with channel "<name>" was never defined on the server. An Unreliable on waits up to 10 seconds in total for its remote, then errors.
Directive CK012
Section titled “Directive CK012”python tools/directives.py (and so python tools/check.py) reads src/Shared/Game/Channels.luau without running it and reports:
- any name that breaks the naming rules;
- any
Intent,PredictedIntentorRequestwithout aRateLimitfield; - any of those without a
Validatefield and without the literalOpen = true; - any of those with an empty
Validate = {}and withoutOpen = true.
src/Shared/Game/Channels.luau:9: CK012 channel VoteMap (Intent) must declare RateLimitsrc/Shared/Game/Channels.luau:9: CK012 channel VoteMap (Intent) must declare Validate rules or Open = truesrc/Shared/Game/Channels.luau:10: CK012 channel 'RBXThing': names must not start with RBX (reserved for attribute names)src/Shared/Game/Channels.luau:11: CK012 channel Gift (Intent) has Validate = {}, which checks nothing: add rules or Open = truedirectives: 4 finding(s) in 1 file(s); 1 files scannedCK012 checks presence only. Whether the rules are strict enough is a review question, which the review-game skill asks for every channel (see Agent workflow). The Directives page lists every rule.