Skip to content

Replicating

Once the replicators are set up, replication has two parts:

  • A system running in the server that collects what changed and sends it to every player.
  • Activating each player when they join, by sending them the full state of the world.

Both parts live in the same system. A player is activated right before the updates are collected, this page explains why.

Changes should be sent inside a system that runs every frame. Its recommended that you throttle the updates to a lower interval, a good rate is somewhere around 10-30 times a second.

In the server, you will call server_replicator:collect_updates(). This will return an iterator that will be executed for every player.

The iterator will give you the following values:

  • player: Player - the player to send the updates to
  • buf: buffer - the buffer containing the updates
  • variants: { {any} } - the variants tables containing non-serialized values

Updates can then be applied in the client using client_replicator:apply_updates(buf, variants).

replecs system
local replicator = require("@server/replicator")
local interval = require("@utils/interval")
local system = require("@utils/scheduler/system")
-- interval throttles systems to a specific interval
local updates_interval = interval(1 / 20)
function replecs_server()
-- will send updates 20 times a second
if updates_interval() then
for player, buf, variants in replicator:collect_updates() do
remotes_server.send_updates:fire(player, buf, variants)
end
end
end
system(replecs_server)

Unreliable updates are sent similarly to normal updates. You can call server_replicator:collect_unreliable(). This will return an iterator that will be executed for every player.

The iterator will give you the same values as normal updates.

Updates can then be applied in the client using client_replicator:apply_unreliable(buf, variants).

Because unreliable values are always sent, deciding how often you send them is crucial, making it slower to not overload the network or faster for smoother replication.

Replecs ignores every player that joins the game until you activate them, even if a filter names them explicitly. This prevents players from getting updates when they haven’t received the full state of the world yet.

Activating a player takes two calls, in this order:

  1. server_replicator:mark_player_ready(player) activates the player.
  2. server_replicator:get_full(player) returns the current state of the world for that player, as a buffer and a variants table with the non-serialized values.

The client applies the result with client_replicator:apply_full(buf, variants). You can check if a player has been activated with server_replicator:is_player_ready(player).

Both calls must happen in the same frame as collect_updates(), right before it, with no world changes in between.

Remote callbacks run at arbitrary points of the frame, so calling get_full directly inside one leaves room for your systems to change the world before the updates are collected. Instead, defer the work to the replication system. There are two ways to do this:

The client requests the full state with a remote event. The server only queues the player, and the replication system activates the queued players right before collecting the updates.

replecs system
local replicator = require("@server/replicator")
local interval = require("@utils/interval")
local system = require("@utils/scheduler/system")
local updates_interval = interval(1 / 20)
-- players waiting for the full state of the world
local pending_players: { Player } = {}
remotes_server.request_full:on(function(player)
table.insert(pending_players, player)
end)
function replecs_server()
if updates_interval() then
-- activate pending players right before collecting updates
for _, player in pending_players do
if not player.Parent then
continue -- left before we got here
end
replicator:mark_player_ready(player)
local buf, variants = replicator:get_full(player)
remotes_server.receive_full:fire(player, buf, variants)
end
table.clear(pending_players)
for player, buf, variants in replicator:collect_updates() do
remotes_server.send_updates:fire(player, buf, variants)
end
end
end
system(replecs_server)

If you prefer a remote function, the callback can yield its coroutine and let the replication system resume it right before collecting the updates. The callback then calls get_full from inside the system and returns the result.

replecs system
local replicator = require("@server/replicator")
local interval = require("@utils/interval")
local system = require("@utils/scheduler/system")
local updates_interval = interval(1 / 20)
-- callbacks waiting to be resumed by the system
local pending_threads: { thread } = {}
remotes_server.receive_full:set_callback(function(player)
-- yield until the replication system resumes us
table.insert(pending_threads, coroutine.running())
coroutine.yield()
if not player.Parent then
return -- left while waiting
end
replicator:mark_player_ready(player)
return replicator:get_full(player)
end)
function replecs_server()
if updates_interval() then
-- resume the callbacks right before collecting updates
local threads = table.clone(pending_threads)
table.clear(pending_threads)
for _, thread in threads do
coroutine.resume(thread)
end
for player, buf, variants in replicator:collect_updates() do
remotes_server.send_updates:fire(player, buf, variants)
end
end
end
system(replecs_server)

A few things to keep in mind:

  • In both cases the full state is sent before the updates of that same frame, so the client receives them in order. Make sure the full state is applied before any update packet.
  • Mark the player as ready before calling get_full. Non-ready players are always excluded from replication so calling it on a player that is not ready logs a warning and returns an empty state.
  • Make sure the player is also marked as ready just right before it gets full.

Combining updates, unreliable values and player activation, your client/server systems should look something like this:

replecs system
local replicator = require("@server/replicator")
local interval = require("@utils/interval")
local system = require("@utils/scheduler/system")
local updates_interval = interval(1 / 20)
local unreliables_interval = interval(1 / 30)
local pending_players: { Player } = {}
remotes_server.request_full:on(function(player)
table.insert(pending_players, player)
end)
function replecs_server()
-- will send updates 20 times a second
if updates_interval() then
-- new players get the full state right before the updates are collected
for _, player in pending_players do
if not player.Parent then
continue
end
replicator:mark_player_ready(player)
local buf, variants = replicator:get_full(player)
remotes_server.receive_full:fire(player, buf, variants)
end
table.clear(pending_players)
for player, buf, variants in replicator:collect_updates() do
remotes_server.send_updates:fire(player, buf, variants)
end
end
-- will send unreliables 30 times a second
if unreliables_interval() then
for player, buf, variants in replicator:collect_unreliable() do
remotes_server.send_unreliables:fire(player, buf, variants)
end
end
end
system(replecs_server)