Skip to content

Networking and state

Recipes for validated intents, shared channel definitions, state pushes, delta replicas, requests, the Bus, cross-server events and filtering player text.

Moving data client to server, server to clients, system to system and server to server. Every game channel is declared once in src/Shared/Game/Channels.luau and reaches a service or controller as self.Net (see Channels). Schemas use Packet type names ("NumberU16", "Vector3F24", "String"). Server examples are services in src/Server/Game/Services/; client examples are controllers in src/Client/Game/Controllers/. Recipes that need a published place carry a Status note.

The client sends an intent, the server validates it through a fail-closed hook chain, and only then does the handler act. This one uses a typed schema, a rate limit, a declarative range rule and a game rule:

src/Shared/Game/Channels.luau
--!strict
-- Every network channel the game uses.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Registry = require(ReplicatedStorage.ChloeKernel.Net.Registry)
return Registry.define({
UseItem = {
Kind = "Intent",
Schema = { "NumberU16" }, -- item id
RateLimit = 5, -- sends per second per player
Validate = { [1] = { Range = { 1, 999 } } },
},
})
src/Server/Game/Services/ItemService.luau
--!strict
-- ItemService: consumable items with per-item cooldowns.
local Items = require(script.Parent.Parent.Config.Items) -- { [id] = { Cooldown, Heal } }
local ItemService = {
Name = "ItemService",
}
function ItemService.init(self: any, kernel: any)
self.Kernel = kernel
self.Scope = kernel:createScope()
end
function ItemService.start(self: any)
-- Game rules: server state only, never a client claim. Lower numbers run first
self.Scope:add(self.Kernel.Hooks:on("Intent.UseItem", function(context)
if Items[context.Args[1]] == nil then
return false
end
return os.clock() >= (context.Session.Data.ItemReadyAt or 0)
end, 10))
-- Runs only after every validator passed
self.Net.UseItem.on(function(session, itemId)
local Item = Items[itemId]
session.Data.ItemReadyAt = os.clock() + Item.Cooldown
local Character = session.Player.Character
local Humanoid = Character and Character:FindFirstChildOfClass("Humanoid")
if Humanoid and Humanoid.Health > 0 then
Humanoid.Health = math.min(Humanoid.Health + Item.Heal, Humanoid.MaxHealth)
end
end)
end
function ItemService.stop(self: any)
self.Scope:destroy()
end
return ItemService

The client calls self.Net.UseItem.fire(3).

Each message passes a session check, then the player’s token bucket for that channel, then the Intent.UseItem hook chain, then the handler. The Validate rules install at priority 5, ahead of game hooks, and reject out-of-range or NaN values. Integers saturate into their wire type (300 sent as NumberU8 arrives as 255), so the rule is what rejects a bad payload; the schema only clamps it. A validator that throws counts as a rejection. After the chain the driver re-checks that the session is still active and not locked by a trade, because a game validator may yield. An accepted intent also publishes Intent.UseItem(session, itemId) on the Bus; a rejected one publishes Net.IntentRejected(player, channelName).

Fail-closed covers absence as well as errors. An intent or request channel with no validator hook and no Open = true rejects every payload, whether a handler or a Bus subscriber consumes it: a missing validator is not permission. Validate rules count as a validator; an empty Validate = {} has no rules and installs nothing. A channel that needs no authorization (a payload-less ready-up) says so with Open = true. Kernel:start() warns once for each unguarded channel, Net:auditValidators() returns the list, and the NET debug window shows the verdict Unguarded. Directive CK012 requires RateLimit and either Validate rules or Open = true on every client-to-server channel in Game.Channels, and flags Validate = {}.

Handlers run on their own thread, so one may yield (a TextFilter call, a Matchmaking enqueue). Re-check the session and character after any yield.

Go deeper: NetDriver, Registry, Hooks, Rate limiting.

Registry.define is the single source of truth for every game channel, so schemas cannot drift between client and server. Each kind gets its own handle methods:

src/Shared/Game/Channels.luau
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,
RateLimit = 4,
Validate = { [1] = { Range = { 1, 999 } } },
},
Aim = { Kind = "Unreliable", Schema = { Pitch = "NumberF16", Yaw = "NumberF16" } },
Dash = { Kind = "PredictedIntent", Schema = {}, RateLimit = 4, Open = true },
})
Kind Server handle (self.Net.X) Client handle (self.Net.X)
Intent .on(fn(session, ...)) .fire(...)
PredictedIntent .on(fn(session, ...)) .predict({ Predict = fn })
State .send(player, ...), .broadcast(...) .on(fn(...))
Unreliable .send(player, data), .broadcast(data) .on(fn(data))
Request .handle(fn(session, ...) -> ...) .invoke(...) (yields)

src/Server/Game/init.luau calls Registry.server(kernel, Channels) once and hands the handles to every service; the client entry does the same with Registry.client(Channels). Registry.define shape-checks the table when the module loads, so a mistyped Kind fails the boot instead of a send later. Rules are Range = { min, max } (inclusive, NaN rejected), OneOf = { ... } and MaxLength = n for strings.

Channel names become attribute and remote names, so they use letters, digits and . / - _ only, at most 94 characters, and never start with RBX; "Shop:Buy" fails at define. Unreliable channels keep only the latest datagram per stream (a 2-byte sequence footer drops stale and duplicate ones) and carry at most 980 bytes; set Ordered = false when every datagram is an independent event, and Priority = "High" when players on Poor links must still receive it (the default "Low" sheds to them under a NetGovernor). Kits define their own channels (SH_Buy, ML_Attack, SK_Cast); do not redeclare them here.

Go deeper: Registry, Channels, Packet and wire types, Prediction.

For one-off notifications (a kill feed line, a toast, a round event), a State channel:

-- Server, inside a service
self.Net.KillFeed.broadcast(killer.Name, victim.Name) -- every player
self.Net.KillFeed.send(player, killer.Name, victim.Name) -- one player
-- Client, inside a controller's start()
self.Net.KillFeed.on(function(killer: string, victim: string)
self.Kernel.Bus:publish("Hud.FeedLine", `{killer} eliminated {victim}`)
end)

State channels are one-way, server to client, so there is no client input to validate. Registry.client constructs them at once, so a frame that arrives before .on() still decodes, but frames that arrive before .on() are not delivered to it: connect in a controller’s start(). A screen can be built after the pushes went out (its ScreenGui may arrive at first spawn), so a screen never listens to a State channel for data it needs at once; it reads what a controller kept since boot, or asks through a request. InventoryClient is that controller-side cache for InventoryKit’s INV_Sync. For state that changes over time (scores, health bars, timers), use a replica, which handles late joiners and sends only changes.

Go deeper: NetDriver, NetClient.

Server-owned data that clients watch. A late joiner gets a snapshot; after that, changed fields go out as deltas at the replica’s own cadence:

src/Server/Game/Services/MatchService.luau
--!strict
-- MatchService: the replicated scoreboard.
local ServerScriptService = game:GetService("ServerScriptService")
local ReplicaService = require(ServerScriptService.ChloeKernelServer.Replica)
local MatchService = {
Name = "MatchService",
}
function MatchService.init(self: any, kernel: any)
self.Replicas = ReplicaService.new(kernel)
self.Match = self.Replicas:create("Match", {
Schema = { TimeLeft = "NumberU16", RedScore = "NumberU8", BlueScore = "NumberU8" },
Data = { TimeLeft = 300, RedScore = 0, BlueScore = 0 },
TickRate = 4, -- a scoreboard does not need the default 10 Hz
})
end
function MatchService.score(self: any, team: string)
local Field = `{team}Score`
self.Match:set(Field, self.Match:get(Field) + 1)
end
function MatchService.stop(self: any)
self.Replicas:destroy()
end
return MatchService
src/Client/Game/Controllers/ScoreboardController.luau
--!strict
-- ScoreboardController: follows the Match replica.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ReplicaClient = require(ReplicatedStorage.ChloeKernel.Net.ReplicaClient)
local Schema = { TimeLeft = "NumberU16", RedScore = "NumberU8", BlueScore = "NumberU8" }
local ScoreboardController = {
Name = "ScoreboardController",
}
function ScoreboardController.init(self: any, kernel: any)
self.Kernel = kernel
end
function ScoreboardController.start(self: any)
self.Replicas = ReplicaClient.new()
self.Replicas:listen("Match", Schema, function(match)
self.Kernel.Bus:publish("Hud.Score", match.Data)
-- One notification per changed packet, after every field committed
match.OnBatch:connect(function(_changes)
self.Kernel.Bus:publish("Hud.Score", match.Data)
end)
end)
end
function ScoreboardController.stop(self: any)
if self.Replicas then
self.Replicas:destroy()
end
end
return ScoreboardController

OnChange(key, newValue, oldValue) fires per field; OnBatch(changes) fires once per changed packet with a frozen { [key] = { NewValue, OldValue } }, after all fields are committed, and never on creation. Keep the schema in a shared module so the two sides cannot drift. To show different players different data, pass Interest = function(player) return ... end; it must return exactly true to subscribe, and it is polled every 1 / TickRate unless you set InterestRefreshSeconds or InterestMode = "Manual" (then call replica:invalidateInterest() or Replicas:refreshPlayer(player) when the answer changes).

Costs, cheapest levers first:

  • Every field costs its width: NumberU8 1 byte, NumberU16 2, NumberU24 3, NumberU32 4, Vector3S16 6, Vector3F24 9, Vector3F32 12, Boolean8 1. A delta changing one NumberU16 has a 4-byte body; each replica packet also carries the replica’s 2-byte id and two 4-byte counters (generation and revision), plus Packet framing.
  • Quantize = { Position = 0.1 } skips a write that moved less than the threshold from the last replicated value. Drift stays bounded by the threshold, so slow movement still replicates once it accumulates.
  • A lower TickRate batches every change in the window into one delta.
  • Split replicas by audience and cadence: a small fast one scoped by interest plus a wide slow one beats one replica ticking everything at the fastest field’s rate.

ReplicaService.new(kernel, { MaxLiveSlots }) caps replicas alive at once (default and ceiling 65535). Deploy the server and client together: replicas use the CKRep2_* wire.

Go deeper: Replica, NetGovernor, Debug panels.

A Request answers one client with a typed response:

src/Server/Game/Services/ShopStockService.luau
function ShopStockService.start(self: any)
-- self.Stock is this service's { [itemId] = count }
self.Net.GetStock.handle(function(_session, itemId: number)
return self.Stock[itemId] or 0
end)
end
-- Client: yields; 0 on rejection or timeout
local Stock = self.Net.GetStock.invoke(itemId)

Requests use the same pipeline as intents, but their hook chain is Request.<Name> (Request.GetStock), and a request with no validator and no Open rejects every call. RejectValue is what the caller receives for a rejection, a missing handler, a session that changed, or a timeout, so pick a value that reads as “no” in game terms. It must encode under the Response schema, or defineRequest errors at boot; an { "Any" } response accepts a nil RejectValue. The client gives up after 10 seconds (the ResponseTimeoutSeconds attribute on the Packet module). Registry.client passes the RejectValue to the client handle, so a timeout returns it too.

Go deeper: NetDriver, NetClient.

-- The quest service does not require the combat service
Kernel.Bus:subscribe("Combat.Kill", function(_topic, killerSession, victim)
advanceQuest(killerSession, "KillEnemies")
end)
-- A family subscription sees every topic in it
Kernel.Bus:subscribe("Combat.*", function(topic, ...)
track(topic, ...)
end)

Handlers always receive the topic first, which is what makes Family.* subscriptions work. Publishers and subscribers never require each other; the Bus is the only shared dependency, which is why kits can publish Inventory.Changed or Round.PhaseChanged without knowing who listens. subscribe returns a connection: add it to a scope or disconnect it in stop().

Go deeper: Bus, Hooks and Bus topics.

BusBridge forwards marked topics over the wire as ordinary Bus publishes, with no channel per event family:

-- Server, in a service's init()
BusBridge.attach(kernel, {
ServerTopics = { "Round.*", "Combat.Kill" }, -- forwarded to every client
ClientTopics = { "Emote.Play" }, -- exact names clients may publish upward
ClientSchemas = { ["Emote.Play"] = { "NumberU8" } },
RateLimit = 20, -- per player, upward
})
kernel.Bus:publishRemote("Server.Announcement", "Double XP weekend")
-- Client publishes arrive with the session prepended
kernel.Bus:subscribe("Emote.Play", function(_topic, session, emoteId) end)
-- Client, in a controller's init()
BusBridgeClient.attach(kernel, { ClientSchemas = { ["Emote.Play"] = { "NumberU8" } } })
kernel.Bus:subscribe("Round.*", function(topic, ...) end)
kernel.Bus:publishRemote("Emote.Play", 4)

Upward publishes pass a per-player rate limit, an exact-name allowlist (no wildcards upward), a typed schema per topic, and the Intent.BusPublish.<Topic> hook chain for game rules. The upward channels are defined Open = true, so hooks are optional and the security audit lists each one for review. A client topic without a ClientSchemas entry, or with an invalid name, errors at attach. Arguments keep their positions across the wire, nil included. Use one mechanism per topic: a topic in ServerTopics that you also publishRemote arrives twice.

Go deeper: BusBridge, Bus.

MessageDriver carries events between servers. MemoryDriver holds cross-server state and DataDriver holds storage:

local Messages = MessageDriver.new() -- { Codec = Serde.schema({...}) } packs payloads; { Compress = true } adds Zstd
Messages:subscribe("LiveOps", function(data, sentAt)
if data.Event == "BossSpawn" then
spawnGlobalBoss(data.BossId)
end
end)
local Ok, Err = Messages:publish("LiveOps", { Event = "BossSpawn", BossId = 3 })

Payloads are Serde-packed and base64-encoded, and publish returns false and a reason instead of throwing when a payload passes the 950-character guard under MessagingService’s 1 KB limit (send a MemoryDriver key instead of a large body). Compress = true Zstd-compresses a payload through EncodingService when that makes it smaller; every publisher and subscriber of a topic must use the same setting. The driver enforces MessagingService limits up front: topics of 1 to 80 characters, a send budget of 600 + 240 x players per minute, and a warning past 20 + 8 x players subscriptions. Only transient failures retry (1, 2 and 4 seconds, jittered by up to 25 percent); quota, size and argument errors return at once. Messages:usage() reports { SendLimit, SendTokens, Subscriptions, SubscriptionLimit }. Decode failures and throwing subscribers are isolated per message.

Go deeper: MessageDriver, Serde.

Text one player writes and other players see (a sign, a pet name, a guild name) must pass TextService:FilterStringAsync. TextFilter wraps it and fails closed:

src/Shared/Game/Channels.luau
SetSign = { Kind = "Intent", Schema = { "String" }, RateLimit = 1, Validate = { [1] = { MaxLength = 60 } } },
SignChanged = { Kind = "State", Schema = { "String", "String" } }, -- author, filtered text
src/Server/Game/Services/SignService.luau
--!strict
-- SignService: player-written signs, filtered before anyone sees them.
local ServerScriptService = game:GetService("ServerScriptService")
local TextFilter = require(ServerScriptService.ChloeKernelServer.TextFilter)
local SignService = {
Name = "SignService",
}
function SignService.init(self: any, _kernel: any)
self.Filter = TextFilter.new()
end
function SignService.start(self: any)
self.Net.SetSign.on(function(session, text: string)
local Shown = self.Filter:broadcast(session, text) -- yields; nil on any failure
if Shown == nil or not session.Active then
return -- show nothing, never the raw text
end
self.Net.SignChanged.broadcast(session.Player.Name, Shown)
end)
end
return SignService

broadcast(session, text) filters for the strictest audience (the public chat context, then GetNonChatStringForBroadcastAsync), which is right for text that outlives its author. forUser(session, text, viewer) filters for one recipient’s age and settings. Any error returns nil. Neither retries, because FilterStringAsync retries internally, and the author must still be in the server, so filter when the text is submitted and store the filtered result. Keep a MaxLength rule on the channel. A Player settings string rule with Filter = "Broadcast" does all of this for a stored setting (see Save player settings and keybinds).

Go deeper: TextFilter.

Rate limiting runs per player and per channel with no setup. What you add is the consequence:

Kernel.Bus:subscribe("Net.RateLimited", function(_topic, player, channelName)
noteStrike(player, "rate")
end)
Kernel.Bus:subscribe("Net.FloodDetected", function(_topic, player, bytes, droppedBytes, budgetBytes, reason, count)
if reason == "Malformed" then
-- A call the client transport never sends: forged
Bans:ban(player, { DisplayReason = "Banned for exploiting.", Violation = "Malformed" })
else
noteStrike(player, "flood")
end
end)

There are two layers. Net.RateLimited(player, channelName) is a channel’s token bucket rejecting a send over its RateLimit (30 per second when a channel sets none). Net.FloodDetected is the transport underneath: 8,000 bytes per window, with every remote call charged at least 800 bytes, plus a message bucket of 240 per second (burst 128). A client can stay under every channel’s rate and still flood raw bytes, which is why both exist. Flood reports are coalesced to one per player and reason per window, with reason one of ByteLimit, MessageLimit, DuplicateRequest or Malformed and count the reports folded in. Bans here is an AntiExploit Consequences instance (see Ban a cheater across servers).

Go deeper: Rate limiting, Consequences.

local Replicas = ReplicaService.new(kernel)
local Regions = Zones.attach(kernel)
Regions:add("Dungeon", workspace.DungeonVolume)
local DungeonState = Replicas:create("DungeonState", {
Schema = { BossHealth = "NumberU32", Phase = "String" },
Data = { BossHealth = 5000, Phase = "Idle" },
})
local Unbind = DungeonState:bindZone(Regions, "Dungeon")

Players outside the dungeon receive nothing for its state. Entering delivers the full snapshot when Zone.Entered fires, without waiting for the replica’s next interest check, and leaving unsubscribes the same way. Players already inside when the bind lands still converge through isInside. Unbind() restores the interest and refresh rate the bind replaced; with several binds stacked on one replica, each unbind removes only its own.

Go deeper: Replica, Zones.

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