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.
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:
When using RPC functions, the client’s and server’s node paths must be identical.
Since Godot 4, we must define RPC functions in both the client’s and server’s scripts.
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)
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 hopefully it can be useful.
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.