Part 1 - Fixes and Different Synchronization
June 13, 2019

Attention: This tutorial was written for Godot 3. Not only the information here contains several flaws, networking has changed significantly in Godot 4.

As mentioned in the introduction, there were a few problems left in the previous tutorial, which we have to fix. First, from the point of client's view, we have to better handle what happens when the server is closed. We will do that. Then we quickly see the deprecated features that were used and what is the new way to do things. And then we "finish" one aspect of the "main menu", where the icon should reflect the chosen color.

Then we see an alternative way to perform the synchronization in order to close some doors to cheaters.

The Fixes

The very first thing that we will fix here is the way the client handles the disconnection. Right now, when it gets disconnected from the server not only it will look frozen, a lot of errors will be thrown into the output panel. That's something that I completely forgot to mention in the previous tutorial! Luckily the fix is not that complicated. The thing is, if you take a look into our current code, more specifically the _on_disconnected_from_server() function in the network.gd file we basically clear the internal player list and reset the cached network id. That's all that is being done.

The problem is that probably the client is in the game world scene, with a bunch of things that are controlled by the server. We will do the simplest thing here, which is to transition back into the main menu. However, in the end, a message box must be given to the player, so he/she knows that something has happened to the connection.

The network code knows about the disconnection event but it is not its task to control which scene must be active. So, we will emit a signal so other code can act accordingly. First, we declare it:

network.gd
signal server_created                          # when server is successfully created
signal join_success                            # When the peer successfully joins a server
signal join_fail                               # Failed to join a server
signal player_list_changed                     # List of players has been changed
signal player_removed(pinfo)                   # A player has been removed from the list
signal disconnected                            # So outside code can act to disconnections from the server

We now have to update the previously mentioned event handler to emit this new signal. There are two other things that we will do in there. One of them is pause the tree, which will effectively stop any node from being processed. This will give the opportunity for the client to remain responsive. If you add a message box, make sure to enable processing for that node even on pause state. The other thing that will be done is actually an oversight from the previous tutorial. We have to cleanup the network object. The updated function:

network.gd
func _on_disconnected_from_server():
	print("Disconnected from server")
	# Stop processing any node in the world, so the client remains responsive
	get_tree().paused = true
	# Clear the network object
	get_tree().set_network_peer(null)
	# Allow outside code to know about the disconnection
	emit_signal("disconnected")
	# Clear the internal player list
	players.clear()
	# Reset the player info network ID
	gamestate.player_info.net_id = 1

Next, in the game world we have to handle this event and from it change the scene back into the main menu. Again, in the end a message box is almost necessary in order to inform the player about the disconnection! Anyway, first we have to connect a function to the new signal:

game_world.gd
func _ready():
	# Connect event handler to the player_list_changed signal
	network.connect("player_list_changed", self, "_on_player_list_changed")
	# Must act if disconnected from the server
	network.connect("disconnected", self, "_on_disconnected")
	# If we are in the server, connect to the event that will deal with player despawning
	if (get_tree().is_network_server()):
		network.connect("player_removed", self, "_on_player_removed")
   
   ... # Previous code

Sure we need to implement the new function. It should be really simple as all we will do is change to the main menu scene:

game_world.gd
func _on_disconnected():
	# Ideally pause the internal simulation and display a message box here.
	# From the answer in the message box change back into the main menu scene
	get_tree().change_scene("res://main_menu.tscn")

Once in the main menu we have to ensure the game is not paused! So, in the _ready() function we add a new line to make sure of that:

main_menu.gd
func _ready():
	network.connect("server_created", self, "_on_ready_to_play")
	network.connect("join_success", self, "_on_ready_to_play")
	network.connect("join_fail", self, "_on_join_fail")
	get_tree().paused = false

And that's it. If the client is disconnected from the server it will not look frozen and the amount of errors thrown in the output panel should be smaller!

Now to the next fix, which should be really fast because all we will do is change a few "keywords". To be more specific, the slave keyword is deprecated and should be replaced by puppet. In other words, in the player.gd file, the line

slave var repl_position = Vector2()

becomes

puppet var repl_position = Vector2()

And in the bot.gd the lines

slave var repl_position = Vector2()
slave var repl_rotation = 0.0

becomes

puppet var repl_position = Vector2()
puppet var repl_rotation = 0.0

Finally, we work on the last problem that was meant to be dealt with in the previous tutorial. In the main menu we have added an icon bellow the box requesting the player name. On the right side of this icon there are two buttons, one meant to set the avatar modulation color and the other to reset to the default. The idea was to update the icon modulation in the main menu, which was not done. And, of course, make the Default button actually reset the color, which is not happening now. To fix that we have connect a function to the color_changed signal of the btColor button and another function to the pressed signal of the btDefaultColor.

In the case of the color_changed function all we do is take the received color and apply it to the PlayerIcon.modulate property. As for the pressed function we have to reset the color of the color picket to all white as well as the modulate of the icon. The functions:

main_menu.gd
func _on_btColor_color_changed(color):
	$PanelPlayer/PlayerIcon.modulate = color


func _on_btDefaultColor_pressed():
	$PanelPlayer/PlayerIcon.modulate = Color(1, 1, 1)
	$PanelPlayer/btColor.color = Color(1, 1, 1)

Alternate Synchronization

First let's take a look at our current synchronization code (with some simplifications on the snippet) and analyze it:

player.gd
func _process(delta):
	if (is_network_master()):
		# Initialize the movement vector
		var move_dir = Vector2(0, 0)
		
		# Poll the actions keys
		... # calculate the move_dir based on the input keys
		
		# Apply the movement formula to obtain the new actor position
		position += move_dir.normalized() * move_speed * delta
		
		# Replicate the position
		rset("repl_position", position)
	else:
		# Take replicated variables to set current actor state
		position = repl_position

The best thing about this system is in how simple and easy it is to implement! Another thing is that once the player input is processed, the avatar's position is immediately calculated and updated within the local machine which gives the fastest response to the player. But it also has a major flaw, which comes from the rset() function. Not the function itself, mind you, but where it's being called.

Why is that a problem? Suppose someone discovers this and then tamper the game in a way to give different position values in order to gain advantages over the other players. How would the server detect such tampering? Because the server has no control over the calculations, it becomes really hard to combat cheating.

Basically we want to move the game state calculation to the server, while the clients will mostly deal with the rendering. This is what is called authoritative server. With that in mind we can think about moving the position calculation to the server and then it broadcasts the result back to everyone, including the player controlling that avatar. In other words, the following two lines would have to be moved to the server:

player.gd
# Apply the movement formula to obtain the new actor position
position += move_dir.normalized() * move_speed * delta

# Replicate the position
rset("repl_position", position)

Well, almost like that. The thing is, what is the move_dir from the server's point of view anyway? That information must come from the client! Basically, when there is some input in the client, it must then tell the server, something like "hey, I have some input could you please give me my new position?".

And how about the delta time? Where does that come from? In our current code we are using something that does not have a fixed value between loop iterations. Not only that, server and clients will also have differing values which will certainly lead to problems. The obvious thing we can't do is use the delta from the client. That happens because a cheater can manipulate tha value to get faster (or slower) movements than intended. Using the server's delta is less problematic, but also not free of problems.

Let's first work on a bare-bones, very basic, authoritative server structure, for the moment ignoring the delta, then analyze the issues and come up with a way to better synchronize the game. As mentioned, what we want is to move the simulation calculation into the server and the client deal mostly with the rendering. With that said, let's first create a remote function meant to run only on the server, which will be called by clients in order to request calculations based on input. For the moment this input will be sent as the move_dir vector and we will ignore the delta:

player.gd
remote func server_get_player_input(input):
	if (get_tree().is_network_server()):
		position += input.normalized() * move_speed

Next we have to change how the synchronization updates are done. Currently we are using rset(), which is meant to broadcast class property changes across connected peers. However that can only be done by the network master, which is no longer the case for the server. OK, we could change the master in the spawning function, but that would also mean we have to incorporate other means to know who controls that actor in order to properly gather the input.

We will actually get rid of the repl_position variable and perform the position update through a new remote function, which we will call client_get_player_update(). Since all we are synchronizing for the player's actors is their position, this new function becomes rather simple:

player.gd
remote func client_get_player_update(pos):
	position = pos

Finally we rework the _process() function in order to use those new remote functions. The new code still checks if it's the network master. In that case we poll the input keys like before. Then, if there is any input we call the server_get_player_input() function. There is an special case here. If it's the network master and the server, we have to directly call that function, without the rpc(). Otherwise, we use the rpc_id() directing the call to the server, since that function is meant to be run there. Finally, regardless if being network master or not, being on the server means that the position has to be replicated, by calling the client_get_player_update() function. We will use the unreliable protocol, specially because we are dealing with multiple packets per second here. Loosing some of them here and there will not be a problem. The new function's code looks like this:

player.gd
func _process(delta):
	if (is_network_master()):
		# Initialize the movement vector
		var move_dir = Vector2(0, 0)
		
		# Poll the actions keys
		if (Input.is_action_pressed("move_up")):
			# Negative Y will move the actor UP on the screen
			move_dir.y -= 1
		if (Input.is_action_pressed("move_down")):
			# Positive Y will move the actor DOWN on the screen
			move_dir.y += 1
		if (Input.is_action_pressed("move_left")):
			# Negative X will move the actor LEFT on the screen
			move_dir.x -= 1
		if (Input.is_action_pressed("move_right")):
			# Positive X will move the actor RIGHT on the screen
			move_dir.x += 1
		
		# Check if there is any (meaningful) input
		if (move_dir.x != 0 || move_dir.y != 0):
			# There is some input. If on the server, just update the position
			if (get_tree().is_network_server()):
				server_get_player_input(move_dir)
			# Otherwise, request the server to calculate the new position
			else:
				rpc_id(1, "server_get_player_input", move_dir)
	
	# Regardless if this is the master or not, being on the server means: replicate the actor state
	if (get_tree().is_network_server()):
		# Replicate the position, using the unreliable protocol
		rpc_unreliable("client_get_player_update", position)

There are a lot of problems now, although the game is synchronized! First, because we did eliminate the delta time from the motion formula, the actors are probably moving way too fast, to the point of disappearing from the screen. Temporarily you can reduce the move_speed from the default 300 used in the tutorial to 5. By doing so, the behavior should be almost the same as before, besides the possible movement speed difference.

While we did achieve a synchronized game with (bare-bones) authoritative server, it also brought a multitude of problems. I will mention some of them in no particular order.

As you can see, dealing with authoritative servers is not an easy task! Unfortunately we have to do this in order to reduce the places where cheaters could exploit. In here (this current tutorial part) we will quickly deal with the first 3 problems in the list, mostly because they are somewhat interconnected and will serve as a preparation to the next bullets, which will be dealt with only in part 3. Then, before moving to the next part we will revisit the disconnection handling.

So we will indeed use a delta time to simulate the game, but a fixed value one. With this we can ensure the same "frame step" across the connected peers which will greatly help with the synchronization. We will still incorporate the delta in the formula because that gives us the opportunity to tweak the update rate without having to also change other values.

There are some implementations where there is also a separate rate (from the update rate) in which input is gathered and sent to the server. Doing so is really nice in order to get some granularity when tweaking values, but it also brings a lot of complexity when calculating the simulation and bringing an smooth animation. In order to keep things simple we will gather input (and send to the server) at the same rate the game is updated.

By default our system will deal with 30 simulation updates per second. So let's declare our variables meant to deal with this rate in the gamestate.gd class. Associated with the rate there is also a variable that will hold the fixed delta time. Because one depends on the other, we will use the setget feature to automatically calculate the delta when the rate is set. Because we don't want to manually set the delta (which should be computed from the rate), we assign an "empty" function for the setter of that variable:

gamestate.gd
var update_rate = 30 setget set_update_rate                          # How many game updates per second
var update_delta = 1.0 / update_rate setget no_set, get_update_delta # And the delta time for the specified update rate

Within set_update_rate() we automatically update the update_delta:

gamestate.gd
func set_update_rate(r):
	update_rate = r
	update_delta = 1.0 / update_rate

func get_update_delta():
	return update_delta

func no_set(r):
	pass

With this code, if we use gamestate.update_delta = some_value then nothing will happen, which is desireable. Now we have to use those to control the pace in which the input data is sent to the server as well as the result of the calculation. First we will move the input polling into a separate function, which should make the logic in the _process() function slightly more clear (and will help us later when improving the system):

player.gd
func gather_input():
	# Initialize the movement vector
	var move_dir = Vector2(0, 0)
	
	# Poll the actions keys
	if (Input.is_action_pressed("move_up")):
		# Negative Y will move the actor UP on the screen
		move_dir.y -= 1
	if (Input.is_action_pressed("move_down")):
		# Positive Y will move the actor DOWN on the screen
		move_dir.y += 1
	if (Input.is_action_pressed("move_left")):
		# Negative X will move the actor LEFT on the screen
		move_dir.x -= 1
	if (Input.is_action_pressed("move_right")):
		# Positive X will move the actor RIGHT on the screen
		move_dir.x += 1
	
	return move_dir

Now we will do our own time counting, within the _process() function. Although we do have the fixed time step function given by Godot we will use our own counting in order to obtain some flexibility related to the update_rate changes. Depending on how we do things, we will be able to change this value while the game is running. For our time counting we will use a new variable:

player.gd
var move_speed = 5
onready var current_time = 0         # Used to count input timeouts

Next we update the _process() function. The first thing to keep in mind is the fact that gamestate.update_delta gives us how many seconds we have to wait before gathering new input data and sending to the server. So, once in the function, we update the current_time and check if it's smaller than the delta. If so, then we are outside of the "input window" and just bail from the function. Otherwise we proceed, first by "resetting" current_time variable. In here instead of setting it to 0 we subtract update_delta from it in order to try to compensate for some inaccuracies in the time counting. Then the rest of the function is almost the same, with the biggest difference from the fact that move_dir receives the value returned by the gather_input() function that we have created early:

player.gd
func _process(delta):
	# Update the timeout counter and if "outside of the update window", bail
	current_time += delta
	if (current_time < gamestate.update_delta):
		return
	
	# Inside an "input" window. First "reset" the time counting variable.
	# Rather than just resetting to 0, subtract update_delta from it to try to compensate
	# for some variances in the time counting. Ideally it would be a good idea to check if the
	# current_time is still bigger than update_delta after this subtraction which would indicate
	# some major lag in the game
	current_time -= gamestate.update_delta
	
	if (is_network_master()):
		# Initialize the movement vector
		var move_dir = gather_input()
		
		# Check if there is any (meaningful) input
		if (move_dir.x != 0 || move_dir.y != 0):
			# There is some input. If on the server, just update the position
			if (get_tree().is_network_server()):
				server_get_player_input(move_dir)
			# Otherwise, request the server to calculate the new position
			else:
				rpc_id(1, "server_get_player_input", move_dir)
	
	# Regardless if this is the master or not, being on the server means: replicate the actor state
	if (get_tree().is_network_server()):
		# Replicate the position, using the unreliable protocol
		rpc_unreliable("client_get_player_update", position)

Just a few changes in order to reach a game behavior very similar to the one we had before. First, reset the move_speed variable to 300, or whatever you had there before. Now we will have yet another temporary code within the server_get_player_input(), which will use the gamestate.update_delta:

player.gd
remote func server_get_player_input(input):
	if (get_tree().is_network_server()):
		position += input.normalized() * move_speed * gamestate.update_delta

If you test now the result should be very close to what we had before, only the animation may seem a bit non smooth, probably because of the default value of the updates per second which is set to 30. Increasing this value to 60 will definitely make the animation a lot smoother, although also largely increasing the bandwidth demands. Later in this tutorial (part 3) we will see some ways to try to smooth things out while still keeping the update_rate at a relatively small value.


In this part, besides incorporating a few fixes to our previous code, we have also changed the synchronization to a very bare-bones authoritative server model, meaning that what dictates the game state is the server. Clients in this case are meant to be "just" a rendering window which also gives the players the opportunity to interact with what is being done on the server.

Yet, we have got into a multitude of problems that has been brought by this system. That's why I'm calling this a "bare-bones" implementation. On a local machine, running multiple instances of the game we will certainly not get too much trouble because we have near 0 latency. In order to properly test the solutions that are given to this problem we need means to simulate latency, which can't be directly done by Godot. So, in the next part we will implement as very basic system to do that, which should give us the necessary tool to test the subject of part 3, which involves snapshot interpolation to synchronize the game.

Introduction
1
234Next