Skip to content

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.

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.

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 set that changes a value marks the field dirty and schedules the job at max(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, so set("Wave", 2) then set("Wave", 3) sends one delta with Wave = 3.
  • A set that 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.

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. PlayerAdded also 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 a PlayerRemoving sweep as a backstop. A player who leaves while an Interest callback yields is not resubscribed when the callback returns.

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

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:

src/Shared/Game/Config/MatchSchema.luau
--!strict
return { TimeLeft = "NumberU16", RedScore = "NumberU8", BlueScore = "NumberU8" }
src/Server/Game/Services/MatchService.luau
--!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 delta
end
function MatchService.stop(self: any)
self.Replicas:destroy()
end
return MatchService

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

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, with changes mapping 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 has NewValue == 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.

Replica traffic can reach a client before its code is ready, at two levels:

  1. Before ReplicaClient.new() runs, the CKRep2_* 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.
  2. 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 = 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.

src/Server/Game/Services/WalletService.luau
--!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 WalletService

When 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 = { 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.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, 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.

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 byte, NumberU16 2, NumberU24 3, NumberU32 4, Boolean8 1, Vector3S16 6 (integer studs), Vector3F24 9, Vector3F32 12. A score that never exceeds 16 million in NumberU24 instead of NumberU32 saves a byte on every delta. Replica schemas are Serde struct types, so Packet’s bit-packed Boolean1 is not available here.
  2. Quantize noisy fields. A 20 Hz position wobbling by hundredths of a stud goes from 20 deltas a second to none.
  3. 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.
  4. 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.

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 Interest with zones:isInside(zoneName, player), so scans agree with the events. Unless the replica set InterestRefreshSeconds or Manual mode, it also polls at 1 / TickRate as a safety check.
  • It subscribes to Zone.Entered and Zone.Left on the Bus and flips the subscription at once: the snapshot goes out when Zone.Entered fires, and the remove when Zone.Left fires.

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.

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.

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

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

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

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.