⚠️ Article May Be Outdated
This article was last updated 1 year 3 months ago. The content may no longer be accurate or relevant.
This article mainly serves as study notes.
The following content is mainly written based on Godot’s related documentation and my understanding and practice. It may not be the best practice and may contain errors, but it should basically work.
Tips
This article is based on version 4.3.
Basics of Network Communication in Godot
Godot provides some lower-level implementations, but since I don’t use them, I won’t cover them.
The high-level API provided by Godot uses UDP and supports IPv6.
Network Initialization
Each node has a multiplayer property, which is a reference to the MultiplayerAPI instance configured for it by the scene tree. You can set MultiplayerAPI individually for nodes, overriding all child nodes. Therefore, you can run multiple servers and clients in one Godot instance.
You can create clients and servers using the following methods.
# Create client
var peer = ENetMultiplayerPeer.new()
peer.create_client(IP_ADDRESS, PORT)
multiplayer.multiplayer_peer = peer
# Create server
var peer = ENetMultiplayerPeer.new()
peer.create_server(PORT, MAX_CLIENTS)
multiplayer.multiplayer_peer = peer
End networking using the following method.
multiplayer.multiplayer_peer = null
Tips
For Android export, you need to check INTERNET permission, otherwise you can’t connect to the network.
Managing Connections
A concept used here is peer, which can be directly understood as an object without properties.
The system assigns each peer a unique ID (UID). The server’s ID is always 1, while client IDs are assigned random positive integers.
You can detect connection establishment and disconnection through MultiplayerAPI signals:
peer_connected(id: int)This signal is emitted on every other peer with the newly connected peer’s ID, and on the new peer multiple times, once with each other peer’s ID.peer_disconnected(id: int)This signal is emitted on every remaining peer when a peer disconnects.
The following signals are only sent on clients:
connected_to_server()connection_failed()server_disconnected()
Get the associated UID through multiplayer.get_unique_id().
Determine whether the peer is server or client through multiplayer.is_server().
Remote Procedure Calls (RPC)
Remote procedure calls are functions called on other peers. Adding @rpc before a function definition creates that function as an RPC.
To call an RPC, call it through the Callable’s rpc() method on each peer, or use rpc_id() to call it on a specific peer.
func _ready():
if multiplayer.is_server():
print_once_per_client.rpc()
@rpc
func print_once_per_client():
print("Every connected client will print me to the console once.")
To implement RPC, the sender and receiver nodes need to have the same NodePath, including node names. Also, @rpc must exist in the corresponding function on both server and client, and the function declaration and return type must be the same. When calling add_child() on nodes expected to use RPC, set the parameter force_readable_name to true.
Tips
This issue has more information about RPC errors. The documentation also explains this.
@rpc can have multiple parameters. When not set, it uses the following preset:
@rpc("authority", "call_remote", "unreliable", 0)
Its parameters and available values are as follows:
mode:
"authority": Only the multiplayer authority (server) can remotely call this function."any_peer": Also allows clients to remotely call this function, used for transmitting user input.
sync:
"call_remote": Makes the function not be called on the local peer."call_local": Makes the function also be called on the local peer, very useful when the server is also a player.
transfer_mode:
"unreliable": Packets are not acknowledged, may be lost, and can arrive at the receiver in any order."unreliable_ordered": Packets are received in the order they were sent, achieved by ignoring late-arriving packets (if another packet sent after those packets has already been received). Improper use may cause packet loss."reliable": Sends retransmission attempts until the packet is acknowledged, and the order of these packets is preserved. Has a noticeable performance cost.
transfer_channel is the channel index.
The first 3 parameters can be in any order in the annotation, but the transfer_channel parameter must always be last in the annotation.
In the function called by RPC, you can use the function multiplayer.get_remote_sender_id() to get the UID of the RPC sender.
func _on_some_input(): # Connected to some input.
transfer_some_input.rpc_id(1) # Only send input to the server.
# When the server is also a player, need to set to call_local.
@rpc("any_peer", "call_local", "reliable")
func transfer_some_input():
# Get sender UID.
var sender_id = multiplayer.get_remote_sender_id()
Channels
You can set up multiple channels to achieve interference-free communication, especially when different reliability levels are needed, which can improve efficiency.
The channel with index 0 is actually 3 different channels for 3 different transfer modes. (But I don’t quite understand this sentence)
Official Example
Below is an example lobby that can handle peer joining and leaving, notify the UI scene through signals, and start the game after all clients have loaded the game scene.
extends Node
# Autoload named Lobby
# These signals can be connected to by a UI lobby scene or the game scene.
signal player_connected(peer_id, player_info)
signal player_disconnected(peer_id)
signal server_disconnected
const PORT = 7000
const DEFAULT_SERVER_IP = "127.0.0.1" # IPv4 localhost
const MAX_CONNECTIONS = 20
# This will contain player info for every player,
# with the keys being each player's unique IDs.
var players = {}
# This is the local player info. This should be modified locally
# before the connection is made. It will be passed to every other peer.
# For example, the value of "name" can be set to something the player
# entered in a UI scene.
var player_info = {"name": "Name"}
var players_loaded = 0
func _ready():
multiplayer.peer_connected.connect(_on_player_connected)
multiplayer.peer_disconnected.connect(_on_player_disconnected)
multiplayer.connected_to_server.connect(_on_connected_ok)
multiplayer.connection_failed.connect(_on_connected_fail)
multiplayer.server_disconnected.connect(_on_server_disconnected)
func join_game(address = ""):
if address.is_empty():
address = DEFAULT_SERVER_IP
var peer = ENetMultiplayerPeer.new()
var error = peer.create_client(address, PORT)
if error:
return error
multiplayer.multiplayer_peer = peer
func create_game():
var peer = ENetMultiplayerPeer.new()
var error = peer.create_server(PORT, MAX_CONNECTIONS)
if error:
return error
multiplayer.multiplayer_peer = peer
players[1] = player_info
player_connected.emit(1, player_info)
func remove_multiplayer_peer():
multiplayer.multiplayer_peer = null
# When the server decides to start the game from a UI scene,
# do Lobby.load_game.rpc(filepath)
@rpc("call_local", "reliable")
func load_game(game_scene_path):
get_tree().change_scene_to_file(game_scene_path)
# Every peer will call this when they have loaded the game scene.
@rpc("any_peer", "call_local", "reliable")
func player_loaded():
if multiplayer.is_server():
players_loaded += 1
if players_loaded == players.size():
$/root/Game.start_game()
players_loaded = 0
# When a peer connects, send them my player info.
# This allows transfer of all desired data for each player, not only the unique ID.
func _on_player_connected(id):
_register_player.rpc_id(id, player_info)
@rpc("any_peer", "reliable")
func _register_player(new_player_info):
var new_player_id = multiplayer.get_remote_sender_id()
players[new_player_id] = new_player_info
player_connected.emit(new_player_id, new_player_info)
func _on_player_disconnected(id):
players.erase(id)
player_disconnected.emit(id)
func _on_connected_ok():
var peer_id = multiplayer.get_unique_id()
players[peer_id] = player_info
player_connected.emit(peer_id, player_info)
func _on_connected_fail():
multiplayer.multiplayer_peer = null
func _on_server_disconnected():
multiplayer.multiplayer_peer = null
players.clear()
server_disconnected.emit()
The root node of the game scene should be named Game. In the script attached to it:
extends Node3D # Or Node2D.
func _ready():
# Preconfigure game.
Lobby.player_loaded.rpc_id(1) # Tell the server that this peer has loaded.
# Called only on the server.
func start_game():
# All peers are ready to receive RPCs in this scene.
Automatic Synchronization
The MultiplayerSynchronizer node can achieve automatic multi-peer synchronization.
After adding the node, select Replicate -> Add Sync Property below, then select the node and property to sync.
For example, you can sync the Transform of multiple Sprite2Ds, so the positions of Sprite2Ds on all peers will be synchronized.
Implementing a Client That Works for Both Single-Player and Multiplayer
Actually modified from the official example. I just added a script to make single-player and multiplayer use the same logic, plus my own annotations.
Overall Node Architecture
Main scene
Control_Main
├── LineEdit_InputName
├── Button_Solo
├── Button_HostMutiplayer
└── Button_JoinMutiplayer
Actual game
Control_Game
├── Button_GetScore
├── Button_End
├── Label_Players
└── MultiplayerSynchronizer_MultiplayerSynchronizer
Host multiplayer
Control_HostMutiplayer
├── Label_Players
├── Button_Start
└── Button_End
Join multiplayer
Control_JoinMutiplayer
├── Label-Players
├── LineEdit_InputIP
├── Button_Start
└── Button_End
Writing Scripts
server.gd (needs to be autoloaded)
extends Node
# Custom signals for getting information elsewhere
signal player_connected(peer_id, player_info)
signal player_disconnected(peer_id, player_info)
signal server_disconnected
signal player_getscore
# Default server configuration
var PORT = 7000
var DEFAULT_SERVER_IP = "127.0.0.1"
const MAX_CONNECTIONS = 20
# Indexed by UID
@export var players = {}
var player_info = {"name": "MasoFod", "score": 0} # This peer's player info, needs to be modified before establishing connection
func _ready():
multiplayer.peer_connected.connect(_on_player_connected)
multiplayer.peer_disconnected.connect(_on_player_disconnected)
multiplayer.connected_to_server.connect(_on_connected_ok)
multiplayer.connection_failed.connect(_on_connected_fail)
multiplayer.server_disconnected.connect(_on_server_disconnected)
# Connect to server at specified IP
func join_game(address = ""):
if address.is_empty():
address = DEFAULT_SERVER_IP
var peer = ENetMultiplayerPeer.new()
var error = peer.create_client(address, PORT)
if error:
return error
multiplayer.multiplayer_peer = peer
# Create server
func create_game():
var peer = ENetMultiplayerPeer.new()
var error = peer.create_server(PORT, MAX_CONNECTIONS)
if error:
return error
multiplayer.multiplayer_peer = peer
# Set default player as server player
players[1] = player_info
player_connected.emit(1, player_info)
# Stop networking
func remove_multiplayer_peer():
multiplayer.multiplayer_peer = null
# Can execute Server.load_game.rpc(filepath) to restart game, only server can call
@rpc("call_local", "reliable")
func load_game(game_scene_path):
get_tree().change_scene_to_file(game_scene_path)
# When client connects to server, send player info
func _on_player_connected(id):
_register_player.rpc_id(id, player_info)
# All clients register player info
@rpc("any_peer", "reliable")
func _register_player(new_player_info):
var new_player_id = multiplayer.get_remote_sender_id()
players[new_player_id] = new_player_info
player_connected.emit(new_player_id, new_player_info)
# Server handles after client disconnects
func _on_player_disconnected(id):
players.erase(id)
player_disconnected.emit(id, player_info)
# After client establishes connection, register self locally
func _on_connected_ok():
var peer_id = multiplayer.get_unique_id()
players[peer_id] = player_info
player_connected.emit(peer_id, player_info)
# Client disconnects after connection fails
func _on_connected_fail():
multiplayer.multiplayer_peer = null
# Client handles after disconnecting from server
func _on_server_disconnected():
multiplayer.multiplayer_peer = null
players.clear()
player_info.score = 0
server_disconnected.emit()
get_tree().change_scene_to_file("res://main.tscn")
# When a player's score changes
@rpc("any_peer", "call_local", "reliable")
func _on_player_get_score(id):
players[id].score += 1
player_getscore.emit()
# All peers start game
@rpc("authority","call_local","reliable")
func start_game() -> void:
get_tree().change_scene_to_file("res://game.tscn")
# End local game
func end_game() -> void:
multiplayer.multiplayer_peer = null
players.clear()
player_info.score = 0
get_tree().change_scene_to_file("res://main.tscn")
main.gd (main’s script, needs to connect signals)
extends Control
@onready var input_name: LineEdit = $InputName
func _ready() -> void:
input_name.text = str(Server.player_info.name)
func _on_solo_pressed() -> void:
Server.player_info.name = input_name.text
Server.players[1] = Server.player_info
Server.create_game()
get_tree().change_scene_to_file("res://game.tscn")
func _on_host_mutiplayer_pressed() -> void:
Server.player_info.name = input_name.text
get_tree().change_scene_to_file("res://host_mutiplayer.tscn")
func _on_join_mutiplayer_pressed() -> void:
Server.player_info.name = input_name.text
Server.players[1] = Server.player_info
get_tree().change_scene_to_file("res://join_mutiplayer.tscn")
game.gd (game’s script, needs to connect signals)
extends Control
@onready var players: Label = $Players
func _ready() -> void:
Server.player_getscore.connect(refresh_text)
Server.player_disconnected.connect(refresh_text)
for p in Server.players:
players.text += Server.players[p].name + ' ' + ("%d"%Server.players[p].score) + ' points\n'
func _on_end_pressed() -> void:
Server.end_game()
func _on_get_score_pressed() -> void:
# Add 1 point to current player
Server._on_player_get_score.rpc(Server.multiplayer.get_unique_id())
# Refresh scoreboard
func refresh_text(_id = 0, _player_info = "") -> void:
players.text = ""
for p in Server.players:
players.text += Server.players[p].name + ' ' + ("%d"%Server.players[p].score) + ' points\n'
host_mutiplayer.gd (HostMutiplayer’s script, needs to connect signals)
extends Control
@onready var players: Label = $Players
func _ready() -> void:
Server.player_connected.connect(update_players)
Server.player_disconnected.connect(update_players)
Server.create_game()
func update_players(_id:int, _player_info) -> void:
players.text = "Players:\n"
for p in Server.players:
players.text += Server.players[p].name + '\n'
func _on_start_pressed() -> void:
Server.start_game.rpc()
func _on_end_pressed() -> void:
Server.end_game()
join_mutiplayer.gd (JoinMutiplayer’s script, needs to connect signals)
extends Control
@onready var start: Button = $Start
@onready var players: Label = $Players
@onready var input_ip: LineEdit = $InputIP
func _ready() -> void:
Server.player_connected.connect(update_players)
Server.player_disconnected.connect(update_players)
func update_players(id:int, player_info) -> void:
players.text = "Players:\n"
for p in Server.players:
players.text += Server.players[p].name + '\n'
func _on_start_pressed() -> void:
var ip = str(input_ip.text)
var error = Server.join_game(ip)
if error:
players.text = "Server not found"
else:
start.queue_free()
func _on_end_pressed() -> void:
Server.remove_multiplayer_peer()
get_tree().change_scene_to_file("res://main.tscn")
Tips
Top left Debug -> Custom Run Instances... can set how many instances to open when running, convenient for testing networking.
Summary
What this article covers is basically not very useful, only suitable for making small game multiplayer. If you want to do large-scale multiplayer, you still need to make a dedicated server.
If you still don’t understand after reading this article, I recommend watching this video. Although the speech is fast, it’s all useful content. If you can’t understand, you can read the documentation again. Combining both makes it very easy to understand.
It seems I’m still not using it well, stuck in reinventing the wheel mindset, always wanting to implement things myself, not realizing there are ready-made things to use.
Therefore, my code definitely has many not “elegant” places, please understand.