somethinglikegames.de

My personal Blog about Game Development as a single person in part time


Categories

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:

  1. Networking tutorial 1: General information and overview
  2. Networking tutorial 2: The "walking skeleton"
  3. Networking tutorial 3: Login 1 - The game client
  4. Networking tutorial 4: Login 2 - gateway and authentication server
  5. Networking tutorial 5: Login 3 - world server
  6. 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())
One should not be surprised about the relatively high values ~15-20ms for the RTT in local tests. This may sound high at first, since the connection is local, but you have to consider that the RTT not only includes the way there and back, which is omitted locally, but also the processing time on the server.

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.