~3 min read

Multiplayer — MultiplayerAPI and RPC

ENet/WebSocket/WebRTC peers, RPC, MultiplayerSpawner and Synchronizer.

Godot has built-in high-level multiplayer: MultiplayerAPI + transports + Spawner/Synchronizer nodes. It is the analog of Unity Netcode for GameObjects.

Architecture

MultiplayerAPI works on top of MultiplayerPeer. Transports:

  • ENetMultiplayerPeer — UDP, the main one for native platforms.
  • WebSocketMultiplayerPeer — TCP/WebSocket, the only path for the web target.
  • WebRTCMultiplayerPeer — peer-to-peer, NAT traversal via STUN/TURN.
  • OfflineMultiplayerPeer — a stub for single-player.

The standard model is an authoritative server: one peer with ID=1 (the server), the rest are clients with ID >= 2.

Starting a server and a client

extends Node

const PORT := 7777
const MAX_PEERS := 4

func host_game() -> Error:
    var peer = ENetMultiplayerPeer.new()
    var err = peer.create_server(PORT, MAX_PEERS)
    if err != OK:
        return err
    multiplayer.multiplayer_peer = peer
    print("Server up on port ", PORT)
    return OK

func join_game(ip: String) -> Error:
    var peer = ENetMultiplayerPeer.new()
    var err = peer.create_client(ip, PORT)
    if err != OK:
        return err
    multiplayer.multiplayer_peer = peer
    print("Connecting to ", ip)
    return OK

multiplayer is a built-in getter for the MultiplayerAPI on any node.

MultiplayerAPI signals:

  • peer_connected(id) / peer_disconnected(id) — on the server for each client.
  • connected_to_server / connection_failed / server_disconnected — on the client.
func _ready() -> void:
    multiplayer.peer_connected.connect(_on_peer_joined)
    multiplayer.peer_disconnected.connect(_on_peer_left)

func _on_peer_joined(id: int) -> void:
    print("Peer ", id, " joined")
    if multiplayer.is_server():
        spawn_player(id)

RPC — remote calls

GDScript uses the @rpc annotation on functions that can be called remotely:

extends Node3D

# from_who: any_peer | authority
# transfer: reliable | unreliable | unreliable_ordered
# call_local: whether to also call locally on a remote call
@rpc("any_peer", "reliable", "call_local")
func shoot(direction: Vector3) -> void:
    spawn_bullet(direction)

# The server tells everyone (call → this method fires on every client)
@rpc("authority", "reliable")
func play_explosion_sound(at: Vector3) -> void:
    var player := AudioStreamPlayer3D.new()
    player.stream = preload("res://audio/explosion.ogg")
    player.global_position = at
    add_child(player)
    player.play()
    player.finished.connect(player.queue_free)

Calling:

# From client → server (if the remote method has "authority"):
rpc("shoot", direction)

# Only to a specific peer:
rpc_id(target_peer_id, "play_explosion_sound", pos)

# Specifically to the server:
rpc_id(1, "do_something")

The @rpc parameters:

  • From who: any_peer (any client can call) or authority (only the node’s owner).
  • Transfer: reliable (TCP-style), unreliable (UDP with no guarantees), unreliable_ordered.
  • Call local: whether to call locally in addition to remotely.
  • Channel: the channel number (for sending different message types in parallel).
Validate everything on the server

In the authoritative model the server is the source of truth. Do not trust RPC parameters from a client (any client can send damage = 99999). The server must validate: check the shot distance, cooldown, and permissions.

Multiplayer Authority

Every node has an authority — the peer ID that “owns” the node. By default it is 1 (the server). You can hand it to a client:

# On the server: give the peer control of its player
var player = player_scene.instantiate()
player.name = str(peer_id)  # name for uniqueness
add_child(player, true)
player.set_multiplayer_authority(peer_id)

Inside the script:

func _physics_process(delta: float) -> void:
    if not is_multiplayer_authority():
        return  # only the owner moves itself
    # ... handle input

MultiplayerSpawner

A node that automatically replicates the spawning of child nodes from the server to the clients. You don’t need to manually send a “create_enemy_at” RPC.

World
├── Players (Node)         ← children are spawned for each player
│   └── MultiplayerSpawner ← spawn_path = "../Players"
│                            spawnable_scenes = [Player.tscn]

On the server you call players.add_child(player). The spawner catches this and sends it to all clients, which create the same node locally.

MultiplayerSynchronizer

Declarative synchronization of properties. You attach it to a node and specify a list of properties in the Inspector (for example, position, rotation, hp). MultiplayerAPI automatically sends changes from the authority to the others.

Player (CharacterBody3D, authority = peer_id)
├── ... 
└── MultiplayerSynchronizer
    Replication Config:
       :position           [Always]
       :rotation:y         [Always]
       hp                  [On Change]
       state               [On Change]

Replication Mode:

  • Always — every tick.
  • On Change — when the value changes.
  • Never — do not replicate (for visibility filtering).

This removes 80% of the manual synchronization code you would otherwise write with RPCs. It’s the analog of Unity’s NetworkVariable, but declarative.

Comparison with Unity Netcode

ConceptUnity NGOGodot
Network entityNetworkObjectA node with authority
SpawnSpawn() via NetworkObjectMultiplayerSpawner
Sync propertyNetworkVariable<T>MultiplayerSynchronizer
RPC (client→server)[ServerRpc]@rpc("any_peer") + rpc_id(1, ...)
RPC (server→clients)[ClientRpc]@rpc("authority")
TransportUnityTransportENetMultiplayerPeer
RelayUnity RelayYour own (or third-party, e.g. Nakama)
MatchmakingUnity LobbyYour own

Web

Only via WebSocketMultiplayerPeer:

var peer = WebSocketMultiplayerPeer.new()
peer.create_server(8080)
# or peer.create_client("ws://example.com:8080")
multiplayer.multiplayer_peer = peer

ENet doesn’t work in the browser. WebRTC requires STUN/TURN servers for NAT traversal.

Pitfalls

  1. Don’t confuse local multiplayer with networked multiplayer. Split-screen is a different task, handled through viewports and PlayerInput sources.
  2. call_local — people forget it, and then the method doesn’t fire locally. If you want it to “run both on yourself and remotely,” enable it.
  3. MultiplayerSynchronizer requires the node to exist on all clients. Use it together with the Spawner.
  4. Order matters when spawning: if the server calls add_child(player) after set_multiplayer_authority, the authority will get replicated in time. Otherwise it’s the reverse.

In the next chapter — resource loading and the Resource Loader.