Free Tutorial

Godot 4 Multiplayer Tutorial
Peer-to-Peer & Client-Server Networking

by ScriptSnapMar 7, 2026~18 min read

Multiplayer used to be the scariest thing in game development. Raw sockets, packet serialization, authoritative servers — it was enough to make indie devs stick to single-player forever. Godot 4 changed that. The engine ships with a High-Level Multiplayer API that handles connections, packet reliability, and peer management out of the box. You write GDScript functions, tag them with @rpc, and Godot routes the calls across the network for you. Combine that with MultiplayerSpawner and MultiplayerSynchronizer and you can build a full godot online game without touching raw packets.

In this comprehensive godot 4 multiplayer tutorial, we cover everything you need for production-grade godot networking: the ENet transport layer, the godot multiplayer api lifecycle, remote procedure calls (godot rpc), automatic spawning and state synchronization, peer-to-peer vs dedicated server architecture, lobby systems, and lag compensation fundamentals. By the end you'll have working code you can drop into any project.

If you've already built single-player systems — like a state machine or a signal bus — you already know enough GDScript to follow along. Familiar with serializing game data? Even better — the same patterns apply to network payloads. Let's get connected.

1 — The High-Level Multiplayer API

Godot 4's multiplayer stack is layered. At the bottom sits ENetMultiplayerPeer — a wrapper around the battle-tested ENet library that gives you reliable and unreliable UDP channels. You plug this peer into the engine's MultiplayerAPI via multiplayer.multiplayer_peer, and the godot multiplayer api handles connection events, disconnection cleanup, and RPC routing automatically.

Every peer in the network gets a unique integer ID. The server is always ID 1. Each client receives a random positive integer on connect. You use these IDs to target specific peers when sending RPCs or to identify who owns a particular node. The multiplayer.get_unique_id() function returns the local peer's ID, while multiplayer.is_server() tells you whether you're running as the host.

The API fires several key signals you'll rely on: peer_connected, peer_disconnected, connected_to_server, and server_disconnected. If you've used the signal bus pattern, think of these as engine-level events you can relay into your own event system. Connecting callbacks in _ready() lets you react to every network lifecycle event cleanly.

2 — ENetMultiplayerPeer: Server & Client Setup

Create a new scene with a Node root called Main. Attach the following script. It creates either a server or a client depending on which button the player presses in the UI — the same pattern used in every godot networking setup.

network_manager.gd
extends Node

const PORT = 9999
const MAX_CLIENTS = 8

func host_game() -> void:
    var peer = ENetMultiplayerPeer.new()
    peer.create_server(PORT, MAX_CLIENTS)
    multiplayer.multiplayer_peer = peer
    print("Server started on port ", PORT)

func join_game(address: String) -> void:
    var peer = ENetMultiplayerPeer.new()
    peer.create_client(address, PORT)
    multiplayer.multiplayer_peer = peer
    print("Connecting to ", address)

That's it — five meaningful lines per function. Call host_game() on one instance and join_game("127.0.0.1") on another (or the same machine for testing), and you have a live connection. Now you need to know when peers connect and disconnect. Wire up the signals in _ready():

network_manager.gd — connection signals
func _ready() -> void:
    multiplayer.peer_connected.connect(_on_peer_connected)
    multiplayer.peer_disconnected.connect(_on_peer_disconnected)
    multiplayer.connected_to_server.connect(_on_connected)
    multiplayer.server_disconnected.connect(_on_server_lost)

func _on_peer_connected(id: int) -> void:
    print("Peer connected: ", id)

func _on_peer_disconnected(id: int) -> void:
    print("Peer disconnected: ", id)

func _on_connected() -> void:
    print("Successfully joined server!")

func _on_server_lost() -> void:
    multiplayer.multiplayer_peer = null
    get_tree().change_scene_to_file("res://main_menu.tscn")

These signals fire on every peer. When client B connects, both the server and client A receive peer_connected. The server_disconnected handler gracefully resets the peer and sends the player back to the main menu — a pattern your players will thank you for.

3 — RPCs and @rpc Annotations

RPCs are the backbone of godot rpc communication. You annotate a function with @rpc, and Godot lets you call it remotely on other peers. The annotation accepts three arguments that control who can call it, whether it also runs locally, and the delivery guarantee. Here's a practical chat message example:

chat.gd
extends Node

# Called locally — broadcasts to all peers
func send_chat(text: String) -> void:
    receive_chat.rpc(multiplayer.get_unique_id(), text)

@rpc("any_peer", "call_local", "reliable")
func receive_chat(sender_id: int, text: String) -> void:
    chat_log.append("[Peer %d]: %s" % [sender_id, text])
    _update_chat_ui()

The three @rpc arguments explained:

  • "any_peer" vs "authority" — who is allowed to call this RPC. Use any_peer for things like chat. Use authority when only the server (or node owner) should trigger an action — ideal for game-state-changing events.
  • "call_local" vs "call_remote" — whether the function also runs on the sender. call_local means the caller sees the result immediately without waiting for a round trip.
  • "reliable" vs "unreliable" vs "unreliable_ordered" — reliable guarantees delivery (TCP-like). Unreliable is faster but packets can drop — perfect for position updates that get overwritten every frame. unreliable_ordered drops stale packets but delivers them in order.

To call an RPC on a specific peer instead of all peers, use receive_chat.rpc_id(target_id, sender_id, text). This is how you implement whisper messages, targeted spawns, or server-only validation responses. If you're familiar with GDScript tricks, you'll find RPCs are just another tool in the same toolbox.

Building multiplayer game architecture? Clean state management is critical. Our State Machines course teaches reusable patterns that scale with online complexity.

Take the course — $4.99 →
📧

Want more Godot tricks?

Get our free GDScript Cheat Sheet with 20+ copy-paste snippets for signals, exports, state machines & more.

GDSJoin 500+ Godot devs · No spam · Unsubscribe anytime

4 — MultiplayerSpawner & MultiplayerSynchronizer

Godot 4 introduced two scene-tree nodes that eliminate most of the boilerplate multiplayer code: MultiplayerSpawner and MultiplayerSynchronizer. Together they handle the two hardest parts of any godot online game — creating replicated objects and keeping their properties in sync.

MultiplayerSpawner

Add a MultiplayerSpawner node as a child of the container where spawned nodes live (e.g. a World node). In the Inspector, add your player scene to the Auto Spawn List. Now whenever the server adds a child under the spawn path, every connected client automatically instantiates the same scene — no RPCs needed. When the server removes the node, clients clean it up too.

world.gd — server-side spawning
extends Node2D

@export var player_scene: PackedScene

func spawn_player(peer_id: int) -> void:
    # Only the server spawns — clients replicate via MultiplayerSpawner
    if not multiplayer.is_server():
        return
    var player = player_scene.instantiate()
    player.name = str(peer_id)
    # Set authority so the owning client controls this node
    player.set_multiplayer_authority(peer_id)
    $Players.add_child(player)

Naming the node with the peer ID is a common convention — it makes look-ups trivial and the spawner uses the name to match nodes across peers.

MultiplayerSynchronizer

Add a MultiplayerSynchronizer as a child of the node you want to keep in sync (e.g. the player character). In the Inspector, add the properties you want replicated — typically position, velocity, and any gameplay-relevant variables. The synchronizer sends delta updates every physics frame from the authority to all other peers. No RPCs, no manual serialization.

A typical player scene tree for a godot online game looks like this:

Player scene tree
Player (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
├── MultiplayerSynchronizer
  → Synced properties: position, animation_state
  → Replication interval: 0.05s (20 Hz)
└── InputHandler (only processes on authority)

The synchronizer respects set_multiplayer_authority(). Only the authority peer sends updates; everyone else receives them. This means the owning player processes input locally while remote players see smooth replicated movement. If you've built animation trees, you can sync the animation state variable and have animations play correctly on remote clients too.

5 — Peer-to-Peer vs Dedicated Server Architecture

Every godot 4 multiplayer tutorial needs to address the architecture question: should you go peer-to-peer or run a dedicated server? The answer depends on your game genre, player count, and anti-cheat needs.

Peer-to-Peer (P2P)

In P2P, one player's instance acts as both the server and a client. There's no separate binary or cloud machine. This is the default Godot model and it works beautifully for co-op games, turn-based games, and small lobbies (2–8 players). The tradeoffs:

  • Pros: Zero hosting cost, dead-simple setup, works for LAN play.
  • Cons: Host has zero latency advantage, IP exposure risk, game dies if host leaves, harder to prevent cheating.

Dedicated Server

A dedicated server runs the same Godot project in headless mode (--headless flag) on a cloud machine. No player is "the server" — everyone connects as a client. This gives you authoritative game state, better cheat prevention, and the game survives any single player disconnecting. It's the right choice for competitive games, MMOs, and anything with more than 8 players.

Launch as headless dedicated server
# Terminal command — no window, no rendering
./my_game.x86_64 --headless -- --server

# In your _ready(), check the CLI argument:
func _ready() -> void:
    if "--server" in OS.get_cmdline_user_args():
        host_game()
        print("Dedicated server running…")

The beauty of Godot's architecture is that your game code stays nearly identical between P2P and dedicated server. You just change who calls create_server(). The entire godot multiplayer api, RPCs, spawners, and synchronizers work the same way in both modes.

6 — Building a Lobby System

A lobby lets players gather, set their name, pick a team, and signal "ready" before the match starts. The pattern: each client sends its player info to the server via RPC, the server aggregates it into a dictionary, and broadcasts the updated lobby state back to all clients.

lobby_manager.gd
extends Node

# Server-side lobby data: {peer_id: {name, ready, team}}
var players: Dictionary = {}

func _ready() -> void:
    multiplayer.peer_connected.connect(_on_peer_joined)
    multiplayer.peer_disconnected.connect(_on_peer_left)

func _on_peer_joined(id: int) -> void:
    if multiplayer.is_server():
        players[id] = {"name": "Player", "ready": false, "team": "A"}
        _broadcast_lobby()

func _on_peer_left(id: int) -> void:
    players.erase(id)
    _broadcast_lobby()

@rpc("any_peer", "reliable")
func set_player_info(info: Dictionary) -> void:
    var sender = multiplayer.get_remote_sender_id()
    if sender in players:
        players[sender].merge(info, true)
        _broadcast_lobby()

func _broadcast_lobby() -> void:
    _update_lobby_ui.rpc(players)

@rpc("authority", "call_local", "reliable")
func _update_lobby_ui(lobby_data: Dictionary) -> void:
    # Rebuild your lobby UI from the data
    players = lobby_data
    emit_signal("lobby_updated", lobby_data)

Each client calls set_player_info.rpc_id(1, {"name": "Alice", "ready": true}) to update the server. The server validates, merges, and broadcasts. This server-authoritative pattern prevents clients from spoofing other players' data. If you've built a UI system with containers, the lobby UI is just a VBoxContainer that rebuilds its children whenever lobby_updated fires.

To start the match, check if every player's ready flag is true, then call a start_game.rpc() that triggers a scene change on all peers simultaneously.

Want clean, decoupled game architecture for your multiplayer project? Our Signals course covers the event-driven patterns that keep netcode manageable.

Grab the course — $4.99 →

7 — Syncing Player State

While MultiplayerSynchronizer handles continuous properties like position, you often need to sync discrete game events — health changes, item pickups, ability casts. The pattern: the authority (server or owning peer) validates the action, then broadcasts the result via RPC.

player.gd — health sync
extends CharacterBody2D

var health: int = 100

# Called on the server when a hit is detected
func take_damage(amount: int) -> void:
    if not multiplayer.is_server():
        return
    health = max(health - amount, 0)
    _sync_health.rpc(health)
    if health == 0:
        _on_death()

@rpc("authority", "call_local", "reliable")
func _sync_health(new_hp: int) -> void:
    health = new_hp
    health_bar.value = new_hp
    # Play hit flash using a tween
    _play_damage_effect()

Notice the pattern: take_damage() is a server-only function that validates the action, then _sync_health.rpc() pushes the result to everyone. The RPC uses "authority" so only the server can call it. This prevents a hacked client from broadcasting fake health values. If you need smooth HUD animations, use tweens inside _play_damage_effect() to animate the health bar.

For properties that change every frame (position, rotation), let MultiplayerSynchronizer handle them automatically. For discrete events (damage, item pickup, ability cast), use explicit RPCs. This hybrid approach gives you the best of both worlds: automatic continuous sync plus validated discrete events.

8 — Lag Compensation Basics

Even on fast connections, network latency is real. A 60ms round trip means your remote players are always slightly "in the past." Without compensation, movement looks jittery and hit registration feels unfair. Here are the three core techniques every godot networking project should consider.

Client-Side Prediction

The owning client processes input immediately without waiting for server confirmation. If the server later disagrees (e.g. the player hit a wall the client didn't know about), the client corrects. In practice, this means you run movement in _physics_process() locally and let the MultiplayerSynchronizer reconcile when the server's position arrives. For most indie games, the synchronizer's built-in interpolation is enough.

Interpolation

Instead of snapping remote characters to each new server position, smoothly blend between the last two known states. This turns choppy 20 Hz network updates into visually smooth 60 fps movement. You can implement basic interpolation with a simple lerp:

remote_player_smoother.gd
extends Node2D

var target_pos: Vector2
var smooth_speed: float = 15.0

func _physics_process(delta: float) -> void:
    # Only interpolate on non-authority peers
    if not is_multiplayer_authority():
        global_position = global_position.lerp(target_pos, smooth_speed * delta)

# Called by the MultiplayerSynchronizer when new data arrives
func _on_sync_received(pos: Vector2) -> void:
    target_pos = pos

Server Reconciliation

For competitive games, you store a history of client inputs with timestamps. When the server sends back its authoritative state, the client compares it against the predicted state at that timestamp. If there's a mismatch, the client replays all inputs from that point forward to "catch up." This is advanced territory, but the concept is simple: predict → compare → correct.

For most indie godot online game projects, client-side prediction plus interpolation covers 90% of your needs. Server reconciliation only matters if you're building a fast-paced competitive shooter or racing game. Start simple, profile with real players, and add complexity only where lag becomes noticeable.

Next Steps

You now have a complete foundation for godot 4 multiplayer development. From ENet setup to RPCs, spawners, synchronizers, lobby systems, and lag compensation — you're equipped to build real networked games. Here are some paths forward:

  • Add an inventory system with networked item trading between players.
  • Use procedural generation to create shared worlds from a seed sent by the server.
  • Build a dialogue system for multiplayer NPC interactions with choice synchronization.
  • Animate multiplayer effects with custom shaders for hit feedback, team colors, and networked particle effects.
  • Explore @export annotations to make your network configuration inspector-friendly.

Multiplayer is a skill that compounds. Every project you ship teaches you something new about latency, synchronization, and player experience. Start with P2P co-op, graduate to dedicated servers, and before you know it you'll be shipping the godot online game you've always wanted to build.

Free download
+ weekly tips

Get the GDScript Multiplayer Cheat Sheet

Subscribe for a printable RPC reference card, plus weekly Godot networking tips straight to your inbox.

20+ GDScript snippets
Weekly Godot tips
Zero spam, cancel anytime
>
GDSJoin 500+ Godot devs. Unsubscribe in one click.