The source code to the articles can be found in this GitHub repository. The following list shows all articles of this series published so far:
- Networking tutorial 1: General information and overview
- Networking tutorial 2: The "walking skeleton"
- Networking tutorial 3: Login 1 - The game client
- Networking tutorial 4: Login 2 - gateway and authentication server
- Networking tutorial 5: Login 3 - world server
- Networking tutorial 6: Encrypted connections using DTLS
In the last article we finished implementing the gateway and authentication server, so that the game client is issued a token for valid credentials. This token should now be used to log in to the world server. Therefore, in this article, we will extend the game client so that it forwards the token to the world server, and then enable the world server to verify the token.
Extension of the game client
So far the login process in the game client ends in the following function of main.gd
:
33func _login_callback(return_code: int, token: String) -> void:
34 gateway_server.queue_free()
35 if return_code == OK:
36 print("Login was successful")
37 print("Current Token is: %s" % token)
38 # Connect to World Server
39 else :
40 print("Something went wrong…")
41 get_node("LoginScreen").reset()
We already have minimal error handling so that the interface resets if something fails on login (line 41). But in case of success we currently end up with the once well-intentioned comment in line 38, so we will now make all preparations to replace the comment with real functionality. First we create a new script called world_server.gd
below scenes/network
.
World server network client (./scenes/network/world_server.gd)
The basic framework that follows should be mostly known from the client for the gateway server. Probably one should extract the similarities once into a superclass, but that would make it more unclear at this point. The function connect_to_server()
(line 9-25) tries to establish a connection to the world server. Server address and port are taken from a NetworkConnectionData
object, which we must set beforehand. The considered error cases (lines 18 and 20) are handled as usual using _on_connection_failed()
. To continue in our login process in case of success, we connect the connected_to_server
signal from our MultiplayerAPI
object to the _on_connection_succeeded()
function (line 17).
1extends Node
2
3var network := ENetMultiplayerPeer.new()
4var world_server : NetworkConnectionData
5var token : String
6var callback : Callable
7
8
9func connect_to_server(_token: String, _callback: Callable) -> void:
10 token = _token
11 callback = _callback
12
13 print("Connecting to %s" % world_server.to_string())
14 var ret = network.create_client(world_server.address, world_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)
21
22
23func _on_connection_failed(ret: int) -> void:
24 print("Failed to connect to world server on %s, errorcode was %d"
25 % [world_server.to_string(), ret])
26 callback.call(ret, "")
Once the connection to the server is established, the function _on_connection_succeeded()
is called. There we then pass the token to the world server (line 32). We get the result of the token-based login as a parameter of the function c_login_response()
. There we remove the link to the signal connected_to_server
, since we are now no longer in possession of a token. Afterwards the passed callback is called so that the actual program flow in the game client can continue.
29func _on_connection_succeeded() -> void:
30 print("Succesfully connected to world server %s" % [world_server.to_string()])
31 print("send login request to world server")
32 s_login_request.rpc_id(MultiplayerPeer.TARGET_PEER_SERVER, token)
33 token = ""
34
35
36@rpc("call_remote", "any_peer", "reliable")
37func s_login_request(_token: String) -> void:
38 pass # on game server
39
40
41@rpc("call_remote", "authority", "reliable")
42func c_login_response(result: bool) -> void:
43 print("login request result is %s" % str(result))
44 get_multiplayer().connected_to_server.disconnect(_on_connection_succeeded)
45 callback.call(OK if result else ERR_INVALID_DATA)
Main scene (./scenes/main.gd)
Now we have prepared our network client for the world server so far that we can integrate it now also into the actual program flow. Therefore the previous function _login_callback()
is extended in such a way that it now uses the just created network client to transmit the token to the world server. To make the code fragment shown below work, I added a new script variable called world_server
of type node
, similar to the already existing variable gateway_server
.
34func _login_callback(return_code: int, token: String) -> void:
35 gateway_server.queue_free()
36 gateway_server = null
37 if return_code == OK:
38 print("Current Token is: %s" % token)
39 world_server = Node.new()
40 world_server.name = "WorldServer"
41 add_child(world_server)
42 world_server.set_script(load("res://scenes/network/world_server.gd"))
43 get_tree().set_multiplayer(SceneMultiplayer.new(), ^"/root/Main/WorldServer")
44 world_server.world_server = world_server_data
45 world_server.connect_to_server(token, _world_server_callback)
46 else :
47 print("Something went wrong…")
48 get_node("LoginScreen").reset()
The newly added source code (lines 39-45) binds the network client of the world server into the SceneTree
to subsequently call the function login_to_server()
with the token there. For this I copied the code already existing from Tutorial 3 for the gateway server client and adapted it to the world server. A new function _world_server_callback
was passed as callback, which looks like this for now:
52func _world_server_callback(return_code: int) -> void:
53 if return_code == OK:
54 print("Login to world server was successful")
55 # Add further game logic
56 else:
57 gateway_server.queue_free()
58 gateway_server = null
59 print("Login to world server failed")
60 get_node("LoginScreen").reset()
The error case leads, as before, to the reset of the interface, so that a new login attempt can be made, and in case of success we end now for the time being with a new comment 😉 in line 55.
Extension of the world server
Even if the game client is now prepared to log in to the world server, the world server is far from it. It only consists of the server-base and does not know anything about a login, let alone token validation. Therefore, the first step is to install the GDScript JWT add-on, similar to the last tutorial on the authentication server. To do this, we first download the latest release for Godot 4 and unzip the addons folder into our Godot project on the World server. Afterwards we have to activate the addon in the project settings -> plugins
. After that we copy the public key from the authentication server from the last tutorial into a new folder crypto
below the world server project directory.
World server (./scenes/network/world_server.gd)
The preparations for validating JWT tokens are similar to those for generating such tokens. First we create a script variable of type JWTAlgorithm
, then initialize it inside _ready()
. An essential difference is that we only need the public key for the validation and therefore have to specify this as second parameter when loading the key (line 10). We store this key in our JWTAlgorithm
variable as _public_crypto
and of course set JWTAlgorithm.Type.RSA256
as algorithm identical to the authentication server.
6var jwt_algorithm: JWTAlgorithm
7
8func _ready() -> void:
9 var public_key := CryptoKey.new()
10 var load_ret := public_key.load("res://crypto/jwt_rsa.pem", true)
11 if load_ret == OK:
12 jwt_algorithm = JWTAlgorithm.new()
13 jwt_algorithm._public_crypto = public_key
14 jwt_algorithm._alg = JWTAlgorithm.Type.RSA256
15 else:
16 print("Error while reading RSA public key: %d" % load_ret)
17 get_tree().quit(load_ret)
The verification of the token takes place within s_login_request
. For this we create a JWTDecoder
with the help of the token (line 45) and then verify the signature with the help of the JWTAlgorithm
initialized in _ready()
(line 46). After that we make sure that the token is still valid (line 47). If the token both has a valid signature and is still valid, we send a positive result back to the client. If one of the tests is negative, we first send a negative response to the client and then disconnect.
41@rpc("call_remote", "any_peer", "reliable")
42func s_login_request(token: String) -> void:
43 var now := int(Time.get_unix_time_from_system())
44 var user_id := get_multiplayer().get_remote_sender_id()
45 var jwt := JWTDecoder.new(token)
46 var is_signature_valid := jwt_algorithm.verify(jwt)
47 var is_unexpired = now <= jwt.get_expires_at()
48 if is_signature_valid and is_unexpired:
49 c_login_response.rpc_id(user_id, true)
50 else:
51 c_login_response.rpc_id(user_id, false)
52 disconnect_player(user_id)
53
54
55@rpc("call_remote", "authority", "reliable")
56func c_login_response(_result: bool) -> void:
57 pass # on game client
58
59
60func disconnect_player(user_id: int) -> void:
61 get_multiplayer().disconnect_peer(user_id)
Now (finally) our login process is complete and our game client has established an authenticated connection to the world server without the need for the world server to have passwords.
Obtaining connection information
To conclude this tutorial I would like to talk about another new feature of Godot 4: “Lower-level ENet”. ENet also has some interesting features besides just network communication that can be very useful. For example, ENet actually has a direct way to query the “round-trip time” (RTT) of a connection. Only until Godot 4, ENet was encapsulated in such a way that you couldn’t access this information within GDScript. So I’m pleased to report that, among the incredible number of other changes in Godot 4, this has also been changed. Thus, wrappers now exist with ENetConnection
and ENetPacketPeer
that provide access to the actual ENet functionality. Fabio Alessandrelli (Faless) has also written a worth reading Blogpost as Progress Report on the Godot site.
To test these new possibilities, it might be a good idea to extend the game client so that it regularly outputs the RTT towards the world server. For this we first extend ./scenes/network/world_server.gd
with a new script variable, where we store our ENetPacketPeer
object:
4var peer : ENetPacketPeer = null
Since the game client as a network client is only connected to the world server, it only knows one peer. Therefore, in my humble opinion, the easiest way to get the appropriate object is to get access to the associated ENetConnection
via our ENetMultiplayerPeer
object, where we then grab the first (and only) ENetPacketPeer
(line 46).
41@rpc("call_remote", "authority", "reliable")
42func c_login_response(result: bool) -> void:
43 print("login request result is %s" % str(result))
44 get_multiplayer().connected_to_server.disconnect(_on_connection_succeeded)
45 if result:
46 peer = network.get_host().get_peers()[0]
47 callback.call(OK if result else ERR_INVALID_DATA)
Thus, we can subsequently call get_statistic()
at any time on our network connection and provide, for example, a function to query the average RTT:
50func get_rtt() -> float:
51 if peer:
52 return peer.get_statistic(ENetPacketPeer.PEER_ROUND_TRIP_TIME)
53 else:
54 return -1.0
Now we just have to extend our main scene script ./scenes/main.gd
a bit. First we add a variable for a timer:
8var rtt_timer : Timer
And then we extend _world_server_callback()
. If we managed a successful connection to the world server, we create a new timer, add it to the SceneTree
, link the timeout
signal of the timer and start it with an interval of 5 seconds. In the function that is now called by the timeout
signal of the timer, we query our RTT.
54func _world_server_callback(return_code: int) -> void:
55 if return_code == OK:
56 print("Login to world server was successful")
57 rtt_timer = Timer.new()
58 add_child(rtt_timer)
59 rtt_timer.timeout.connect(_on_rtt_timer_timeout)
60 rtt_timer.start(5)
61 # Add further game logic
62 else:
63 gateway_server.queue_free()
64 gateway_server = null
65 print("Login to world server failed")
66 get_node("LoginScreen").reset()
67
68
69func _on_rtt_timer_timeout() -> void:
70 if world_server:
71 print("RTT: %f" % world_server.get_rtt())
The next step
Before we start spawning player characters or actively exchanging messages between the game client and the world server, which are all scheduled topics for this tutorial series, we will deal with DTLS in the next tutorial. So we will take care of encrypting the network connections between all communication partners in order to protect passwords sent by the game client on the one hand and to make “hacking” by e.g. cheat software more difficult on the other hand.