Skip to content

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.

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.

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.

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:

  1. Interest scan. For every player: newly interested players subscribe and get the full snapshot (CKRep_Create); players who lost interest unsubscribe and get CKRep_Remove. With no Interest function 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 most 1 / TickRate seconds after joining.
  2. Quantize flush. If anyone joined this tick and the replica quantizes fields, pending sub-threshold drift is flushed to the dirty set (see Quantize).
  3. 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 delta

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.

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 = 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)

Other clients never receive a byte of this replica — interest is enforced at send time on the server, not filtered on the client.

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.2

Deadbanding 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.

Per-recipient deltas are already small; the levers that shrink them further, cheapest first:

  1. Pick the narrowest wire type that fits. Every field costs exactly its width: NumberU8 1 B, NumberU16 2 B, NumberU24 3 B, NumberU32 4 B, Boolean8 1 B, Vector3S16 6 B (integer studs), Vector3F24 9 B, Vector3F32 12 B. A score that can’t exceed 16 M in NumberU24 instead of NumberU32 saves a byte on every delta forever. Replica schemas are Serde struct types — the bit-packed Boolean1 from raw Packet channels is not available here.
  2. Quantize chatty fields. A 20 Hz position wiggling by hundredths of a stud goes from 20 deltas/s to zero.
  3. 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.
  4. 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.

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 Interest with zones:isInside(zoneName, player), so the regular tick scan stays wired underneath and agrees with the events.
  • Subscribes to the Zone.Entered / Zone.Left bus topics, flipping subscription immediately — the snapshot goes out the moment Zone.Entered fires 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.

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.

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
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
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)
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

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