Der Quellcode zu den Artikeln ist in diesem GitHub Repository zu finden. Die folgende Liste zeigt alle bislang erschienenen Artikel dieser Serie:
- Netzwerktutorial 1: Allgemeines und Übersicht
- Netzwerktutorial 2: Das "gehende Skelett"
- Netzwerktutorial 3: Login 1 - Der Game-Client
- Netzwerktutorial 4: Login 2 - Gateway- und Authentication-Server
- Netzwerktutorial 5: Login 3 - World-Server
- Netzwerktutorial 6: Verschlüsselte Verbindungen mit Hilfe von DTLS
Mit Hilfe des letzten Artikels haben wir nun ein Grundgerüst, bestehend aus drei Servern, die auf den UDP-Ports 1909
-1911
laufen und uns per Konsolenausgabe über Verbindungsaufbau/-abbau von Clients informieren. Dieser Artikel beschäftigt sich mit dem ersten Teil des Loginvorgangs und wir erstellen den Game-Client soweit, dass er eine Verbindung zum Gateway-Server aufbaut und die Logindaten dorthin verschickt. Aber zuvor besprechen wir noch etwas allgemeiner den Loginvorgang und RPCs bei Godot.
Loginvorgang
In der klassischen synchronen Softwareentwicklung würde die geplante Loginsequenz wie in dem nachfolgenden Sequenzdiagramm aussehen. Auch wenn synchrone Programmierung oftmals bequemer für den Entwickler ist, erzielt sie gerade bei rechenintensiven oder latenzbehafteten Aufrufen, also immer wenn ein Netzwerk involviert ist, für den Nutzer schlechte Ergebnisse. Dazu kommt, dass die auf ENet basierende Netzwerkschicht in Godot auf asynchrone Kommunikation ausgelegt ist. Denn es werden auch Broadcasts unterstützt, bei denen der Server bei allen aktuell verbundenen Clients einen Funktionsaufruf, einen sogenannten Remote Procedure Call (RPC), auslösen kann. Wie lange soll dann ein solcher Aufruf dauern? Bis alle, also bis zu 4k, Clients ihr Resultat zurückgeliefert haben? Zumal man zusätzlich bedenken muss, dass bei den Godot-RPCs auch konfiguriert werden kann, dass solche Aufrufe unzuverlässig zugestellt werden können, es also keine Garantie gibt, dass ein Client den Aufruf bekommen hat, oder nicht.

Synchrone Loginsequenz
Aus diesem Grund nehmen wir uns den synchronen Ablauf zum Vorbild und bilden ihn asynchron nach. Das bedeutet, dass die jeweiligen RPCs, wie im Sequenzdiagramm eingezeichnet, stattfinden. Die eingezeichnete “Wartezeit” wiederum kann nun anderweitig genutzt werden. Die GUI des Game-Clients kann z.B. weiterhin auf Nutzereingaben reagieren oder auch eine Fortschrittsanzeige anzeigen und aktualisieren. Der Gateway-Server kann im selben Thread auf weitere Login-Requests reagieren. Entscheidend ist, dass die eingezeichneten Antworten nun auch RPCs sind und der Gateway-Server in der Lage sein muss, die Antwort an den ursprünglichen Aufrufer weiterzuleiten.
Remote Procedure Call (RPC)
Bei RPC-basierter Kommunikation handelt es sich, neben der nachrichtenbasierten Kommunikation, um ein grundlegendes Modell der Netzwerkprogrammierung. Das Schöne dabei ist, dass es für den Entwickler ähnlich wie ein lokaler Funktionsaufruf ist. Bei der von uns verwendeten Netzwerkschicht von Godot ENet sind alle RPC-Aufrufe asynchrone Aufrufe, also generell schon mal Funktionen mit Rückgabewert void
. Wenn man aber nach synchroner Denkweise einen Rückgabewert haben möchte bzw. muss, um weiter “arbeiten” zu können, muss man ebenfalls eine Funktion zur Verfügung stellen, einen sogenannten Callback, die der Kommunikationspartner dann mit dem Ergebnis (synchron: Rückgabewert) per RPC aufruft.
Zwischen Godot 3.x und Godot 4 hat sich einiges bei der Syntax im Netzwerkbereich getan. Ich werde hier zwar nur noch auf die neue Syntax eingehen, aber wer sich die alte Syntax anschauen möchte, wird sicherlich in der alten Dokumentation dazu fündig. Die erste und größte Neuerung ist wohl die neue Annotation @rpc
für alle Funktionen, die via RPC aufgerufen können werden soll.
@rpc("call_remote", "any_peer", "reliable")
func s_login_request(_username: String, _password: String) -> void:
pass
Es sind vier Parameter für @rpc
vorgesehen:
mode
- Legt fest, wer die Funktion aufrufen darf. Mögliche Werte sind
"authority"
(Standard) und"any_peer"
sync
- Bestimmt, ob die Funktion ausschließlich remote aufgerufen wird oder zusätzlich ebenfalls lokal. Mögliche Werte sind
"call_remote"
(Standard) und"call_local"
transfer_mode
- Es gibt insgesamt drei mögliche Modi.
unreliable
(Standard) bedeutet, dass die Funktionsaufrufe ohne weitere Garantien abgesendet werden. Mitunreliable_ordered
kann festgelegt werden, dass immer nur die zuletzt abgeschickte Nachricht ausgewertet wird, wenn also zuerst A und dann B abgesendet werden und B vor A beim Empfänger ankommt, wird ausschließlich B ausgewertet und A verworfen. Kommt A vor B an, so wird natürlich zuerst A ausgewertet und anschließend B. Beireliable
werden Mechanismen etabliert, um die Ankunft der Nachricht/des Funktionsaufrufs zu garantieren (ein wenig wie bei TCP), was zur Folge hat, dass nach jedemreliable
-Versand der Empfänger den Emfang quittieren muss, bevor die nächstereliable
-Nachricht auf diesem Kanal verschickt werden kann. channel
- Bestimmt, auf welchem Kanal eine Nachricht/ein Funktionsaufruf stattfindet. Dabei sind alle Kanäle Teile der gleichen Netzwerkverbindung. Der Standardwert ist
0
, ansonsten sind, soweit ich das momentan absehen kann, alle anderenint
Werte zugelassen. Durch unterschiedliche Kanäle kann man bspw. Latenzen, die durchreliable
Aufrufe entstehen, verringern, indem man unterschiedlichereliable
Aufrufe über unterschiedliche Kanäle laufen lässt. Wie zuvor erklärt, wird ansonsten bei jedemreliable
Funktionsaufruf auf die Quittierung des Empfängers gewartet, bevor der nächstereliable
Funktionsaufruf auf dem Kanal verschickt werden kann. Ein weiterer essentieller Einsatzzweck für unterschiedliche Kanäle entsteht bei der Nutzung vomtransfer_mode
unreliable_ordered
, da sich die Sortierung auf alle Netzwerknachrichten des Kanals bezieht und nicht nur auf gleiche Funktionsaufrufe. Dies hat zur Folge, dass bei 3 abgeschickten unterschiedlichen RPCs mitunreliable_ordered
über den selben Kanal, abhängig von der Empfangsreihenfolge, 1 bis maximal 3 davon ausgeführt und alle nicht ausgeführten verworfen werden, auch wenn jeder Aufruf eine andere Funktion aufrufen sollte.
Die Reihenfolge der ersten drei Parameter ist, wie im Beispiel gezeigt, egal, aber die Angabe des Kanals muss immer als viertes Argument erfolgen.
Mehr Informationen und kleinere Beispiele zu diesem Thema finden sich auch im zugehörigen Blog-Post dieser Syntax-Änderung.
Eine zweite Syntax-Änderung, die mit Godot 4 kommt, ist dass jedes Callable
nun die Funktionen rpc()
bzw. rpc_id()
besitzt. Dadurch lässt sich der RPC direkt auf dem Callable
aufrufen und man benötigt eigentlich keine weitere allgemeingültige Funktion mehr, der der Funktionsname per String übergeben werden muss. Dadurch fallen Umbenennungen von Funktionen schon direkt zur Compile-Zeit auf und nicht erst zur Laufzeit. Der Unterschied zwischen rpc()
und rpc_id()
ist, dass bei rpc_id()
noch die Netzwerk-ID des Empfängers angegeben wird. Die neue Syntax im Einsatz sieht dann z.B. wie folgt aus:
# Neue Syntax:
s_login_request.rpc_id(MultiplayerPeer.TARGET_PEER_SERVER, username, password)
# Alternative/ bisherige Syntax:
rpc_id(MultiplayerPeer.TARGET_PEER_SERVER, "s_login_request", username, password)
Game-Client
Login-GUI (./scenes/gui/login_screen.tscn)

SceneTree der Login-GUI.
Nach einem langen inneren Kampf, ob ich wirklich eine Login-GUI bauen soll, fangen wir nun mit genau einer solchen GUI an. Vorab: Meine Kenntnisse bzgl. GUI-Design, vor allem mit Godot, sind ziemlich beschränkt. Mir geht es bei dieser GUI auch nicht um das schönste oder beste Design, sondern um eine möglichst sinnvolle Funktionalität für dieses Tutorial. Daher kann man in der GUI auch Netzwerk-Adresse und -Port für Gateway- und World-Server einstellen. Wie man am SceneTree
erkennen kann, habe ich versucht, möglichst viel Funktionalität in möglichst wenig Design unterzubringen. Ich hatte nur zwei Kriterien an das Design: Erstens sollen die GUI-Elemente unabhängig von der Fenstergröße zentriert werden. Zweitens sollen Label und Eingabefelder (LineEdit
) ordentlich untereinander stehen. Mit Hilfe von CenterContainer
lässt sich die Anforderung des Zentrierens sehr einfach umsetzen, da dieser Container alle seine Control
Kinder automatisch zentriert. Mein zweites Kriterium habe ich mit einem zweispaltigen GridContainer
gelöst. Damit das wie gewünscht funktioniert, müssen die Kindelemente immer zwischen Label
und Eingabefeld abwechseln. Damit dies auch für die Eingabefelder der Server-Einträge gelingt, habe ich die jeweiligen Textfelder (LineEdit
) für Netzwerk-Adresse und -Port in einem HBoxContainer
zusammengefasst, so passt es dann auch mit der alternierenden Reihenfolge. Um mir den Tippaufwand zu sparen, sind die Textfelder für Gateway- und World-Server schon passend auf 127.0.0.1
und 1910
bzw. 1909
vorbelegt. Die GUI sieht dann wie folgt aus:

Login-GUI
Passend dazu ist auch das zugehörige GDScript sehr einfach gehalten. Es besitzt nur zwei Funktionen:
_on_login_pressed()
, die mit dem Signalpressed
vonlogin_btn
verknüpft istreset()
, die die GUI wieder zurücksetzen soll
1extends Control
2
3@onready var gw_server_addr_txt := $Center/Grid/GatewayServer/Server
4@onready var gw_server_port_txt := $Center/Grid/GatewayServer/Port
5@onready var world_server_addr_txt := $Center/Grid/WorldServer/Server
6@onready var world_server_port_txt := $Center/Grid/WorldServer/Port
7@onready var username_txt := $Center/Grid/Username
8@onready var password_txt := $Center/Grid/Password
9@onready var login_btn := $Center/Grid/Login
10
11var login : Callable
12
13
14func reset() -> void:
15 login_btn.disabled = false
16
17
18func _on_login_pressed() -> void:
19 login_btn.disabled = true
20 login.call(NetworkConnectionData.new(gw_server_addr_txt.text,
21 gw_server_port_txt.text as int),
22 NetworkConnectionData.new(world_server_addr_txt.text,
23 world_server_port_txt.text as int),
24 username_txt.text, password_txt.text)
Die ganze “Magie” dieses Skripts findet innerhalb von _on_login_pressed()
statt. Dort wird zuerst login_btn
deaktiviert (Zeile 19), um ein Mehrfachdrücken zu verhindern. Anschließend wird ein Callable
namens login
mit den Inhalten der Eingabefelder aufgerufen. Was sich genau hinter login
verbirgt, sehen wir uns gleich an. Zuerst habe ich noch eine neue Klasse namens NetworkConnectionData
in ./scripts/classes/network_connection_data.gd
hinzugefügt. Wie man im nachfolgenden Quellcode sehen kann, handelt es sich dabei lediglich um einen einfachen Datencontainer, der Netzwerk-Adresse und -Port bündeln soll und noch eine _to_string()
Funktion besitzt, um bessere Konsolenausgaben zu erzeugen:
1class_name NetworkConnectionData
2
3var address: String
4var port: int
5
6func _init(_address: String, _port: int) -> void:
7 address = _address
8 port = _port
9
10func _to_string() -> String:
11 return "%s:%d" % [address, port]
SceneHandler (./scenes/main.tscn)
Wie im vorigen Artikel bereits kurz erwähnt, möchte ich die Startszene (./scenes/main.tscn
) des Game-Clients als SceneHandler konzipieren. Das heißt, der gesamte Kontrollfluss des Game-Clients wird über diese Szene und das zugehörige GDScript gesteuert. Aus diesem Grund ist dort auch der notwendige Glue-Code zu finden, um die Verdrahtung von Signalen, Callbacks, Abhängigkeiten etc. durchzuführen:
1extends Node
2
3var login_screen := preload("res://scenes/gui/login_screen.tscn")
4
5var gateway_server : Node
6var world_server_data : NetworkConnectionData
7
8
9func _ready() -> void:
10 _load_login_screen()
11
12
13func _load_login_screen() -> void:
14 for c in get_children():
15 c.queue_free()
16 var instance = login_screen.instantiate()
17 instance.login = _login
18 add_child(instance)
Beim Start des Game-Clients wird in _ready()
die Funktion _load_login_screen()
(Zeile 10) aufgerufen. Die Aufgabe dieser Funktion ist es, die zuvor vorgestellte Szene der Login-GUI zu initialisieren und zu laden. Bevor dies aber geschieht, wird “aufgeräumt”. Dafür werden in Zeile 14 alle Kinder der Szene durchlaufen, um sie für die Löschung vorzumerken. Anschließend wird der vorgeladene login_screen
(Zeile 3) instanziiert, der Callback login
wird gesetzt und dann wird die Login-GUI dem aktuellen SceneTree
hinzugefügt (Zeile 18).
21func _login(gw_server: NetworkConnectionData, wrld_server: NetworkConnectionData,
22 username: String, password: String) -> void:
23 world_server_data = wrld_server
24 gateway_server = Node.new()
25 gateway_server.name = "GatewayServer"
26 add_child(gateway_server)
27 gateway_server.set_script(load("res://scenes/network/gateway_server.gd"))
28 get_tree().set_multiplayer(SceneMultiplayer.new(), ^"/root/Main/GatewayServer")
29 gateway_server.gateway_server = gw_server
30 gateway_server.callback = _login_callback
31 gateway_server.login_to_server(username, password)
Wie wir ja bereits im Abschnitt zuvor gesehen haben, wird der übergebene Callback _login()
(Zeile 21-22) mit den Daten aller Eingabefelder der Login-GUI aufgerufen . Die Netzwerkkoordinaten des World-Servers werden in einer lokalen Variable für die spätere Verwendung gespeichert, während alle anderen Informationen direkt weiterverarbeitet werden. Dafür wird ab Zeile 24 ein Node
für den Gateway-Server erzeugt, benannt, dem SceneTree
hinzugefügt und initialisiert. Am Ende wird noch die Funktion login_to_server()
des Gateway-Servers aufgerufen, wodurch dann die Netzwerkverbindung zum Gateway-Server aufgebaut wird, was wir uns im nächsten Abschnitt näher ansehen werden.
34func _login_callback(return_code: int, token: String) -> void:
35 remove_child(gateway_server)
36 if return_code == OK:
37 print("Login was successful")
38 print("Current Token is: %s" % token)
39 # Connect to World Server
40 else :
41 print("Something went wrong…")
42 get_node("LoginScreen").reset()
Die Funktion _login_callback()
(ab Zeile 34) beherbergt den Callback für den Gateway-Server. Dort wird der Node
des Gateway-Servers wieder gelöscht, da er nun nicht mehr benötigt wird. Anschließend wird aktuell im Erfolgsfall eine kleine Nachricht und der erhaltene Token als Konsolenmeldung ausgegeben. Später soll hier dann, mit Hilfe des Tokens, der Kontakt zum World-Server hergestellt werden, aber soweit sind wir noch nicht. Im Fehlerfall wird eine kleine Debug-Nachricht ausgegeben und anschließend die Funktion reset()
der Login-GUI aufgerufen.
Gateway-Server-Client (./scenes/network/gateway_server.gd)
Die Aufgabe des Gateway-Server-Clients ist es, unschwer zu erraten, die Kommunikation mit dem Gateway-Server zu übernehmen. Zum “Leben” erweckt wird er, wie wir bereits besprochen haben, im SceneHandler, wo, nach der Instanziierung und Initialisierung, die Funktion login_to_server()
aufgerufen wird.
1extends Node
2
3var network := ENetMultiplayerPeer.new()
4var gateway_server : NetworkConnectionData
5var username : String
6var password : String
7var callback : Callable
8
9func login_to_server(_username: String, _password: String) -> void:
10 username = _username
11 password = _password
12
13 print("Connecting to %s" % gateway_server.to_string())
14 var ret = network.create_client(gateway_server.address, gateway_server.port)
15 if ret == OK:
16 get_multiplayer().multiplayer_peer = network
17 get_multiplayer().connected_to_server.connect(_on_connection_succeeded)
18 get_multiplayer().connection_failed.connect(_on_connection_failed.bind(FAILED))
19 else:
20 _on_connection_failed(ret)
Um Kontakt mit dem Gateway-Server aufzunehmen wird in Zeile 14 zunächst ein ENet-Client erzeugt. Wenn dies erfolgreich war, werden, ähnlich wie im letzten Tutorial, der ENetMultiplayerPeer
im Multiplayer
-Objekt hinterlegt und die Signale connected_to_server
und connection_failed
verknüpft. Im Fehlerfall wird direkt die Funktion _on_connection_failed()
aufgerufen, wo dann die Fehlerbehandlung getriggert wird.
23func _on_connection_failed(ret: int) -> void:
24 print("Failed to connect to gateway server on %s, errorcode was %d"
25 % [gateway_server.to_string(), ret])
26 callback.call(ret, "")
Die Fehlerbehandlung in _on_connection_failed()
ist sehr minimalistisch gehalten. Es wird eine kurze Konsolenmeldung ausgegeben und anschließend der Callback
, den der SceneHandler hinterlegt hatte, aufgerufen. Diese Funktion wird nicht nur aufgerufen, wenn die Erzeugung des Netzwerk-Clients misslingt, sondern auch, wenn das Signal connection_failed
getriggert wird.
29func _on_connection_succeeded() -> void:
30 print("Succesfully connected to gateway server %s" % [gateway_server.to_string()])
31 print("send login request to gateway server")
32 s_login_request.rpc_id(MultiplayerPeer.TARGET_PEER_SERVER, username, password)
33 username = ""
34 password = ""
35
36
37@rpc("call_remote", "any_peer", "reliable")
38func s_login_request(_username: String, _password: String) -> void:
39 pass # on gateway server
Wenn _on_connection_succeeded()
nach erfolgreichem Verbindungsaufbau zum Gateway-Server durch connected_to_server
aufgerufen wird, findet dort der eigentliche Loginvorgang statt. Dafür wird in Zeile 32 die Funktion s_request_login
als RPC auf dem Server aufgerufen. Als Übergabeparameter steht bei der Funktion rpc_id()
an erster Stelle immer die numerische ID des entfernten Systems. Der Server hat immer die ID 1, welche auch als Konstante MultiplayerPeer.TARGET_PEER_SERVER
verfügbar ist. Alle weiteren Parameter werden an die aufgerufene Funktion übergeben.
Wie man ab Zeile 37 sehen kann, ist die Funktion s_login_request()
auch im Client vorhanden, auch wenn sie ausschließlich auf dem Server aufgerufen wird. Neben identischem Netzwerkpfad und SceneTree
müssen auf Server und Client auch alle Funktionen, die per RPC verfügbar sind, vorhanden sein. Dabei müssen alle @rpc
-Parameter, der Funktionsname und auch die Übergabeparameter (Anzahl und Typ) übereinstimmen, während die Implementierungen hingegen unterschiedlich sein können und sollten. Dem Server kann egal sein, was der Client in seinen Funktionen macht und umgekehrt gilt es erst recht. Man bezeichnet solche funktionslosen Implementierungen auch gerne als Stubs. Um dennoch gut zwischen Server- und Client-Funktionen unterscheiden zu können beginnen bei mir aktuell alle Server-Funktionen mit s_
und alle Client-Funktionen mit c_
. Zusätzlich versuche ich, zusammengehörige Kommunikationsverläufe durch gleichnamige Funktionen, die dann entweder auf _request
oder _response
enden, sichtbar zu machen.
42@rpc("call_remote", "authority", "reliable")
43func c_login_response(result: bool, token: String) -> void:
44 print("login response is %s" % str(result))
45 print("token is '%s'" % token)
46 get_multiplayer().connected_to_server.disconnect(_on_connection_succeeded)
47 multiplayer.set_multiplayer_peer(null)
48 network = null
49 callback.call(OK if result else ERR_INVALID_DATA, token)
Die Funktion c_login_result()
(Zeile 43) wird vom Gateway-Server als Antwort auf s_login_request()
aufgerufen. Nach der Ausgabe von Debug-Konsolenmeldungen wird die Verbindung zum Server getrennt und der Callback
mit dem Ergebnis aufgerufen. Ab da verläuft es dann, wie zuvor im SceneHandler gezeigt, weiter. Im Fehlerfall wird der Login-Button wieder freigeschaltet und im Erfolgsfall gibt es weitere Konsolenausgaben, da ich die Kommunikation mit dem World-Server für ein späteres Tutorial geplant habe.
Der nächste Schritt
Im nächsten Artikel wird es um den nächsten Schritt gehen: Der Gateway-Server nimmt den Login-Request des Game-Clients entgegen und leitet diesen an den Authentication-Server weiter, welcher die Logindaten verifiziert und im Erfolgsfall ein Token erstellt. Das Resultat wird dann über den Gateway-Server zurück an den Game-Client gesendet.