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 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:
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:
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:
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:
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:
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()
puppet var repl_position = Vector2()
And in the
bot.gd the lines
slave var repl_position = Vector2() slave var repl_rotation = 0.0
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
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:
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)
First let's take a look at our current synchronization code (with some simplifications on the snippet) and analyze it:
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:
# 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
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:
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:
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.
deltatime. It can't be ignored, specially if we are dealing with the motion using a variable loop iteration (Godot does provide a fixed time step function,
_physics_process()). The thing is, even if only the server is dealing with the simulation, on some situations it will lead to inconsistencies. So we will have to incorporate some sort of time counting.
server_get_player_input()at a faster pace, meaning the motion will also be faster, even if we incorporate a
deltato the motion computation. We have to add some more measures in order to avoid this kind of advantage.
_process()function. Adding latency to the mix means the player will perceive a delay in the actor's motion. We could locally update the actor position and then correct it when the server's answer arrive. That can potentially lead to non smooth movements. There are more problems, which will be mentioned at a later moment in this tutorial.
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:
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
set_update_rate() we automatically update the
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):
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:
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:
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
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.