Course Unlocked — 5 Lessons
🌐Networking

Multiplayer Networking Basics with Godot

By Jordan Reeves — Networking engineer turned game dev. Built multiplayer prototypes for three game jams and counting.

1

Setting Up ENet Multiplayer

Godot 4's multiplayer system is built on ENetMultiplayerPeer, a reliable UDP transport that handles packet ordering, channels, and connection management for you. Unlike raw TCP or UDP, ENet gives you the best of both worlds: unreliable fast packets for position updates, and reliable ordered packets for critical game events. We'll build a minimal lobby scene with Host and Join buttons that establishes a connection between two game instances.

The architecture is peer-to-peer with a designated server. One player creates the server (hosts), and others connect as clients. The host is both a server and a player — they have peer ID 1. Every other connected player gets a unique peer ID (2, 3, 4, etc.). This ID is how you identify who sent an RPC and who owns which game objects.

Create a new scene with a simple UI: two buttons (Host and Join), a LineEdit for the IP address, and a Label for connection status. Attach this script:

gdscript
class_name Lobby
extends Control

const PORT := 9999
const MAX_CLIENTS := 4

@onready var ip_input: LineEdit = $IPInput
@onready var status_label: Label = $StatusLabel
@onready var host_btn: Button = $HostButton
@onready var join_btn: Button = $JoinButton

func _ready() -> void:
    # Connect multiplayer signals
    multiplayer.peer_connected.connect(_on_peer_connected)
    multiplayer.peer_disconnected.connect(_on_peer_disconnected)
    multiplayer.connected_to_server.connect(_on_connected)
    multiplayer.connection_failed.connect(_on_connection_failed)

func _on_host_pressed() -> void:
    var peer := ENetMultiplayerPeer.new()
    var err := peer.create_server(PORT, MAX_CLIENTS)
    if err != OK:
        status_label.text = "Failed to create server: %s" % error_string(err)
        return

    multiplayer.multiplayer_peer = peer
    status_label.text = "Hosting on port %d... (You are peer 1)" % PORT
    host_btn.disabled = true
    join_btn.disabled = true

func _on_join_pressed() -> void:
    var peer := ENetMultiplayerPeer.new()
    var ip := ip_input.text if ip_input.text != "" else "127.0.0.1"
    var err := peer.create_client(ip, PORT)
    if err != OK:
        status_label.text = "Failed to connect: %s" % error_string(err)
        return

    multiplayer.multiplayer_peer = peer
    status_label.text = "Connecting to %s:%d..." % [ip, PORT]
    host_btn.disabled = true
    join_btn.disabled = true

The multiplayer singleton (`multiplayer`) is available on every node and manages the network layer globally. When you assign a peer to `multiplayer.multiplayer_peer`, Godot starts the network loop automatically — you don't need to call any update functions. Signals fire on the main thread, so you can safely update UI from the callbacks.

Now wire up the signal callbacks to handle connection events. These fire on different peers at different times, which is a common source of confusion:

gdscript
func _on_peer_connected(id: int) -> void:
    # Fires on ALL peers (including the server) when someone connects
    status_label.text = "Peer %d connected! (%d players total)" % [
        id, multiplayer.get_peers().size() + 1
    ]
    print("Peer connected: ", id)

func _on_peer_disconnected(id: int) -> void:
    status_label.text = "Peer %d disconnected" % id
    # Clean up that player's character (covered in Lesson 3)

func _on_connected() -> void:
    # Only fires on CLIENTS when they successfully connect to the server
    status_label.text = "Connected! You are peer %d" % multiplayer.get_unique_id()

func _on_connection_failed() -> void:
    # Only fires on CLIENTS when connection attempt fails
    status_label.text = "Connection failed. Is the host running?"
    multiplayer.multiplayer_peer = null
    host_btn.disabled = false
    join_btn.disabled = false

A critical distinction: `peer_connected` fires on everyone (including the server) whenever a new client joins. `connected_to_server` only fires on the client that just connected. `connection_failed` only fires on clients. The server never receives `connected_to_server` or `connection_failed` because it IS the server. Getting these signal semantics wrong is the #1 source of multiplayer bugs in Godot projects.

To test locally, run two instances of your project (Project → Run Multiple Instances in the editor, or export and run two copies). Click Host on one instance, then Join on the other with IP 127.0.0.1. You should see the peer_connected signal fire on both sides. If it doesn't connect, check your firewall — ENet uses UDP, and some firewalls block UDP by default.

2

RPCs and the Authority Model

Remote Procedure Calls (RPCs) are how peers communicate gameplay actions across the network. Instead of manually serializing packets, you annotate a function with @rpc and Godot handles the rest — serialization, transport, and calling the function on the remote peer. But RPCs have several modes that dramatically affect how they behave, and picking the wrong one causes desync bugs that are maddening to debug.

The four RPC configuration axes are: transfer mode (reliable vs. unreliable vs. unreliable_ordered), call local (whether the function also runs on the sender), channel (for packet ordering), and authority (who is allowed to call this RPC). Let's break each one down.

Transfer mode determines delivery guarantees. Reliable RPCs are like TCP — guaranteed delivery, guaranteed order, but higher latency. Use these for critical game events: player died, item picked up, game state changes. Unreliable RPCs are like UDP — fast but may be dropped or arrive out of order. Use these for frequent updates that get superseded quickly: position, rotation, animation state. Unreliable ordered is the middle ground — no delivery guarantee, but packets that DO arrive come in order, dropping stale ones.

gdscript
class_name Player
extends CharacterBody2D

# Position sync: unreliable because we send it every frame and old positions are useless
@rpc("any_peer", "unreliable")
func sync_position(pos: Vector2, vel: Vector2) -> void:
    if not is_multiplayer_authority():
        # Only apply received position if we don't own this player
        position = pos
        velocity = vel

# Damage: reliable because missing a hit would desync health
@rpc("any_peer", "reliable")
func take_damage(amount: int, attacker_id: int) -> void:
    if is_multiplayer_authority():
        # Only the authority processes damage
        health -= amount
        if health <= 0:
            die.rpc()  # Tell everyone we died

# Death: reliable + call_local so the dying player also sees it
@rpc("any_peer", "reliable", "call_local")
func die() -> void:
    # Play death animation, disable collision, etc.
    $AnimatedSprite2D.play("death")
    $CollisionShape2D.disabled = true

The @rpc annotation's first argument controls who can call this RPC. 'any_peer' means any connected peer can invoke it. 'authority' (the default) means only the peer who has authority over this node can call it. This is a security feature — without it, a hacked client could call `take_damage` on other players to grief them.

Authority in Godot's multiplayer is per-node: every node has a multiplayer authority (defaulting to peer 1, the server). You set it with `set_multiplayer_authority(peer_id)`. The authority is the peer that 'owns' that node and has the definitive state for it. For player characters, the authority should be the player who controls that character. For enemies and world objects, the authority is typically the server.

gdscript
# When spawning a player character, set its authority to the owning peer
func spawn_player(peer_id: int) -> void:
    var player := preload("res://player/player.tscn").instantiate()
    player.name = str(peer_id)  # Important: name must be unique
    player.set_multiplayer_authority(peer_id)
    $Players.add_child(player)

    # Now peer_id's client can call authority-restricted RPCs on this node
    # Other peers cannot — Godot will silently drop unauthorized RPC calls

A common gotcha: the node's `name` property must be unique and consistent across all peers. Godot's RPC system uses the node path to route calls to the correct node, so if the player node is named 'Player' on one peer and 'Player2' on another, RPCs will fail silently. The safest pattern is to name nodes after the peer ID, as shown above. This guarantees consistency because peer IDs are assigned deterministically by ENet.

To call an RPC, use `function_name.rpc()` to send to all peers, `function_name.rpc_id(peer_id)` to send to a specific peer, or just call the function normally to run it locally without sending anything over the network. The `.rpc()` syntax was introduced in Godot 4 — if you see tutorials using `rpc('function_name')`, that's the old Godot 3 syntax and won't work.

3

Syncing Player Positions Smoothly

The most visible part of any multiplayer game is seeing other players move smoothly. Naive position syncing (just teleporting to the latest received position) produces jittery, teleporting movement that feels terrible. We need interpolation: smoothly blending between received positions to create the illusion of continuous movement even when we only receive updates 20-30 times per second.

Godot 4 provides a built-in tool for this: MultiplayerSynchronizer. It automatically replicates properties from the authority to other peers at a configurable interval. But for learning purposes (and finer control), we'll implement it manually first, then show the synchronizer approach.

The manual approach uses a pattern called 'snapshot interpolation.' Each peer sends its position at a fixed rate (e.g., 20Hz). Receiving peers store the last two positions and interpolate between them over time. This means remote players are always displayed slightly in the past — typically one network tick behind — but the motion looks smooth.

gdscript
class_name NetworkedPlayer
extends CharacterBody2D

const SYNC_INTERVAL := 0.05  # 20 Hz — sync position 20 times per second
const INTERPOLATION_SPEED := 12.0  # How fast to lerp toward target position
const SPEED := 300.0

var sync_timer := 0.0
var target_position := Vector2.ZERO
var target_velocity := Vector2.ZERO

func _ready() -> void:
    target_position = position

func _physics_process(delta: float) -> void:
    if is_multiplayer_authority():
        # We own this player — handle input and send position to others
        _handle_input(delta)
        sync_timer += delta
        if sync_timer >= SYNC_INTERVAL:
            sync_timer = 0.0
            _send_position.rpc(position, velocity)
    else:
        # Remote player — interpolate toward last received position
        position = position.lerp(target_position, INTERPOLATION_SPEED * delta)

func _handle_input(delta: float) -> void:
    var input_dir := Input.get_vector("move_left", "move_right", "move_up", "move_down")
    velocity = input_dir * SPEED
    move_and_slide()

@rpc("any_peer", "unreliable")
func _send_position(pos: Vector2, vel: Vector2) -> void:
    target_position = pos
    target_velocity = vel

The `INTERPOLATION_SPEED` constant controls how quickly the visual representation catches up to the actual position. Too low and players feel sluggish; too high and you get jitter. A value of 10-15 works well for most games. The `lerp` call in `_physics_process` creates smooth motion between sync points.

One problem with simple lerp: if a packet is dropped or delayed, the player appears to slow down as it lerps toward a stale target. A better approach adds velocity prediction — when we don't receive a new position, we extrapolate forward using the last known velocity:

gdscript
# Enhanced interpolation with velocity prediction
var time_since_last_sync := 0.0

func _physics_process(delta: float) -> void:
    if is_multiplayer_authority():
        _handle_input(delta)
        sync_timer += delta
        if sync_timer >= SYNC_INTERVAL:
            sync_timer = 0.0
            _send_position.rpc(position, velocity)
    else:
        time_since_last_sync += delta
        # Predict where the player should be based on last velocity
        var predicted := target_position + target_velocity * time_since_last_sync
        position = position.lerp(predicted, INTERPOLATION_SPEED * delta)

@rpc("any_peer", "unreliable")
func _send_position(pos: Vector2, vel: Vector2) -> void:
    target_position = pos
    target_velocity = vel
    time_since_last_sync = 0.0  # Reset prediction timer

Now let's look at the MultiplayerSynchronizer approach, which is simpler for common cases. Add a MultiplayerSynchronizer node as a child of your player scene. In the inspector, add the properties you want to sync (position, velocity, animation state). Set the replication interval and Godot handles the rest:

gdscript
# With MultiplayerSynchronizer, your player script becomes much simpler:
class_name SyncedPlayer
extends CharacterBody2D

const SPEED := 300.0

# MultiplayerSynchronizer handles replicating 'position' automatically
# Just add 'position' and 'velocity' to the synchronizer's replication config

func _physics_process(delta: float) -> void:
    if is_multiplayer_authority():
        var input_dir := Input.get_vector("move_left", "move_right", "move_up", "move_down")
        velocity = input_dir * SPEED
        move_and_slide()
    # Non-authority peers: position is updated by the synchronizer automatically
    # No manual interpolation needed if you enable the synchronizer's interpolation

The MultiplayerSynchronizer supports built-in interpolation (enable it in the inspector). It handles the lerp/prediction logic internally and often produces better results than a hand-rolled solution. The tradeoff is less control — if you need custom interpolation curves, dead reckoning, or animation-aware blending, the manual approach is better. For most indie multiplayer games, the synchronizer is the right choice.

4

Building a Lobby System

A lobby is the glue between 'game can connect' and 'game is fun to play.' Without a lobby, players have to coordinate externally (Discord, text messages) to get into the same game. A good lobby system handles player lists, readiness checks, game settings, and the synchronized transition to gameplay. We'll build one that supports 2-4 players with a ready-up mechanism.

The lobby has two phases: waiting (players join, see who else is in the lobby, and mark themselves ready) and starting (all players are ready, the host initiates the game, and everyone transitions to the game scene together). The host has special privileges: they can kick players and start the game. Let's build the data model and sync logic:

gdscript
class_name LobbyManager
extends Node

signal player_list_changed
signal game_starting

# Player info stored on the HOST and synced to all clients
var players: Dictionary = {}  # peer_id -> { name: String, ready: bool }

func _ready() -> void:
    multiplayer.peer_connected.connect(_on_peer_connected)
    multiplayer.peer_disconnected.connect(_on_peer_disconnected)

func _on_peer_connected(id: int) -> void:
    if multiplayer.is_server():
        # Server sends current player list to the new peer
        for peer_id in players:
            _add_player_remote.rpc_id(id, peer_id, players[peer_id]["name"])

func _on_peer_disconnected(id: int) -> void:
    if multiplayer.is_server():
        players.erase(id)
        _remove_player_remote.rpc(id)

# Client tells server their name
@rpc("any_peer", "reliable")
func register_player(player_name: String) -> void:
    var sender := multiplayer.get_remote_sender_id()
    players[sender] = { "name": player_name, "ready": false }
    # Broadcast to all clients
    _add_player_remote.rpc(sender, player_name)

@rpc("authority", "reliable", "call_local")
func _add_player_remote(id: int, player_name: String) -> void:
    players[id] = { "name": player_name, "ready": false }
    player_list_changed.emit()

@rpc("authority", "reliable", "call_local")
func _remove_player_remote(id: int) -> void:
    players.erase(id)
    player_list_changed.emit()

The pattern here is 'client requests, server validates and broadcasts.' When a client wants to register their name, they call `register_player.rpc_id(1)` — sending the request to the server (peer 1). The server stores the data and broadcasts it to all clients via `_add_player_remote.rpc()`. This ensures the server is always the source of truth for the player list.

Now add the ready-up system. Players toggle their ready state, and when all players are ready, the host can start the game:

gdscript
# Client requests to toggle ready state
@rpc("any_peer", "reliable")
func set_ready(is_ready: bool) -> void:
    var sender := multiplayer.get_remote_sender_id()
    if sender in players:
        players[sender]["ready"] = is_ready
        _sync_ready_state.rpc(sender, is_ready)

@rpc("authority", "reliable", "call_local")
func _sync_ready_state(id: int, is_ready: bool) -> void:
    if id in players:
        players[id]["ready"] = is_ready
        player_list_changed.emit()

func all_players_ready() -> bool:
    if players.size() < 2:
        return false  # Need at least 2 players
    for peer_id in players:
        if not players[peer_id]["ready"]:
            return false
    return true

# Host-only: start the game when everyone is ready
func start_game() -> void:
    if not multiplayer.is_server():
        return
    if not all_players_ready():
        return
    _load_game_scene.rpc()

@rpc("authority", "reliable", "call_local")
func _load_game_scene() -> void:
    game_starting.emit()
    # All peers load the game scene simultaneously
    get_tree().change_scene_to_file("res://scenes/game.tscn")

The `change_scene_to_file` call happens on every peer at roughly the same time, triggered by the server's RPC. There will be some timing variation (different network latencies), but ENet's reliable delivery guarantees everyone receives the call. The game scene's `_ready` function should wait briefly before spawning players to account for this variance — a 0.5 second countdown timer works well.

For the lobby UI, connect the `player_list_changed` signal to a function that rebuilds the player list display. Each entry shows the player name, a ready indicator (green checkmark or gray circle), and a kick button visible only to the host:

gdscript
# In your lobby UI script
@onready var player_list: VBoxContainer = $PlayerList
@onready var start_btn: Button = $StartButton
@onready var ready_btn: Button = $ReadyButton

var my_ready := false

func _on_player_list_changed() -> void:
    # Clear and rebuild the player list UI
    for child in player_list.get_children():
        child.queue_free()

    for peer_id in lobby_manager.players:
        var info = lobby_manager.players[peer_id]
        var entry := HBoxContainer.new()

        var name_label := Label.new()
        name_label.text = info["name"]
        entry.add_child(name_label)

        var status := Label.new()
        status.text = " Ready" if info["ready"] else " Not Ready"
        status.modulate = Color.GREEN if info["ready"] else Color.GRAY
        entry.add_child(status)

        player_list.add_child(entry)

    # Only show start button to host, and only when everyone is ready
    start_btn.visible = multiplayer.is_server()
    start_btn.disabled = not lobby_manager.all_players_ready()

func _on_ready_pressed() -> void:
    my_ready = not my_ready
    lobby_manager.set_ready.rpc_id(1, my_ready)
    ready_btn.text = "Cancel Ready" if my_ready else "Ready Up"

This lobby system handles the essential flow: join → see other players → ready up → host starts game → everyone transitions together. For a production game, you'd add features like chat, game settings (map selection, difficulty), team assignment, and reconnection handling. But this foundation covers 90% of what indie multiplayer games need for a functional lobby.

5

Handling Disconnects and Reconnection

Players disconnect. Wi-Fi drops, laptops close, games crash, rage-quits happen. Your multiplayer game needs to handle all of these gracefully — both for the player who disconnected and for everyone else still in the game. Without proper disconnect handling, a single player's network hiccup can freeze or crash the game for everyone.

Godot fires `peer_disconnected` when a peer's connection is lost. But there's a nuance: ENet uses heartbeat packets to detect disconnections, and the default timeout is quite long (several seconds). During this window, the disconnected player's character just freezes in place. You should detect 'likely disconnected' states earlier by tracking the time since last received packet:

gdscript
class_name ConnectionMonitor
extends Node

const TIMEOUT_WARNING := 2.0   # Show warning after 2 seconds of silence
const TIMEOUT_DISCONNECT := 8.0  # Consider disconnected after 8 seconds

var last_heard: Dictionary = {}  # peer_id -> timestamp

signal peer_timeout_warning(peer_id: int)
signal peer_timed_out(peer_id: int)

func _ready() -> void:
    multiplayer.peer_connected.connect(func(id): last_heard[id] = Time.get_ticks_msec() / 1000.0)
    multiplayer.peer_disconnected.connect(func(id): last_heard.erase(id))

func _process(_delta: float) -> void:
    var now := Time.get_ticks_msec() / 1000.0
    for peer_id in last_heard:
        var silence := now - last_heard[peer_id]
        if silence > TIMEOUT_DISCONNECT:
            peer_timed_out.emit(peer_id)
            last_heard.erase(peer_id)
        elif silence > TIMEOUT_WARNING:
            peer_timeout_warning.emit(peer_id)

# Call this whenever you receive any data from a peer
func record_activity(peer_id: int) -> void:
    last_heard[peer_id] = Time.get_ticks_msec() / 1000.0

Call `record_activity` from any RPC handler that receives data from a peer. The position sync RPC is perfect for this since it fires frequently — if you stop receiving position updates, the player is probably disconnected even if ENet hasn't detected it yet.

When a disconnect is confirmed, the server needs to clean up the departed player's state and notify remaining players. The cleanup should be centralized on the server to prevent inconsistencies:

gdscript
# On the server/host
func handle_player_disconnect(peer_id: int) -> void:
    print("Player %d disconnected — cleaning up" % peer_id)

    # Remove their character node
    var player_node := get_node_or_null("/root/Game/Players/%d" % peer_id)
    if player_node:
        # Brief death/fadeout animation before removal
        _play_disconnect_effect.rpc(peer_id, player_node.position)
        player_node.queue_free()

    # Remove from lobby/game state
    lobby_manager.players.erase(peer_id)
    lobby_manager._remove_player_remote.rpc(peer_id)

    # Drop any items they were holding back into the world
    _drop_player_inventory(peer_id)

    # Check win/loss conditions — did the disconnect end the game?
    if _check_game_over():
        _end_game.rpc()

@rpc("authority", "reliable", "call_local")
func _play_disconnect_effect(peer_id: int, pos: Vector2) -> void:
    # Show a visual indicator where the player disappeared
    var effect := preload("res://effects/disconnect_poof.tscn").instantiate()
    effect.position = pos
    add_child(effect)

For reconnection support, you need to save enough state that a rejoining player can be restored to their previous position. The simplest approach is to keep the player's data in the server's `players` dictionary (marked as disconnected) for a grace period. If the same player reconnects (identified by a stored secret token or username), restore their state:

gdscript
var disconnected_players: Dictionary = {}  # name -> { state, timestamp }
const RECONNECT_GRACE_PERIOD := 60.0  # 60 seconds to reconnect

func handle_player_disconnect(peer_id: int) -> void:
    var player_name: String = lobby_manager.players[peer_id]["name"]

    # Save their game state for potential reconnection
    disconnected_players[player_name] = {
        "state": _serialize_player_state(peer_id),
        "timestamp": Time.get_ticks_msec() / 1000.0,
    }

    # Remove active player but keep saved state
    _cleanup_player_nodes(peer_id)

func try_reconnect(peer_id: int, player_name: String) -> bool:
    if player_name not in disconnected_players:
        return false

    var saved = disconnected_players[player_name]
    var elapsed := Time.get_ticks_msec() / 1000.0 - saved["timestamp"]
    if elapsed > RECONNECT_GRACE_PERIOD:
        disconnected_players.erase(player_name)
        return false

    # Restore the player with their saved state
    _restore_player_state(peer_id, saved["state"])
    disconnected_players.erase(player_name)
    return true

func _serialize_player_state(peer_id: int) -> Dictionary:
    var player_node := get_node("/root/Game/Players/%d" % peer_id)
    return {
        "position": player_node.position,
        "health": player_node.health,
        "inventory": player_node.inventory.duplicate(),
    }

Production multiplayer games often implement a heartbeat system where clients periodically send a small 'I'm alive' packet. This serves double duty: it keeps the connection alive through NAT firewalls (which drop idle UDP mappings after 30-60 seconds), and it provides accurate latency measurements. A simple heartbeat looks like this:

gdscript
# On every peer — send heartbeat every 2 seconds
var heartbeat_timer := 0.0

func _process(delta: float) -> void:
    heartbeat_timer += delta
    if heartbeat_timer >= 2.0:
        heartbeat_timer = 0.0
        _heartbeat.rpc_id(1, Time.get_ticks_msec())

@rpc("any_peer", "unreliable")
func _heartbeat(client_time: int) -> void:
    var sender := multiplayer.get_remote_sender_id()
    connection_monitor.record_activity(sender)
    # Bounce back for RTT measurement
    _heartbeat_response.rpc_id(sender, client_time)

@rpc("authority", "unreliable")
func _heartbeat_response(original_time: int) -> void:
    var rtt := Time.get_ticks_msec() - original_time
    latency_ms = rtt / 2  # One-way latency estimate

The round-trip time (RTT) measurement from heartbeats feeds into your interpolation and prediction code from Lesson 3. When latency spikes, you can increase the interpolation buffer to maintain smooth visuals. When latency is low, you can tighten the buffer for more responsive gameplay. This adaptive approach handles varying network conditions gracefully — important for players on Wi-Fi or mobile hotspots.

🏆

Course Complete

You've finished all 5 lessons of Multiplayer Networking Basics with Godot. Go build something amazing.