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.

Prefer side-specific access through Riptide.Server.State or Riptide.Client.State. The root Riptide.State alias remains available for compatibility and dynamic shared code.

type Callback = (value: any) -> ()
type StateReplicationBaseAPI = {
Events: { Delta: string, Snapshot: string },
Get: (self, key: string, player: Player?) -> any,
}
type StateReplicationServerAPI = StateReplicationBaseAPI & {
Set: (self, key: string, value: any) -> (),
SetForPlayer: (self, player: Player, key: string, value: any) -> (),
UpdateForPlayer: (self, player: Player, key: string, updater: (oldValue: any) -> any) -> any,
TypedServer: <T>() -> T,
}
type StateReplicationClientAPI = StateReplicationBaseAPI & {
Subscribe: (self, key: string, callback: Callback) -> () -> (),
RequestSync: (self) -> boolean,
TypedClient: <T>() -> T,
}

Typed key proxies introduced in Maelstrom-2 remain available. Dynamic string methods remain available, but typed proxies give autocomplete for state keys and typed values.

type ClientState = {
coins: StateReplication.ClientKey<number>,
matchPhase: StateReplication.ClientKey<"Intermission" | "Running">,
}
local State = Riptide.Client.State.TypedClient<ClientState>()
local coins = State.coins:Get()
local unsubscribe = State.matchPhase:Subscribe(function(phase)
if phase == "Running" then
showRoundUi()
end
end)
type ServerState = {
coins: StateReplication.ServerKey<number>,
matchPhase: StateReplication.ServerKey<"Intermission" | "Running">,
}
local State = Riptide.Server.State.TypedServer<ServerState>()
State.matchPhase:Set("Intermission")
State.coins:SetForPlayer(player, 100)
local newCoins = State.coins:UpdateForPlayer(player, function(old)
return (old or 0) + 25
end)

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.Server.State:Set("matchPhase", "Intermission")
Riptide.Server.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.Server.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.Server.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.Server.State:Get("matchPhase")
local playerCoins = Riptide.Server.State:Get("coins", player)

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

local coins = Riptide.Client.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.Client.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. Called automatically on init, but can be invoked manually to force a re-sync.

The request is asynchronous. It includes a request ID and has a deadline with at most three attempts. Live deltas keep applying while the snapshot is in flight. When the snapshot arrives, per-key versions preserve values from newer deltas, including deletions.

Returns true if the request was dispatched, false if Network is unavailable, called on server, or a request is already in flight.

local sent = Riptide.Client.State:RequestSync()
if sent then
print("Snapshot re-sync requested.")
end

Client connects
→ FireServer("__riptide_state_snapshot", requestId) [request]
Server responds
→ FireClient(player, "__riptide_state_snapshot", {
requestId = requestId,
global = { ... },
globalVersions = { ... },
player = { ... },
playerVersions = { ... },
})
If the request arrives during the per-player cooldown, the server may send:
→ FireClient(player, "__riptide_state_snapshot", {
requestId = requestId,
retryAfter = seconds,
})
After initial sync, deltas arrive as flat args:
→ FireAllClients("__riptide_state_delta", scope, key, value, version)
→ FireClient(player, "__riptide_state_delta", scope, key, value, version)
scope = "global" | "player"
key = string
value = any
version = number (monotonically increasing per key per scope)
  • Each key has a monotonically increasing version per scope.
  • The client discards deltas with a version ≤ the current known version (idempotent).
  • Deltas remain live during snapshot sync. Per-key versions prevent an older snapshot from rolling them back.
  • Internal state traffic uses the reliable remote and is separate from public Network handlers and middleware.
  • On player leave, the server automatically cleans up all player-scoped state via PlayerLifecycle.