Channel Registry
The Registry makes one shared module the source of truth for every network channel. Both sides consume the same table, so schemas cannot drift between client and server — the bug class where one side edits a schema and the other silently misdecodes dies structurally, not defensively. On top of that, channel definitions carry declarative Validate rules that compile into the server’s fail-closed hook chains before any game code runs.
-- ReplicatedStorage.Game.Channels (a module your game owns)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, 999 } } }, }, KillFeed = { Kind = "State", Schema = { "String", "String" } }, GetStock = { Kind = "Request", Schema = { "NumberU16" }, Response = { "NumberU8" }, RejectValue = 0 }, Aim = { Kind = "Unreliable", Schema = { Pitch = "NumberF16", Yaw = "NumberF16" } }, Dash = { Kind = "PredictedIntent", Schema = {}, Open = true },})Mental model
Section titled “Mental model”One table, three consumers:
Registry.define(definitions)shape-checks every entry at module load and returns the table frozen. A typo’dKind, aRequestwithout aResponse, aValidateon a server → client channel — each fails the boot with a named error instead of a random send failing later.Registry.server(kernel, definitions)walks the table on the server: defines every channel on the NetDriver, compilesValidaterules into validators registered at priority 5 on the channel’s hook point, and returns typed handles (.on,.handle,.send,.broadcast).Registry.client(definitions)walks the same table on the client and returns NetClient-backed handles (.fire,.invoke,.on,.predict) — schemas filled in from the definitions, never retyped.
Because rules count as validators, a channel with Validate rules satisfies the fail-closed absence rule — it is guarded from the moment it is defined.
Shape checks at load
Section titled “Shape checks at load”Registry.define rejects malformed definitions before anything touches the wire:
| Check | Error |
|---|---|
| Channel names must be non-empty strings | Registry channel names must be non-empty strings |
Kind must be one of the five kinds |
channel "X": unknown Kind "Telepathy" |
Schema must be a table |
channel "X": Schema must be a table |
Request channels must declare Response |
channel "X": Request channels need a Response schema |
Validate is only legal on client → server kinds (Intent, PredictedIntent, Request) |
channel "X": Validate only applies to client->server kinds |
Validate keys must be argument positions (numbers) |
channel "X": Validate keys are argument positions |
Range must be a two-element table |
channel "X": Range must be {min, max} |
The returned table is frozen — definitions are load-time facts, not runtime state.
Channel definition fields
Section titled “Channel definition fields”| Field | Applies to | Type | Meaning |
|---|---|---|---|
Kind |
all (required) | "Intent" | "PredictedIntent" | "State" | "Unreliable" | "Request" |
Which channel kind to build — see the decision table |
Schema |
all (required) | array of wire type names; string-keyed map for Unreliable |
The wire shape both sides pack against |
Response |
Request (required) |
array of wire type names | The answer’s wire shape |
RateLimit |
Intent, PredictedIntent, Request |
number? (default 30) |
Token-bucket refill, messages/second per player |
Burst |
Intent, PredictedIntent, Request |
number? (default ceil(RateLimit), min 1) |
Bucket capacity |
RejectValue |
Request |
any? |
Answered on every server-side failure; also becomes the client’s timeout value, so both failure modes read the same |
Validate |
Intent, PredictedIntent, Request |
{ [argPosition] = Rule }? |
Declarative argument rules — compiled into a priority-5 validator |
Open |
Intent, PredictedIntent, Request |
boolean? |
Explicit acknowledgement that the channel needs no authorization. Without Validate, a later game validator, or Open, a handler-bearing channel rejects everything (fail-closed) |
State and Unreliable definitions take only Kind and Schema — they are server → client, so there is nothing to gate.
Validate rules
Section titled “Validate rules”Rules express game constraints the wire schema cannot. Type coercion already clamps values into the wire type (NumberU16 cannot carry 70,000); rules say which of the representable values your game accepts:
| Rule | Shape | Passes when |
|---|---|---|
Range |
{ min, max } |
The value is a number and min <= value <= max (inclusive). Non-numbers reject |
OneOf |
{ value1, value2, … } |
The value equals one of the listed values. Compiled to a hash set once at install, so per-message cost is a lookup, not a scan |
MaxLength |
n |
The value is a string with length at most n. 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" }, Validate = { [1] = { Range = { 1, 99 }, OneOf = { 5, 7, 42 } }, [2] = { MaxLength = 8 }, },},Semantics worth knowing:
- Rules REJECT, they never clamp. An out-of-range id is rejected outright, not snapped into range. Clamping is a validator’s choice (mutate
context.Args), never a rule’s. - Rules install at priority 5, ahead of hand-attached game validators (default priority 100, and conventionally 10+). By the time your validator runs, the arguments are already shape-legal — your code checks ownership and cooldowns, not “is this a number”.
- Passing rules return nothing, so the chain continues into your game validators. A rules pass is necessary, not sufficient.
- Rules see one argument at a time. A constraint between arguments (“
amountmust not exceed the stack size ofitemId”) is a game validator’s job.
One module defines; each side consumes:
-- Bootstrap (server). Kernel is the booted server kernel.local ReplicatedStorage = game:GetService("ReplicatedStorage")local Channels = require(ReplicatedStorage.Game.Channels)local Registry = require(ReplicatedStorage.ChloeKernel.Net.Registry)
local Net = Registry.server(Kernel, Channels)
Net.UseItem.on(function(session, itemId) applyItem(session, itemId)end)
Net.GetStock.handle(function(session, itemId) return Stock[itemId] or 0end)
Net.KillFeed.broadcast(killerName, victimName)Net.Aim.broadcast({ Pitch = Boss.Pitch, Yaw = Boss.Yaw })-- Client Bootstraplocal ReplicatedStorage = game:GetService("ReplicatedStorage")local Channels = require(ReplicatedStorage.Game.Channels)local Registry = require(ReplicatedStorage.ChloeKernel.Net.Registry)
local Net = Registry.client(Channels)
Net.UseItem.fire(3)
Net.KillFeed.on(function(killer, victim) addFeedLine(`{killer} eliminated {victim}`)end)
local Stock = Net.GetStock.invoke(3) -- yields; 0 on rejection or timeout
local Dash = Net.Dash.predict({ Predict = function() playDashVfx() return stopDashVfx -- the rollback closure end,})Dash.fire()Game validators attach exactly as they do without the Registry — Kernel.Hooks:on("Intent.UseItem", fn) — and run after the priority-5 rules. The Registry replaces channel plumbing, not your authorization logic.
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 via Net:onIntent |
State |
.send(player, ...) · .broadcast(...) |
Unreliable |
.send(player, data) · .broadcast(data) |
Request |
.handle(fn(session, ...) → ...) — attaches the handler via Net:onRequest |
Registry.client(definitions, netClient?) returns the client-side mirror (the optional second argument reuses an existing NetClient; by default one is created):
| Kind | Client handle |
|---|---|
Intent |
.fire(...) |
PredictedIntent |
.predict({ Predict?, TimeoutSeconds? }?) → { fire(...) → seq, OnResolved } — Prediction wrap; Predict runs immediately and returns the rollback closure |
State |
.on(fn(...)) → connection |
Unreliable |
.on(fn(data)) → connection |
Request |
.invoke(...) → ... — yields; resumes with RejectValue on rejection or timeout |
Handles are plain closures over the underlying driver and client calls — nothing stops you from mixing Registry handles with raw Net:defineIntent channels, but one mechanism per channel keeps the audit surface readable.
Why single-module truth kills schema drift
Section titled “Why single-module truth kills schema drift”The wire is positional bytes with no field names and no type tags — the receiver knows how to read a message only because its schema says so. Two hand-copied schema tables will eventually disagree: someone widens a NumberU8 to NumberU16 on the server and forgets the client, and the result is not a type error but misframed buffers and garbage values, silent in production.
With the Registry there is exactly one table. Widening that NumberU8 is a one-line change that both sides pick up on the next deploy, and the diff reviewer sees the channel’s rate limit, rules, and Open status in the same hunk — the whole security posture of a channel lives in one greppable place.