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 State Proxies
Section titled “Typed State Proxies”Maelstrom-2 adds typed key proxies for state schemas. Dynamic string methods remain available, but typed proxies give autocomplete for state keys and typed values.
Client Schema
Section titled “Client Schema”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() endend)Server Schema
Section titled “Server Schema”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) + 25end)Scopes
Section titled “Scopes”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.
Server-Only Methods
Section titled “Server-Only Methods”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())SetForPlayer
Section titled “SetForPlayer”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)UpdateForPlayer
Section titled “UpdateForPlayer”State:UpdateForPlayer(player: Player, key: string, updater: (oldValue: any) -> any) -> anyAtomically 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) + 50end)print("Player now has", newCoins, "coins")Shared Methods
Section titled “Shared Methods”State:Get(key: string, player: Player?) -> anyReads 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")Client-Only Methods
Section titled “Client-Only Methods”Subscribe
Section titled “Subscribe”State:Subscribe(key: string, callback: Callback) -> () -> ()Subscribes to changes on a specific key. The callback is invoked:
- Immediately with the current resolved value (synchronous).
- 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()RequestSync
Section titled “RequestSync”State:RequestSync() -> booleanManually 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 fires an event to the server and applies the snapshot when the server responds. Deltas that arrive during the round-trip are buffered and replayed after the snapshot is applied.
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.")endSync Protocol
Section titled “Sync Protocol”Client connects → FireServer("__riptide_state_snapshot") [request]
Server responds → FireClient(player, "__riptide_state_snapshot", { global = { ... }, globalVersions = { ... }, player = { ... }, playerVersions = { ... }, })
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 received during the snapshot round-trip are buffered and replayed in order after the snapshot is applied.
- On player leave, the server automatically cleans up all player-scoped state via
PlayerLifecycle.