Launch Discount: 25% off for the first 50 customers — use code LAUNCH25

StraySparkStraySpark
ProductsFree AssetsDocsBlogGamesAbout
StraySparkStraySpark

Game Studio & UE5 Tool Developers. Building professional-grade tools for the Unreal Engine community.

Products

  • Complete Toolkit (Bundle)
  • Procedural Placement Tool
  • Cinematic Spline Tool
  • Blueprint Template Library
  • DetailForge
  • Unreal MCP Server
  • Blender MCP Server
  • Godot MCP Server

Resources

  • Free Assets
  • Documentation
  • Blog
  • Changelog
  • Roadmap
  • FAQ
  • Contact

Legal

  • Privacy Policy
  • Terms of Service

© 2026 StraySpark. All rights reserved.

Back to Blog
tutorial
StraySparkMarch 31, 20265 min read
Multiplayer Networking in Godot 4: Building an Authoritative Server from Scratch 
GodotGodot 4MultiplayerNetworkingTutorialGame DevelopmentReplicationGdscript

Multiplayer networking is where confident game developers become humble. The concepts are straightforward: send data between computers. The implementation is a minefield of desynchronization, latency compensation, cheat prevention, and debugging sessions where the bug only reproduces when two clients are connected and one has 150ms of artificial latency.

This guide covers building a multiplayer game in Godot 4 with a server-authoritative architecture. We'll start with the basics (connecting peers), build up through RPCs and synchronization, and end with a working multiplayer game loop. Along the way, we'll cover the mistakes that waste the most development time.

Fair warning: this is a long post. Networking has no shortcuts. Every section builds on the previous one, and skipping ahead will leave you with code that works on localhost and breaks everywhere else.

Why Server-Authoritative

Before writing any code, you need to understand the architecture choice and why it matters.

In a client-authoritative model, each client controls its own state and tells the server what happened. "I moved here." "I shot this player." "I picked up this item." The server accepts what clients report.

The problem is obvious: clients lie. In a client-authoritative model, cheating is trivial. A modified client says "I teleported to the flag" and the server accepts it. Competitive games cannot use client-authoritative networking.

In a server-authoritative model, the server owns all game state. Clients send inputs (I pressed the forward key). The server processes those inputs, updates the game state, and tells clients what happened. Clients are display terminals that show the server's version of reality.

AspectClient-AuthoritativeServer-Authoritative
Cheat resistanceNone. Clients control state.Strong. Server validates everything.
Latency feelInstant response (client moves immediately)Delayed without prediction (server round-trip)
ComplexityLowHigh
BandwidthLower (clients send state)Higher (server sends state to all clients)
State consistencyClients may disagreeServer is single source of truth
Best forCo-op, casual, trust-based gamesCompetitive, PvP, any game where cheating matters

Server-authoritative is harder to build but is the correct choice for any game where players can affect each other's experience. We'll also add client-side prediction to eliminate the latency feel problem.

Setting Up the Network Layer

Godot 4's multiplayer API is built around the MultiplayerPeer abstraction. The peer handles the actual network transport. Godot provides two built-in options:

  • ENetMultiplayerPeer: UDP-based, reliable and unreliable channels, good for real-time games
  • WebSocketMultiplayerPeer: WebSocket-based, works in web browsers, higher latency

For most games, use ENet. Use WebSocket only if you need web browser support.

Basic Server/Client Setup

# network_manager.gd (Autoload)
extends Node

signal player_connected(peer_id: int)
signal player_disconnected(peer_id: int)
signal connection_established
signal connection_failed

const DEFAULT_PORT: int = 7777
const MAX_PLAYERS: int = 8

var peer: ENetMultiplayerPeer = null
var is_server: bool = false
var connected_players: Dictionary = {}  # peer_id -> PlayerInfo

func _ready() -> void:
    multiplayer.peer_connected.connect(_on_peer_connected)
    multiplayer.peer_disconnected.connect(_on_peer_disconnected)
    multiplayer.connected_to_server.connect(_on_connected_to_server)
    multiplayer.connection_failed.connect(_on_connection_failed)
    multiplayer.server_disconnected.connect(_on_server_disconnected)

func host_game(port: int = DEFAULT_PORT) -> Error:
    peer = ENetMultiplayerPeer.new()
    var error = peer.create_server(port, MAX_PLAYERS)

    if error != OK:
        push_error("Failed to create server: %s" % error_string(error))
        return error

    multiplayer.multiplayer_peer = peer
    is_server = true

    # Server is also peer ID 1
    connected_players[1] = PlayerInfo.new(1, "Host")
    print("Server started on port %d" % port)

    return OK

func join_game(address: String, port: int = DEFAULT_PORT) -> Error:
    peer = ENetMultiplayerPeer.new()
    var error = peer.create_client(address, port)

    if error != OK:
        push_error("Failed to create client: %s" % error_string(error))
        return error

    multiplayer.multiplayer_peer = peer
    is_server = false
    print("Connecting to %s:%d..." % [address, port])

    return OK

func disconnect_game() -> void:
    if peer:
        peer.close()
        peer = null
    multiplayer.multiplayer_peer = null
    is_server = false
    connected_players.clear()

func _on_peer_connected(id: int) -> void:
    print("Peer connected: %d" % id)
    connected_players[id] = PlayerInfo.new(id, "Player_%d" % id)
    player_connected.emit(id)

func _on_peer_disconnected(id: int) -> void:
    print("Peer disconnected: %d" % id)
    connected_players.erase(id)
    player_disconnected.emit(id)

func _on_connected_to_server() -> void:
    print("Connected to server. My ID: %d" % multiplayer.get_unique_id())
    connection_established.emit()

func _on_connection_failed() -> void:
    print("Connection failed")
    disconnect_game()
    connection_failed.emit()

func _on_server_disconnected() -> void:
    print("Server disconnected")
    disconnect_game()
# player_info.gd
class_name PlayerInfo
extends RefCounted

var peer_id: int
var player_name: String
var team: int = 0
var is_ready: bool = false
var ping_ms: int = 0

func _init(id: int, name: String) -> void:
    peer_id = id
    player_name = name

Register NetworkManager as an autoload. Every networked operation goes through this singleton.

Connection UI

A simple lobby screen to host or join:

# lobby_ui.gd
extends Control

@onready var host_button: Button = $VBoxContainer/HostButton
@onready var join_button: Button = $VBoxContainer/JoinButton
@onready var address_input: LineEdit = $VBoxContainer/AddressInput
@onready var player_list: ItemList = $VBoxContainer/PlayerList
@onready var start_button: Button = $VBoxContainer/StartButton

func _ready() -> void:
    host_button.pressed.connect(_on_host)
    join_button.pressed.connect(_on_join)
    start_button.pressed.connect(_on_start)

    NetworkManager.player_connected.connect(_refresh_player_list)
    NetworkManager.player_disconnected.connect(_refresh_player_list)
    NetworkManager.connection_established.connect(_on_connected)

    start_button.visible = false

func _on_host() -> void:
    var error = NetworkManager.host_game()
    if error == OK:
        host_button.disabled = true
        join_button.disabled = true
        start_button.visible = true
        _refresh_player_list(0)

func _on_join() -> void:
    var address = address_input.text
    if address.is_empty():
        address = "127.0.0.1"
    NetworkManager.join_game(address)
    host_button.disabled = true
    join_button.disabled = true

func _on_connected() -> void:
    _refresh_player_list(0)

func _refresh_player_list(_id: int) -> void:
    player_list.clear()
    for peer_id in NetworkManager.connected_players:
        var info = NetworkManager.connected_players[peer_id]
        player_list.add_item("%s (ID: %d)" % [info.player_name, info.peer_id])

func _on_start() -> void:
    if not NetworkManager.is_server:
        return
    # Server tells everyone to start
    _start_game.rpc()

@rpc("authority", "call_local", "reliable")
func _start_game() -> void:
    get_tree().change_scene_to_file("res://scenes/game.tscn")

Understanding RPCs

RPCs (Remote Procedure Calls) are how nodes communicate across the network. A function marked with @rpc can be called remotely on other peers.

RPC Annotations Explained

Godot 4 uses annotations to configure RPCs:

# The @rpc annotation takes up to 4 arguments:
# @rpc("authority_mode", "call_mode", "transfer_mode", channel)

@rpc("any_peer", "call_local", "reliable", 0)
func my_rpc_function(data: String) -> void:
    pass

Authority mode:

  • "authority" -- Only the multiplayer authority (default: server, peer ID 1) can call this RPC. Clients calling it on the server get rejected.
  • "any_peer" -- Any peer can call this RPC. Dangerous if not validated. Use for client-to-server requests like input submission.

Call mode:

  • "call_remote" -- Only executes on remote peers, not on the calling peer. Default.
  • "call_local" -- Executes on both remote peers AND the calling peer.

Transfer mode:

  • "reliable" -- Guaranteed delivery, ordered. Use for important state changes (player died, item picked up). Higher overhead.
  • "unreliable" -- No delivery guarantee, no ordering. Use for frequent updates where the next update supersedes the previous (position, rotation). Lower overhead.
  • "unreliable_ordered" -- No delivery guarantee, but if a packet arrives it will be in order. Older packets are dropped.

Channel: Integer channel number for packet ordering. Multiple channels allow different types of data to be ordered independently.

Common RPC Patterns

Client sends input to server:

# On the client
func _physics_process(delta: float) -> void:
    if not is_multiplayer_authority():
        return

    var input = _gather_input()
    _submit_input.rpc_id(1, input)  # Send to server (ID 1)

@rpc("any_peer", "call_remote", "unreliable_ordered")
func _submit_input(input: Dictionary) -> void:
    # This runs on the server
    if not multiplayer.is_server():
        return

    var sender_id = multiplayer.get_remote_sender_id()
    # Validate and process input for this player
    _process_player_input(sender_id, input)

Server broadcasts state to all clients:

# On the server
func _physics_process(delta: float) -> void:
    if not multiplayer.is_server():
        return

    # Update game state
    _simulate_game(delta)

    # Send state to all clients
    var state = _gather_game_state()
    _receive_state.rpc(state)

@rpc("authority", "call_local", "unreliable")
func _receive_state(state: Dictionary) -> void:
    # This runs on all peers (including server if call_local)
    _apply_game_state(state)

Server sends event to specific client:

# Server-side
func _notify_player_hit(target_id: int, damage: float) -> void:
    _on_hit.rpc_id(target_id, damage)

@rpc("authority", "call_remote", "reliable")
func _on_hit(damage: float) -> void:
    # Runs only on the targeted client
    _show_damage_effect(damage)
    _shake_camera()

MultiplayerSpawner and MultiplayerSynchronizer

Godot 4 provides two high-level nodes for common networking tasks: spawning networked objects and synchronizing state.

MultiplayerSpawner

The MultiplayerSpawner automatically replicates scene instantiation across peers. When the server spawns a scene, all clients automatically spawn a copy.

Setup:

  1. Add a MultiplayerSpawner node to your scene
  2. Set the Spawn Path to the parent node where spawned scenes will be added
  3. Add the scenes that can be spawned to the Auto Spawn List
# game_manager.gd
extends Node

@onready var spawner: MultiplayerSpawner = $MultiplayerSpawner
@onready var players_container: Node = $Players

func _ready() -> void:
    if multiplayer.is_server():
        # Spawn a player for each connected peer
        for peer_id in NetworkManager.connected_players:
            _spawn_player(peer_id)

        # Also spawn for future connections
        NetworkManager.player_connected.connect(_spawn_player)
        NetworkManager.player_disconnected.connect(_despawn_player)

func _spawn_player(peer_id: int) -> void:
    var player_scene = preload("res://scenes/player/networked_player.tscn")
    var player = player_scene.instantiate()

    # The node name MUST be unique and deterministic
    # Using peer_id as the name ensures consistency
    player.name = str(peer_id)

    # Set the multiplayer authority to the owning peer
    player.set_multiplayer_authority(peer_id)

    players_container.add_child(player, true)

func _despawn_player(peer_id: int) -> void:
    var player = players_container.get_node_or_null(str(peer_id))
    if player:
        player.queue_free()

Critical detail: The spawned node's name must be deterministic and unique. If you use str(peer_id) as the name, every peer generates the same node name for the same player, and the MultiplayerSpawner can match them up. Random names or auto-generated names break replication.

MultiplayerSynchronizer

The MultiplayerSynchronizer automatically replicates property values from the authority peer to other peers. It replaces manual RPC-based state sync for simple properties.

Setup:

  1. Add a MultiplayerSynchronizer as a child of the node you want to sync
  2. In the inspector, configure which properties to synchronize
  3. Set the Replication Interval (how often to send updates)
NetworkedPlayer (CharacterBody3D)
  MultiplayerSynchronizer
    -- Synced properties:
    -- :position (unreliable, on change)
    -- :rotation (unreliable, on change)
    -- :velocity (unreliable, on change)
    -- health (reliable, on change)
  CollisionShape3D
  MeshInstance3D
  Camera3D

The synchronizer handles serialization, delta compression, and network transport automatically. The authority peer (usually the server for server-authoritative games) sends property updates, and all other peers receive and apply them.

# networked_player.gd
extends CharacterBody3D

@onready var sync: MultiplayerSynchronizer = $MultiplayerSynchronizer

@export var speed: float = 5.0
@export var jump_velocity: float = 6.0

# These get synced by MultiplayerSynchronizer
var health: float = 100.0
var is_alive: bool = true

func _ready() -> void:
    # Only enable camera for the local player
    if is_multiplayer_authority():
        $Camera3D.current = true
    else:
        $Camera3D.current = false

    # Only the authority processes input
    set_physics_process(is_multiplayer_authority() or multiplayer.is_server())

func _physics_process(delta: float) -> void:
    if multiplayer.is_server():
        _server_process(delta)
    elif is_multiplayer_authority():
        _client_process(delta)

func _client_process(delta: float) -> void:
    # Gather input and send to server
    var input_vector = Input.get_vector(
        "move_left", "move_right", "move_forward", "move_back"
    )
    var jump = Input.is_action_just_pressed("jump")

    _send_input.rpc_id(1, input_vector, jump)

@rpc("any_peer", "call_remote", "unreliable_ordered")
func _send_input(input_vector: Vector2, jump: bool) -> void:
    if not multiplayer.is_server():
        return

    # Validate sender
    var sender_id = multiplayer.get_remote_sender_id()
    if str(sender_id) != name:
        return  # This player isn't the authority for this node

    _apply_input(input_vector, jump)

func _server_process(delta: float) -> void:
    # Server simulates physics for all players
    # Input was already applied via RPC
    if not is_on_floor():
        velocity += get_gravity() * delta

    move_and_slide()

func _apply_input(input_vector: Vector2, jump: bool) -> void:
    var direction = (transform.basis * Vector3(
        input_vector.x, 0, input_vector.y
    )).normalized()

    if direction:
        velocity.x = direction.x * speed
        velocity.z = direction.z * speed
    else:
        velocity.x = move_toward(velocity.x, 0, speed)
        velocity.z = move_toward(velocity.z, 0, speed)

    if jump and is_on_floor():
        velocity.y = jump_velocity

When to Use Synchronizer vs. RPCs

SituationUse MultiplayerSynchronizerUse RPCs
Continuous state (position, rotation)YesNo (too verbose)
Infrequent events (died, scored, used ability)NoYes (reliable)
Complex data (inventory contents, full game state)Depends on sizeYes for large payloads
Client-to-server inputNoYes
Server-to-client correctionWorks for simple valuesYes for complex corrections

Use both in combination. Synchronizer handles the continuous state replication. RPCs handle discrete events and client-to-server communication.

Building the Authoritative Game Loop

Here's a complete multiplayer game loop with server authority.

Server Architecture

# server_game.gd
extends Node

## Server-side game management

var player_states: Dictionary = {}  # peer_id -> PlayerState
var game_state: GameState = GameState.new()

func _ready() -> void:
    if not multiplayer.is_server():
        set_physics_process(false)
        return

func _physics_process(delta: float) -> void:
    # Step 1: Process pending inputs for all players
    for peer_id in player_states:
        var state = player_states[peer_id]
        if state.pending_input != null:
            _process_input(peer_id, state.pending_input, delta)
            state.pending_input = null

    # Step 2: Update game simulation
    _update_game_simulation(delta)

    # Step 3: Check game rules (kills, objectives, etc.)
    _check_game_rules()

    # Step 4: State is automatically replicated by MultiplayerSynchronizer
    # No manual broadcast needed for synced properties

func _process_input(peer_id: int, input: Dictionary, delta: float) -> void:
    var player_node = _get_player_node(peer_id)
    if player_node == null:
        return

    # Validate input
    var move_vector = input.get("move", Vector2.ZERO)
    move_vector = move_vector.limit_length(1.0)  # Prevent speed hacks

    var jump = input.get("jump", false)
    var shoot = input.get("shoot", false)

    # Apply validated input to player
    player_node._apply_input(move_vector, jump)

    if shoot:
        _handle_shoot(peer_id, player_node, input.get("aim_direction", Vector3.FORWARD))

func _handle_shoot(shooter_id: int, shooter: CharacterBody3D,
                    aim_direction: Vector3) -> void:
    # Server performs the raycast -- never trust client hit detection
    aim_direction = aim_direction.normalized()

    var space_state = get_viewport().get_world_3d().direct_space_state
    var ray_origin = shooter.global_position + Vector3.UP * 1.5
    var ray_end = ray_origin + aim_direction * 100.0

    var query = PhysicsRayQueryParameters3D.create(ray_origin, ray_end)
    query.exclude = [shooter.get_rid()]
    query.collision_mask = 0b0110  # Players and environment

    var result = space_state.intersect_ray(query)

    if result:
        var hit_node = result.collider
        if hit_node is CharacterBody3D and hit_node.has_method("take_damage"):
            var damage = 25.0  # Server controls damage values
            hit_node.take_damage(damage, shooter_id)

            # Notify the shooter of the hit (for hit markers, sound effects)
            _notify_hit.rpc_id(shooter_id, result.position)

            # Notify the victim
            var victim_id = int(str(hit_node.name))
            _notify_damage.rpc_id(victim_id, damage, shooter_id)

@rpc("authority", "call_remote", "reliable")
func _notify_hit(hit_position: Vector3) -> void:
    # Client-side hit feedback
    pass

@rpc("authority", "call_remote", "reliable")
func _notify_damage(damage: float, attacker_id: int) -> void:
    # Client-side damage feedback
    pass

func _update_game_simulation(delta: float) -> void:
    # Update projectiles, AI, pickup respawns, etc.
    # All on the server
    pass

func _check_game_rules() -> void:
    # Check win conditions, round timers, etc.
    pass

func _get_player_node(peer_id: int) -> CharacterBody3D:
    var players = $Players
    return players.get_node_or_null(str(peer_id)) as CharacterBody3D

Client Architecture

# client_game.gd
extends Node

## Client-side game management

var input_sequence: int = 0
var pending_inputs: Array[Dictionary] = []

func _ready() -> void:
    if multiplayer.is_server():
        set_physics_process(false)
        return

func _physics_process(delta: float) -> void:
    var my_player = _get_my_player()
    if my_player == null:
        return

    # Gather input
    var input = _gather_input()
    input_sequence += 1
    input["sequence"] = input_sequence

    # Send to server
    _submit_input.rpc_id(1, input)

    # Client-side prediction: apply input locally immediately
    _predict_movement(my_player, input, delta)

    # Store input for reconciliation
    pending_inputs.append(input)

    # Trim old inputs (keep last 60 frames worth)
    while pending_inputs.size() > 60:
        pending_inputs.pop_front()

func _gather_input() -> Dictionary:
    return {
        "move": Input.get_vector(
            "move_left", "move_right", "move_forward", "move_back"
        ),
        "jump": Input.is_action_just_pressed("jump"),
        "shoot": Input.is_action_just_pressed("shoot"),
        "aim_direction": _get_aim_direction(),
    }

func _get_aim_direction() -> Vector3:
    var camera = get_viewport().get_camera_3d()
    if camera == null:
        return Vector3.FORWARD
    return -camera.global_basis.z

func _predict_movement(player: CharacterBody3D, input: Dictionary,
                        delta: float) -> void:
    # Apply the same movement logic locally for instant feedback
    # This matches the server's _apply_input logic
    var move_vector = input.get("move", Vector2.ZERO)
    var direction = (player.transform.basis * Vector3(
        move_vector.x, 0, move_vector.y
    )).normalized()

    if direction:
        player.velocity.x = direction.x * player.speed
        player.velocity.z = direction.z * player.speed
    else:
        player.velocity.x = move_toward(player.velocity.x, 0, player.speed)
        player.velocity.z = move_toward(player.velocity.z, 0, player.speed)

    if input.get("jump", false) and player.is_on_floor():
        player.velocity.y = player.jump_velocity

    if not player.is_on_floor():
        player.velocity += player.get_gravity() * delta

    player.move_and_slide()

@rpc("any_peer", "call_remote", "unreliable_ordered")
func _submit_input(input: Dictionary) -> void:
    # Received on server, handled by server_game.gd
    pass

func _get_my_player() -> CharacterBody3D:
    var my_id = multiplayer.get_unique_id()
    return $Players.get_node_or_null(str(my_id)) as CharacterBody3D

Client-Side Prediction and Reconciliation

The code above includes basic client-side prediction. The client applies movement locally for instant feedback, then the server's authoritative state arrives and corrects any discrepancies. But we need reconciliation to handle the correction smoothly.

The Problem

Without reconciliation:

  1. Client presses forward at frame 100
  2. Client predicts position at frame 100 (immediate feedback)
  3. Server processes frame 100 input, calculates position
  4. Server sends authoritative position back (arrives at client frame 105)
  5. Client snaps to server position at frame 105
  6. But client has also predicted frames 101-105. Snap discards those predictions.
  7. Player sees rubber-banding

The Solution: Server Reconciliation

# client_prediction.gd
extends Node

## Client-side prediction with server reconciliation

var predicted_states: Array[Dictionary] = []
var last_acknowledged_sequence: int = 0
var reconciliation_threshold: float = 0.1  # Tolerate 10cm of desync

func store_predicted_state(sequence: int, position: Vector3,
                            velocity: Vector3) -> void:
    predicted_states.append({
        "sequence": sequence,
        "position": position,
        "velocity": velocity,
    })

    # Keep buffer manageable
    while predicted_states.size() > 120:
        predicted_states.pop_front()

func reconcile(server_position: Vector3, server_velocity: Vector3,
               server_sequence: int, player: CharacterBody3D) -> void:
    last_acknowledged_sequence = server_sequence

    # Find the predicted state for this sequence
    var predicted_index = -1
    for i in predicted_states.size():
        if predicted_states[i].sequence == server_sequence:
            predicted_index = i
            break

    if predicted_index == -1:
        # No matching prediction, just snap
        player.global_position = server_position
        player.velocity = server_velocity
        return

    # Compare prediction to server reality
    var predicted = predicted_states[predicted_index]
    var error = predicted.position.distance_to(server_position)

    if error < reconciliation_threshold:
        # Prediction was close enough, no correction needed
        # Remove acknowledged predictions
        predicted_states = predicted_states.slice(predicted_index + 1)
        return

    # Prediction was wrong. Correct and re-simulate.
    # 1. Snap to server state
    player.global_position = server_position
    player.velocity = server_velocity

    # 2. Remove old predictions up to and including the corrected one
    var remaining_inputs = predicted_states.slice(predicted_index + 1)
    predicted_states.clear()

    # 3. Re-apply all inputs that happened AFTER the corrected frame
    for state in remaining_inputs:
        # Re-simulate this input
        # (You'd need to store the input alongside the state)
        # This is simplified -- full implementation stores inputs too
        predicted_states.append(state)

Client-side prediction is the most complex part of multiplayer networking. The concept is simple: predict locally, correct from server. The implementation requires careful bookkeeping of input sequences, predicted states, and re-simulation logic.

Honest assessment: The reconciliation code above is simplified. A production implementation needs to handle:

  • Delta time variations between client and server
  • Physics simulation determinism (Godot's physics isn't perfectly deterministic across different frame rates)
  • Interpolation of corrections to avoid visual snapping
  • Prediction of other players' movement (entity interpolation)

Entity Interpolation

Your player character uses prediction. Other players use interpolation. You don't predict what other players will do. Instead, you buffer their past states and smoothly interpolate between them.

# network_interpolation.gd
extends Node

## Interpolation for remote entities (other players)

var state_buffer: Array[Dictionary] = []
var interpolation_delay: float = 0.1  # 100ms delay

func add_state(timestamp: float, position: Vector3, rotation: Vector3) -> void:
    state_buffer.append({
        "timestamp": timestamp,
        "position": position,
        "rotation": rotation,
    })

    # Keep buffer size reasonable
    while state_buffer.size() > 30:
        state_buffer.pop_front()

func get_interpolated_state(current_time: float) -> Dictionary:
    var render_time = current_time - interpolation_delay

    # Find the two states to interpolate between
    if state_buffer.size() < 2:
        if state_buffer.size() == 1:
            return state_buffer[0]
        return {}

    # Find states bracketing render_time
    var before: Dictionary = state_buffer[0]
    var after: Dictionary = state_buffer[1]

    for i in range(1, state_buffer.size()):
        if state_buffer[i].timestamp >= render_time:
            after = state_buffer[i]
            before = state_buffer[i - 1]
            break

    # Interpolate
    var time_range = after.timestamp - before.timestamp
    if time_range <= 0:
        return before

    var t = clampf((render_time - before.timestamp) / time_range, 0.0, 1.0)

    return {
        "position": before.position.lerp(after.position, t),
        "rotation": before.rotation.lerp(after.rotation, t),
    }

This introduces 100ms of visual delay for remote entities. Players see other players where they were 100ms ago. For most games, this is imperceptible. For fast-paced competitive games, you might reduce this to 50ms and accept some jitter.

Common Mistakes

These are the networking mistakes that waste the most development time. Learn from other people's suffering.

Mistake 1: Trusting Client State

The single most important rule of server-authoritative networking:

Never trust anything the client sends except input.

If the client sends "my position is (100, 0, 200)", ignore it. If the client sends "I dealt 500 damage to player 3", ignore it. If the client sends "I have 99 health potions", ignore it.

Clients send inputs: which keys are pressed, which direction they're aiming. The server processes those inputs and determines the results. The client doesn't get to decide outcomes.

# BAD: Client tells server the result
@rpc("any_peer", "call_remote", "reliable")
func report_kill(victim_id: int, damage: float) -> void:
    # A cheater sends: report_kill(every_player, 999999)
    # Server blindly applies it
    var victim = get_player(victim_id)
    victim.health -= damage  # NEVER DO THIS

# GOOD: Client tells server the action, server determines result
@rpc("any_peer", "call_remote", "reliable")
func request_shoot(aim_direction: Vector3) -> void:
    if not multiplayer.is_server():
        return

    var sender_id = multiplayer.get_remote_sender_id()
    var shooter = get_player(sender_id)

    # Server validates: is this player alive? Has weapon? Not on cooldown?
    if not shooter.is_alive:
        return
    if shooter.weapon_cooldown > 0:
        return

    # Server performs the raycast
    aim_direction = aim_direction.normalized()  # Prevent modified magnitude
    var hit = _server_raycast(shooter, aim_direction)

    if hit:
        # Server calculates damage from server-side weapon stats
        var damage = shooter.weapon_damage  # Server-controlled value
        hit.collider.take_damage(damage, sender_id)

Mistake 2: RPC Spam

Sending an RPC every frame from every client creates bandwidth problems fast.

# BAD: Sending full state every frame
func _physics_process(delta: float) -> void:
    update_server.rpc_id(1, {
        "position": global_position,
        "rotation": global_rotation,
        "velocity": velocity,
        "health": health,
        "ammo": ammo,
        "inventory": inventory.serialize(),  # HUGE payload every frame
    })

# GOOD: Send only input, only what changed
func _physics_process(delta: float) -> void:
    var input = {
        "move": Input.get_vector("left", "right", "forward", "back"),
        "jump": Input.is_action_just_pressed("jump"),
    }

    # Only send shoot when it actually happens
    if Input.is_action_just_pressed("shoot"):
        input["shoot"] = true
        input["aim"] = _get_aim_direction()

    send_input.rpc_id(1, input)

Bandwidth math: 8 players, 60 ticks/second, 200 bytes per input packet = 96KB/s upstream to server. Server sends state to 7 other players = 672KB/s downstream from server. This is manageable. If each input packet is 2KB because you're sending inventory data, that's 960KB/s upstream. That's not manageable on typical connections.

Mistake 3: Not Validating RPC Senders

Every any_peer RPC must validate who sent it.

# BAD: No sender validation
@rpc("any_peer", "call_remote", "reliable")
func move_player(player_id: int, new_position: Vector3) -> void:
    # Any client can move any player!
    get_player(player_id).global_position = new_position

# GOOD: Validate sender matches the action
@rpc("any_peer", "call_remote", "reliable")
func submit_input(input: Dictionary) -> void:
    var sender_id = multiplayer.get_remote_sender_id()

    # Only process input for the sender's own player
    var player = get_player(sender_id)
    if player == null:
        return

    _process_input(player, input)

Mistake 4: Forgetting About Latency in Game Design

Some game mechanics are fundamentally hostile to networked play. Design around latency, don't fight it.

Problematic mechanics:

  • Instant-hit interactions that require frame-perfect timing (parrying)
  • Mechanics where 50ms of latency changes the outcome (racing games with close finishes)
  • Systems that require all clients to see the exact same state at the exact same time (real-time puzzles)

Latency-friendly mechanics:

  • Projectile-based combat (projectile travel time hides latency)
  • Turn-based or semi-turn-based systems
  • Actions with wind-up animations (the animation plays during the network round trip)
  • Area-of-effect abilities (exact position matters less)

Mistake 5: Testing Only on Localhost

Your game works perfectly on localhost because latency is 0ms, bandwidth is infinite, and packets never drop. That's not the real world.

Test with artificial latency and packet loss. Godot doesn't have built-in network simulation, but you can use:

  • clumsy (Windows): Adds latency, drops packets, duplicates packets
  • tc (Linux): Traffic control built into the kernel
  • Network Link Conditioner (macOS): Apple's network simulation tool

Test at these conditions at minimum:

ConditionLatencyPacket Loss
Good connection20-50ms0%
Average connection80-120ms1-2%
Poor connection150-250ms5%
Mobile connection100-300ms (variable)3-5%

If your game is playable at 200ms latency with 3% packet loss, it'll work for most players.

Performance Considerations

Tick Rate

The server's physics tick rate determines how often the game state updates. Higher tick rates mean smoother simulation but more bandwidth and CPU usage.

Game TypeRecommended Tick Rate
Casual / Turn-based10-20 ticks/second
Action RPG / MMO20-30 ticks/second
FPS / Fighting game60-128 ticks/second
Racing30-60 ticks/second

In Godot, the physics tick rate is set in Project Settings > Physics > Common > Physics Ticks Per Second. This affects all physics processing, not just networking. If you need different rates for local physics and network updates, implement your own network tick in _process with a timer.

var network_tick_rate: float = 20.0  # 20 updates per second
var tick_accumulator: float = 0.0

func _process(delta: float) -> void:
    tick_accumulator += delta

    while tick_accumulator >= 1.0 / network_tick_rate:
        _network_tick()
        tick_accumulator -= 1.0 / network_tick_rate

func _network_tick() -> void:
    if multiplayer.is_server():
        _broadcast_state()

Bandwidth Optimization

Reduce bandwidth by sending only what's necessary:

  1. Delta compression: Only send properties that changed since the last update. MultiplayerSynchronizer does this automatically.
  2. Quantization: Reduce precision. Position doesn't need float64 precision. Compress to int16 or int32.
  3. Interest management: Only send data about entities near the player. A player on the east side of the map doesn't need updates about entities on the west side.
  4. Variable rate: Send fast-moving objects more frequently than stationary ones.
# Simple interest management
func _broadcast_state() -> void:
    for receiver_id in NetworkManager.connected_players:
        var receiver_player = get_player(receiver_id)
        if receiver_player == null:
            continue

        var relevant_state = {}
        for player_id in NetworkManager.connected_players:
            var other_player = get_player(player_id)
            if other_player == null:
                continue

            var distance = receiver_player.global_position.distance_to(
                other_player.global_position
            )

            if distance < 100.0:  # Within relevance range
                relevant_state[player_id] = _get_player_state(other_player)

        _receive_state.rpc_id(receiver_id, relevant_state)

Dedicated Server vs. Listen Server

In a listen server, one player's game instance is also the server. Simple to set up, but the host has zero latency (advantage in competitive games) and the server's performance depends on the host's hardware.

In a dedicated server, a separate application runs the server without rendering. Fair for all players, better performance, but requires separate server infrastructure.

Godot supports both. For a dedicated server, you can export with the --headless flag and disable rendering:

# Export dedicated server
godot --headless --export-release "Linux/X11" server.x86_64

Or in your code:

func _ready() -> void:
    if OS.has_feature("dedicated_server") or "--server" in OS.get_cmdline_args():
        # Disable rendering for headless operation
        get_viewport().disable_3d = true
        # Auto-host
        NetworkManager.host_game()

Comparison: Godot vs. Unreal Networking

If you're choosing between Godot and Unreal for a multiplayer project, here's how their networking stacks compare.

FeatureGodot 4Unreal Engine 5
TransportENet, WebSocketCustom UDP (NetDriver), can swap to EOS, Steam
RPC system@rpc annotations, flexibleUFUNCTION(Server), UFUNCTION(Client), UFUNCTION(NetMulticast)
Property replicationMultiplayerSynchronizer, manualUPROPERTY(Replicated), automatic with conditions
Object spawningMultiplayerSpawnerAutomatic for replicated actors
Authority modelFlexible (set_multiplayer_authority)Actor ownership, server-authoritative by default
PredictionManual implementationBuilt-in Character Movement Component prediction
RelevancyManual implementationBuilt-in NetRelevancy system
Bandwidth toolsBasicProfiler, net stats, replay system
Lobby/matchmakingManual or addonOnline Subsystem, EOS integration
ComplexityModerate, but manual work neededHigh, but more built-in features
Learning curveLower initial, higher ceiling for advanced featuresHigher initial, lower ceiling (more is provided)

Unreal's networking is more mature and more automated. Property replication with conditions (DOREPLIFETIME_CONDITION), built-in character movement prediction, network relevancy, and the Online Subsystem provide a lot of out-of-the-box functionality that you'd build manually in Godot.

The Blueprint Template Library goes further by providing 15 complete gameplay systems with built-in Server RPCs across all of them. Inventory, abilities, health, interaction, AI -- each system already handles the server-authoritative pattern with proper validation and replication. If you're building a multiplayer game in Unreal, that's weeks of networking implementation you don't have to do yourself.

Godot's networking is simpler and more explicit. You write the networking code, so you understand exactly what's happening. But you also write all of it -- prediction, reconciliation, interest management, bandwidth optimization. That's a significant time investment for a multiplayer game.

For indie multiplayer games, both engines are viable. Godot is better if you want to understand and control every aspect of your netcode. Unreal is better if you want more built-in functionality and are willing to learn a more complex system.

What Doesn't Work Well

Honest assessment of Godot 4's multiplayer limitations.

Large Player Counts

Godot's MultiplayerSynchronizer works well for 2-16 players. At 32+ players, you'll hit bandwidth and CPU bottlenecks. The synchronizer sends full property updates rather than true delta compression. For large player counts, you'll need a custom synchronization solution.

Mobile Networking

WebSocket support works for browser games, but mobile games with unreliable connections need additional robustness. Godot's ENet implementation doesn't have built-in reconnection logic. If a player disconnects and reconnects, they get a new peer ID. You need to implement session persistence yourself.

Anti-Cheat

Godot has no built-in anti-cheat. Server-authoritative architecture prevents the worst cheats (teleportation, infinite health), but it doesn't prevent:

  • Aimbots (client-side input automation)
  • Wallhacks (client has all entity positions for prediction)
  • Speed hacks (sending input faster than the tick rate)

Mitigating these requires server-side validation (rate limiting input, validating aim snap patterns, monitoring movement speed) and potentially third-party anti-cheat solutions.

Matchmaking and Lobbies

There's no built-in matchmaking. You need a separate backend service (or use a platform SDK like Steam, EOS, or PlayFab) for:

  • Player authentication
  • Lobby creation and discovery
  • Skill-based matchmaking
  • NAT traversal / relay servers

This is a significant amount of infrastructure work that's separate from game networking.

Debugging

Multiplayer bugs are the hardest bugs to debug. Godot's debugging tools for networking are basic compared to Unreal's network profiler. You'll rely heavily on print() statements, custom debug overlays, and reproducing issues in controlled test environments.

Build a debug overlay early:

# network_debug.gd
extends CanvasLayer

@onready var label: Label = $Label

func _process(delta: float) -> void:
    if not visible:
        return

    var text = "=== Network Debug ===\n"
    text += "Peer ID: %d\n" % multiplayer.get_unique_id()
    text += "Is Server: %s\n" % str(multiplayer.is_server())
    text += "Connected Peers: %d\n" % NetworkManager.connected_players.size()

    var my_player = _get_my_player()
    if my_player:
        text += "Position: %s\n" % str(my_player.global_position)
        text += "Velocity: %s\n" % str(my_player.velocity)

    label.text = text

func _input(event: InputEvent) -> void:
    if event.is_action_pressed("toggle_debug"):
        visible = not visible

Testing Checklist

Before shipping, test every item on this list:

  • Two players can connect and see each other
  • Player movement is smooth at 100ms latency
  • Player movement is playable at 200ms latency
  • Disconnection is handled gracefully (no crash, player removed)
  • Reconnection works (if supported)
  • Client can't move other players
  • Client can't deal arbitrary damage
  • Client can't spawn items or modify state
  • Game state recovers from 5% packet loss
  • Server handles 0 players without crashing
  • Server handles max players without crashing
  • Host migration works (if supported) or fails gracefully

Conclusion

Building multiplayer networking in Godot 4 is entirely doable. The tools are there: ENet peers, RPCs with proper annotation configuration, MultiplayerSpawner, MultiplayerSynchronizer, and a flexible authority system.

What Godot doesn't give you is the high-level automation. You build prediction, reconciliation, interest management, and matchmaking yourself. This is both Godot's strength (you understand and control everything) and its weakness (you build and debug everything).

For your first multiplayer project, start with a simple authoritative server, two players, and basic movement synchronization. Get that working perfectly before adding combat, inventory, or complex game mechanics. Networking bugs compound -- a small desync in movement becomes a large desync in combat becomes an impossible-to-debug cascade of wrong state.

If you're working across both Godot and Unreal, the Godot MCP Server and Unreal MCP Server can help automate the repetitive parts of project setup in both engines, letting you focus on the networking logic that actually requires human judgment.

And test with latency. Always test with latency. Localhost lies to you.

Tags

GodotGodot 4MultiplayerNetworkingTutorialGame DevelopmentReplicationGdscript

Continue Reading

tutorial

The 2026 Indie Game Marketing Playbook: Why You Should Market Before You Build

Read more
tutorial

AI Slop in Game Development: How to Use AI Without Becoming the Problem

Read more
tutorial

Blueprint Nativization in UE 5.7: When and How to Convert Blueprints to C++

Read more
All posts