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.
| Aspect | Client-Authoritative | Server-Authoritative |
|---|---|---|
| Cheat resistance | None. Clients control state. | Strong. Server validates everything. |
| Latency feel | Instant response (client moves immediately) | Delayed without prediction (server round-trip) |
| Complexity | Low | High |
| Bandwidth | Lower (clients send state) | Higher (server sends state to all clients) |
| State consistency | Clients may disagree | Server is single source of truth |
| Best for | Co-op, casual, trust-based games | Competitive, 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:
- Add a
MultiplayerSpawnernode to your scene - Set the Spawn Path to the parent node where spawned scenes will be added
- 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:
- Add a
MultiplayerSynchronizeras a child of the node you want to sync - In the inspector, configure which properties to synchronize
- 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
| Situation | Use MultiplayerSynchronizer | Use RPCs |
|---|---|---|
| Continuous state (position, rotation) | Yes | No (too verbose) |
| Infrequent events (died, scored, used ability) | No | Yes (reliable) |
| Complex data (inventory contents, full game state) | Depends on size | Yes for large payloads |
| Client-to-server input | No | Yes |
| Server-to-client correction | Works for simple values | Yes 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:
- Client presses forward at frame 100
- Client predicts position at frame 100 (immediate feedback)
- Server processes frame 100 input, calculates position
- Server sends authoritative position back (arrives at client frame 105)
- Client snaps to server position at frame 105
- But client has also predicted frames 101-105. Snap discards those predictions.
- 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:
| Condition | Latency | Packet Loss |
|---|---|---|
| Good connection | 20-50ms | 0% |
| Average connection | 80-120ms | 1-2% |
| Poor connection | 150-250ms | 5% |
| Mobile connection | 100-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 Type | Recommended Tick Rate |
|---|---|
| Casual / Turn-based | 10-20 ticks/second |
| Action RPG / MMO | 20-30 ticks/second |
| FPS / Fighting game | 60-128 ticks/second |
| Racing | 30-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:
- Delta compression: Only send properties that changed since the last update.
MultiplayerSynchronizerdoes this automatically. - Quantization: Reduce precision. Position doesn't need float64 precision. Compress to int16 or int32.
- 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.
- 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.
| Feature | Godot 4 | Unreal Engine 5 |
|---|---|---|
| Transport | ENet, WebSocket | Custom UDP (NetDriver), can swap to EOS, Steam |
| RPC system | @rpc annotations, flexible | UFUNCTION(Server), UFUNCTION(Client), UFUNCTION(NetMulticast) |
| Property replication | MultiplayerSynchronizer, manual | UPROPERTY(Replicated), automatic with conditions |
| Object spawning | MultiplayerSpawner | Automatic for replicated actors |
| Authority model | Flexible (set_multiplayer_authority) | Actor ownership, server-authoritative by default |
| Prediction | Manual implementation | Built-in Character Movement Component prediction |
| Relevancy | Manual implementation | Built-in NetRelevancy system |
| Bandwidth tools | Basic | Profiler, net stats, replay system |
| Lobby/matchmaking | Manual or addon | Online Subsystem, EOS integration |
| Complexity | Moderate, but manual work needed | High, but more built-in features |
| Learning curve | Lower initial, higher ceiling for advanced features | Higher 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.