Skip to content

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.

src/Shared/Game/Channels.luau
--!strict
local 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 },
})

One table, three consumers:

  1. Registry.define(definitions) checks every entry’s name and shape when the module loads and returns the table frozen. A misspelled Kind, a Request without a Response, or a Validate on a server-to-client channel fails the boot with a named error instead of a send failing later.
  2. Registry.server(kernel, definitions) walks the table on the server. It defines every channel on the driver (kernel:net()), registers each channel’s Validate rules as a validator at priority 5 on its hook point, and returns typed handles (.on, .handle, .send, .broadcast).
  3. 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 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?).

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.

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.

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).
  • Range rejects 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:

src/Server/Game/Services/ItemService.luau
--!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

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

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.

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.

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

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