Godot Multiplayer Tutorial: A Two-Player Test
Build a tiny Godot multiplayer test with one server-authoritative action, then verify connection, authority, disconnect, and packet-loss behavior.

The safest first Godot multiplayer project is not your whole game online. It is two local instances, one server and one client, sharing one server-approved action. Build that narrow test first, then verify who owns the state, what happens when a peer disconnects, and which messages may be lost.
This Godot multiplayer tutorial creates a tiny counter. Either player can request an increment, but only the server changes the canonical value and broadcasts it. The mechanic is deliberately boring. That makes networking failures visible instead of hiding them inside movement, animation, physics, prediction, inventory, and spawning at once.
Godot's high-level multiplayer API is managed through the scene tree. You create a multiplayer peer, initialize it as a server or client, and assign it to the multiplayer API. The example below uses ENetMultiplayerPeer, the same peer shown in the official initialization examples.
What This Test Proves
The finished test answers five questions:
- Can a server listen and a client connect on the same machine?
- Can either player request one action?
- Does only the server mutate the shared value?
- Do both peers display the same confirmed value?
- Does the interface recover when a connection fails or disappears?
It does not prove Internet hosting, NAT traversal, cheat resistance, matchmaking, lag compensation, account identity, save integrity, or production security. Godot's documentation carries a direct security warning: networked applications can introduce cheats, exploits, and machine compromise when implemented incorrectly. Treat this as a learning fixture, not a production backend.
If the project still needs a basic scene structure, work through Godot for a first game before adding a network boundary. The Godot UI tutorial pairs with this test because the connection controls must remain usable with a mouse or controller.
Create the Scene
Make a Control scene named NetworkTest with this tree:
NetworkTest (Control)
└── PanelContainer
└── VBoxContainer
├── StatusLabel (Label)
├── ValueLabel (Label)
├── AddressInput (LineEdit)
├── HostButton (Button)
├── JoinButton (Button)
├── IncrementButton (Button)
└── DisconnectButton (Button)
Set the line edit to 127.0.0.1, the IPv4 loopback address. Use unique names for every interactive child. Disable Increment and Disconnect at startup because no session exists yet.
Attach this script to the root:
extends Control
const PORT := 7000
const MAX_CLIENTS := 1
@onready var status_label: Label = %StatusLabel
@onready var value_label: Label = %ValueLabel
@onready var address_input: LineEdit = %AddressInput
@onready var host_button: Button = %HostButton
@onready var join_button: Button = %JoinButton
@onready var increment_button: Button = %IncrementButton
@onready var disconnect_button: Button = %DisconnectButton
var shared_value := 0
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)
host_button.pressed.connect(host_session)
join_button.pressed.connect(join_session)
increment_button.pressed.connect(request_increment)
disconnect_button.pressed.connect(disconnect_session)
_show_offline()
Godot provides those connection signals on MultiplayerAPI. The server's peer ID is always 1; connected clients receive other positive IDs. Signals are better than guessing connection state from elapsed time.
Host and Join Locally
Add the connection functions:
func host_session() -> void:
var peer := ENetMultiplayerPeer.new()
var error := peer.create_server(PORT, MAX_CLIENTS)
if error != OK:
status_label.text = "Could not host: %s" % error_string(error)
return
multiplayer.multiplayer_peer = peer
shared_value = 0
_apply_confirmed_value(shared_value)
_show_connected("Hosting on port %d" % PORT)
func join_session() -> void:
var address := address_input.text.strip_edges()
if address.is_empty():
address = "127.0.0.1"
var peer := ENetMultiplayerPeer.new()
var error := peer.create_client(address, PORT)
if error != OK:
status_label.text = "Could not start client: %s" % error_string(error)
return
multiplayer.multiplayer_peer = peer
status_label.text = "Connecting to %s:%d" % [address, PORT]
host_button.disabled = true
join_button.disabled = true
increment_button.disabled = true
disconnect_button.disabled = false
func disconnect_session() -> void:
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
shared_value = 0
_apply_confirmed_value(shared_value)
_show_offline()
The client connection call begins the attempt. Do not display “Connected” until connected_to_server fires. Likewise, a successful create_server() means the local peer is listening; it does not mean a client has joined.
For two local instances, run the project once as the server, then start another instance as the client. Godot's editor has options for multiple debug instances, or you can run an exported debug build alongside the editor. Keep the first test on 127.0.0.1. Internet hosting adds router, firewall, public address, and security variables before the basic state flow is proven.
Send a Request, Not a Client-Side Result
The Increment button calls one local function:
func request_increment() -> void:
if multiplayer.is_server():
_accept_increment(multiplayer.get_unique_id())
else:
request_increment_on_server.rpc_id(1)
@rpc("any_peer", "call_remote", "reliable")
func request_increment_on_server() -> void:
if not multiplayer.is_server():
return
_accept_increment(multiplayer.get_remote_sender_id())
func _accept_increment(sender_id: int) -> void:
if not multiplayer.is_server():
return
shared_value += 1
apply_confirmed_value.rpc(shared_value, sender_id)
_apply_confirmed_value(shared_value)
@rpc("authority", "call_remote", "reliable")
func apply_confirmed_value(value: int, _sender_id: int) -> void:
_apply_confirmed_value(value)
func _apply_confirmed_value(value: int) -> void:
shared_value = value
value_label.text = "Confirmed value: %d" % shared_value
The client sends intent: “I requested an increment.” It does not send “the new value is 918.” The server decides whether the request is valid, changes the canonical number, and sends the result.
The sample uses reliable RPCs because every accepted increment matters and the next value depends on the previous one. Godot documents reliable transfer as acknowledged and ordered, with a performance cost. It also offers unreliable and unreliable-ordered modes for state where dropping stale packets can be acceptable. Choosing a transfer mode is a property of the message, not a project-wide badge.
One detail matters when copying RPC code: the sending and receiving nodes must have the same node path and matching RPC declarations. Run the same scene and script on both peers. Renaming the root in only one build can break calls even though the function name still looks correct.
Handle Connection State Explicitly
Add the signal handlers and interface helpers:
func _on_connected_to_server() -> void:
_show_connected("Connected as peer %d" % multiplayer.get_unique_id())
func _on_connection_failed() -> void:
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
_show_offline("Connection failed")
func _on_server_disconnected() -> void:
shared_value = 0
_apply_confirmed_value(shared_value)
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
_show_offline("Server disconnected")
func _on_peer_connected(peer_id: int) -> void:
status_label.text = "Peer %d connected" % peer_id
if multiplayer.is_server():
apply_confirmed_value.rpc_id(peer_id, shared_value, 1)
func _on_peer_disconnected(peer_id: int) -> void:
status_label.text = "Peer %d disconnected" % peer_id
func _show_connected(message: String) -> void:
status_label.text = message
host_button.disabled = true
join_button.disabled = true
increment_button.disabled = false
disconnect_button.disabled = false
func _show_offline(message := "Offline") -> void:
status_label.text = message
host_button.disabled = false
join_button.disabled = false
increment_button.disabled = true
disconnect_button.disabled = true
The server sends its current value when a peer connects. Without that snapshot, a late client could wait indefinitely for the next increment and display an old default.
For a real game, connection UI would need timeouts, cancellation, authentication, version checks, and better errors. This fixture keeps the state transitions visible so those requirements have somewhere honest to attach later.
Run the Authority Test
Start a server and one client. Click Increment five times on each instance in any order. Both windows should finish on the same confirmed value.
Now try to violate the design deliberately:
- Put a breakpoint or print inside
_accept_increment(). - Click on the client.
- Confirm that the authoritative mutation executes on the server process.
- Temporarily change the client display before the RPC returns.
- Confirm that the next server update replaces the fake local value.
This is not cheat prevention. It only proves where this one mutation occurs. Every future action needs its own validation rules. A server-authoritative label does not make unvalidated RPC input safe.
Run the Disconnect Test
With both peers connected, close the client. The server should receive peer_disconnected and remain usable. Reopen the client and confirm that it receives the server's current value.
Then close the server while the client is connected. The client should receive server_disconnected, return to its offline controls, and clear the stale value.
Repeat using the Disconnect button. A clean user-requested exit and an abrupt process loss should both leave the interface in a recoverable state.
Run a Packet-Behavior Test
On localhost, packets rarely face meaningful delay or loss. Still document the contract for each message before expanding:
| Message | Sender | Authority | Transfer choice | If lost |
|---|---|---|---|---|
| Increment request | client | server validates | reliable | player action disappears |
| Confirmed value | server | server | reliable | peers disagree |
| Cosmetic cursor | owning player | no game authority | unreliable | next update replaces it |
| Chat message | player | server moderates | reliable, separate channel | message disappears |
Godot notes that UDP packets may be lost or arrive out of order. Its high-level ENet implementation adds optional reliability and channels. For serious testing, introduce delay and loss with a network simulation tool outside the game, then confirm that reliable actions eventually arrive and cosmetic unreliable updates recover on the next packet.
Do not claim a multiplayer mechanic is robust because two localhost windows agreed once. Test at least duplicate requests, rapid input, a late join, client exit, server exit, version mismatch, and adverse network conditions before building more systems on top.
Before Testing Outside Your LAN
Godot's high-level multiplayer uses UDP. Hosting across the Internet may require forwarding the UDP port through the router, and the host must account for firewall and NAT behavior. Do not tell players to open ports until you understand the exposure and have validated the application protocol.
For Android exports, Godot's documentation says to enable the INTERNET permission in the export preset or network communication will be blocked. Browser exports have different constraints and do not expose raw TCP or UDP in the same way.
If the goal is a shipped online game, the next design decision is not “add player movement.” It is the trust model:
- Which actions may clients request?
- What does the server validate?
- What state is private to one player?
- What state must late joiners receive?
- What survives a reconnect?
- Which message can be dropped safely?
Answer those for one mechanic, implement it, and test its failure modes. Then add the second mechanic. That pace feels slower than copying a giant lobby script, but it gives you a networked game whose behavior you can actually explain.
Related Articles

Godot UI Tutorial: Build a Mouse and Gamepad Menu
Follow a practical Godot UI tutorial to build a responsive menu with containers, anchors, keyboard and controller focus, and a working pause state.

Game Design Document: A One-Page GDD Template
Build a game design document you will actually update, using a one-page GDD template for the core loop, loss condition, scope, and next playable build.

How to Publish a Game on Steam and What It Costs
The honest rundown of how to publish a game on Steam, the $100 Direct fee, Steam's 30 percent cut, the 30-day wait, and whether it's worth it yet.