Skip to Content
DocsOverview

Multiplayer

Ziva’s hosted relay terminates Godot’s WebSocketMultiplayerPeer protocol server-side. You write straight Godot stdlib code — there is no Ziva multiplayer addon, and no token to fetch. @rpc annotations, MultiplayerSynchronizer, and MultiplayerSpawner all work natively against the relay.

How it works

When you enable multiplayer for a project (Settings → Ziva Cloud), the plugin writes three project settings into your Godot project. They bake into your web exports, so the running game already knows how to reach the relay:

SettingWhat it is
ziva/multiplayer/user_idYour Ziva account id — the account bandwidth bills against.
ziva/multiplayer/game_idA stable id for this project, generated once on enable.
ziva/multiplayer/relay_urlThe relay’s WebSocket base URL.

These are public values, not secrets — they identify which account and game a connection belongs to so the relay can apply the right limits and meter usage. Bandwidth is billed to the account named by user_id.

Connecting

Read the three settings and open the relay URL directly. No HTTP round-trip, no auth header:

extends Node func join_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", "") # Fail loud rather than fall back to a default origin: a missing value would # silently connect to the wrong relay (or nowhere) and waste a debug cycle. if user_id.is_empty() or game_id.is_empty() or relay_url.is_empty(): push_error("Ziva multiplayer settings missing — enable multiplayer in Settings → Ziva Cloud.") return var url: String = "%s/r/%s?u=%s&g=%s&v=1" % [relay_url, room_id, user_id, game_id] var peer: WebSocketMultiplayerPeer = WebSocketMultiplayerPeer.new() peer.create_client(url) multiplayer.multiplayer_peer = peer

room_id is any string you choose. Every client that opens the same room id lands in the same room on the relay.

The relay has no dedicated server

This is the one architectural fact that drives the rest of the pattern. The relay is a message switch between peers — it is not a Godot server running your game. Peer 1 (the high-level multiplayer “server” id) is a phantom slot the relay owns; it never runs your _process. So one of the real peers has to act as the authority that spawns players and owns shared world state.

Pick that authority deterministically, so every peer agrees on it with no networked handoff and no election message:

Host = the lowest real peer id. Real peers are everyone with id > 1 (id 1 is the relay’s phantom server slot).

The first client to land in a fresh room is provably id 2 — there is no lower real peer it could be racing — so it can host the moment it connects, with no timer and no grace window. Every later joiner learns the roster a few frames after connected_to_server and is spawned reactively by the host; it must not claim authority for itself in the meantime (more on that below).

Host-authority MultiplayerSpawner

The host owns the MultiplayerSpawner and is the only peer that calls spawn(). Spawned players are replicated to everyone by the relay.

extends Node var _players: Node var _spawner: MultiplayerSpawner var _host: int = 0 func _ready() -> void: _players = Node.new() _players.name = "Players" add_child(_players) _spawner = MultiplayerSpawner.new() _spawner.name = "Spawner" add_child(_spawner) _spawner.spawn_path = _spawner.get_path_to(_players) _spawner.spawn_function = Callable(self, "_spawn_player") multiplayer.peer_connected.connect(_on_peer) multiplayer.peer_disconnected.connect(_on_peer_gone) multiplayer.connected_to_server.connect(_on_connected) # join_room(...) from the snippet above goes here. func _real_peers() -> Array: var out: Array = [] for p in multiplayer.get_peers(): if int(p) > 1: out.append(int(p)) return out # Host = lowest real peer id. `include_self_floor` lets the caller exclude `me` # from the candidate set during the connect burst (see _on_connected): at that # instant get_peers() may not yet list the lower peers the relay is about to # deliver, and claiming self as authority there would make this peer REJECT the # real host's SPAWN with ERR_UNAUTHORIZED until it caught up. func _refresh_host(include_self_floor: bool = true) -> void: var cands: Array = _real_peers() var me: int = multiplayer.get_unique_id() if include_self_floor and me > 1: cands.append(me) cands.sort() # Never drop a known host to 0 on a transient empty view — only adopt a new # one when we actually have a candidate, so spawner authority never flickers # off the real host mid-burst. if cands.size() > 0: _host = int(cands[0]) _spawner.set_multiplayer_authority(_host) func _i_am_host() -> bool: return multiplayer.get_unique_id() == _host and _host > 0 func _host_spawn(id: int) -> void: if not _i_am_host() or id <= 1: return if _players.has_node("player_%d" % id): return _spawner.spawn(id) func _host_spawn_all() -> void: if not _i_am_host(): return _host_spawn(multiplayer.get_unique_id()) for id in _real_peers(): _host_spawn(id) func _on_connected() -> void: # id 2 is the ONLY id that provably has no lower peer, so it is the only peer # that may treat itself as the host floor on connect (and self-spawn). Every # other peer waits to learn the roster before it knows the host, and is # spawned reactively by the host in _on_peer. Passing include_self_floor=false # for id > 2 keeps it from transiently claiming spawner authority. var me: int = multiplayer.get_unique_id() _refresh_host(me == 2) if me == 2: _host_spawn_all() func _on_peer(id: int) -> void: if id <= 1: return _refresh_host() _host_spawn_all() func _on_peer_gone(id: int) -> void: # Reactive failover: when any peer drops, recompute the host from the roster # (now authoritative). If that makes US the new lowest peer, we adopt the # spawner and re-spawn everyone still present. No timer, no grace window. if _i_am_host() and _players.has_node("player_%d" % id): _players.get_node("player_%d" % id).queue_free() _refresh_host() if _i_am_host(): if _players.has_node("player_%d" % id): _players.get_node("player_%d" % id).queue_free() _host_spawn_all() func _spawn_player(data) -> Node: var id: int = int(data) var p := Node2D.new() p.name = "player_%d" % id p.set_script(load("res://player.gd")) var sync := MultiplayerSynchronizer.new() sync.replication_interval = 0.0 # 0 = push every network frame, lowest observer lag var cfg := SceneReplicationConfig.new() cfg.add_property(NodePath(".:position")) cfg.property_set_spawn(NodePath(".:position"), true) cfg.property_set_replication_mode(NodePath(".:position"), SceneReplicationConfig.REPLICATION_MODE_ALWAYS) sync.replication_config = cfg sync.root_path = NodePath("..") p.add_child(sync) return p

Per-node authority from _enter_tree

Each player node owns itself. Derive the owner id from the node name in _enter_tree, so authority is set the instant the node enters every peer’s tree — deterministic, identical everywhere, with no networked handoff. The owner moves its node; the MultiplayerSynchronizer replicates position to everyone else.

# res://player.gd extends Node2D func _enter_tree() -> void: var owner_id: int = int(str(name).trim_prefix("player_")) set_multiplayer_authority(owner_id) func _process(_delta: float) -> void: if is_multiplayer_authority(): # Only the owning peer drives this node; everyone else receives its # position over the MultiplayerSynchronizer. position += Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down") * 200.0 * _delta

Limitation: host leaves → the session ends

Reactive failover keeps the room alive while peers join and leave around the host, but it cannot migrate a live world. A MultiplayerSpawner only replicates the scenes it spawned to peers that were present at spawn time; it does not hand an already-running world (and its accumulated state) to a new authority. So when the host itself leaves:

  • the new lowest peer becomes the host and re-runs _host_spawn_all(), re-establishing the player roster, but
  • any state the old host held that was not covered by a MultiplayerSynchronizer (scores, inventories, world mutations) is gone.

For a transient session — a lobby, a deathmatch round, an orbit demo — this is fine: the survivors keep playing with a fresh authority. For anything that must outlive the host, the host-client model is the wrong tool. See Multiplayer limitations for the durable-state alternative and when to reach for it.

Tier and enable requirements

Multiplayer is gated on two flags from your Ziva account:

  • Subscription tier: must be basic, pro, or ultra. The hobby tier cannot use the relay. Upgrade at ziva.sh/account#subscription .
  • Multiplayer enabled: even on Basic+, multiplayer is off by default. Enable it in the plugin under Settings → Ziva Cloud.

The relay checks both before accepting a connection. If your account is on hobby, has multiplayer disabled, or has crossed its monthly bandwidth cap, the WebSocket upgrade is rejected and the settings are never written.

What about abuse?

Because the connection URL carries a public user_id, the relay does not trust it blindly:

  • Per-connection byte budget: each connection has a rolling messages-per-second and bytes-per-second limit. Floods are closed, not relayed.
  • Connection-rate limits: the relay bounds raw connection attempts per source IP and per (user_id, game_id) before doing any account lookup, so a leaked id can’t be turned into unbounded load.
  • Per-game allowlist (planned): a future option to restrict which origins/games may connect under your account.

Bandwidth is metered against the owning account, and crossing your monthly cap throttles new connections until the next cycle.

Last updated on