Skip to Content
DocsPersistent and Shared State

Persistent and shared state

This is Model 2 from Multiplayer limitations: the room’s state is owned by a server-side Durable Object, not by any player. Because no player is the authority, the state survives every peer leaving and is handed to the next peer who joins — even after the room sat empty. Use it for turn-based, card, board, and persistent-world games, or to checkpoint any game’s progress.

It gives you two things over the same connection:

  • A persistent world blobsave_world(bytes) / world_loaded — one opaque save file per room the relay stores durably and replays to every joiner.
  • A live shared-state documentset_state(key, value) / state_changed — per-key values the relay orders, broadcasts to everyone, and persists. The relay assigns a monotonic revision so concurrent writes resolve last-writer-wins with no desync.

This is not Godot’s MultiplayerSpawner/MultiplayerSynchronizer netcode. There is no host and no scene replication — if you want host-authoritative realtime sync (co-op, deathmatch), use the host-client model in the Multiplayer guide instead. Pick one model per game; they are not meant to be combined.

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 speaks the relay’s state frames. This exact script is covered by an automated conformance test (snapshot-on-join, last-writer-wins, and a ~1 MB world round-trip).

# ZivaState — Model 2 (durable-object state) client for the Ziva relay. # # Register as an autoload named "ZivaState" (Project Settings > Autoload). # Opens its OWN raw WebSocketPeer to a room — SEPARATE from any # WebSocketMultiplayerPeer — and speaks the relay's world/shared-state control # frames. The relay's Durable Object owns, orders and persists the state; this # client is a thin codec + local cache. # # Why a separate socket: WebSocketMultiplayerPeer gives no hook to send/receive # non-Godot frames, and a pure Model-2 game (card/turn-based) has no # MultiplayerSpawner at all. One WebSocketPeer per client, used only for these # control frames — the relay routes them by their first byte and never confuses # them with Godot's @rpc/spawn/sync traffic. 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 — enable multiplayer in Settings > Ziva Cloud.") 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 every committed write back to all peers (including the writer) with a revision number, so every client converges to the same ordered state. A joiner gets the whole document in one snapshot, so late and reconnecting players are never out of date.

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 yet

Values are opaque bytes — encode however you like. var_to_bytes/bytes_to_var is the easy path for Godot Variants (ints, dictionaries, arrays); to_utf8_buffer() for plain strings; JSON if you want it human-readable. 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 save_world it. The relay stores it durably and hands it to every future joiner via world_loaded — including the first peer to reopen a room everyone had left.

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 Durable Object is the authority. Any peer can read and write; the relay orders writes and persists them. A peer leaving never loses state.
  • Last-writer-wins by revision. Every committed write gets a monotonic rev. If two peers write the same key, the higher rev wins everywhere; ZivaState ignores any update older than what it already holds, so peers never flicker backwards.
  • Snapshot on join. A new or reconnecting peer receives the full current state (state_changed fires per key) and the saved world (world_loaded) in its join burst — no extra request.
  • ~1 MB blob cap. The persisted world is capped at roughly 1 MB per room; an oversized save_world is rejected loudly (the socket closes with a debuggable reason) rather than silently truncated. Keep ids and counters, not asset payloads. Shared-state values share the same durable storage — keep them small too.
  • Latency is a full relay round-trip. Every set_state travels peer → Cloudflare → the room’s Durable Object → back. That is comfortable for turn-based and card games but not for twitch gameplay. See Multiplayer limitations for the measured numbers and the use-case table.
Last updated on