Skip to main content
Coding Quests
The Scroll Library
Tutorials

Godot 4 Multiplayer Tutorial for Beginners: Host, Join, Spawn, Sync and RPCs

October 13, 202614 min read

To make multiplayer in Godot 4, one copy of your game hosts as the server and every other copy joins it as a client. You create an ENetMultiplayerPeer, call create_server(port) on the host or create_client(address, port) on everyone else, and assign it to multiplayer.multiplayer_peer. A MultiplayerSpawner then copies each player's scene to every window, a MultiplayerSynchronizer keeps values like position in step, and @rpc functions carry one-off messages such as chat. You can test all of it on one computer with Debug > Customize Run Instances.

This post builds exactly that, in typed GDScript for Godot 4.7. I ran every script below in Godot 4.7.2 with a host and a client connected over ENet. I also broke things on purpose, so the error messages you see here are real. This is online multiplayer, where each player runs their own copy of the game. Two people sharing one keyboard is local co-op, a different job, which I cover in my local co-op and split screen guide.

The 30-second mental model

Five words explain almost everything in Godot's multiplayer docs:

  • Peer. One running copy of your game that is part of the session. Two windows on your desk are two peers.
  • Server. The peer that hosts. Its peer id is always 1. Every client gets a large random id instead, like 851750580.
  • Authority. The peer whose copy of a node counts. By default that is the server, for every node.
  • Replication. Godot copying nodes and property values from the authority to every other peer.
  • RPC (remote procedure call). A function you call on your machine that runs on another peer's machine.

The server is a normal copy of your game that also accepts connections. If the host plays too, it is a listen server, which is what this post builds. If it runs with no player, it is a dedicated server.

Host and join with ENetMultiplayerPeer

ENet is the networking library built into Godot. It sends data over UDP and adds its own delivery rules on top, which you choose per message later. ENetMultiplayerPeer wraps it, so you never touch a socket.

Put the connection code in an autoload. An autoload is a script Godot creates once at startup and keeps alive across scene changes, reachable by name from anywhere. Save this as net.gd and add it as Net in Project Settings > Globals > Autoload.

GDScript
# net.gd. Add it as an autoload named Net.
extends Node
​
const DEFAULT_PORT: int = 7654
const MAX_PLAYERS: int = 4
​
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:
var peer: ENetMultiplayerPeer = ENetMultiplayerPeer.new()
# max_clients counts clients only. The host is a player too.
var err: Error = peer.create_server(port, MAX_PLAYERS - 1)
if err != OK:
return err
multiplayer.multiplayer_peer = peer
return OK
​
func join_game(address: String, port: int = DEFAULT_PORT) -> Error:
var peer: ENetMultiplayerPeer = ENetMultiplayerPeer.new()
var err: Error = peer.create_client(address, port)
if err != OK:
return err
multiplayer.multiplayer_peer = peer
return OK # Only an attempt so far. Wait for connected_to_server.
​
func _on_peer_connected(id: int) -> void:
print("Peer %d connected" % id)
​
func _on_peer_disconnected(id: int) -> void:
print("Peer %d left" % id)
​
func _on_connected_to_server() -> void:
print("Connected. My id is %d" % multiplayer.get_unique_id())
​
func _on_connection_failed() -> void:
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
​
func _on_server_disconnected() -> void:
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()

Three details matter here. The second argument of create_server is the number of clients, so four players means three clients plus the host. Assigning the peer to multiplayer.multiplayer_peer is the line that actually switches networking on. And create_client returning OK only means the attempt started. You are connected when connected_to_server fires.

Godot reports the session through five signals on multiplayer:

  • peer_connected(id) fires on every peer when someone joins. A new client also gets it once for each peer already there, server included.
  • peer_disconnected(id) fires on every remaining peer when someone leaves.
  • connected_to_server fires on a client when its connection is ready to use.
  • connection_failed fires on a client when nobody answered: wrong address, wrong port, or no host running.
  • server_disconnected fires on every client when the host goes away. The session is over.

If connect() and signals are new to you, my Godot 4 signals guide covers them first.

Spawn players with MultiplayerSpawner

A MultiplayerSpawner watches one node. When the server adds a child there from an allowed scene, the spawner creates the same node on every other peer. A player who joins late also gets every node that already exists, and when the server frees a node, it disappears everywhere.

Simplified scene-tree example: world.tscnWorld is the main scene, so every peer has it loaded before it connects. The spawner copies anything the server adds under Players.
  • World : Node2Dscriptworld.gd. Hosts or joins, and spawns one player per peer on the server.
    • Players : Node2DEvery player node lives here.
    • PlayerSpawner : MultiplayerSpawnerSpawn Path: ../Players. Auto Spawn List: res://player.tscn.

Select the spawner and set two things in the Inspector. Point Spawn Path at Players. Then add player.tscn to the Auto Spawn List, because the spawner only copies scenes on that list. Make world.tscn your main scene.

GDScript
# world.gd, on the root node of world.tscn (your main scene)
extends Node2D
​
const PLAYER: PackedScene = preload("res://player.tscn")
​
@onready var players: Node2D = $Players
​
func _ready() -> void:
var args: PackedStringArray = OS.get_cmdline_args() + OS.get_cmdline_user_args()
if args.has("--host"):
start_host()
elif args.has("--join"):
Net.join_game("127.0.0.1")
​
func start_host() -> void:
if Net.host_game() != OK:
return
multiplayer.peer_connected.connect(add_player)
multiplayer.peer_disconnected.connect(remove_player)
add_player(1) # The host plays too.
​
func add_player(id: int) -> void:
if players.has_node(str(id)):
return
var player: CharacterBody2D = PLAYER.instantiate()
player.name = str(id) # Name it after the peer id, before add_child.
players.add_child(player)
​
func remove_player(id: int) -> void:
var player: Node = players.get_node_or_null(str(id))
if player != null:
player.queue_free()

Only the server ever calls add_player. Clients never spawn players themselves. They just receive them.

The name is the important line. Godot matches nodes across machines by their path, so /root/World/Players/851750580 must mean the same player on every peer. Naming each player after its owner's peer id gives you that path for free, and it lets the player script work out who owns it.

Sync movement with MultiplayerSynchronizer

A MultiplayerSynchronizer sends property values from its node's authority to every other peer. Add one as a child of the player scene.

Simplified scene-tree example: player.tscnThe synchronizer's replication config lists position, sent on spawn and kept updated.
  • Player : CharacterBody2Dscriptplayer.gd. Named after its owner's peer id by world.gd.
    • CollisionShape2D : CollisionShape2D
    • Sprite2D : Sprite2D
    • MultiplayerSynchronizer : MultiplayerSynchronizerReplicates Player:position. Spawn ticked, replication mode Always.

Select the synchronizer and the Replication panel opens at the bottom of the editor. Add the position property of Player. Each synced property has two settings:

  • Spawn sends the value once, when the node is created on a peer. Tick it, so a late joiner sees the current position.
  • Replication mode decides what happens after that. Always sends updates constantly, unreliably. On Change sends them reliably, but only when the value changes. Never sends nothing after the spawn.

Use Always for position. Now the player script decides who the authority is:

GDScript
# player.gd, on the root node of player.tscn
extends CharacterBody2D
​
const SPEED: float = 220.0
​
func _enter_tree() -> void:
# The node is named after its owner's peer id. Read it back.
set_multiplayer_authority(str(name).to_int())
​
func _ready() -> void:
if is_multiplayer_authority():
# The owner picks its own start spot. See Common mistakes.
position = Vector2(randf_range(-200.0, 200.0), 0.0)
​
func _physics_process(_delta: float) -> void:
if not is_multiplayer_authority():
return # Someone else's player. Their updates move it.
var direction: Vector2 = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
velocity = direction * SPEED
move_and_slide()

set_multiplayer_authority makes the owning peer the authority of this player. It applies to child nodes by default, so the synchronizer gets the same owner. is_multiplayer_authority() is true only on the owner's machine. Everywhere else, the script skips input and the synchronizer moves that copy.

The timing gotcha: set the authority in _enter_tree(), never in _ready(). A node's _enter_tree() runs as soon as it joins the scene tree, before its children enter and before any _ready(). When I moved that line into _ready(), the client printed this the moment its player spawned:

ERROR: The MultiplayerSynchronizer at path "/root/World/Players/2004636777/MultiplayerSynchronizer" is unable to process the pending spawn since it has no network ID. This might happen when changing the multiplayer authority during the "_ready" callback. Make sure to only change the authority of multiplayer synchronizers during the "_enter_tree" callback of their multiplayer spawner.

Godot tells you the fix in the message itself.

Send messages with @rpc

Synced properties are for state that changes all the time, like position. An RPC is for an event: a chat line, "I'm ready", "open this door". You mark a function with @rpc, then call it with .rpc() to reach every peer or .rpc_id(id) to reach one.

@rpc takes these settings:

  • Who may call it. "authority" (the default) means only the node's authority. For an autoload, that is the server. "any_peer" lets any peer call it, so you must check who did.
  • Where it runs. "call_remote" (the default) runs it only on the other peers. "call_local" runs it on the caller too.
  • How it travels. "reliable" (the default) is resent until it arrives, in order. "unreliable" is never resent and can arrive in any order. "unreliable_ordered" is never resent but arrives in the order it was sent.
  • Channel. A number, default 0. Separate channels behave like separate connections, so a lost reliable message only holds up its own channel.

So a bare @rpc means @rpc("authority", "call_remote", "reliable", 0). Use reliable for anything that must not be lost, like chat or a player dying. Use the unreliable modes for streams where only the newest value matters, like input.

Add this to the same Net autoload:

GDScript
signal message_received(sender_id: int, text: String)
​
var ready_players: Dictionary = {}
​
func send_chat(text: String) -> void:
post_message.rpc(text)
​
# Any peer may call this. call_local also runs it on the sender.
@rpc("any_peer", "call_local", "reliable")
func post_message(text: String) -> void:
var sender: int = multiplayer.get_remote_sender_id()
message_received.emit(sender, text.left(160))
​
func set_ready(value: bool) -> void:
if multiplayer.is_server():
_apply_ready(1, value)
else:
_request_ready.rpc_id(1, value)
​
# A client asks. Only the server acts on it.
@rpc("any_peer", "call_remote", "reliable")
func _request_ready(value: bool) -> void:
if not multiplayer.is_server():
return
_apply_ready(multiplayer.get_remote_sender_id(), value)
​
func _apply_ready(id: int, value: bool) -> void:
ready_players[id] = value
_receive_ready.rpc(ready_players)
​
# Only the authority (the server) may call this one.
@rpc("authority", "call_remote", "reliable")
func _receive_ready(snapshot: Dictionary) -> void:
ready_players = snapshot

multiplayer.get_remote_sender_id() returns the peer id of whoever sent the RPC. The engine fills it in, so a client cannot fake it. In my test, a call_local RPC running on the sender's own machine reported the sender's own id, so the chat line shows the right name everywhere.

The ready-up flow is worth copying for any request a client makes. The client asks the server with rpc_id(1, ...). The server reads the sender id itself and decides. Then the server tells everyone with an "authority" RPC. When my test client tried to call _receive_ready directly, the server refused it:

ERROR: RPC '_receive_ready' is not allowed on node /root/Net from: 595523778. Mode is "authority", authority is 1.

One more rule from the official docs: every RPC must be declared on both sides, at the same node path, with the same @rpc settings. Godot checks all the RPCs in a script at once, so a mismatch in one can break the others.

Test multiplayer with two windows in the editor

You do not need two computers. Godot 4.7 can launch several copies of your game from the editor:

  1. Open Debug > Customize Run Instances.
  2. Tick Enable Multiple Instances and set the count to 2.
  3. Give instance 1 the launch arguments --host. Give instance 2 --join.
  4. Press F5 to run the project. Two windows open, and the second one joins the first.

world.gd reads both argument lists, OS.get_cmdline_args() and OS.get_cmdline_user_args(), because arguments from this dialog can land in either one. From a terminal, the same thing is godot --path . -- --host in one window and godot --path . -- --join in another.

Only the focused window gets keyboard input, so click a window before you press keys. To test three or four players, raise the instance count and give each extra instance --join.

Server authority: why you never trust client positions

The version above trusts each client about its own position. A modified client can send any position it likes, and the synchronizer passes it on to everyone. For a game jam with friends, that is fine. For anything with a winner, it is not.

The fix is server authority. The server owns every player, and clients send their input instead of their position:

GDScript
# player.gd, server-authoritative version
extends CharacterBody2D
​
const SPEED: float = 220.0
​
var owner_peer_id: int = 0
var input_direction: Vector2 = Vector2.ZERO
​
func _enter_tree() -> void:
owner_peer_id = str(name).to_int()
set_multiplayer_authority(1) # The server owns every player.
​
func _physics_process(_delta: float) -> void:
if owner_peer_id == multiplayer.get_unique_id():
var direction: Vector2 = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
if multiplayer.is_server():
input_direction = direction
else:
send_input.rpc_id(1, direction)
if multiplayer.is_server():
velocity = input_direction * SPEED
move_and_slide()
​
# The owner sends input. The server checks it, then moves the player.
@rpc("any_peer", "call_remote", "unreliable_ordered")
func send_input(direction: Vector2) -> void:
if not multiplayer.is_server():
return
if multiplayer.get_remote_sender_id() != owner_peer_id:
return # Someone else's keyboard.
if not direction.is_finite() or direction.length() > 1.001:
return # No real key or stick gives this.
input_direction = direction

In my test, the client held right for one second and its player moved 223.7 pixels, about what a speed of 220 predicts. The client never moves that node itself, so every pixel came from the server. Then I tried two cheats from the client: driving the host's player, and sending a direction fifty times too long. The server ignored both.

The server can now place players too. Add player.position = Vector2(80 * players.get_child_count(), 0) before add_child in add_player(), and that spot reaches every peer. The CodingQuests co-op campaign and the free Multiplayer Starter both use this model.

The cost shows up on a real connection. Your own player now moves only after your input reaches the server and the new position comes back. That delay is why client-side prediction exists. The client moves its own player the moment you press a key, keeps a numbered list of its inputs, and quietly corrects itself if the server disagrees (reconciliation). Other players get drawn slightly in the past and smoothed between updates (interpolation). The starter draws remote players 100 ms in the past.

Real limits beginners hit

Two windows on one desk hide a few problems that show up the first time a friend tries to join.

  • Internet play needs a reachable host. On a home connection, the host must forward the game's port on their router. ENet only uses UDP, so forwarding TCP does nothing. Godot's UPNP class can ask a compatible router to open the port, but not every router allows it. The other route is a dedicated server on a machine with a public address.
  • There is no built-in lobby browser, relay, or NAT punch-through. NAT is the router feature that hides home computers behind one address, and it is why port forwarding exists. Players join by IP address, and a list of open games needs your own server or an outside service.
  • Web exports cannot use ENet. Godot's web export docs say low-level networking is not available in browsers, only HTTP, WebSocket (as a client) and WebRTC. Use WebSocketMultiplayerPeer with the server running outside the browser, or WebRTCMultiplayerPeer, which needs a signaling server to introduce peers. The spawner, synchronizer and RPCs work on top of either, because they only talk to multiplayer.multiplayer_peer. My Godot 4 web export guide covers the rest of the browser build.
  • There is no host migration. If the host quits, every client gets server_disconnected and the session ends.

Common mistakes

I reproduced the first three myself while testing this post, so the messages and numbers below are real.

Setting authority in _ready()

You get the "has no network ID" error from the synchronizer section. Move set_multiplayer_authority() into _enter_tree().

Not naming the spawned node

When I removed the player.name line, the second player got an automatic name and the spawner refused it:

ERROR: Unable to auto-spawn node with reserved name: @CharacterBody2D@2. Make sure to add your replicated scenes via 'add_child(node, true)' to produce valid names.

That player never reached the client. The authority broke too, because the script reads the owner from the name: the first player, still called Player, got authority 0. Name every player after its peer id, before add_child.

Expecting the server's spawn position on a client-owned player

Spawn data comes from the synchronizer's authority, and in the client-owned version that is the client. In my test, the server placed the client's player at (120, 7), but it arrived at (0, 0) on the client. The client's next update then dragged the server's copy back to (0, 0) as well. Let the owner pick its start spot, as player.gd does, or switch to server authority.

Forgetting the authority check in movement code

Without if not is_multiplayer_authority(): return, the focused window runs your input code on every player in its world, not just yours.

Trusting a player id passed as an argument

A function like request_ready(player_id, value) lets any client speak for anyone. Read multiplayer.get_remote_sender_id() on the server instead.

Loading the game scene after connecting

If the server spawns a player before a client has the World scene, the spawn has nowhere to go. Keep the spawner in a scene every peer loads before it connects. The Multiplayer Starter goes further: replicated nodes stay hidden from a peer until that peer says its world has loaded.

FAQ

What is the difference between MultiplayerSpawner and MultiplayerSynchronizer?

MultiplayerSpawner copies nodes. When the server adds a player scene under the spawn path, the same node appears on every peer, and it disappears everywhere when the server frees it. MultiplayerSynchronizer copies property values, like position, from the node's authority to every other peer. Most games use both: the spawner creates the player and the synchronizer keeps it moving.

What does @rpc("any_peer", "call_local", "reliable") mean in Godot 4?

any_peer lets any connected peer call the function, not only the authority. call_local runs it on the caller's own machine as well as on the others. reliable makes Godot resend it until it arrives, in order. Because any peer can call it, check multiplayer.get_remote_sender_id() inside the function before you trust it.

How do I test Godot multiplayer with two windows?

Open Debug > Customize Run Instances, tick Enable Multiple Instances, and set the count to 2. Give each instance its own launch arguments, such as --host for the first and --join for the second, and read them in your script with OS.get_cmdline_args() and OS.get_cmdline_user_args(). Press F5 and both windows open.

Do players need to port forward to play my Godot game online?

The host does, unless it already has a public address. ENet uses UDP, so the UDP port must be forwarded on the host's router. Godot's UPNP class can ask a compatible router to open it. Godot has no built-in relay or NAT punch-through, so the other option is a dedicated server on a machine with a public address.

Can I use ENet multiplayer in a Godot web export?

No. Browsers do not give Godot low-level UDP access, so ENet is not available in web exports. Use WebSocketMultiplayerPeer with the server running outside the browser, or WebRTCMultiplayerPeer, which needs a signaling server. MultiplayerSpawner, MultiplayerSynchronizer and RPCs work the same on top of either one.

Your next step

Prefer to see the big picture on video first? I cover it in Everything You Need to Know About Multiplayer in Godot.

Two windows talking is the start. The real questions come once a game sits on top, like who owns a bullet, or what happens when a packet goes missing.

The free Two Windows Talking quest is where I teach this step by step. Over 9 lessons you connect a desktop Godot host and clients and publish a complete player roster. Then you build a network inspector, an application-message fault dial and a bot connection. The first two lessons are browser experiments about delay and lost messages, and from lesson 3 you build in Godot 4.7.2 itself. The next quest, RPCs: Who Is Allowed To Say What, is free too. Over 8 lessons you send correctly permitted RPCs, sync names, and add chat and ping.

Both open the Godot 4 multiplayer campaign. Its eight quests continue through replication, server authority, prediction, real game systems and shipping, and end with a finished co-op survival shooter. The paid quests have their first 3 lessons free.

Want finished code to read first? The free, MIT-licensed Multiplayer Starter hosts, joins or runs a headless dedicated server for up to 4 players. It has a ready-up lobby, rounds, chat, server-owned players, prediction and a sender check on every RPC. It skips the client-owned player from the first half of this post and uses the server-authority model instead. Learn the nodes here, then open the starter to see where they lead.

godottutorialmultiplayernetworkingrpc

Stop reading. Start building.

Write real Godot code in your browser, then build this for real in the Two Windows Talking Quest.

Free, and no card needed. Built by a real person, with new quests every month.

Get the next Godot build in your inbox

New quests, project breakdowns, and game-dev tips. Free, no spam, unsubscribe anytime.

Written by Omar

We teach Godot 4 by making you build complete systems: inventories, save systems, action roguelike controllers, enemy AI. The scrolls are free. The quests are where it sticks.