Skip to content

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:

ScopeDescriptionExample
GlobalShared across all players. Set with Set()."matchPhase", "serverTime"
PlayerPrivate 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.