Channel Registry
Defines every network channel once for both sides, checks names and shapes at load, compiles declarative Validate rules into priority-5 validators, and hands each side typed handles.
- Realm
- Shared
- Require
ReplicatedStorage.ChloeKernel .Net .Registry - Since
- v0.3.0
- Source
- Net/Registry.luau
The Registry makes one shared module the source of truth for every network channel. Both sides read the same table, so
a schema cannot change on one side and not the other. Channel definitions also carry declarative Validate rules that
compile into the server’s fail-closed hook chain before any game code runs. The server side is built on
NetDriver and the client side on NetClient.
In a ChloeKernel game that module is src/Shared/Game/Channels.luau (ReplicatedStorage.Game.Channels), and the Game
layer turns it into handles for every service and controller. The Channels guide walks through
that workflow.
--!strictlocal ReplicatedStorage = game:GetService("ReplicatedStorage")
local Registry = require(ReplicatedStorage.ChloeKernel.Net.Registry)
return Registry.define({ UseItem = { Kind = "Intent", Schema = { "NumberU16" }, RateLimit = 5, Validate = { [1] = { Range = { 1, 500 } } } }, StockChanged = { Kind = "State", Schema = { "NumberU16", "NumberU8" } }, -- itemId, count GetStock = { Kind = "Request", Schema = { "NumberU16" }, Response = { "NumberU8" }, RejectValue = 0, RateLimit = 4, Validate = { [1] = { Range = { 1, 500 } } }, }, Aim = { Kind = "Unreliable", Schema = { Pitch = "NumberF16", Yaw = "NumberF16" } }, Dash = { Kind = "PredictedIntent", Schema = {}, RateLimit = 4, Open = true },})Mental model
Section titled “Mental model”One table, three consumers:
Registry.define(definitions)checks every entry’s name and shape when the module loads and returns the table frozen. A misspelledKind, aRequestwithout aResponse, or aValidateon a server-to-client channel fails the boot with a named error instead of a send failing later.Registry.server(kernel, definitions)walks the table on the server. It defines every channel on the driver (kernel:net()), registers each channel’sValidaterules as a validator at priority 5 on its hook point, and returns typed handles (.on,.handle,.send,.broadcast).Registry.client(definitions, netClient?)walks the same table on the client and returns handles (.fire,.invoke,.on,.predict) with the schemas filled in from the definitions.
Rules count as validators, so a channel with Validate rules satisfies the
fail-closed absence rule from the moment it is defined. An
empty Validate = {} has no rules, so Registry.server installs nothing for it and the channel stays fail-closed until
a game validator registers. Before 0.9.1 the empty table installed a validator that passed every payload.
Channel names
Section titled “Channel names”Channel names become Packet id attribute names behind a kernel prefix of up to six characters (CKACK_), and
UnreliableRemoteEvent names. They follow the attribute naming rules:
- letters, digits and
. / - _only - at most 94 characters (the 100-character attribute limit minus the prefix)
- never starting with
RBX
Registry.define enforces this, and so do BusBridge client topics and directive CK012 on Game.Channels. Before 0.9.0 a
bad name failed later, inside Packet or Instance.Name, far from the definition. Registry.validateName(name) exposes
the same check and returns (ok, problem?).
Shape checks at load
Section titled “Shape checks at load”Registry.define rejects malformed definitions before anything touches the wire. Every error names the channel:
| Check | Error |
|---|---|
| Name is a non-empty string | channel "X": names must be non-empty strings |
Name uses only letters, digits and . / - _ |
channel "X": names use only letters, digits and . / - _ |
| Name is at most 94 characters | channel "X": names are at most 94 characters |
Name does not start with RBX |
channel "X": names cannot start with RBX |
Kind is one of the five kinds |
channel "X": unknown Kind "Telepathy" |
Schema is a table |
channel "X": Schema must be a table |
Request channels declare Response |
channel "X": Request channels need a Response schema |
Priority is "Low" or "High", and appears only on Unreliable |
channel "X": Priority is "Low" or "High" and only applies to Unreliable channels |
Validate appears only on client-to-server kinds (Intent, PredictedIntent, Request) |
channel "X": Validate only applies to client->server kinds |
Validate keys are argument positions (numbers) |
channel "X": Validate keys are argument positions |
Range is a two-element table |
channel "X": Range must be {min, max} |
The returned table is frozen, because definitions do not change at run time. RejectValue is checked later, when
Registry.server defines the request: one that does not encode under the Response schema errors at boot.
Channel definition fields
Section titled “Channel definition fields”| Field | Applies to | Type | Meaning |
|---|---|---|---|
Kind |
all, required | "Intent", "PredictedIntent", "State", "Unreliable" or "Request" |
Which channel kind to build. See Choosing a channel kind |
Schema |
all, required | array of wire type names; a string-keyed map for Unreliable |
The wire shape both sides pack against |
Response |
Request, required |
array of wire type names | The reply’s wire shape |
RateLimit |
Intent, PredictedIntent, Request |
number?, default 30 |
Token refill, messages per second per player |
Burst |
Intent, PredictedIntent, Request |
number?, default max(1, ceil(RateLimit)) |
Bucket capacity |
RejectValue |
Request |
any? |
Answered on every server-side failure, and the client’s timeout value. Must encode under Response; { "Any" } accepts nil |
Validate |
Intent, PredictedIntent, Request |
{ [argPosition]: Rule }? |
Declarative argument rules, compiled into a priority-5 validator. An empty table installs nothing |
Open |
Intent, PredictedIntent, Request |
boolean? |
Declares that the channel needs no authorization. Without Validate rules, a game validator or Open, the channel rejects everything |
Ordered |
Unreliable |
boolean?, default true |
false keeps datagrams that arrive behind a newer one, for independent events |
Priority |
Unreliable |
"Low" or "High", default "Low" |
"Low" sheds sends to players a NetGovernor grades Poor; "High" always sends. Added in 0.9.1. |
State definitions take only Kind and Schema, and Unreliable adds Ordered and Priority. They are server to
client, so there is nothing to gate. Directive CK012 also requires RateLimit plus Validate or Open = true on every
client-to-server channel in Game.Channels, so the default rate never ships by accident, and flags Validate = {}
because it checks nothing.
Validate rules
Section titled “Validate rules”Rules express game constraints the wire schema cannot. Integer types already saturate into their range (NumberU16
cannot carry 70,000). Rules say which of the representable values the game accepts:
| Rule | Shape | Passes when |
|---|---|---|
Range |
{ min, max } |
The value is a number, not NaN, and min <= value <= max (inclusive). Anything else rejects |
OneOf |
{ value1, value2, ... } |
The value equals one of the listed values. Compiled to a hash set once, so the per-message cost is a lookup |
MaxLength |
n |
The value is a string of at most n bytes. Non-strings reject |
Rules are keyed by argument position and combine per argument. An argument with both Range and OneOf must satisfy
both:
Buy = { Kind = "Intent", Schema = { "NumberU16", "String" }, RateLimit = 5, Validate = { [1] = { Range = { 1, 99 }, OneOf = { 5, 7, 42 } }, [2] = { MaxLength = 8 }, },},How they behave:
- Rules reject and never clamp. An out-of-range id is rejected outright. Clamping is a game validator’s choice (it may
rewrite
context.Args). Rangerejects NaN explicitly. NaN fails both comparisons, and before 0.9.0 it passed the rule.- Rules install at priority 5, ahead of game validators (default priority 100, lower numbers run first). By the time a game validator runs, the arguments are shape-legal, and it can go straight to ownership and cooldowns.
- Passing rules return nothing, so the chain continues into game validators. Passing the rules is necessary, not sufficient.
- Rules see one argument at a time. A constraint between arguments (“amount must not exceed the stack size of itemId”) belongs in a game validator.
The Game layer builds the handles once and injects them into every service and controller as self.Net
(src/Server/Game/init.luau calls Registry.server(kernel, Channels), src/Client/Game/init.luau calls
Registry.client(Channels)). With the Channels module above:
--!strict-- ItemService: applies item uses and answers stock queries.
local ItemService = { Name = "ItemService", Dependencies = {},}
local Stock: { [number]: number } = { [3] = 12 }
function ItemService.init(self: any, kernel: any) self.Kernel = kernel
-- Game validator: runs after the priority-5 Range rule kernel.Hooks:on("Intent.UseItem", function(context) if (Stock[context.Args[1]] or 0) < 1 then return false end return nil end)
self.Net.UseItem.on(function(_session, itemId: number) Stock[itemId] -= 1 self.Net.StockChanged.broadcast(itemId, Stock[itemId]) end)
self.Net.GetStock.handle(function(_session, itemId: number) return Stock[itemId] or 0 end)end
function ItemService.start(_self: any) end
function ItemService.stop(_self: any) end
return ItemService--!strict-- ItemController: uses items and mirrors stock counts.
local ItemController = { Name = "ItemController", Dependencies = {},}
function ItemController.init(self: any, kernel: any) self.Kernel = kernelend
function ItemController.start(self: any) self.StockConnection = self.Net.StockChanged.on(function(itemId: number, count: number) print(`item {itemId}: {count} left`) end)end
function ItemController.useItem(self: any, itemId: number) if self.Net.GetStock.invoke(itemId) > 0 then -- yields; 0 on rejection or timeout self.Net.UseItem.fire(itemId) endend
function ItemController.stop(self: any) self.StockConnection:Disconnect()end
return ItemControllerGame validators attach exactly as they do without the Registry, with Hooks:on("Intent.<name>", fn), and run after
the priority-5 rules. The Registry replaces channel plumbing only; authorization stays in validators.
Returned surfaces
Section titled “Returned surfaces”Registry.server(kernel, definitions) returns one handle per channel, shaped by its Kind:
| Kind | Server handle |
|---|---|
Intent, PredictedIntent |
.on(fn(session, ...)) attaches the handler through Net:onIntent |
State |
.send(player, ...) and .broadcast(...) |
Unreliable |
.send(player, data) and .broadcast(data) |
Request |
.handle(fn(session, ...) -> ...) attaches the handler through Net:onRequest |
Registry.client(definitions, netClient?) returns the client mirror. The optional second argument reuses an existing
NetClient; by default one is created.
| Kind | Client handle |
|---|---|
Intent |
.fire(...) |
PredictedIntent |
.predict({ Predict?, TimeoutSeconds?, PingProvider? }?) -> { fire(...) -> seq, OnResolved }, a Prediction handle. One handle per channel name for the whole client |
State |
.on(fn(...)) -> connection. The channel is constructed when Registry.client runs |
Unreliable |
.on(fn(data)) -> connection. Yields up to 10 s for the remote |
Request |
.invoke(...) -> .... Yields; resumes with RejectValue on rejection or timeout |
Registry.client constructs every State channel at once through NetClient:declareState. A State frame that arrives
before .on() is decoded and dropped like any signal nobody hears, instead of parking and replaying a stale value
seconds later. Channels built lazily (ReplicaClient, SettingsClient, a direct NetClient:onState) still park and
replay; see frame parking.
Handles are closures over the driver and client calls. Mixing Registry handles with raw Net:defineIntent channels
works, but one mechanism per channel keeps the audit surface readable.
Why one module
Section titled “Why one module”The wire is positional bytes with no field names and no type tags; the receiver can read a message only because its
schema says how. Two hand-copied schema tables eventually disagree: someone widens a NumberU8 to NumberU16 on the
server and forgets the client, and the result is a
desynced frame or garbage values, silent in production.
With the Registry there is one table. Widening that field is a one-line change both sides pick up on the next deploy,
and a reviewer sees the channel’s rate limit, rules and Open status in the same hunk.
API reference
Section titled “API reference”| Member | Description |
|---|---|
Registry.define(definitions) -> definitions |
Checks every name and shape, then returns the table frozen. Errors name the channel |
Registry.validateName(name) -> (boolean, string?) |
The name check alone. Returns false and the broken rule for a name Packet or Instance.Name would reject |
Registry.server(kernel, definitions) -> handles |
Defines every channel on kernel:net(), passes Ordered and Priority to unreliable channels, and installs non-empty Validate rules at priority 5. Channels can be defined before or after kernel:start() |
Registry.client(definitions, netClient?) -> handles |
Client handles for the same definitions. Constructs State channels immediately |