Multiplayer
Ask Ziva: “Add multiplayer so two players can join the same room and see each other move. Keep it simple and explain what happens when someone leaves.”
Ziva’s hosted relay connects the players without you running a dedicated game server. One player’s game acts as the host for shared activity.
Tier requirements
Multiplayer is included on Pro and Ultra, with setup handled the first time you ask Ziva to add it. Basic accounts that have already used multiplayer keep access. Upgrade at ziva.sh/account#subscription .
Bandwidth is billed to your account. Rooms allow up to 32 simultaneous connections. An account without relay access, or one over its monthly bandwidth cap, cannot make new connections. Reaching the cap blocks new connections until the next cycle.
When the host leaves
A remaining player can become host and rebuild the player roster, but this is not a saved game or a transfer of the whole running world. Scores, inventories and world changes not synchronized to the remaining players can be lost.
For progress players can return to after everyone leaves, use Persistent and shared state instead. That mode uses bandwidth plus storage, with roughly 1 MB of saved data per room, and is not meant to be combined with this scene-replication mode. See Multiplayer limitations to choose.
Using your own networking
The relay is optional. Ask for ENet, a dedicated server, Steam or Epic Online Services and Ziva builds that instead, on any plan, without setting up the relay.
What about abuse?
Game data passes through the hosted relay to connected players. The account and game IDs in your project are public identifiers, not secrets; bandwidth is charged to the named account. Do not send secrets as game data.
The relay closes flooding connections and limits connection attempts. These protections do not remove your account’s usage responsibility.
Optional reference: Godot relay connection and player scripts
How it works
The relay supports Godot’s built-in WebSocketMultiplayerPeer, @rpc, MultiplayerSynchronizer, and MultiplayerSpawner. No Ziva multiplayer addon or separate token is needed.
Multiplayer is included on Pro and Ultra. When you ask Ziva to add multiplayer on the relay, it runs setup_multiplayer, which writes three ziva/multiplayer/* project settings into your Godot project. They bake into your web exports, so the running game already knows how to reach the relay:
| Setting | What it is |
|---|---|
ziva/multiplayer/user_id | Your Ziva account id - the account bandwidth bills against. |
ziva/multiplayer/game_id | A stable id for this project, generated once on enable. |
ziva/multiplayer/relay_url | The 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 to connect to your room:
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", "")
if user_id.is_empty() or game_id.is_empty() or relay_url.is_empty():
push_error("Ziva multiplayer settings missing — ask Ziva to add multiplayer to this project.")
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 = peerroom_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
The relay is a message switch between peers, 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. One of the real peers must 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(id1is the relay’s phantom server slot).
The first client in a fresh room is id 2, with no lower real peer to wait for, so it can host as soon as 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.
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()
# Keep the known host until a replacement is available.
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 pPer-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 as the node enters every peer’s tree, identically 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 * _deltaConnection safeguards
Connections require plan access and available monthly bandwidth. Excessive traffic can close a connection.