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.
Send a player action to the server
Section titled “Send a player action to the server”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 endend, 10)
-- Validator 2: state gate — server-side state, never client claimsKernel.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 endend, 20)
-- Handler: runs only after every validator passedNet:onIntent("UseItem", function(session, itemId) session.Data.LastUse = os.clock() applyItem(session, itemId)end)-- Clientlocal 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
Define channels once for both sides
Section titled “Define channels once for both sides”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 calllocal 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)
-- Clientlocal 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 underneathRules: 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
Push updates to clients
Section titled “Push updates to clients”For fire-and-forget notifications (kill feed, toasts, round events):
-- ServerNet:defineState("KillFeed", { "String", "String" }) -- killer, victimNet:broadcastState("KillFeed", killerName, victimName) -- everyoneNet:sendState("KillFeed", somePlayer, killerName, victimName) -- one player-- ClientNet: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
Replicate live state with deltas
Section titled “Replicate live state with deltas”Server-owned data that clients watch. Late joiners get a snapshot automatically; changes go out as field deltas (one changed NumberU16 = 4 bytes):
-- Serverlocal 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)-- Clientlocal 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:
NumberU81B,NumberU162B,NumberU243B,NumberU324B,Vector3S166B (integer studs),Vector3F249B,Vector3F3212B,Boolean1packs 8 flags into 1 byte. A score that can’t exceed 16M inNumberU24instead ofNumberU32saves 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
Ask the server a question
Section titled “Ask the server a question”Request/response with a timeout and a safe rejection value:
-- ServerNet:defineRequest("GetShopStock", { "NumberU16" }, { "NumberU8" }, { RejectValue = 0, Handler = function(session, itemId) return Stock[itemId] or 0 end,})-- Clientlocal GetStock = Net:request("GetShopStock", { "NumberU16" }, { "NumberU8" })local Stock = GetStock.invoke(itemId) -- yields; 0 on rejection or timeoutRequests 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
Decouple systems with events
Section titled “Decouple systems with events”-- 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
Bridge bus events across the network
Section titled “Bridge bus events across the network”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, locallykernel.Bus:publishRemote("Emote.Play", 4) -- rides the intent pipeline upUpward 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
Send events across servers
Section titled “Send events across servers”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 payloadsMessages:subscribe("LiveOps", function(data, sentAt) if data.Event == "BossSpawn" then spawnGlobalBoss(data.BossId) endend)Messages:publish("LiveOps", { Event = "BossSpawn", BossId = 3 }) -- every server receives itPayloads 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
Punish network spammers
Section titled “Punish network spammers”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
Scope replicas to zones
Section titled “Scope replicas to zones”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 functionPlayers 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.