Replica
Server-owned state replicated to interested clients as field deltas, delivered when data changes, with snapshots for joiners, interest scoping and stale-packet protection.
- Realm
- Server + Client
- Require
ServerScriptService.ChloeKernelServer .Replica - Since
- v0.1.0
- Source
- Replica.luauNet/ReplicaClient.luau
A replica is server-owned data that clients watch: scores, timers, boss health, wallet balances. The server never
re-sends state. It sends only the fields that changed, as field-indexed deltas, and each replica sends at most
TickRate deltas a second on its own schedule. Delivery is driven by writes: a replica nobody writes to, and that does
not poll interest, costs no scheduled work. Joiners get a full snapshot at once; everyone else pays only for changes.
One changed NumberU16 is a 4-byte delta payload.
ReplicaService (ServerScriptService.ChloeKernelServer.Replica) owns replicas on the server and ReplicaClient
(ReplicatedStorage.ChloeKernel.Net.ReplicaClient) mirrors them on the client. For one-shot events with no history (a
kill-feed line, a weather flip), use a State channel instead. Replicas are for state
that changes over time and must survive a late join. NetGovernor slows delta delivery to
clients on bad links.
Mental model
Section titled “Mental model”create() compiles the schema into a Serde struct codec. The struct gives every field a 1-byte id by
sorting field names, so a server and a client that agree on the schema agree on the ids without exchanging anything.
Structs support at most 255 fields.
Each replica carries a dirty set. set() writes into Data at once and marks the field dirty; nothing touches the
network until the replica’s next delivery. A delta buffer is 1 byte of changed-field count, then per field 1 byte of
field id and the field’s exact schema width.
Delivery
Section titled “Delivery”Replicas with the same TickRate and Priority share one Scheduler job, which visits only its work set: replicas with
dirty fields, replicas with delivery debt to a slow client, replicas whose interest was invalidated, and replicas that
poll interest. Quiet members stay registered and cost nothing.
- A
setthat changes a value marks the field dirty and schedules the job atmax(now, last flush + 1 / TickRate). The first write after a quiet period goes out on the next Scheduler step. Later writes coalesce until the replica’s next deadline, soset("Wave", 2)thenset("Wave", 3)sends one delta withWave = 3. - A
setthat does not change a scalar value costs nothing: it does not mark the field or wake the scheduler. Table values are exempt from that check, because identity says nothing about a mutated table’s contents; setting a table always replicates it. - At delivery the dirty set is detached and encoded once, and the buffer goes to every subscriber without delivery debt. With a NetGovernor attached, subscribers on slow links receive only every Nth delivery and bank the fields in between. Writes that land while a send yields belong to the next batch.
- A replica with no dirty fields, no debt and no interest polling leaves no scheduled job.
Interest and joins
Section titled “Interest and joins”A replica with no Interest callback goes to every player, and needs no periodic scan because player and session events
cover arrivals and departures. A replica with an Interest callback polls it once per TickRate interval by default
(InterestRefreshSeconds sets another interval), or never with InterestMode = "Manual". See Interest.
- Joins: when a session starts, the service runs
refreshPlayer(player), which evaluates every replica’s interest for that player at once and sends a snapshot for each match.PlayerAddedalso invalidates interest on every replica that is not Manual, which wakes poll groups that slept on an empty server. Joins do not invalidate Manual replicas; the joiner’s own refresh still evaluates them. - Empty server: a poll group with no players sleeps until a player joins.
- Departures: a leaving player is removed from every replica’s subscribers on
Kernel.SessionEnd, with aPlayerRemovingsweep as a backstop. A player who leaves while anInterestcallback yields is not resubscribed when the callback returns.
The three packets
Section titled “The three packets”Replica protocol V2 uses three Packet channels. Every packet carries the replica’s U16 slot, a U32 generation and a U32 revision:
| Wire channel | Arguments | Frame size, small payloads |
|---|---|---|
CKRep2_Create |
slot NumberU16, generation NumberU32, revision NumberU32, name StringLong, snapshot BufferLong |
16 bytes + name + snapshot |
CKRep2_Delta |
slot, generation, revision, delta BufferLong |
14 bytes + delta |
CKRep2_Remove |
slot, generation, revision | 12 bytes |
Frame sizes include the id byte and the one-byte varint length Packet adds
to server-to-client frames under 128 body bytes. One changed NumberU16 field is an 18-byte delta frame per recipient.
Fan-out totals are frame size times recipients; the NET panel shows the per-recipient size and
the total so a 20-byte delta to 30 players is not misread as one 600-byte buffer.
Slots, generations and revisions
Section titled “Slots, generations and revisions”Slots limit how many replicas are alive at once; lifetime creations are unlimited. Destroying a replica frees its slot,
and the next replica in that slot gets a strictly higher generation, even with the same name. MaxLiveSlots (default
and ceiling 65,535, the U16 range) caps live replicas; create asserts
replica live slot range exhausted (N slots) when every slot is taken.
The allocator’s state is keyed by the shared Packet channel registry. It survives a service’s destroy() and
recreation while clients stay connected, and concurrent services on that registry share its slot pool.
The revision increases with every packet the replica sends, to any subscriber. The client keeps one
(generation, revision) watermark per slot, including for removed replicas, and rejects any create, delta or remove that
is not newer before decoding it. That makes stale packets harmless: an old delta cannot write into a slot’s new
occupant, and a delayed snapshot or remove cannot undo a newer interest rejoin within the same incarnation.
Counters never wrap. A slot whose generation reaches 4,294,967,295 is retired when destroyed, which lowers live capacity
instead of reusing an old generation. A replica reserves its final revision for removal and asserts
replica packet revision exhausted; destroy and recreate the replica once it reaches 4,294,967,294.
Try it
Section titled “Try it”Change fields and watch the dirty set. Each change schedules a delivery at most 1 / TickRate after the last one,
writes inside that window merge, and an unchanged value sends nothing. The frame shows the real CKRep2_Delta
layout: 14 bytes of header, then the delta. The channel id and slot are this example’s.
Keep the schema in one shared module so both sides compile the same field ids:
--!strictreturn { TimeLeft = "NumberU16", RedScore = "NumberU8", BlueScore = "NumberU8" }--!strict-- MatchService: owns the round scoreboard replica.
local ReplicatedStorage = game:GetService("ReplicatedStorage")local ServerScriptService = game:GetService("ServerScriptService")
local MatchSchema = require(ReplicatedStorage.Game.Config.MatchSchema)local ReplicaService = require(ServerScriptService.ChloeKernelServer.Replica)
local MatchService = { Name = "MatchService", Dependencies = {},}
function MatchService.init(self: any, kernel: any) self.Kernel = kernel self.Replicas = ReplicaService.new(kernel) self.Match = self.Replicas:create("Match", { Schema = MatchSchema, Data = { TimeLeft = 300, RedScore = 0, BlueScore = 0 }, TickRate = 4, -- a scoreboard needs at most 4 deltas a second })end
function MatchService.start(_self: any) end
function MatchService.scoreRed(self: any) self.Match:set("RedScore", self.Match:get("RedScore") + 1)end
function MatchService.setClock(self: any, timeLeft: number) self.Match:patch({ TimeLeft = timeLeft }) -- several fields still send one deltaend
function MatchService.stop(self: any) self.Replicas:destroy()end
return MatchService--!strict-- ScoreboardController: mirrors the Match replica into the scoreboard.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local MatchSchema = require(ReplicatedStorage.Game.Config.MatchSchema)local ReplicaClient = require(ReplicatedStorage.ChloeKernel.Net.ReplicaClient)
local ScoreboardController = { Name = "ScoreboardController", Dependencies = {},}
local function render(data: { [string]: any }) print(`{data.RedScore} : {data.BlueScore}, {data.TimeLeft} s left`)end
function ScoreboardController.init(self: any, kernel: any) self.Kernel = kernel self.Replicas = ReplicaClient.new() self.Replicas:listen("Match", MatchSchema, function(match) local Lifetime = kernel:createScope() render(match.Data) -- creation fires no change notification Lifetime:add(match.OnBatch:connect(function() render(match.Data) -- once per packet, however many fields changed end)) Lifetime:add(match.OnRemove:connect(function() Lifetime:destroy() end)) end)end
function ScoreboardController.start(_self: any) end
function ScoreboardController.stop(self: any) self.Replicas:destroy()end
return ScoreboardControllerThe listen callback runs on its own thread once per replica incarnation with that name, so a replica that is destroyed
and recreated calls it again with a new handle. match.Data is the client’s decoded copy; local writes replicate
nowhere.
Change notifications
Section titled “Change notifications”ReplicaClient commits every field of a decoded packet into Data before any callback runs. Then:
OnChange(key, newValue, oldValue)fires once per changed field.OnBatch(changes)fires once after them, withchangesmapping each field to{ NewValue, OldValue }. The map and its records are frozen (shallow; nested values keep their references). A field removed by a full resync hasNewValue == nil.
Equality is the ordinary Luau value or reference comparison, not deep table equality. The initial creation and packets
with no detected change fire neither signal. Yielding listeners are not awaited, and field order is unspecified.
Destroying the replica or the client inside an OnChange callback stops the remaining notifications, including the
batch. Both signals are destroyed with the replica.
OnBatch (added in 0.8.0) is for views that redraw once per packet. It adds a small allocation per changed packet, and
it saves work only when a view moves its combined refresh from per-key subscriptions to the batch.
Boot order never loses state
Section titled “Boot order never loses state”Replica traffic can reach a client before its code is ready, at two levels:
- Before
ReplicaClient.new()runs, theCKRep2_*channels do not exist on the client. Packet parks those frames, at most 64 per channel and each for 10 seconds, and replays them in order once the client constructs the channels. - After construction, snapshots and deltas for a name nobody
listens to yet are buffered and replayed in order the moment the listener registers, so the callback sees converged data. At most 64 pending replicas are kept (the oldest is evicted with a warning), and at most 64 deltas per pending replica (the oldest drops, with one warning).
A delta that arrives before its incarnation’s snapshot cannot create a replica and is dropped. Revisions reject
reordered and duplicate packets, but they cannot rebuild a delta that was discarded. After a gap, call the server’s
resyncPlayer(player) to send a fresh snapshot.
Interest
Section titled “Interest”Interest = function(player) return ... end scopes a replica to some players. Entering interest delivers the full
snapshot; leaving delivers a remove, and the client fires OnRemove and tears the handle down. Interest is enforced at
send time on the server, so other clients never receive a byte of a scoped replica.
| Mode | When interest is checked | Use for |
|---|---|---|
No Interest |
Player and session events only | Replicas everyone sees |
Poll (default with Interest) |
Every 1 / TickRate seconds, or every InterestRefreshSeconds |
Rules that change continuously, such as distance |
InterestMode = "Manual" |
At creation, then only on refreshPlayer(player), replica:invalidateInterest() or a session start |
Ownership, team membership and other rules whose changes already have events |
In Manual mode the game calls one of those after every policy change. Do not use it for distance checks that change without an event.
--!strict-- WalletService: one private wallet replica per player.
local ServerScriptService = game:GetService("ServerScriptService")
local ReplicaService = require(ServerScriptService.ChloeKernelServer.Replica)
local WalletService = { Name = "WalletService", Dependencies = {},}
local WalletSchema = { Coins = "NumberU32", Gems = "NumberU16" }
function WalletService.init(self: any, kernel: any) self.Kernel = kernel self.Replicas = ReplicaService.new(kernel) kernel:onSession(function(session) local Owner = session.Player local Wallet = self.Replicas:create(`Wallet_{Owner.UserId}`, { Schema = WalletSchema, Data = { Coins = 0, Gems = 0 }, TickRate = 2, InterestMode = "Manual", -- the owner never changes, so nothing needs polling Interest = function(viewer: Player) return viewer == Owner end, }) session:bind(function() Wallet:destroy() end) Wallet:set("Coins", 150) end)end
function WalletService.start(_self: any) end
function WalletService.stop(self: any) self.Replicas:destroy()end
return WalletServiceWhen access changes for a known viewer, refresh that viewer: Replicas:refreshPlayer(viewer) evaluates every replica
in the service for that player at once. When a change affects an unknown group, call replica:invalidateInterest() to
recheck every viewer on the next Scheduler step without marking data dirty.
Replicas:resyncPlayer(player) replays full state for every replica the player is interested in, and removes the ones
they are not. ReplicaClient applies a repeated snapshot to the existing replica and signals the changed fields, so UI
bindings survive recovery.
Quantize (deadbanding)
Section titled “Quantize (deadbanding)”Quantize = { Progress = 0.1 } gives a field a deadband. A write that moved less than the threshold from the last
replicated value updates server Data but skips the wire. The comparison is against a per-field baseline, not the
previous write, so slow drift still replicates once it adds up past the threshold. A client’s view never lags the
server by more than the threshold. Pick an error nobody can see: 0.1 studs, 1 score point.
local Track = Replicas:create("Track", { Schema = { Progress = "NumberF32", Wave = "NumberU16" }, Data = { Progress = 0, Wave = 1 }, Quantize = { Progress = 1 },})
Track:set("Progress", 0.4) -- Data = 0.4, nothing sent (0.4 from baseline 0)Track:set("Progress", 0.8) -- Data = 0.8, nothing sent (still under 1 from baseline 0)Track:set("Progress", 1.2) -- drift crossed 1: replicates, baseline = 1.2Deadbanding applies to number (absolute difference), Vector3 and Vector2 (magnitude of the difference). Any other
type, or a write that changes the value’s type, always replicates.
A joiner’s snapshot carries raw Data while older subscribers sit at the baseline, so without correction the two could
differ by up to twice the threshold. Whenever a player joins (by scan, refresh, resync or zone entry), pending
sub-threshold drift on quantized fields is flushed as a delta to the older subscribers. The joiner is skipped because its
snapshot already has the value. Every view converges within one delivery.
Slimming the wire
Section titled “Slimming the wire”Per-recipient deltas are already small. The levers that shrink them further, cheapest first:
- Pick the narrowest wire type that fits. Every field costs exactly its width:
NumberU81 byte,NumberU162,NumberU243,NumberU324,Boolean81,Vector3S166 (integer studs),Vector3F249,Vector3F3212. A score that never exceeds 16 million inNumberU24instead ofNumberU32saves a byte on every delta. Replica schemas are Serde struct types, so Packet’s bit-packedBoolean1is not available here. - Quantize noisy fields. A 20 Hz position wobbling by hundredths of a stud goes from 20 deltas a second to none.
- Lower
TickRate. Dirty fields coalesce until the replica’s own deadline, so a 4 Hz scoreboard sends everything that changed in 250 ms as one delta. - Split replicas by audience and cadence. One fast, small, interest-scoped replica (positions at 20 Hz) and one slow, wide, broadcast replica (stats at 2 Hz) beat a single replica sending every field at the fastest field’s rate.
Zone-scoped interest
Section titled “Zone-scoped interest”replica:bindZone(zones, zoneName) scopes a replica to a zone. Players outside it pay nothing for its
state:
local ServerScriptService = game:GetService("ServerScriptService")
local Zones = require(ServerScriptService.ChloeKernelServer.Zones)
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")bindZone does two things:
- It replaces the replica’s
Interestwithzones:isInside(zoneName, player), so scans agree with the events. Unless the replica setInterestRefreshSecondsor Manual mode, it also polls at1 / TickRateas a safety check. - It subscribes to
Zone.EnteredandZone.Lefton the Bus and flips the subscription at once: the snapshot goes out whenZone.Enteredfires, and the remove whenZone.Leftfires.
Players already inside when the bind lands, and any case the events miss, converge on the next interest check. A zone entry also flushes quantize drift, so the entrant may get one harmless duplicate delta. Zone events for tracked non-player entities are ignored, and an entry for a player who already left is ignored too.
The returned function unbinds. It disconnects the zone subscriptions and gives back the Interest and
InterestRefreshSeconds the bind replaced, unless the game changed them since. With stacked binds, unbinding one in the
middle hands its saved values to the bind above it. Before 0.9.0 an unbound replica stayed zone-scoped and polled
forever. destroy() unbinds automatically.
Destroy semantics
Section titled “Destroy semantics”replica:destroy() is idempotent. It unbinds every zone bind, leaves its Scheduler job (the job is cancelled when its
last member leaves), frees its slot before any callback can run, and sends CKRep2_Remove to every current subscriber.
On each client, OnRemove fires and then all three signals are destroyed; there is nothing to disconnect.
service:destroy() disconnects the service’s listeners and destroys every replica it owns. Creating a replica on a
destroyed service errors. Slot generations survive in the Packet registry, so a new service on the same transport
cannot collide with a connected client’s watermarks.
ReplicaClient:destroy() tears down the client mirror. Packet connections disconnect, live replica signals are destroyed
without firing OnRemove (the server removed nothing), and every buffer and watermark clears.
API reference
Section titled “API reference”ReplicaService (server)
Section titled “ReplicaService (server)”| Member | Description |
|---|---|
ReplicaService.new(kernel, options?) -> ReplicaService |
options: MaxLiveSlots (1 to 65,535, default 65,535) and the test seams PacketFactory, GetPlayers, SkipLoop, Clock |
service:create(name, options) -> Replica |
Compiles the schema, allocates a slot and joins the Scheduler job for its TickRate and Priority. Errors when every live slot is taken or the service is destroyed |
service:refreshPlayer(player) |
Evaluates every replica’s interest for one player now. Runs automatically on Kernel.SessionStart |
service:resyncPlayer(player) |
Sends a fresh snapshot of every replica the player is interested in, clears their delivery debt, and removes the rest |
service:destroy() |
Destroys every replica and disconnects the service. Idempotent |
ReplicaService.onCadence(tickCount, divisor, player) -> boolean |
Pure. The governor’s cadence gate: divisor 1 always sends, divisor N sends every Nth delivery with a per-player phase |
create options:
| Field | Type | Description |
|---|---|---|
Schema |
table | Field name to wire type (or nested spec). The same table the client passes to listen |
Data |
table | Initial values, held by reference. Write through set and patch, never directly |
TickRate |
number?, default 10 |
Maximum deltas per second, and the default interest poll rate. Must be finite and positive |
Priority |
number?, default kernel.Priority.Low |
Scheduler priority of the replica’s job |
Interest |
((player) -> boolean)? |
Scopes recipients. Absent means everyone |
InterestMode |
"Poll" or "Manual", optional |
"Manual" checks interest at creation and then only on refresh, invalidation or session start |
InterestRefreshSeconds |
number? |
Poll interval for a custom Interest. Default 1 / TickRate |
Quantize |
{ [field]: threshold }? |
Per-field deadband against the last replicated value |
Replica handle (server)
Section titled “Replica handle (server)”| Member | Description |
|---|---|
replica:set(key, value) |
Writes Data and marks the field dirty. Errors if key is not in the schema. Unchanged scalars and writes inside the deadband cost nothing |
replica:patch(changes) |
set for each pair; still one delta at the next delivery |
replica:get(key) -> any |
Reads Data |
replica:invalidateInterest() |
Rechecks every viewer at the next Scheduler step without marking data dirty |
replica:bindZone(zones, zoneName) -> unbind |
Zone-scoped interest with instant enter and leave. unbind() restores what the bind replaced |
replica:destroy() |
Removes the replica from every subscribed client, frees its slot. Idempotent |
replica.Id, replica.Generation, replica.Name, replica.Data, replica.Destroyed |
Read-only state. Id is the U16 slot |
ReplicaClient
Section titled “ReplicaClient”| Member | Description |
|---|---|
ReplicaClient.new({ PacketFactory? }?) -> ReplicaClient |
Connects the three CKRep2_* handlers. Construct it early: parked frames expire after 10 s |
client:listen(name, schema, callback) |
Registers for replicas named name and drains any buffered snapshot and deltas first. Errors with already listening to replica "<name>" on a second listen |
client:unlisten(name) |
Affects only future creates. Live replicas keep applying deltas and resyncs |
client:destroy() |
Disconnects everything and destroys live signals without OnRemove |
Client replica handle
Section titled “Client replica handle”| Member | Description |
|---|---|
.Name |
Replica name |
.Data |
Local mirror, fully updated before any signal fires |
.Destroyed |
True after removal or client destroy |
.OnChange |
Signal (key, newValue, oldValue), once per changed field per packet |
.OnBatch |
Signal (changes), once per packet with changes, after the OnChange calls |
.OnRemove |
Signal (), on server destroy(), interest loss or replacement by a newer incarnation. All signals are destroyed after it |
Bus topics consumed
Section titled “Bus topics consumed”Replica publishes no Bus topics of its own. It consumes:
| Topic | Role |
|---|---|
Kernel.SessionStart |
Runs refreshPlayer for the new session’s player |
Kernel.SessionEnd |
Drops the player from every replica’s subscribers and delivery debt |
Zone.Entered |
Instant subscribe and snapshot for zone-bound replicas |
Zone.Left |
Instant unsubscribe and remove for zone-bound replicas |
It also listens to Players.PlayerAdded (to wake non-Manual interest) and Players.PlayerRemoving (the departure
backstop).
Gotchas
Section titled “Gotchas”- Each packet increments the replica’s revision once per recipient. A replica that sends long enough to reach 4,294,967,294 revisions must be destroyed and recreated; at 300 packets a second that takes over 160 days.
- Replacing the Packet or Replica module registry itself beneath live clients requires a reconnect. Do not hot-reload it.