Skip to content

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 skeleton ships an empty definition:

src/Shared/Game/Channels.luau
--!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:

src/Shared/Game/Channels.luau
--!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.

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.

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:

  • PredictedIntent prepends a NumberU16 sequence number and adds an ack channel named CKACK_<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’s predict({ Predict = fn }) runs Predict at once and calls the rollback it returns on a rejection or on a timeout of max(2, 3 x round trip + 0.5) seconds. The Prediction page has the details. A second predict for the same channel returns the first handle and ignores its options.
  • Unreliable takes a table of named fields ({ Pitch = "NumberF16", Yaw = "NumberF16" }), and send and broadcast take 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 = false keeps 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.
  • Request needs a Response schema and, for any typed response, a RejectValue. See Typed requests and RejectValue.

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

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.

Every Intent and Request message passes the same gate before a handler sees it:

  1. Session check. No session, or a session that is ending, drops the message.
  2. Transaction lock. A profile held by a trade, or blocked after a trade that needs recovery, drops the message.
  3. The token bucket for this player and channel.
  4. The fail-closed check (next section).
  5. The hook chain for Intent.<Name> (or Request.<Name>). The Validate rules from Channels.luau run first at priority 5. Game hooks registered at a higher number run after them.
  6. A re-check that the session is still the player’s active session, in case a hook yielded.
  7. The handler, under pcall, with (session, ...).
  8. 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 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.

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 run as hooks on the channel’s hook point. A hook receives a mutable context and cancels by returning false:

src/Server/Game/Services/VoteService.luau (start)
-- 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 nil
end, 10))
self.Net.VoteMap.on(function(session: any, mapIndex: number)
self.Votes[session] = mapIndex
end)

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.

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.

On the server, bind handlers in a service’s start. Each channel holds one handler; binding again replaces it.

-- In a service's start
self.Net.GetMyVote.handle(function(session: any): number
return self.Votes[session] or 0
end)
-- An accepted Intent is published on the Bus as Intent.<Name>, handler or not
self.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 start
self.Net.VoteDecided.broadcast("Harbor") -- every client
self.Net.VoteDecided.send(player, "Harbor") -- one client
self.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 path
task.spawn(function()
local MyVote = self.Net.GetMyVote.invoke()
if not self.Scope.Destroyed then
self.MyVote = MyVote
end
end)

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.

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, PredictedIntent or Request without a RateLimit field;
  • any of those without a Validate field and without the literal Open = true;
  • any of those with an empty Validate = {} and without Open = true.
src/Shared/Game/Channels.luau:9: CK012 channel VoteMap (Intent) must declare RateLimit
src/Shared/Game/Channels.luau:9: CK012 channel VoteMap (Intent) must declare Validate rules or Open = true
src/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 = true
directives: 4 finding(s) in 1 file(s); 1 files scanned

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

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