Skip to content
GitHub

State Replication

The StateReplication module provides minimal, server-authoritative state synchronization. The server owns all state; clients receive automatic snapshot sync on connect and delta updates in real-time.

Access via Riptide.State.

type Callback = (value: any) -> ()
type StateReplicationAPI = {
Events: { Delta: string, Snapshot: string },
-- Server-only
Set: (self, key: string, value: any) -> (),
SetForPlayer: (self, player: any, key: string, value: any) -> (),
UpdateForPlayer: (self, player: any, key: string, updater: (oldValue: any) -> any) -> any,
-- Shared
Get: (self, key: string, player: any?) -> any,
-- Client-only
Subscribe: (self, key: string, callback: Callback) -> () -> (),
RequestSync: (self) -> boolean,
}

State has two scopes:

Scope Description Example
Global Shared across all players. Set with Set(). "matchPhase", "serverTime"
Player Private to one player, overrides global. Set with SetForPlayer(). "coins", "inventory"

On the client, Get(key) resolves player-scoped value first, falling back to global if no player override exists.


State:Set(key: string, value: any) -> ()

Sets a global state value and broadcasts a delta to all connected clients.

Riptide.State:Set("matchPhase", "Intermission")
Riptide.State:Set("serverTime", os.time())

State:SetForPlayer(player: Player, key: string, value: any) -> ()

Sets a player-scoped state value. Only the target player receives the delta.

Riptide.State:SetForPlayer(player, "coins", 500)

State:UpdateForPlayer(player: Player, key: string, updater: (oldValue: any) -> any) -> any

Atomically updates a player-scoped value using a callback. Returns the new value.

local newCoins = Riptide.State:UpdateForPlayer(player, "coins", function(old)
return (old or 0) + 50
end)
print("Player now has", newCoins, "coins")

State:Get(key: string, player: Player?) -> any

Reads a state value.

Server — returns the player-scoped value if player is given and a player override exists, otherwise returns the global value:

local globalPhase = Riptide.State:Get("matchPhase")
local playerCoins = Riptide.State:Get("coins", player)

Client — ignores the player parameter. Resolves player-scoped first, then falls back to global:

local coins = Riptide.State:Get("coins")

State:Subscribe(key: string, callback: Callback) -> () -> ()

Subscribes to changes on a specific key. The callback is invoked:

  1. Immediately with the current resolved value (synchronous).
  2. On every subsequent change (via delta or snapshot sync).

Returns an unsubscribe function.

local unsubscribe = Riptide.State:Subscribe("coins", function(value)
coinLabel.Text = "Coins: " .. tostring(value or 0)
end)
-- Later, when no longer needed:
unsubscribe()

State:RequestSync() -> boolean

Manually requests a full state snapshot from the server. This is called automatically during initialization, but you can call it manually to force a re-sync.

Returns true on success, false on failure (e.g., server unreachable or called on server).

local ok = Riptide.State:RequestSync()
if ok then
print("State re-synced!")
end

Client connects → InvokeServer("__riptide_state_snapshot")
Server responds with:
{
global = { ... },
globalVersions = { ... },
player = { ... },
playerVersions = { ... },
}
After initial sync, deltas arrive via:
FireClient(player, "__riptide_state_delta", {
scope = "global" | "player",
key = "...",
value = ...,
version = number,
})
  • Each key has a monotonically increasing version per scope.
  • The client discards deltas with a version ≤ the current known version (idempotent).
  • On player leave, the server automatically cleans up all player-scoped state via PlayerLifecycle.