The right way to make a multiplayer game with a dedicated server in Godot.

Godot Version

4.5.1

Context

I am trying to create an online card game. In my game, a player is supposed to know only their own cards, so I don’t want to pass any information about opponents’ cards to the clients. You may ask: why can’t a player’s client know the opponents’ cards? Couldn’t it just store them without displaying? The answer is simple: cheating. If the opponents’ cards are stored on a player’s computer in any way, there will be a possibility to extract that data and use it for an unfair advantage. Considering Godot’s open-source nature, I think this would be extremely easy for cheaters.

Therefore, I want only the dedicated server to know all players’ cards, eliminating this type of cheating.

Question

There aren’t many guides on how to make a dedicated server, especially for Godot 4. However, the ones I found (“Godot dedicated server tutorial” and the “Godot Dedicated Server” playlist) provide enough information to replicate this in Godot 4.

The real problem is: it feels wrong to code a game this way.

I’ll quote one of the Godot developers (source):

The system was designed from the beginning with the idea of client and server running the same scripts.

This design decision leads to some pitfalls with RPC functions:

  1. When using RPC functions, the client’s and server’s node paths must be identical.
  2. Since Godot 4, we must define RPC functions in both the client’s and server’s scripts.
  3. It’s inconvenient to manipulate the client’s data from the server using RPC functions when they have different internal structures. If anything changes in the client’s structure, the entire client-server communication may break.

I’m not trying to say the design decision mentioned above was wrong; I think it was made for peer-to-peer setups, for which it is well-suited. But when it comes to creating a dedicated server, it feels like using a screwdriver as a chisel.

So, my question is: am I understanding or doing something wrong? What is the right way to make a multiplayer game with a dedicated server in Godot?

The only limitation that rpc has is that the NodePaths and the number of arguments of the function have to be the same. Anything else can be different.

They can be different types, different scenes,… as long as the NodePath and the number of arguments of the function is the same it will work.

For example, the following script creates a server (Server) and multiple clients (Client). The server keeps track of multiple lobbies and each client joins one at random when it connects. The lobbies in the server keep track of all the client data (LobbyServer) while the lobby in the client (LobbyClient) only set the client data to its own client and not the others. Notice that LobbyClient is a Node2D while LobbyServer isn’t one.

Both Server and Client have different code for the rpc functions.

extends Node


const PORT = 8909


@onready var mode_label: Label = $ModeLabel


func _ready() -> void:
	if OS.has_feature("server"):
		get_window().position.x -= ceil(get_window().size.x / 2.0 + 4)
		add_child(Server.new())
		mode_label.text = "SERVER"
	else:
		get_window().position.x += ceil(get_window().size.x / 2.0 + 4)
		await get_tree().create_timer(1).timeout
		add_child(Client.new())
		mode_label.text = "CLIENT"


class Server extends Node:


	var peer: ENetMultiplayerPeer
	var lobbies: Dictionary


	func _ready() -> void:
		name = "SERVER"
		multiplayer.root_path = get_path()

		multiplayer.peer_connected.connect(_on_peer_connected)
		multiplayer.peer_disconnected.connect(_on_peer_disconnected)

		peer = ENetMultiplayerPeer.new()
		peer.create_server(PORT)
		multiplayer.multiplayer_peer = peer
		print("Server started")

		for i in 2:
			_create_lobby()


	func _create_lobby() -> void:
		var lobby = LobbyServer.new()
		var id = ResourceUID.create_id()
		lobby.name = "Lobby #%s" % id
		add_child(lobby, true)
		lobbies[id] = lobby


	@rpc
	func send_lobbies(_a) -> void:
		pass


	@rpc("any_peer")
	func join_lobby(lobby_id: int) -> void:
		print("peer %s joined lobby %s" % [multiplayer.get_remote_sender_id(), lobby_id])
		var client_id = multiplayer.get_remote_sender_id()
		var lobby = lobbies[lobby_id] as Node
		# spawn locally in server
		lobby.spawn(client_id)

		# sync with other peers
		for child in lobby.get_children():
			var other_peer = child.get_meta("peer_id")
			if client_id == other_peer:
				lobby.spawn.rpc_id(client_id, client_id, lobby.get_data(client_id))
			else:
				lobby.spawn.rpc_id(other_peer, client_id)
				lobby.spawn.rpc_id(client_id, other_peer)


	func _on_peer_connected(id: int) -> void:
		print("Peer connected %s" % id)
		send_lobbies.rpc_id(id, lobbies.keys())


	func _on_peer_disconnected(id: int) -> void:
		print("Peer disconnected %s" % id)


class Client extends Node:


	var peer: ENetMultiplayerPeer


	func _ready() -> void:
		name = "CLIENT"
		multiplayer.root_path = get_path()

		multiplayer.connected_to_server.connect(_on_connected_to_server)
		multiplayer.connection_failed.connect(_on_connection_failed)
		multiplayer.server_disconnected.connect(_on_server_disconnected)

		peer = ENetMultiplayerPeer.new()
		peer.create_client("localhost", PORT)
		multiplayer.multiplayer_peer = peer


	@rpc("any_peer")
	func send_lobbies(lobbies: Array) -> void:
		randomize()
		var chosen = lobbies.pick_random()
		join_lobby.rpc(chosen)


	@rpc("any_peer", "call_local")
	func join_lobby(lobby_id: int) -> void:
		if multiplayer.get_remote_sender_id() == multiplayer.get_unique_id():
			var lobby = LobbyClient.new()
			lobby.name = "Lobby #%s" % lobby_id
			#print("Client adding %s" % lobby.name)
			add_child(lobby, true)


	func _on_connected_to_server() -> void:
		#print("Connected to server")
		pass


	func _on_connection_failed() -> void:
		print("Connection failed")


	func _on_server_disconnected() -> void:
		print("Server disconnected")


class LobbyServer extends Node:


	var client_data: Dictionary


	func get_data(peer_id: int) -> int:
		return client_data[peer_id]


	@rpc("authority", "call_remote", "reliable")
	func spawn(peer_id: int, _data: int = -1) -> void:
		var client = Node.new()
		client.name = "Client #%s" % peer_id
		client.set_meta("peer_id", peer_id)
		client_data[peer_id] = randi()
		client.set_meta("data", client_data[peer_id])
		add_child(client, true)


class LobbyClient extends Node2D:


	@rpc("authority", "call_remote", "reliable")
	func spawn(peer_id: int, data: int = -1) -> void:
		var client = Node.new()
		client.name = "Client #%s" % peer_id
		if data > -1:
			client.set_meta("data", data)
		add_child(client, true)

This is just an example with one script but you can still use the same structure having different scripts for server and clients. You can then export for server and for client filtering which resources you want to add to each one or making a plugin and using an EditorExportPlugin to have more control over what you want to export or not.

1 Like

Thank you for your reply.

At first, I thought the provided script wouldn’t work because the SERVER and CLIENT nodes call each other’s rpc functions, while they, as nodes, have different NodePath-s. In case anyone else is also confused, what matters is the NodePath of the node with the attached script where those rpc calls “reside,” just as stated in the documentation.

If an RPC resides in a script attached to /root/Main/Node1, then it must reside in precisely the same path and node on both the client script and the server script.

In this case, everything works because all rpc calls “reside” in a script attached to the same root node.

However, I see a potential problem here: having just one script with all rpc calls can lead to numerous navigation issues, causing cognitive overload. So, at some point, there will be a need to separate the logic.

But if I have different scripts, I will need to ensure the nodes they are attached to have the same NodePath. I won’t be able to create a node named Server with an attached script that is supposed to call an rpc function on a Client node which has a script implementing that rpc. Yet, that is what I instinctively want to do; my intuition tells me this is the clear way to proceed.

What approach should I choose then? Do I just need to stick to having one root node with the same name everywhere and just attach different scripts to it?

Sorry, I forgot to explain why it works like this. I’m using SceneMultiplayer.root_path to change the root node and be able to have a “server” and “clients” running in the same codebase. It has nothing to do with the script having everything.

NodePaths don’t care about scripts, they are only paths to a node. I made it everything into one script for ease of sharing but It will work just fine having each script in a different file.

The only thing that needs to be the same is the NodePath, the function marked as a rpc, and the function argument number. The scripts can be different. The content of the function can be different. The rpc configuration can be different.

Here’s the same project with different scripts and different executables exporting only the needed files:

Sorry about the video quality :sweat_smile: hopefully it can be useful.

2 Likes

Okay, now I see what those lines with root_path were about.

Actually, I had a little test project (just a simple chat) and was terrified by the way I wrote it, so I sought help here. Now I’ve rewritten it using that workaround, and I’m a lot more satisfied with what I’ve got.

It feels a little strange though that this technique isn’t mentioned anywhere I checked. Well, at least now it is mentioned here.

Thanks a lot for your help and great example, mrcdk. It might be my first time asking questions here, but it is definitely not the first time your responses on this forum have helped me. :+1:

2 Likes