Persistent and shared state
Ask Ziva: “Add multiplayer to my simple board game. Save the board and whose turn it is, so players can leave and return later.”
The relay stores the room’s saved game data, so it can survive everyone disconnecting, including the first player. There is no player host to lose. It can keep one saved-world snapshot and shared values such as the current turn. Your game must choose what to save and rebuild from it; a running scene is not saved automatically.
This suits turn-based, card, board and small persistent-world games. It is not Godot scene replication and is not meant to be combined with MultiplayerSpawner/MultiplayerSynchronizer networking. For real-time movement in a temporary co-op session, see Multiplayer. Compare both in Multiplayer limitations.
Before you use it
- Access and cost: the relay is included on Pro and Ultra; Basic accounts that have already used multiplayer keep access. You pay for bandwidth plus persisted storage. Reaching the account’s monthly bandwidth cap prevents new connections until the next cycle.
- Limits: at most 32 connections per room and roughly 1 MB of persisted room data, including shared values. Keep saves small, not full of artwork.
- Competing changes: any connected player can read and write room data. If two write the same value, the last write accepted by the relay wins.
- Privacy: shared data is sent to the hosted relay, stored there, and sent to players joining the room. Do not include secrets or private account information.
Optional reference: complete persistent-state client and integration examples
The ZivaState autoload
Create res://ziva_state.gd with the code below and register it as an autoload named ZivaState (next section). It opens its own raw WebSocketPeer to the room, separate from any WebSocketMultiplayerPeer, and uses the relay’s state frames. Ask Ziva to add multiplayer first so setup_multiplayer writes the required ziva/multiplayer/* project settings.
# Register as an autoload named "ZivaState" (Project Settings > Autoload).
extends Node
## Emitted once the socket is open and ready for save_world()/set_state().
signal connected
## A persisted world blob arrived (on join, or after another peer saved one).
signal world_loaded(blob: PackedByteArray)
## The relay committed our save_world() to durable storage.
signal world_save_acked
## A shared-state key changed — locally, from another peer, or from the
## join-time snapshot. `rev` is the relay's monotonic commit order.
signal state_changed(key: String, value: PackedByteArray, rev: int)
# Control opcodes — MUST match the relay's protocol. Their low 3 bits are never
# 7, so the relay can't confuse them with Godot traffic and we dispatch on the
# whole first byte.
const _WORLD_SAVE := 0xF1 # client -> relay: [0xF1][blob]
const _WORLD_LOAD := 0xF2 # relay -> client: [0xF2][blob]
const _WORLD_SAVE_ACK := 0xF3 # relay -> client: [0xF3]
const _STATE_SET := 0xF4 # client -> relay: [0xF4][u16 keyLen][key][value]
const _STATE_UPDATE := 0xF5 # relay -> all: [0xF5][u32 rev][u16 keyLen][key][value]
const _STATE_SNAPSHOT := 0xF6 # relay -> joiner: [0xF6][u32 count]{entry}*
# The relay reassembles a chunked (>1MB) world into ONE frame, so the inbound
# buffer must clear the ~1MB persistence ceiling with headroom. The default
# WebSocketPeer buffer is 64 KiB — a large world would be silently dropped.
const _BUFFER_BYTES := 4 * 1024 * 1024
const _STATE_KEY_MAX_BYTES := 1024
var _ws := WebSocketPeer.new()
var _open := false
# Local cache + last-applied rev per key for last-writer-wins. The relay assigns
# the rev; we ignore any update whose rev is <= the one we already hold.
var _state: Dictionary = {} # String -> PackedByteArray
var _rev: Dictionary = {} # String -> int
## Open the relay socket for `room_id`. Reads the three ziva/multiplayer/*
## settings the plugin writes on enable. Fails loud (no silent default origin).
func connect_room(room_id: String) -> void:
var user_id: String = ProjectSettings.get_setting("ziva/multiplayer/user_id", "")
var game_id: String = ProjectSettings.get_setting("ziva/multiplayer/game_id", "")
var relay_url: String = ProjectSettings.get_setting("ziva/multiplayer/relay_url", "")
if user_id.is_empty() or game_id.is_empty() or relay_url.is_empty():
push_error("ZivaState: ziva/multiplayer settings missing — ask Ziva to add multiplayer to this project.")
return
_ws.inbound_buffer_size = _BUFFER_BYTES
_ws.outbound_buffer_size = _BUFFER_BYTES
var url: String = "%s/r/%s?u=%s&g=%s&v=1" % [relay_url, room_id, user_id, game_id]
var err: int = _ws.connect_to_url(url)
if err != OK:
push_error("ZivaState: connect_to_url failed (%d) for %s" % [err, url])
## Persist an opaque world blob for the room (a save file the relay stores
## durably and hands to any future joiner). Emits world_save_acked on commit.
func save_world(blob: PackedByteArray) -> void:
var frame := PackedByteArray()
frame.append(_WORLD_SAVE)
frame.append_array(blob)
_send(frame)
## Write one shared-state key. The relay orders the write, assigns a rev, and
## broadcasts it back to everyone (including us) as a state_changed.
func set_state(key: String, value: PackedByteArray) -> void:
var key_bytes := key.to_utf8_buffer()
if key_bytes.is_empty() or key_bytes.size() > _STATE_KEY_MAX_BYTES:
push_error("ZivaState.set_state: key must be 1..%d bytes (got %d)" % [_STATE_KEY_MAX_BYTES, key_bytes.size()])
return
var frame := PackedByteArray()
frame.append(_STATE_SET)
frame.append(key_bytes.size() & 0xFF)
frame.append((key_bytes.size() >> 8) & 0xFF)
frame.append_array(key_bytes)
frame.append_array(value)
_send(frame)
## Last value seen for `key`, or an empty buffer if unset. Use has_state() to
## tell "unset" apart from "set to empty".
func get_state(key: String) -> PackedByteArray:
return _state.get(key, PackedByteArray())
func has_state(key: String) -> bool:
return _state.has(key)
func _send(frame: PackedByteArray) -> void:
if _ws.get_ready_state() != WebSocketPeer.STATE_OPEN:
push_error("ZivaState: send before socket open — call connect_room() and await the `connected` signal first.")
return
# Explicit binary: the relay drops text frames loudly, so never rely on the
# peer's default write mode.
var err: int = _ws.send(frame, WebSocketPeer.WRITE_MODE_BINARY)
if err != OK:
push_error("ZivaState: send failed (%d)" % err)
func _process(_delta: float) -> void:
_ws.poll()
var st := _ws.get_ready_state()
if st == WebSocketPeer.STATE_OPEN:
if not _open:
_open = true
connected.emit()
while _ws.get_available_packet_count() > 0:
_handle(_ws.get_packet())
elif st == WebSocketPeer.STATE_CLOSED:
if _open:
_open = false
push_error("ZivaState: socket closed (%d %s)" % [_ws.get_close_code(), _ws.get_close_reason()])
func _handle(pkt: PackedByteArray) -> void:
if pkt.size() < 1:
return
# Everything that isn't one of our opcodes (the 4-byte peer-id handshake,
# Godot ADD_PEER/DEL_PEER/RELAY traffic) is not ours — ignore it.
match pkt[0]:
_WORLD_LOAD:
world_loaded.emit(pkt.slice(1))
_WORLD_SAVE_ACK:
world_save_acked.emit()
_STATE_UPDATE:
_apply_update(pkt)
_STATE_SNAPSHOT:
_apply_snapshot(pkt)
func _apply_update(pkt: PackedByteArray) -> void:
if pkt.size() < 7:
push_error("ZivaState: STATE_UPDATE too small (%d)" % pkt.size())
return
var rev := _u32(pkt, 1)
var key_len := _u16(pkt, 5)
if pkt.size() < 7 + key_len:
push_error("ZivaState: STATE_UPDATE truncated (keyLen %d, size %d)" % [key_len, pkt.size()])
return
var key := pkt.slice(7, 7 + key_len).get_string_from_utf8()
var value := pkt.slice(7 + key_len)
_apply(key, value, rev)
func _apply_snapshot(pkt: PackedByteArray) -> void:
if pkt.size() < 5:
push_error("ZivaState: STATE_SNAPSHOT too small (%d)" % pkt.size())
return
var count := _u32(pkt, 1)
var off := 5
for _i in count:
if off + 6 > pkt.size():
push_error("ZivaState: STATE_SNAPSHOT truncated header @%d" % off)
return
var rev := _u32(pkt, off); off += 4
var key_len := _u16(pkt, off); off += 2
if off + key_len + 4 > pkt.size():
push_error("ZivaState: STATE_SNAPSHOT truncated key/valLen @%d" % off)
return
var key := pkt.slice(off, off + key_len).get_string_from_utf8(); off += key_len
var val_len := _u32(pkt, off); off += 4
if off + val_len > pkt.size():
push_error("ZivaState: STATE_SNAPSHOT truncated value @%d" % off)
return
var value := pkt.slice(off, off + val_len); off += val_len
_apply(key, value, rev)
# Apply one committed (key, value, rev) under last-writer-wins: a stale or
# replayed rev (<= the rev we already hold for this key) is ignored.
func _apply(key: String, value: PackedByteArray, rev: int) -> void:
if _rev.get(key, 0) >= rev:
return
_rev[key] = rev
_state[key] = value
state_changed.emit(key, value, rev)
# Explicit little-endian readers — match the relay's LE encoding without relying
# on PackedByteArray.decode_* endianness defaults.
func _u16(b: PackedByteArray, o: int) -> int:
return b[o] | (b[o + 1] << 8)
func _u32(b: PackedByteArray, o: int) -> int:
return b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)Register it as an autoload
Add it to your project so a single shared instance lives for the whole game. Either use the editor (Project > Project Settings > Globals > Autoload, path res://ziva_state.gd, node name ZivaState), or add the line directly to project.godot:
[autoload]
ZivaState="*res://ziva_state.gd"Now any script can reach it as the global ZivaState.
Shared state: a turn-based example
Read and write per-key values. The relay broadcasts each committed write to all peers, including the writer, with a revision number that orders changes. A joining or reconnecting player receives a snapshot of the current document.
extends Node
func _ready() -> void:
ZivaState.state_changed.connect(_on_state_changed)
ZivaState.connect_room("table-7") # any room id you choose
await ZivaState.connected
# Take a turn: write whose turn it is and the latest move.
ZivaState.set_state("turn", var_to_bytes(2)) # player 2 to act
ZivaState.set_state("last_move", "e2e4".to_utf8_buffer())
func _on_state_changed(key: String, value: PackedByteArray, rev: int) -> void:
match key:
"turn":
var whose_turn: int = bytes_to_var(value)
print("now player %d's turn (rev %d)" % [whose_turn, rev])
"last_move":
print("move played: ", value.get_string_from_utf8())
# Read the current value any time (e.g. when drawing the board):
func current_turn() -> int:
if ZivaState.has_state("turn"):
return bytes_to_var(ZivaState.get_state("turn"))
return 1 # nobody has moved yetValues are opaque bytes; choose an encoding for your data. Use var_to_bytes/bytes_to_var for Godot Variants (ints, dictionaries, arrays), to_utf8_buffer() for plain strings, or JSON for human-readable data. Keep keys short ("turn", "deck", "player:2:hand").
Persistent world: save and load a blob
For a single save-file-style snapshot of the whole world, serialize your state and call save_world. The relay stores the bytes durably and sends them through world_loaded to every future joiner, including the first player to reopen an empty room.
extends Node
func _ready() -> void:
ZivaState.world_loaded.connect(_on_world_loaded)
ZivaState.connect_room("world-main")
await ZivaState.connected
# A fresh joiner is sent the saved world automatically (if one exists),
# so _on_world_loaded fires here without you asking for it.
func _on_world_loaded(blob: PackedByteArray) -> void:
var world: Dictionary = bytes_to_var(blob)
print("loaded world with %d entities" % world.get("entities", []).size())
# rebuild your scene from `world` …
func checkpoint(world: Dictionary) -> void:
# Serialize and persist. Keep it small — see the ~1MB cap below.
ZivaState.save_world(var_to_bytes(world))How it behaves
- No host. The relay saves the room data. Any peer can read and write; the relay orders writes and persists them. A player leaving does not erase committed data.
- Last-writer-wins by revision. Each committed write gets an increasing
rev. If two peers write the same key, the higher revision wins.ZivaStateignores updates older than or equal to the revision it already holds. - Snapshot on join. A new or reconnecting peer receives the current shared state (
state_changedfires per key) and saved world (world_loaded), without an extra request. - ~1 MB blob cap. The persisted world is capped at roughly 1 MB per room. An oversized
save_worldcloses the socket with a reason rather than silently truncating the data. Keep IDs and counters, not assets. Shared-state values use the same durable storage and must stay small too. - Relay round-trip delay. Every
set_statetravels to the room’s server-side storage and back. This suits turn-based and card games, not fast-reaction gameplay. See Multiplayer limitations for latency guidance and game types.