My Approach To Multiplayer In Godot
Introduction
This is less of a tutorial or guide and more just a breakdown of my approach to multiplayer in my current project. I’m still learning the ropes of multiplayer development, so please consider my statements and design choices as exploratory. I assume you’re somewhat new to multiplayer development but have a solid understanding of Godot 4.0 and programming in general. Also keep in mind I’m developing a card game so my networking requirements may be simpler than what you’re working on. My goal with this is mainly to share insights I’ve gained while working on my current project.
Getting Started With Multiplayer
Like I said this is not intended to be a tutorial but here is some quick information and resources that will hopefully help you get started.
Initializing The Network
To enable networking for your project, assign a MultiplayerPeer
to the MultiplayerAPI
.
Since all nodes in your scene tree share the same MultiplayerAPI
reference,
setting the peer from any node activates networking throughout the entire game.
# node.gd
func host_game(port: int) -> void:
var peer := ENetMultiplayerPeer.new()
match peer.create_server(port):
OK:
multiplayer.multiplayer_peer = peer
func join_game(address: String, port: int) -> void:
var peer := ENetMultiplayerPeer.new()
match peer.create_client(address, port):
OK:
multiplayer.multiplayer_peer = peer
For more information see Official Docs
MultiplayerSpawner and MultiplayerSynchronizer
I haven’t needed to use either of these nodes yet since they seem more useful for real-time games. However, based on my understanding, here’s a quick introduction:
The MultiplayerSpawner replicates spawnable nodes so they appear for all players, ensuring everyone sees the same new objects in the game. Meanwhile, the MultiplayerSynchronizer mirrors the state of existing objects between peers, keeping everything consistent across all players’ screens.
Only the peer with authority over the spawner and synchronizer can replicate or synchronize using them, which is typically the server.
What Are RPCs?
Remote Procedure Calls (RPCs) allow functions to be executed on different machines across the network. This abstraction simplifies communication between peers by replacing complex packet exchanges with straightforward function calls. Basically, an RPC says, “Execute this function on that remote client.”
To define an RPC function, use the @rpc
annotation before the function definition.
To invoke an RPC call, you can use func.rpc()
to call it on all peers or func.rpc_id()
to call it on a specific peer.
Here are a few things to keep in mind:
- RPCs only support primitive arguments such as strings, integers, floats, and dictionaries or arrays of these primitive types.
- If you need to transmit an entire object across the network, reconsider if it’s necessary… If it is, you’ll need to serialize it somehow.
- When using static typing, note that Arrays are transmitted across the network as
Array[Variant]
. If your RPC function expects a typed array, this may cause an error. To circumvent this, utilizePackedArray
types, which can be serialized. - RPCs rely on both the sending and receiving nodes having the same node path. The type of the nodes on the path are irrelevant, but the node names and rpc method signature must match.
For further details, refer to the Official Documentation.
Sharing Objects Across The Network
At this stage, I haven’t delved into optimizing my packet sizes, but as a general rule, I aim to send only the minimum necessary information across the network. Intuitively, this approach seems reasonable to me. However, if I absolutely need to share objects across the network, I’ve adopted two approaches:
Database Approach
When an item exists on every client and remains identical across all clients, I prefer referencing that item by a unique ID. For instance, in my project, I utilize resources to represent cards, and all clients have these cards saved locally. Instead of transmitting all the information about a card when sharing it with other clients, I simply share the card’s ID. The receiving client can then load the corresponding card using this ID.
To manage these IDs, I employ a CardDB
singleton.
This singleton includes a register_card
method,
allowing me to manually associate each card with a specific ID.
Subsequently, the CardDB
is used to serialize and deserialize cards as necessary.
The big advantage of this is approach is you only have to transmit an int per card when sharing with peers. However, the downside is it is not very flexible.
Dictionary SerDe Approach
When I need to transmit the complete state of an object, or at least its relevant parts, I implement two methods on it:
- from_deserialize: This static method takes the dictionary data as an argument and returns a new instance of the object based on that data.
- serialize: This method returns a dictionary representing the current state of the object.
# object.gd
class_name Obj
var health: int = 1
var name: String = ""
static func from_deserialize(data: Dictionary) -> Obj:
var obj := Obj.new()
obj.health = data.health
obj.name = data.name
return obj
func serialize() -> Dictionary:
# It looks weird but this is valid.
# I just don't like JSON style dicts
return {
health=health,
name=name
}
This approach is pretty much the opposite of the previous one when it comes to pros and cons. It’s super flexible, but you end up sending more data across the network than you really need to, especially since dictionary keys tag along for the ride. One workaround could be serializing the data into an array, where each index lines up with a field, cutting down on unnecessary info. But, full disclosure, I haven’t tried this out myself… yet. Seems like a bit of a premature optimization at this point.
Separation Of Client And Server
In a P2P (Peer-to-Peer) setup like mine, the server is technically a client as well. Consequently, a lot of server-only logic coexists with client-only logic. Personally, I find this arrangement makes the code harder to follow, so I aim to separate client and server logic as much as possible, treating the code as if I’m writing for a dedicated server. This approach also involves treating the client-side of the server as just another client whenever feasible.
To facilitate this separation, I introduce a ServerScope
inner-class on my networked objects.
This scope is only instantiated on the server. As a convention, I consider anything defined on a networked object as client-only,
while anything within the networked object’s server scope is designated as server-only.
# network.gd
# Client-Only
var player_name: String = ""
# Server-Only
var server: ServerScope = null
func _ready() -> void:
if multiplayer.is_server():
server = ServerScope.new(self)
class ServerScope:
# Network is a singleton and the singleton Ref can't be used as a type
const Network_T = preload("network.gd")
var members: Dictionary = {} # <peer_id: int, Player>
var _network: Network_T = null
func _init(network: Network_T) -> void:
assert(network.is_multiplayer_server())
_network = network
An added advantage of this approach is that it makes it more difficult to accidentally share information that only the server should know. If I attempt to write code that should only run on the server, I’d immediately realize it because the client would crash trying to access the null server scope object.
For instance, in the card game I’m developing, only the server should have knowledge of each player’s deck contents. While cheating isn’t a significant concern for me, I’ve made it a habit to only share with the client the information they need to know just for good practice.
Managing Scene Tree Parity
Godot’s high-level multiplayer relies on the scene tree and requires node paths to be identical on both client and server. Because of this, you need to carefully consider your game’s tree structure when designing your network.
To simplify managing tree parity, I opted to create a Network
singleton.
This singleton houses the server’s state and acts as the communication hub within the multiplayer API.
It contains four main components: server state, services, RPC request functions, and RPC notify functions.
- Server State: I store server state in my
ServerScope
. - Services: Currently, I don’t have any services implemented, but an example could be a ChatService node responsible for handling messaging between players.
- RPC Request Functions: These functions are called by the client but executed remotely on the server.
I typically annotate them with
@rpc("any_peer", "call_local")
. Theany_peer
config allows clients to make remote calls to the server, andcall_local
enables the function to be called on the ‘client-side’ of the server. - RPC Notify Functions: These functions are called by the server but executed remotely on the client.
Since they are only called by the server, I typically prefix them with an underscore to indicate
they’re private. I annoate them with
@rpc("authority", "call_local")
. Theauthority
config ensures only the server (the authority) can call these functions remotely. Thecall_local
config exits for the same reason descriped in the request section.
Do note request and notify are just a convention I use, there is nothing special about these RPC functions.
# network.gd
signal notified_lobby_updated(duelist_names: PackedStringArray, spectator_names: PackedStringArray)
var player_name: String = ""
var server: ServerScope = null
@rpc("any_peer", "call_local")
func request_join_lobby() -> void:
var sender_id := multiplayer.get_remote_sender_id()
server.members[sender_id] = Player.new(sender_id, player_name)
_notify_lobby_updated.rpc(server.get_duelist_names(), server.get_spectator_names())
@rpc("authority", "call_local")
func _notify_lobby_updated(duelist_names: PackedStringArray, spectator_names: PackedStringArray) -> void:
notified_lobby_updated.emit(duelist_names, spectator_names)
So far, this approach has worked well for my project. However, synchronizing the state of a card game is much simpler compared to other types of games. For example, if you’re developing a bat-themed platformer, it might make more sense for the entire game world to be replicated between clients.
Playing With Players Outside Your Network
For development or LAN play, I stick with the built-in ENetMultiplayerPeer
.
However, if you want to play with people outside your local network, you’ll need to port forward.
It’s a bit inconvenient for players, but there are a couple of solutions.
- UPNP: You can use UPNP to automatically forward a port. Keep in mind, though, that it relies on the host’s router supporting it, so it’s not always reliable.
- Relay Server: Another option is to use a relay server. The host still acts as the primary server, but communication is routed through a dedicated relay server hosted by the developer. This eliminates the need for port forwarding on the host’s end. However, I haven’t delved into implementing one myself. Steam provides a relay server that you can utilize using the GodotSteam MultiplayerPeer Build.
Earlier, I mentioned using the ENetMultiplayerPeer
for development and that’s because I find it quicker to test.
With Godot, you can run multiple instances of the game on the same machine as a debug option.
Testing with GodotSteam, on the other hand, requires two separate computers.
Don’t worry, transitioning to GodotSteam later should be smooth since you mainly just need to swap out the peers.
The Steam peer is an extension of ENet. If my project ever gets on Steam,
I’d probably utilize both ENet and Steam peers: one for direct connections and the other for general online multiplayer.