Skip to content

Networking & State

Complete, copyable recipes for moving data — client to server, server to clients, system to system, and server to server. Kernel is the booted server kernel unless stated otherwise; channel schemas use Packet type names ("NumberU16", "Vector3F24", …) — keep them in one shared module so both sides agree. Recipes Studio can’t fully exercise carry a Status note; everything else has been verified live in Studio play sessions.

Client requests, server validates through a fail-closed hook chain, server acts. The example exercises every part of the feature: a typed schema, a rate limit, two independent validators, and the handler.

-- Server (Bootstrap or a service)
local Net = Kernel:net()
Net:defineIntent("UseItem", { "NumberU16" }, { RateLimit = 5 }) -- schema: one u16 arg; ≤5 sends/sec/player
-- Validator 1: argument validation (priority 10 runs first)
Kernel.Hooks:on("Intent.UseItem", function(context)
if ItemCatalog[context.Args[1]] == nil then
return false -- unknown id: rejected, the handler never runs
end
end, 10)
-- Validator 2: state gate — server-side state, never client claims
Kernel.Hooks:on("Intent.UseItem", function(context)
local LastUse = context.Session.Data.LastUse or 0
if os.clock() - LastUse < ItemCatalog[context.Args[1]].Cooldown then
return false
end
end, 20)
-- Handler: runs only after every validator passed
Net:onIntent("UseItem", function(session, itemId)
session.Data.LastUse = os.clock()
applyItem(session, itemId)
end)
-- Client
local Net = NetClient.new()
local UseItem = Net:intent("UseItem", { "NumberU16" })
UseItem.fire(3)

Rejected intents cost the client nothing and the server ~150ns through the chain. A crashing validator counts as a rejection (fail-closed). Lower priority numbers run first.

Fail-closed means absence too, not just errors. An intent or request that runs a handler with no validator registered rejects every payload — a missing validator is not permission. This closes the classic footgun: an unguarded GrantCoins/SellItem intent where the handler trusts a client-supplied amount. If a channel genuinely needs no authorization (a payload-less ready-up, an idempotent toggle), acknowledge it explicitly with { Open = true }; declaring Validate rules counts as a validator. Net:auditValidators() lists any handler-bearing channel still missing both — call it after boot for a proactive sweep, and watch for the one-time [NetDriver] ... fail-closed warning (and the Unguarded verdict in the NET panel) when a channel rejects for this reason.

Go deeper: NetDriver · NetClient · Hooks · Rate limiting

A single shared module is the source of truth for every channel — schemas cannot drift between client and server, and declarative argument rules compile into the fail-closed hook chains:

-- ReplicatedStorage.Game.Channels (a module your game owns)
local Registry = require(ReplicatedStorage.ChloeKernel.Net.Registry)
return Registry.define({
UseItem = {
Kind = "Intent",
Schema = { "NumberU16" },
RateLimit = 5,
Validate = { [1] = { Range = { 1, 999 } } }, -- rejects out-of-range ids before any game code
},
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 }, -- any client may dash; reconciled, not gated per-arg
})
-- Server (Bootstrap): every channel defined + validators installed in one call
local Net = Registry.server(Kernel, Channels)
Net.UseItem.on(function(session, itemId) ... end)
Net.KillFeed.broadcast(killerName, victimName)
Net.GetStock.handle(function(session, itemId) return Stock[itemId] or 0 end)
-- Client
local Net = Registry.client(Channels)
Net.UseItem.fire(3)
Net.KillFeed.on(function(killer, victim) ... end)
local Stock = Net.GetStock.invoke(3)
local Dash = Net.Dash.predict({ Predict = dashLocally }) -- Prediction.wrap underneath

Rules: Range = {min, max} (numbers, inclusive), OneOf = {...} (allowed values), MaxLength = n (strings). They install at priority 5, ahead of game validators, and REJECT — type coercion already clamps values into the wire type; rules express game limits the schema cannot. Registry.define shape-checks the table at load, so a typo’d Kind fails the boot instead of a random send later.

Go deeper: Registry · Packet & wire types · Prediction

For fire-and-forget notifications (kill feed, toasts, round events):

-- Server
Net:defineState("KillFeed", { "String", "String" }) -- killer, victim
Net:broadcastState("KillFeed", killerName, victimName) -- everyone
Net:sendState("KillFeed", somePlayer, killerName, victimName) -- one player
-- Client
Net:onState("KillFeed", { "String", "String" }, function(killer, victim)
addFeedLine(`{killer} eliminated {victim}`)
end)

State channels are one-way, server to client — there is no client handler to guard, so no validator chain applies. For state that changes over time (scores, health bars, timers), use a replica instead — it handles late joiners and only sends changes.

Go deeper: NetDriver · NetClient

Server-owned data that clients watch. Late joiners get a snapshot automatically; changes go out as field deltas (one changed NumberU16 = 4 bytes):

-- Server
local Replicas = ReplicaService.new(Kernel)
local Match = Replicas:create("Match", {
Schema = { TimeLeft = "NumberU16", RedScore = "NumberU8", BlueScore = "NumberU8" },
Data = { TimeLeft = 300, RedScore = 0, BlueScore = 0 },
TickRate = 4, -- scoreboard doesn't need 60Hz
})
Match:set("RedScore", Match:get("RedScore") + 1)
-- Client
local Replicas = ReplicaClient.new()
Replicas:listen("Match", { TimeLeft = "NumberU16", RedScore = "NumberU8", BlueScore = "NumberU8" }, function(match)
Scoreboard.update(match.Data)
match.OnChange:connect(function(key, value)
Scoreboard.update(match.Data)
end)
end)

To show different players different data (team-only info, proximity), pass Interest = function(player) return ... end — players entering interest get the snapshot, leaving gets a remove.

Slimming the wire. Per-recipient deltas are already small (the NET window’s fan-out totals are per-message bytes x recipients, not one buffer); the levers that shrink them further, cheapest first:

  • Pick the narrowest type that fits. Every field costs exactly its width: NumberU8 1B, NumberU16 2B, NumberU24 3B, NumberU32 4B, Vector3S16 6B (integer studs), Vector3F24 9B, Vector3F32 12B, Boolean1 packs 8 flags into 1 byte. A score that can’t exceed 16M in NumberU24 instead of NumberU32 saves a byte on every delta forever.
  • Quantize chatty fields. Quantize = { Position = 0.1 } deadbands a field: writes that moved less than the threshold from the last replicated value update server data but skip the wire entirely. Drift is bounded by the threshold (slow movement still replicates once it accumulates), so pick the error nobody can see. A 20Hz position wiggling by hundredths of a stud goes from 20 deltas/s to zero.
  • Lower TickRate. Dirty fields coalesce until the replica’s own cadence — a 4Hz scoreboard batches everything that changed in 250ms into one delta envelope instead of fifteen.
  • Split replicas by audience and cadence. One fast small replica (positions, 20Hz, Interest-scoped) plus one slow wide one (stats, 2Hz, broadcast) beats a single replica ticking everything at the fastest field’s rate.

Go deeper: Replica · Packet & wire types · Debug panels

Request/response with a timeout and a safe rejection value:

-- Server
Net:defineRequest("GetShopStock", { "NumberU16" }, { "NumberU8" }, {
RejectValue = 0,
Handler = function(session, itemId)
return Stock[itemId] or 0
end,
})
-- Client
local GetStock = Net:request("GetShopStock", { "NumberU16" }, { "NumberU8" })
local Stock = GetStock.invoke(itemId) -- yields; 0 on rejection or timeout

Requests ride the same fail-closed chain as intents — a request with a handler and no validator rejects everything, and RejectValue is what the caller sees for a rejection or a timeout, so the client never has to distinguish failure modes. Pick a RejectValue that reads as “no” in game terms.

Go deeper: NetDriver · NetClient

-- the quest system doesn't import the combat system:
Kernel.Bus:subscribe("Combat.Kill", function(_, killerSession, victim)
advanceQuest(killerSession, "KillEnemies")
end)
-- analytics sees everything in a family without knowing anyone:
Kernel.Bus:subscribe("Combat.*", function(topic, ...)
track(topic, ...)
end)

Handlers always receive the topic as the first argument — that’s what makes wildcard subscribers possible (a Family.* subscription matches every topic in the family). Publishers and subscribers never import each other; the bus is the only shared dependency, which is why kits can ship events (Inventory.Changed, Round.PhaseChanged, …) without dictating who consumes them.

Go deeper: Signals & Bus · Hooks & bus topics reference

The Bus’s NetBridge slot, fulfilled: attach BusBridge and marked topics cross the wire as ordinary bus publishes — no channel boilerplate per event family.

-- Server (Bootstrap)
BusBridge.attach(Kernel, {
ServerTopics = { "Round.*", "Combat.Kill" }, -- auto-forward to every client
ClientTopics = { "Emote.Play" }, -- EXACT names clients may publish upward
ClientSchemas = { ["Emote.Play"] = { "NumberU8" } },
RateLimit = 20, -- per-player upward budget
})
Kernel.Bus:publishRemote("Server.Announcement", "Double XP weekend") -- any topic, explicit
-- client publishes arrive validated, with the session prepended:
Kernel.Bus:subscribe("Emote.Play", function(_, session, emoteId) ... end)
-- Client (Bootstrap)
BusBridgeClient.attach(kernel, {
ClientSchemas = { ["Emote.Play"] = { "NumberU8" } },
})
kernel.Bus:subscribe("Round.*", function(topic, ...) ... end) -- server events, locally
kernel.Bus:publishRemote("Emote.Play", 4) -- rides the intent pipeline up

Upward publishes pass the standard gates: per-player rate limit, exact-name whitelist (no wildcards up, fail-closed), typed schemas per topic, and the Intent.BusPublish.<Topic> hook chain for game validators. Use one mechanism per topic — a topic in ServerTopics that is also publishRemoted forwards twice.

Go deeper: BusBridge · Signals & Bus

MessageDriver completes the infrastructure trio — MemoryDriver owns cross-server state, DataDriver owns storage, this owns events:

local Messages = MessageDriver.new() -- or { Codec = Serde.schema({...}) } for packed payloads
Messages:subscribe("LiveOps", function(data, sentAt)
if data.Event == "BossSpawn" then spawnGlobalBoss(data.BossId) end
end)
Messages:publish("LiveOps", { Event = "BossSpawn", BossId = 3 }) -- every server receives it

Payloads are Serde-packed and base64’d; the ~1KB MessagingService ceiling is guarded with a clear error (send a MemoryDriver key instead of a large body). Publishes retry with backoff; decode failures and crashing subscribers are isolated per message.

Go deeper: MessageDriver · Serde & Base64

Rate limiting is automatic (per player, per channel, token bucket). What you add is policy:

Kernel.Bus:subscribe("Net.RateLimited", function(_, player, channelName)
noteStrike(player, "rate")
end)
Kernel.Bus:subscribe("Net.FloodDetected", function(_, player, bytes, payloadBytes, budgetBytes)
-- transport-level flooding (Packet's 8KB/heartbeat cap): heavier hand
noteStrike(player, "flood")
end)

Two layers, two topics. Net.RateLimited is the per-channel token bucket rejecting a send that exceeded its declared RateLimit. Net.FloodDetected is the transport-level byte budget underneath — a client can stay under every channel’s rate and still flood raw bytes, which is why both exist. Escalation (strikes, kicks, bans) is deliberately your policy, not the framework’s.

Go deeper: Rate limiting · Signals & Bus

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" },
})
DungeonState:bindZone(Regions, "Dungeon") -- returns an unbind function

Players outside the dungeon pay zero bytes for its state. Entering delivers the full snapshot the moment Zone.Entered fires — no waiting for the replica’s next interest scan — and leaving unsubscribes the same way. The scan stays wired underneath (via isInside), so anyone already in the zone when the bind lands still converges.

Go deeper: Replica · Zones