Skip to content

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 },
})

One table, three consumers:

  1. Registry.define(definitions) shape-checks every entry at module load and returns the table frozen. A typo’d Kind, a Request without a Response, a Validate on a server → client channel — each fails the boot with a named error instead of a random send failing later.
  2. Registry.server(kernel, definitions) walks the table on the server: defines every channel on the NetDriver, compiles Validate rules into validators registered at priority 5 on the channel’s hook point, and returns typed handles (.on, .handle, .send, .broadcast).
  3. 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.

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.

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.

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 (“amount must not exceed the stack size of itemId”) 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 0
end)
Net.KillFeed.broadcast(killerName, victimName)
Net.Aim.broadcast({ Pitch = Boss.Pitch, Yaw = Boss.Yaw })

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.

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.