Replica
A replica is server-owned data that clients watch: scores, timers, boss health, wallet balances. The defining decision is field-indexed delta compression at a per-replica cadence — the server never re-sends state, only the fields that changed since the last tick, and each replica batches at its own TickRate independent of every other replica. Late joiners get a full snapshot automatically; everyone else pays only for changes. One changed NumberU16 is a 4-byte delta payload.
For one-shot events that don’t need history (a kill feed line, a weather flip), use a state broadcast on the NetDriver instead — replicas are for state that changes over time and must survive a late join.
Mental model
Section titled “Mental model”ReplicaService:create() compiles the schema into a Serde struct codec. The struct assigns 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 immediately and marks the field dirty; nothing touches the network until the replica’s next tick. At the tick, the whole dirty set encodes into one delta buffer and fans out to current subscribers.
The three packets
Section titled “The three packets”| Wire channel | Payload | Approximate cost |
|---|---|---|
CKRep_Create |
replica id (NumberU16), name (StringLong), full snapshot (BufferLong) |
snapshot bytes + name length + ~5 B framing |
CKRep_Delta |
replica id (NumberU16), delta (BufferLong) |
delta bytes + ~5 B framing, per recipient |
CKRep_Remove |
replica id (NumberU16) |
~3 B |
A delta buffer is 1 byte of changed-field count, then per field: 1 byte of field id plus the field’s exact schema width. Fan-out totals are per-message bytes × recipients — the NET panel shows both the per-recipient size and the total so a 21-byte delta to 30 players isn’t misread as one 630-byte buffer.
The tick
Section titled “The tick”Every replica ticks at TickRate Hz on the Scheduler at Priority.Low. Replicas that share a tick rate share one scheduler entry — a hundred 10 Hz replicas cost one slot. Each tick:
- Interest scan. For every player: newly interested players subscribe and get the full snapshot (
CKRep_Create); players who lost interest unsubscribe and getCKRep_Remove. With noInterestfunction everyone is interested, so this scan is also the late-joiner mechanism — a new player receives every replica’s snapshot on that replica’s next tick, at most1 / TickRateseconds after joining. - Quantize flush. If anyone joined this tick and the replica quantizes fields, pending sub-threshold drift is flushed to the dirty set (see Quantize).
- Delta fan-out. If the dirty set is non-empty, it encodes once and fires to every subscriber — except same-tick joiners, whose snapshot already carries the values; sending the delta too would be duplicate bytes and a duplicate
OnChange.
Writes coalesce between ticks: set("Wave", 2) then set("Wave", 3) in the same window sends one delta with Wave = 3. A write that doesn’t change a scalar value costs zero bytes — it never marks the field dirty. Table values are exempt from that check, because identity equality says nothing about a mutated table’s contents; re-setting a table always replicates it.
Leaving players are cleaned out of every subscriber set on Kernel.SessionEnd, with a PlayerRemoving sweep as a backstop — a missed session end would otherwise leak a player key across every replica.
Schemas must match on both sides — keep them in one shared module so they cannot drift.
local ServerScriptService = game:GetService("ServerScriptService")local ReplicaService = require(ServerScriptService.ChloeKernelServer.Replica)
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, -- a scoreboard doesn't need 60Hz})
Match:set("RedScore", Match:get("RedScore") + 1)Match:patch({ TimeLeft = 299, BlueScore = 1 }) -- several fields, still one deltalocal ReplicatedStorage = game:GetService("ReplicatedStorage")local ReplicaClient = require(ReplicatedStorage.ChloeKernel.Net.ReplicaClient)
local Replicas = ReplicaClient.new()
Replicas:listen("Match", { TimeLeft = "NumberU16", RedScore = "NumberU8", BlueScore = "NumberU8" }, function(match) Scoreboard.update(match.Data) match.OnChange:connect(function(key, newValue, oldValue) Scoreboard.update(match.Data) end) match.OnRemove:connect(function() Scoreboard.hide() end)end)OnChange fires once per changed field per delta with (key, newValue, oldValue) — match.Data is already updated when it fires. The listen callback runs (via task.spawn) once for every server-side create with that name, so a replica that is destroyed and recreated calls it again with a fresh handle.
Boot order never loses state
Section titled “Boot order never loses state”Snapshots and deltas that arrive before listen() are buffered and replayed in order the moment the listener registers — the callback still sees converged data. The buffer is bounded on both axes:
- At most 64 pending replicas; past the cap the oldest unlistened replica is evicted with a warning.
- At most 64 buffered deltas per pending replica; past the cap the oldest delta drops with a one-time warning.
Interest — who sees what
Section titled “Interest — who sees what”Interest = function(player) return ... end scopes a replica to a subset of players. Entering interest delivers the full snapshot; leaving delivers a remove (the client fires OnRemove and tears the handle down). The scan runs every tick, so interest churn costs snapshot bytes — keep the predicate stable for players who should stay subscribed.
A per-player private replica is just an interest predicate plus a unique name:
Kernel:onSession(function(session) local Player = session.Player local Wallet = Replicas:create(`Wallet_{Player.UserId}`, { Schema = { Coins = "NumberU32", Gems = "NumberU16" }, Data = { Coins = 0, Gems = 0 }, TickRate = 2, Interest = function(viewer) return viewer == Player end, }) session:bind(function() Wallet:destroy() end)
Wallet:set("Coins", 150)end)local Players = game:GetService("Players")
local WalletSchema = { Coins = "NumberU32", Gems = "NumberU16" }
Replicas:listen(`Wallet_{Players.LocalPlayer.UserId}`, WalletSchema, function(wallet) wallet.OnChange:connect(function(key, value) Hud[key].Text = tostring(value) end)end)Other clients never receive a byte of this replica — interest is enforced at send time on the server, not filtered on the client.
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 entirely. The comparison is against a per-field baseline, not the previous write — so slow drift still replicates once it accumulates past the threshold. Drift between a client’s view and the server is bounded by the threshold, never unbounded. Pick a sub-visible error: 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, wire silent (moved 0.4 from baseline 0)Track:set("Progress", 0.8) -- Data = 0.8, wire silent (still < 1 from baseline 0)Track:set("Progress", 1.2) -- accumulated 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 — never deadbands and always replicates.
Convergence on join. A joiner’s snapshot carries raw Data while older subscribers sit at the baseline — without correction they could disagree by up to twice the threshold. The tick closes this: whenever a player joins, any pending sub-threshold drift on quantized fields is flushed as a delta to the older subscribers (the same-tick joiner is skipped; its snapshot already has the value). Every view converges within one tick and the max-drift bound holds.
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 B,NumberU162 B,NumberU243 B,NumberU324 B,Boolean81 B,Vector3S166 B (integer studs),Vector3F249 B,Vector3F3212 B. A score that can’t exceed 16 M inNumberU24instead ofNumberU32saves a byte on every delta forever. Replica schemas are Serde struct types — the bit-packedBoolean1from raw Packet channels is not available here. - Quantize chatty fields. A 20 Hz 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 4 Hz scoreboard batches everything that changed in 250 ms into one delta envelope instead of fifteen. - Split replicas by audience and cadence. One fast small replica (positions, 20 Hz,
Interest-scoped) plus one slow wide one (stats, 2 Hz, broadcast) beats a single replica ticking everything 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 the zone pay zero bytes 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 at once:
- Replaces the replica’s
Interestwithzones:isInside(zoneName, player), so the regular tick scan stays wired underneath and agrees with the events. - Subscribes to the
Zone.Entered/Zone.Leftbus topics, flipping subscription immediately — the snapshot goes out the momentZone.Enteredfires instead of waiting up to a full tick, and leaving sends the remove the same way.
Players already inside the zone when the bind lands (and any case the events miss) converge on the next tick via the scan. An event-driven entry also flushes pending quantize drift, same as a scan join — the entrant may receive one benign duplicate delta. Zone events for tracked entities (non-players) are ignored; replicas subscribe players only.
The returned function unbinds the event connections; destroy() unbinds automatically.
Destroy semantics
Section titled “Destroy semantics”replica:destroy() is idempotent. It unbinds all zone binds, detaches from its tick loop (the shared scheduler entry cancels when its last member leaves), fires CKRep_Remove at every current subscriber, and deregisters from the service. On each client that means: OnRemove fires, then both signals are destroyed — disconnect nothing, keep no references.
ReplicaClient:destroy() tears down the whole client mirror: packet connections disconnect, live replica signals are destroyed (without firing OnRemove — this is a shutdown, not a server removal), and all buffers clear.
API reference
Section titled “API reference”ReplicaService (server)
Section titled “ReplicaService (server)”| Member | Description |
|---|---|
ReplicaService.new(kernel, options?) |
options is test seams: PacketFactory?, GetPlayers?, SkipLoop? — production callers pass only the kernel |
service:create(name, options) → replica |
Compiles the schema, registers the replica, attaches it to the shared tick loop for its rate |
create options:
| Field | Type | Description |
|---|---|---|
Schema |
table | Field name → wire type (or nested spec). Same table the client passes to listen |
Data |
table | Initial values. Held by reference — write through set/patch, never mutate directly |
TickRate |
number? = 10 |
Deltas per second, and the cadence of the interest scan |
Interest |
(player) → boolean? |
Scopes recipients. Absent = everyone |
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, marks dirty. Errors if key is not in the schema. No-op scalar writes and sub-deadband writes cost zero bytes |
replica:patch(changes) |
set per pair; still coalesces into one delta at the next tick |
replica:get(key) → value |
Reads Data |
replica:bindZone(zones, zoneName) → unbind |
Zone-scoped interest with instant enter/leave flips |
replica:destroy() |
Removes on every subscribed client, detaches, deregisters. Idempotent |
replica.Id, replica.Name, replica.Data, replica.Destroyed |
Read-only state |
ReplicaClient
Section titled “ReplicaClient”| Member | Description |
|---|---|
ReplicaClient.new() |
Connects the three CKRep_* packet handlers |
client:listen(name, schema, callback) |
Registers for replicas named name; drains any buffered snapshot + deltas first. Errors on duplicate listen for the same name |
client:unlisten(name) |
Only affects future creates — existing live replicas keep applying deltas |
client:destroy() |
Disconnects everything and destroys live signals (no OnRemove) |
Client replica handle
Section titled “Client replica handle”| Member | Description |
|---|---|
.Name |
Replica name |
.Data |
Local mirror, updated before OnChange fires. A decoded copy — local writes replicate nowhere |
.OnChange |
Signal (key, newValue, oldValue), once per changed field per delta |
.OnRemove |
Signal () — fired on server destroy() or interest loss, then both signals are destroyed |
Bus topics consumed
Section titled “Bus topics consumed”Replica fires no bus topics of its own; it consumes three:
| Topic | Role |
|---|---|
Kernel.SessionEnd |
Drops the leaving player from every replica’s subscriber set |
Zone.Entered |
Instant subscribe + snapshot for zone-bound replicas |
Zone.Left |
Instant unsubscribe + remove for zone-bound replicas |