-->
Attention: This tutorial was written for Godot 3. Not only the information here contains several flaws, networking has changed significantly in Godot 4.
Up to this point we do have a bare-bones authoritative server which can give some very unsatisfying experience, specially when there is some latency. And the problem is, there is always some latency over networked games. With the code incorporated in the previous part we have some means to test how latency will affect the simulation on the clients. With that we can see if what we work on in this part will be of any use or not!
One disclaimer here, this topic is huge and I did my best to keep this as short and simple as possible. To that end, first I list a bunch of articles related to networked synchronization as well as lag mitigation techniques. After that, a brief overview of how snapshot interpolation works and its problems. Then an example of an initial implementation, without optimizations which will likely result in a very bandwidth demanding game. I will mention things that can be done in order to reduce that requirement, though.
All that said, the promised links:
If you have taken some time to at least skim through the links you probably have an idea of how big this topic is, not to mention the fact that there is no "one-solution-fits-all-problems". Still, what must be clear is that all presented techniques are only hiding the lag, not removing it.
The idea here is that the server computes the simulation, generates snapshots, each one with a "signature" (commonly named as timestamp) and transmit those to the connected peers. Once one snapshot arrives, the client checks if it's newer than the last one received. If so, render it, otherwise ignore the data.
The first thing to mention here is how fast this can become very expensive in terms of bandwidth requirements. Let's suppose for a moment that we are representing the state of a 3D entity by its position (Vector3 - 3 floating point numbers, 12 bytes), orientation (Quaternion - 4 floating point numbers, 16 bytes) and health (single integer number, 4 bytes). Chances are big that more data will be necessary, but let's keep it simple to the total of just those 32 bytes.
If there are 500 entities to be synchronized then each snapshot will become 16.000 bytes for the state alone. To each sent snapshot we also have to calculate the IP + UDP header (28 bytes, assuming IPv4) plus the timestamp (while normally this is a two byte number, in GDScript we are limited to 4 byte integers - so, computing 4 bytes here). So, each snapshot sent will require 16.032 bytes.
Now suppose we are at a pace of 30 snapshots per second, which results in 480.960 bytes per second, or roughly 3.85 megabits per second. Now keep in mind something, UDP packets are limited to carry a maximum of 65.507 bytes so, if the snapshot exceeds that, it will probably break into multiple packets, increasing even more the bandwidth demands.
Now let's think for a moment what's going on here. At 30 snapshots per second, the simulation will most likely not be smooth at all, specially because packets will be lost (remember, UDP here). To counteract this one would then increase the amount of packets per second, say to 60, effectively doubling the bandwidth requirement (which already wasn't great).
Now let's see what happens by adding just 100 new entities. Each snapshot would then become 19.200 bytes, without IP+UDP header and timestamp. At the initial 30 snapshots per second that would require 576.960 bytes per second, or roughly 4.62 megabits per second.
In other words, if we are not careful, we can easily get very high bandwidth requirements for the game. And all of that is taking only the game state into account, not the input data! Since snapshot interpolation is the chosen technique for this tutorial we will delve into some more details shortly.
As mentioned, at 30 snapshots per second it's very likely that we will have non smooth movements of the entities. A common solution is to use linear interpolation to transition from the previous snapshot to the last one received over a period of time. This does give some additional latency in the simulation. To have an acceptable latency here we still have to increase the data rate (snapshots per second). Still, depending on what is being simulated, linear interpolation may not be enough and even more data will be necessary for the interpolation.
Still, Godot does provide means to automatically compress data before sending over the network. That is done by changing a property within the networking object. An important thing to keep in mind is the following quote directly taken from the documentation:
The compression method used for network packets. Default is no compression. These have different tradeoffs of compression speed versus bandwidth, you may need to test which one works best for your use case if you use compression at all.
That refers to ENet's buildin range encoding, FastLZ, zlib or ZStandard compression modes. Unfortunately I haven't found a way to ("automatically") measure the packet size differences (or even the original size for that matter) as sent/received through the networking system. I did perform some tests with strange results though. I manually used PollByteArray
in order to try to measure the amount of data and the results I have got raised some flags, mostly because it seems Godot does incorporate a bit of overhead into each variable. As an example, the following code:
var some_value = float(1234)
var bytes = var2bytes(some_value)
print(bytes.size())
This results in 8
being printed. A common float normally should use 4 bytes, not 8! After digging into the source code, it indeed does incorporate extra data, to indicate the variant type. From what I was able to understand, this header data is also sent across the network, which kind of make sense so the objects can be rebuilt on the other side. Furthermore, a dictionary adds a lot of extra overhead. Just to give an example, the following silly example:
var some_value = {
pos = Vector3(),
}
var bytes = var2bytes(some_value)
print("Byte count: ", bytes.size())
This results in 36
being printed. I won't go into details as to why this is the result. If you are interested, check the marshalls.cpp file, under the core/io subdirectory of the source code, more specifically the encode_variant
function. Based on this information dictionaries should be avoided when sending data across the network, specially when we have to constantly send packets of the same type. We can still continue internally using them, we just have to deal with the data in a different way when sending to other machines.
As stated, we will create snapshots containing all of the necessary data to rebuild the scenes. So, the server calculates the state, generates the snapshot and sends it to everyone connected. But, how exactly should we build the snapshot data? Currently, all we are dealing with for each player is the location and the color. This last one doesn't even change during the game, meaning that if desired we could even remove this data from the snapshot. Remember, we want to reduce the packet size as much as possible. As for the (neglected) bots, we have the location and the rotation.
Let's see what we need to send in order to correctly render the players:
We have a total of 32 bytes for each player, consisting of 7 floating point numbers and one integer.
As for the bots, those are the necessary data:
As you can see, we have the exact same set of data for each bot, meaning 32 bytes.
So, if we restrict to 16 connections, we will always have 16 x 32 = 512 bytes of pure state data inside a single snapshot. But, that is not complete. We have to include some extra information into the snapshot. As mentioned in the overview, we need a timestamp in order to correctly identify if that snapshot is newer than the currently rendered state. This will be a single integer value, which means more 4 bytes.
In order to try to reduce the overhead incorporated by the variant flags, we will build the pool byte array ourselves. The idea is to try to be as close to the calculated required bytes as possible. But, how? Well, if you take a look at the first silly snippet of code utilizing the var2bytes()
function, that pretty much gives us a hint. In the case of that example we are converting a floating point number into a sequence of bytes. The first 4 bytes are representing the variant type. The last 4 are the ones containing the actual value we are interested in.
The variable type indication added by Godot is necessary for the automatic data rebuilding, which we are not going to use. By being careful with the encoding (and decoding) we can discard those "header" bytes before appending the values into the PoolByteArray
object. That said, let's create some (helper) functions that we will use to encode the variables into byte arrays. The basic idea of the code in each function is to take the received argument, use the var2bytes()
function, discard the first 4 bytes and return the rest. For that, we use the subarray()
function, starting at the byte index 4 through the end of the resulting array:
# Encode a variable that uses 4 bytes (integer, float)
func encode_4bytes(val):
return var2bytes(val).subarray(4, 7)
# Encode a variable that uses 8 bytes (vector2)
func encode_8bytes(val):
return var2bytes(val).subarray(4, 11)
# Encode a variable that uses 12 bytes (vector3...)
func encode_12bytes(val):
return var2bytes(val).subarray(4, 15)
# Encode a variable that uses 16 bytes (quaternion, color...)
func encode_16bytes(val):
return var2bytes(val).subarray(4, 19)
Later on, we can directly use the return value of each one of those functions and append into the PoolByteArray
that will be sent across the network. But then comes the question, where exactly will we add the code to build the snapshot? The snapshot itself represents the frame state. It would be a great idea to place that code in the gamestate.gd
class. Because it would require some extra code necessary to give us the information of parent node of current scene and probably some other details, I will add the code into the game_world.gd
class for simplicity sake (well, we will actually separate things between the two - shortly you will see). But really, ideally it makes more sense to put everything in the game state.
The way we will do this follow this idea:
update_rate
we perform the game state update within the server in an specific function.PoolByteArray
object.For the moment I'm ignoring the decoding aspect, which we will see in detail after dealing with the snapshot encoding. Speaking of that, the snapshot itself needs the "timestamp" value which will be called signature within our code. This is just an integer value that will be incremented each time an snapshot is generated. Now, as mentioned, Godot's integer values are 4 bytes, meaning that we have more than 4.2 billion snapshots before the value wraps around, which means little more than 2 years of the game running 24 hours per day generating 60 snapshots per second. Because of that I will ignore the wrapping test in the code. Anyway, let's declare the snapshot signature variable as well as the control variable that will hold the signature of the last received. Also notice the fact that we are initializing the snapshot_signature
with 1
just so the very first one will pass the signature > last_snapshot
test.
# The "signature" (timestamp) added into each generated state snapshot
var snapshot_signature = 1
# The signature of the last snapshot received
var last_snapshot = 0
Now let's work on the snapshot encoding function. As mentioned it expects a dictionary containing the game state. Unfortunately we can't create a "contract" to enforce the dictionary will contain the right fields so we will have to be very careful when coding the functions, specially when building the dictionary itself. Nevertheless, we first encode into the byte array the snapshot signature. Then one integer indicating how many players are encoded. After the player data, we add another integer indicating how many bots are encoded. Since this function only deals with data already present in the received argument we will add this into the gamestate.gd
class.
# Based on the "High level" snapshot data, encodes into a byte array
# ready to be sent across the network. This function does not return
# the data, just broadcasts it to the connected players. To that end,
# it is meant to be run only on the server
func encode_snapshot(snapshot_data):
if (!get_tree().is_network_server()):
return
var encoded = PoolByteArray()
# First add the snapshot signature (timestamp)
encoded.append_array(encode_4bytes(snapshot_data.signature))
# Player data count
encoded.append_array(encode_4bytes(snapshot_data.player_data.size()))
# snapshot_data should contain a "players" field which must be an array
# of player data. Each entry in this array should be a dictionary, containing
# the following fields: network_id, location, rot, col
for pdata in snapshot_data.player_data:
# Encode the network_id which should be an integer, 4 bytes
encoded.append_array(encode_4bytes(pdata.net_id))
# Encode the location which should be a Vector2, 8 bytes
encoded.append_array(encode_8bytes(pdata.location))
# Encode the rotation which should be a float, 4 bytes
encoded.append_array(encode_4bytes(pdata.rot))
# Encode the color which should be a Color, 16 bytes
encoded.append_array(encode_16bytes(pdata.col))
# Bot data count
encoded.append_array(encode_4bytes(snapshot_data.bot_data.size()))
# The bot_data field should be an array, each entry containing the following
# fields: bot_id, location, rot, col
for bdata in snapshot_data.bot_data:
# Encode the bot_id which should be an integer, 4 bytes
encoded.append_array(encode_4bytes(bdata.bot_id))
# Encode the location, which should be a vector2, 8 bytes
encoded.append_array(encode_8bytes(bdata.location))
# Encode the rotation which should be a float, 4 bytes
encoded.append_array(encode_4bytes(bdata.rot))
# Encode the color which should be a Color, 16 bytes
encoded.append_array(encode_16bytes(bdata.col))
# Snapshot fully encoded. Time to broadcast it
rpc_unreliable("client_get_snapshot", encoded)
Although the resulting function is a bit big, it should be pretty straightforward. One thing must be clear though: we are trading improved bandwidth usage for complexity in our code and a bunch of possible bugs when decoding. Shortly we will see more about that. For now, let's work on the function that is meant to update the game state, generate the "high level" snapshot (the dictionary) and then send it to the encoding function.
The bot updating should be really easy as all we have to do is iterate through the existing bots and take their data, filling the snapshot dictionary. For the players, however, we need something more. We need their input data. For that we will have to rework on our input system (that will be done shortly). Currently when the server gets a request from the player, it immediately computes the resulting location. Instead of that we will cache the input data which will be processed during the state update. This data will be held by a dictionary which will be filled only on the server. The keys, obviously, the network ID of each connected client. It is possible to reuse the players
dictionary in the network.gd
class, but we will not do so. Instead we will create a new dictionary in the gamestate.gd
class:
# Holds player input data (including the local one) which will be used to update the game state
# This will be filled only on the server
var player_input = {}
Note the comment in the code, telling that we will have this object hold input data even for the player running the server. This will be done just so everyone will get the updates at the same pace. Now, before we can work on the update function itself, I have to take a moment to talk a about how we will handle the input data within this function. Depending on how the latency varies throughout the game, it is possible that we will have multiple cached input entries from a single player. We can't allow more than one input request in a single game update. We can either reduce the "strength" of each entry and use them all in a single loop iteration or use the entry as a queue, taking out only a single one per loop iteration.
If we allow multiple entries per loop without reducing how much it will affect the calculation then a cheater may exploit this by trying to send more input requests than the expected. Of course we could try to block this but it's rather difficult to differentiate from legitimate multiple packets that arrived at once because of network latency variations.
All that said, in this tutorial we will go the route of reducing how much each input entry will affect the simulation in the case of having multiple input entries per loop iteration. Depending on the update rate it will not be really problematic, although at lower rates it can bring some issues for players under less than ideal connections. Although non ideal connections will be bad no matter what solution is incorporated. The way we will do this is by taking the update_delta
(which will be used within the motion formula) and divide it by the number of input entries within our input cache, which will result in an smaller delta
time value, thus decreasing the "strength" that each input entry will cause to the motion.
Ok, now for the update function. Yes, it's huge. It's probably the biggest function in the tutorial. Granted, it's responsible to simulate the entire game. Ideally the code should be broken into multiple functions. But, to make things easier to understand when studying the code I have dumped everything into the same place. So first, in the function, we have to ensure it's running on the server and if not, bail!
We then initialize the "high level" snapshot dictionary, the one that will be sent to the encoding function. It should be pretty straightforward.
Then we come into the player update section. In here we iterate through each player in the network.players
dictionary which, again, must include the player running the server. Not only this updates the server's player, it also ensures everyone else will have the correct state of the player. Anyway, we have to obtain the node corresponding to the iterated player, which will be used regardless of having input data or not. Speaking of that, we check if there is any input data and how many entries are stored in there. We use this to calculate the delta
time as explained early. The input data is expected to have some flags, up, down, left or right as well as the coordinates of the mouse so we can calculate the rotation angle of the object. Using those flags we create the move_dir
like we have been doing up to this point. With that, the usual formula to update the position. As for the rotation angle, we use the get_angle_to()
function that is provided by the parent Node
class. In order to have the "up" section of the icon pointing to the mouse we have to add 90 degrees () to the returned angle. Once the player node is updated, we take the final state and append into the player_data
field of the snapshot object.
Next we come into the bot section, where we just iterate through each spawned bot and take their states directly into entries in the bot_data
field of the snapshot.
Finally, we update the gamestate.snapshot_signature
and then verify if there is any connected player, in which case we call the gamestate.encode_snapshot()
function that we have created early.
So, ready for the large function? Here it is:
# Update and generate a game state snapshot.
func update_state():
# If not on the server, bail
if (!get_tree().is_network_server()):
return
# Initialize the "high level" snapshot
var snapshot = {
signature = gamestate.snapshot_signature,
player_data = [],
bot_data = [],
}
# Iterate through each player.
for p_id in network.players:
# Locate the player's node. Even if there is no input/update, it's state will be dumped
# into the snapshot anyway.
var player_node = get_node(str(p_id))
if (!player_node):
# Ideally should give a warning that a player node wasn't found
continue
# Check if there is any input for this player. In that case, update the state
if (gamestate.player_input.has(p_id)):
# Calculate the delta
var delta = gamestate.update_delta / float(gamestate.player_input[p_id].size())
# Now, for each input entry, calculate the resulting state
for input in gamestate.player_input[p_id]:
# Build the movement direction vector based on the input
var move_dir = Vector2()
if (input.up):
move_dir.y -= 1
if (input.down):
move_dir.y += 1
if (input.left):
move_dir.x -= 1
if (input.right):
move_dir.x += 1
# Update the position
player_node.position += move_dir.normalized() * player_node.move_speed * delta
# And the rotation
var angle = player_node.get_angle_to(input.mouse_pos) + (PI/2.0)
player_node.rotate(angle)
# Cleanup the input vector
gamestate.player_input[p_id].clear()
# Build player_data entry
var pdata_entry = {
net_id = p_id,
location = player_node.position,
rot = player_node.rotation,
col = network.players[p_id].char_color,
}
# Append into the snapshot
snapshot.player_data.append(pdata_entry)
for b_id in range(gamestate.spawned_bots):
# Locate the bot node
var bot_node = get_node(gamestate.bot_info[b_id + 1].name)
if (!bot_node):
# Must give a warning here
continue
# Build bot_data entry
var bdata_entry = {
bot_id = b_id + 1,
location = bot_node.position,
rot = bot_node.rotation,
col = bot_node.color,
}
# Append into the snapshot
snapshot.bot_data.append(bdata_entry)
# Encode and broadcast the snapshot - if there is at least one connected client
if (network.players.size() > 1):
gamestate.encode_snapshot(snapshot)
# Make sure the next update will have the correct snapshot signature
gamestate.snapshot_signature += 1
Taking a closer look into this code it should be clear that we will have to change a bunch of things.
update_state()
function.update_state()
function somewhere in the code.encode_snapshot()
function calls a remote function named client_get_snapshot()
, which wasn't even mentioned within this tutorial up to this point.client_get_snapshot()
we decode the received byte array back into the "high level" snapshot dictionary. Yet, it must be properly applied into the local game state which will be done through a new function in the game_world.gd
class and will be based on a signal.As you can see, we have a lot to do before we can see how all of this is working (or broken)!
While there is still a bit of code that is necessary before we can start decoding data, I have to add some preparatory code that will be used later for that specific case. When we encoded the data we basically stripped out the variant "header" from stored bytes. To obtain the values back (decode) we will use the bytes2var()
function, which basically requires the variant header plus the correct amount of data bytes in order to properly bring the variant with the data we want. To that end we cache those headers within some variables, which we will use to "fill" the byte arrays when using the bytes2var()
function.
The initialization of those values consist in using the var2bytes()
function, sending a "dummy" variable of the desired type while keeping only the first 4 bytes, through the usage of the subarray()
function. However there is one special case, for the boolean. In Godot, when booleans are encoded into variants (which is the case for GDScript), they occupy 4 bytes rather than a single one. This means that if we blindly take the boolean values into byte arrays, they will use 3 additional bytes that we are not exactly expecting. When decoding, those bytes must be present otherwise the bytes2var()
will fail. To that end, when building the boolean header cache we will take 3 additional bytes which will most likely be all 0
.
All that said, the declaration/initialization of those variant headers:
# Cache the variant headers so we can manually decode data sent across the network
var int_header = PoolByteArray(var2bytes(int(0)).subarray(0, 3))
var float_header = PoolByteArray(var2bytes(float(0)).subarray(0, 3))
var vec2_header = PoolByteArray(var2bytes(Vector2()).subarray(0, 3))
var col_header = PoolByteArray(var2bytes(Color()).subarray(0, 3))
var bool_header = PoolByteArray(var2bytes(bool(false)).subarray(0, 6))
Now we will deal with the input data, both the generation of it as well as the handling by the server.
In the player.gd
class we have a function named gather_input()
(which was created back in part 1). Currently it is polling the input mappings and building a direction vector, which is then returned to be further processed and sent to the server. Now, instead of that, we will first create a "high level" input object, much like the dictionary holding the snapshot data. Then, still within the function we check if we are on the server or not. If so, we directly cache that object, otherwise we encode the data into a byte array and send to the server, which will reuse the server_get_player_input()
function.
In order to avoid duplication of code, we will create a function meant to take the high level input data and cache it. The basic idea is to verify if the input dictionary is holding an entry for the player and, if not, create it while initializing the entry as an array. Then, just append the received object:
func cache_input(p_id, data):
if (!get_tree().is_network_server()):
return
if (!player_input.has(p_id)):
player_input[p_id] = []
player_input[p_id].append(data)
The "high level" input data object will be a dictionary containing 4 booleans and a Vector2 (two floating point numbers). The boolean values will be directly assigned based on the polled data from the input mappings, while the vector will hold the mouse position. As for the encoding, we first add the network ID of the client then directly use the append()
function of the array, which will effectively take a single byte from the sent variable. Then we use the shortcut function we created early to encode a sequence of 8 bytes:
func gather_input():
var input_data = {
up = Input.is_action_pressed("move_up"),
down = Input.is_action_pressed("move_down"),
left = Input.is_action_pressed("move_left"),
right = Input.is_action_pressed("move_right"),
mouse_pos = get_global_mouse_position(),
}
if (get_tree().is_network_server()):
# On the server, direcly cache the input data
cache_input(1, input_data, float(gamestate.input_rate))
else:
# On a client. Encode the data and send to the server
var encoded = PoolByteArray()
encoded.append(input_data.up)
encoded.append(input_data.down)
encoded.append(input_data.left)
encoded.append(input_data.right)
encoded.append_array(gamestate.encode_8bytes(input_data.mouse_pos))
rpc_unreliable_id(1, "server_get_player_input", encoded)
The code within our server_get_player_input()
function now doesn't make any sense. Let's update to our new system. The idea here is to decode the pool byte array back into a high level dictionary and then send the resulting object to the cache_input()
function.
Now comes the first decoding logic we will add to the project. Unfortunately this is a bit error prone and will require really careful checking to see if the code (and "math") is correct. What happens is that we will have to use the subarray()
function pointing to the correct sections of the data in order to extract the desired information. And there is the fact that we can't exactly create shortcut functions to help us with this task. One of the reasons is the fact that pool arrays are sent as values rather than references, meaning that we can't manipulate them from a separate function, which would make things a bit easier in this case.
Anyway, we have to extract the data in the same order they were added into the array. That means, we first have to extract the up state, then down state and so on. To obtain the value back into the GDScript's variant, we will use the bytes2var()
function, which expects a byte array object containing the correct header + data. We have already created (cached) the relevant type headers for our project. That means all we have to do is give this header plus the subarray section of the encoded data to the bytes2var()
function. The updated server_get_player_input()
now becomes like this:
remote func server_get_player_input(input):
if (network.fake_latency > 0):
yield(get_tree().create_timer(network.fake_latency / 1000), "timeout")
if (get_tree().is_network_server()):
# Decode the input data
var input_data = {
up = bytes2var(gamestate.bool_header + input.subarray(0, 0)),
down = bytes2var(gamestate.bool_header + input.subarray(1, 1)),
left = bytes2var(gamestate.bool_header + input.subarray(2, 2)),
right = bytes2var(gamestate.bool_header + input.subarray(3, 3)),
mouse_pos = bytes2var(gamestate.vec2_header + input.subarray(4, 11)),
}
# Then cache the decoded data
gamestate.cache_input(get_tree().get_rpc_sender_id(), input_data)
As you can see, decoding data requires the correct indexing and if we mess things up we will get errors that may not be easy to track down. As an example, if we add something to the encoding, we have to ensure the decoding is still pointing to the correct indices.
The last thing that we have to do now (for the input data) is update the _process()
function of the player class. What happens now is that the actual data replication is handled outside of the class. Not only that, our gather_input()
function now doesn't return any value and is entirely dealing with the input handling task. This means that we can completely remove the state replication as well as state calculation from it (the _process()
). All we do is call the gather_input()
function if the object is the network master:
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()):
gather_input()
As stated, we have to update the bot code because the replication is now being dealt by the snapshot. Luckily we don't have much to do here. First, we remove some lines of code from the _process()
function. Most notably, the lines directly related to the replication. The new function looks like this now:
func _process(delta):
if (is_network_master()):
current_time += delta
var alpha = current_time / count_time
if (alpha > 1.0):
alpha = 1.0
var nposition = start_pos.linear_interpolate(dest_location, alpha)
if (alpha >= 1.0):
calculate_motion_vars()
position = nposition
Then, remove the declaration of the two puppet variables, repl_position
and repl_rotation
, since we don't need them anymore. Our snapshot expects a color property in the bot, so we have to declare it:
var color = Color(randf(), randf(), randf(), randf())
In the _ready()
function we change the line that is assigning the modulation color of the bot's icon to:
$icon.modulate = color
Lastly we add a function that we will use to update the bot state at a later moment, when decoding the snapshot. Basically this function will take the high level dictionary object and apply the values to the relevant properties:
func update_state(state):
position = state.location
rotation = state.rot
color = state.col
$icon.modulate = color
In our game_world.gd
class we have created a somewhat huge update_state()
function, which is meant to be called at a fixed pace controlled by the gamestate.update_rate
. But, up to this point we are not calling it from anywhere in the code. Let's fix that now by overriding the _process()
function and counting time similarly to how we have done within the player.gd
class. That said, we first declare the time counting variable:
var player_row = {}
onready var current_time = 0
Then, in the _process()
function we update this new value and check if it's time to update the game state. In that case, we just call the update_state()
function:
func _process(delta):
# Update the timeout counter
current_time += delta
if (current_time < gamestate.update_delta):
return
# "Reset" the time counting
current_time -= gamestate.update_delta
# And update the game state
update_state()
We are almost ready to at least test the code locally with a single player running on the server. The problem now is that we have to correctly set the name of the server's player avatar object because we have to retrieve the relevant node. Luckily the "fix" is a matter of removing an indentation from a single line of code in our player spawning function. More specifically this one:
nactor.set_name(str(pinfo.net_id))
By removing one indentation level it goes out of the if()
scope and the name of the node will be set regardless of being a player on a client machine or on the server. After that, we can test the server (the clients will not work yet). We have got the input working on the server, albeit the motion is not looking very good. It's not smooth at all! The problem here comes from the fact that we are, by default, dealing with just 30 updates per second.
Well, the first thing that we can do is increase the update rate. Remember that by doing this we also increase the amount of input packets that will be sent across the network.
Another thing that we can do (we will shortly) is change the update_state()
so it does not directly change the properties of the objects in the game, leaving that to be done by the function that will apply the snapshot into the game state. By doing so, the moment we add interpolation the server's visual representation will also benefit from that improvement.
And now we have to decode the snapshot data received from the server. This is meant to be done through a remote function called by the server, named client_get_snapshot()
. Our encode_snapshot()
function calls it. Part of how we will decode the data from the PoolByteArray
has already been shown, however now we have a bit more work to do. The thing is, the only sure thing we have when decoding the data is that we have data for at least 2 players and the rest is filled by bots if the amount of players isn't at the maximum allowed. Because of that we will need to use control variables to "move" the reading spots on the byte array. Moreover, we have to check if the received snapshot is newer than the last one. If that's not the case we simply discard (ignore) the received data.
Another thing is that while we will perform the decoding in the gamestate.gd
class, the state will be applied in the game_world.gd
class, meaning that we need means to tell that we have a new snapshot to be applied. We will do this through a new signal, which we have to declare:
signal snapshot_received(snapshot) # Emitted as soon as an snapshot is fully decoded
Now we can work on the decoding function. It expects a single argument, a PoolByteArray
. Basically we have to extract the data back into a new dictionary following the same order we have added the information into the array. We begin by first extracting the snapshot signature and checking if it's newer than the last one. If not, we just bail from the function and that's it. Assuming it's newer, then we have to extract the player count and then enter in a loop to extract only that amount of player data from the array. Next, we do the same with the bot data.
Because we didn't do any special encoding, the only new thing (compared to the input data decoding) we will have to deal with is the control variable to point into the correct array indices. In this case I have to explain a little bit the way the index value is being updated. The variable in question is named idx
(in this code snippet) and when extracting a 4 bytes value from the array, you will see something like .subarray(idx, idx + 3)
. The reason for this is because the subarray range is inclusive, meaning that idx
, idx+1
, idx+2
and idx+3
are included in the returned bytes. And then, we "move" the index by the amount of bytes that are part of the value we want to extract, 4 bytes in this example.
And so, the function code looks like this:
remote func client_get_snapshot(encoded):
if (network.fake_latency > 0):
yield(get_tree().create_timer(network.fake_latency / 1000), "timeout")
var decoded = {
# Extract the signature
signature = bytes2var(int_header + encoded.subarray(0, 3)),
# Initialize the player data and bot data arrays
player_data = [],
bot_data = [],
}
# If the received snapshot is older (or even equal) to the last received one, ignore the rest
if (decoded.signature <= last_snapshot):
return
# Control variable to point into the byte array (skipping player count)
var idx = 8
# Extract player data count
var pdata_count = bytes2var(int_header + encoded.subarray(4, 7))
# Then the player data itself
for i in range(pdata_count):
# Extract the network ID, integer, 4 bytes
var eid = bytes2var(int_header + encoded.subarray(idx, idx + 3))
idx += 4
# Extract the location, Vector2, 8 bytes
var eloc = bytes2var(vec2_header + encoded.subarray(idx, idx + 7))
idx += 8
# Extract rotation, float, 4 bytes
var erot = bytes2var(float_header + encoded.subarray(idx, idx + 3))
idx += 4
# Extract color, Color, 16 bytes
var ecol = bytes2var(col_header + encoded.subarray(idx, idx + 15))
idx += 16
var pd = {
net_id = eid,
location = eloc,
rot = erot,
col = ecol,
}
decoded.player_data.append(pd)
# Extract bot data count
var bdata_count = bytes2var(int_header + encoded.subarray(idx, idx + 3))
idx += 4
# Then the bot data
for i in range(bdata_count):
# Extract the bot id
var bid = bytes2var(int_header + encoded.subarray(idx, idx + 3))
idx += 4
# Extract the location
var eloc = bytes2var(vec2_header + encoded.subarray(idx, idx + 7))
idx += 8
# Extract rotation
var erot = bytes2var(float_header + encoded.subarray(idx, idx + 3))
idx += 4
# Extract color
var ecol = bytes2var(col_header + encoded.subarray(idx, idx + 15))
idx += 16
var bd = {
bot_id = bid,
location = eloc,
rot = erot,
col = ecol
}
decoded.bot_data.append(bd)
# Update the "last_snapshot"
last_snapshot = decoded.signature
# Emit the signal indicating that there is a new snapshot do be applied
emit_signal("snapshot_received", decoded)
The snapshot is now fully decoded and we are emitting the signal telling about that. Now we have to handle this signal and apply the snapshot into the game state so the synchronization finally occur! We will do that from the game_world.gd
class. The basic idea of this function is to iterate through each entry in the player_data
and bot_data
fields of the snapshot object while applying the states into the local players avatars and bots. This should be pretty straightforward:
func apply_snapshot(snapshot):
# In here we assume the obtained snapshot is newer than the last one
# Iterate through player data
for p in snapshot.player_data:
# Locate the avatar node belonging to the currently iterated player
var pnode = get_node(str(p.net_id))
if (!pnode):
# Depending on the synchronization mechanism, this may not be an error!
# For now assume the entities are spawned and kept in sync so just continue
# the loop
continue
# Apply the location, rotation and color to the player node
pnode.position = p.location
pnode.rotation = p.rot
pnode.set_dominant_color(p.col)
# Iterate through bot data
for b in snapshot.bot_data:
# Locate the bot node belonging to the currently iterated bot
var bnode = get_node(gamestate.bot_info[b.bot_id].name)
if (!bnode):
continue
# Apply location, rotation and color to the bot
bnode.update_state(b)
The last necessary thing to get the synchronization happening again is to connect this function to the snapshot_received
signal. Let's do that:
func _ready():
... # Previous code
# 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")
# After receiving and fully decoding a new snapshot, apply it to the game world
gamestate.connect("snapshot_received", self, "apply_snapshot")
... # Previous code
The game synchronization is now fully restored, with a somewhat more robust system when compared to the bare-bones authoritative server resulted from part 1. Yet, the animation is still not smooth, even on the server. The main reason was explained early. While increasing the update rate is an option for which we can even add a control in the HUD just to perform this tweak (in this tutorial we won't though), there is still another option, which is interpolating between the previously applied state and the newly received. In order to do that we need initial and final states.
Before we do anything with the code, let's see the two options we have to perform the interpolation.
Tween
class given by Godot.The Tween
class is interesting in the fact that it can automate a bunch of the calculations, unfortunately I wasn't able to get an acceptable, smooth, result so we will manually calculate the interpolation. In order to interpolate we need an initial state as well as a final state, something like a "from" and a "to". So, let's declare the variable that will hold those states for us:
var player_row = {}
onready var current_time = 0
var from_to = {}
The idea is that, in the apply_snapshot()
function, we fill this variable with an entry per connected player, including the local one. Each entry will also contain the necessary information to perform the interpolation. That is, initial location, final location, initial rotation, final rotation, time control and the player node. Wait, where is the color? It doesn't make much sense in interpolating that, unless you want some kind of transition if a dynamic color changing is incorporated. That's not exactly the case for this tutorial, so we just directly apply the color much like it has been done. And why multiple time control variables if we are updating the game at a somewhat fixed delta time? The reason for that is because it's possible to have a new player joining the game and for a moment that player's avatar will indeed need different time counting. There are ways to handle this kind of special case, but it's a lot simpler (specially for a tutorial) to just create an extra variable for each player.
We have to check if the from_to
variable is holding an entry for the currently iterated player. If not, we create a new object with the initial state, otherwise we just update the data. In this case, in order to update we "transfer" the final location/rotation into the initial ones and then take the new snapshot's values into the final states. We also reset the time control variable and ensure the node field is holding the correct node object. The new apply_snapshot()
function now looks like this:
func apply_snapshot(snapshot):
# In here we assume the obtained snapshot is newer than the last one
# Iterate through player data
for p in snapshot.player_data:
# Locate the avatar node belonging to the currently iterated player
var pnode = get_node(str(p.net_id))
if (!pnode):
# Depending on the synchronization mechanism, this may not be an error!
# For now assume the entities are spawned and kept in sync so just continue
# the loop
continue
if (from_to.has(p.net_id)):
# Currently iterated player already has previous data. Update the interpolation
# control variables
from_to[p.net_id].from_loc = from_to[p.net_id].to_loc
from_to[p.net_id].from_rot = from_to[p.net_id].to_rot
from_to[p.net_id].to_loc = p.location
from_to[p.net_id].to_rot = p.rot
from_to[p.net_id].time = 0.0
from_to[p.net_id].node = pnode
else:
# There isn't any previous data for this player. Create the initial interpolation
# data. The next _process() iteration will take care of applying the state
from_to[p.net_id] = {
from_loc = p.location,
from_rot = p.rot,
to_loc = p.location,
to_rot = p.rot,
time = gamestate.update_delta,
node = pnode,
}
#pnode.position = p.location
#pnode.rotation = p.rot
# There is no point in interpolating the color so just apply it
pnode.set_dominant_color(p.col)
# Iterate through bot data
for b in snapshot.bot_data:
# Locate the bot node belonging to the currently iterated bot
var bnode = get_node(gamestate.bot_info[b.bot_id].name)
if (!bnode):
continue
# Apply location, rotation and color to the bot
bnode.update_state(b)
Probably you have noticed the fact that I completely ignored the bots. Indeed. It would only make this section even bigger so I'm skipping that, but the logic should be exactly the same for the player so that task is left as an exercise to you!
You may also have noticed the fact that we are not changing anything in the players' objects. This will be done from the _process()
function, where we can count time and perform the interpolation. In there, before dealing with the update_delta
we dump in the code to interpolate the state of each player. For the moment we will only perform this if not on the server. Nevertheless, the task consists in iterating through each entry in the from_to
variable, updating the time control variable and calculating the interpolation's alpha. Finally, we use the lerp()
function in order to obtain the interpolated position and rotation:
func _process(delta):
if (!get_tree().is_network_server()):
# Interpolate the state
var count_time = gamestate.update_delta
for p in from_to:
if (from_to[p].time >= count_time):
continue
from_to[p].time += delta
var alpha = from_to[p].time / count_time
from_to[p].node.position = lerp(from_to[p].from_loc, from_to[p].to_loc, alpha)
from_to[p].node.rotation = lerp(from_to[p].from_rot, from_to[p].to_rot, alpha)
# Update the timeout counter
current_time += delta
if (current_time < gamestate.update_delta):
return
# "Reset" the time counting
current_time -= gamestate.update_delta
# And update the game state
update_state()
If you test the game now you may notice the fact that the clients are getting smoother animations than the server. This is the result of the interpolation, which is not being applied on the server! Let's fix that. The first change that we need is to remove the if(!get_tree().is_network_server())
line from the _process()
function and allow the interpolation to happen regardless of being on the server or not (don't forget to remove the indentation of the code that inside the if()
). Next we have to update the update_state()
function. In there, instead of directly applying the motion and rotation results into the player_node
variable, we store them into temporary ones and use them to build the snapshot data entry. Bellow only the relevant sections:
... # previous code
if (!player_node):
# Ideally should give a warning that a player node wasn't found
continue
var p_pos = player_node.position
var p_rot = player_node.rotation
# Check if there is any input for this player. In that case, update the state
if (gamestate.player_input.has(p_id) && gamestate.player_input[p_id].size() > 0):
# Calculate the delta
var delta = gamestate.update_delta / float(gamestate.player_input[p_id].size())
# Now, for each input entry, calculate the resulting state
for input in gamestate.player_input[p_id].state:
# Build the movement direction vector based on the input
var move_dir = Vector2()
if (input.up):
move_dir.y -= 1
if (input.down):
move_dir.y += 1
if (input.left):
move_dir.x -= 1
if (input.right):
move_dir.x += 1
# Update the position
p_pos += move_dir.normalized() * player_node.move_speed * delta
# And rotation
p_rot = p_pos.angle_to_point(input.mouse_pos)
# Cleanup the input vector
gamestate.player_input[p_id].clear()
# Build player_data entry
var pdata_entry = {
net_id = p_id,
location = p_pos,
rot = p_rot,
col = network.players[p_id].char_color,
}
# Append into the snapshot
snapshot.player_data.append(pdata_entry)
... # previous code
Then, at the end of the function (again, update_state()
) we have to apply the generated snapshot, which will create the entries in the from_to
variable:
... # previous code
# Encode and broadcast the snapshot - if there is at least one connected client
if (network.players.size() > 1):
gamestate.encode_snapshot(snapshot)
apply_snapshot(snapshot)
At this point, if you test the synchronization the rotation of the icons "works" but they are not exactly pointing to the mouse cursor. The thing is, we removed the +(PI/2.0) line when generating the snapshot. That was on purpose. If we mathematically correct the rotation, the clients will get seemingly random rotation values, while everything will work on the server! The fix is a lot easier though, all we have to do is open the player.tscn
, click the icon
node in the hierarchy and change the Rotation Degree
to -90
so the top of the icon now points to the left side. This will result in the desired rotation in the end because the icon has a -90 degree compared to the parent node, which is the one that will receive the synchronized rotation angle from the snapshot.
And now, both server and clients are getting relatively smooth movements even with lower update rates, albeit with some delay for the clients when adding latency. However, there is something we still have to do: cleanup! Yes, we have created some internal states whenever a client gets connected to the server, but we never cleanup that data when the relevant client disconnects. The two dictionaries that we have to perform this cleanup are player_input
and from_to
, which are in the gamestate.gd
and game_world.gd
respectively.
For simplicity sake I will cleanup both dictionaries from the same place, in the game_world.gd
class. The thing is, this class already deals with player disconnections with the _on_player_removed()
function. Then you may notice "but this function is run only on the server". Indeed, remember that our dictionaries are also filled only on the server, so we are good here! The updated function looks like this:
func _on_player_removed(pinfo):
gamestate.player_input.erase(pinfo.net_id)
from_to.erase(pinfo.net_id)
despawn_player(pinfo)
sync_bots(-1) # Again, amount doesn't matter at this point because the server side section will take care of it
In this last topic I will comment over a few improvements that can be incorporated into our current system, while not delving into details of how to actually implementing them.
The spawning functions, including the bot synching, can now be turned into non remote functions. We can use the snapshots to perform the spawning of the entities without needing to rely on remote procedure calls solely for this kind of synchronization. In order to do that, however, some kind of reliability checking is necessary to be incorporated. Maybe the server stores the sent snapshot until all clients acknowledge the data has been received. At that point the snapshot can be removed, otherwise keep sending to those clients without an positive answer.
We can better encode the input data. Each non analog state can be packed into a bitmask, which would shrink the amount of bytes sent. In our case, we have up, down, left and right which can easily be packed into a single byte.
An alternative is to encode data in a different way such that we can compress things as much as possible. While it can be combined with the reduced data rate, the idea here is to enable the possibility of keeping a relatively high data rate in order to reduce the latency introduced by the interpolation. On the gafferongames.com, more specifically the Snapshot Compression post contains a lot of information regarding data compression. One of the major problems there is the fact that some of the packings require us to directly deal with the bits of our data, which is not 100% possible with GDScript.
Regarding compression, we could enable Godot's compression within the network object. Once the NetworkedMultiplayerENet
object gets created, change the compression_mode
property to one of the supported (COMPRESS_RANGE_CODER
, COMPRESS_FASTLZ
, COMPRESS_ZLIB
or COMPRESS_ZSTD
). Without means to measure how much the data is being compressed it becomes really difficult to know which one of the methods is being the most efficient. Because we are manually encoding the data we could use the compress()
and decompress()
functions given by the PoolByteArray
class. In this case we have an approximate value of how much data is being sent across the network, since Godot will add some extra information into the packets, not to mention the IP + UPD headers (although those are not usually compressed anyways). Another detail is the fact that we need the decompressed amount of bytes in order to use the decompress()
function.
Another improvement that can be done is that instead of sending a complete snapshot over and over, deal only with the changes. The problem here is that lost packets may contain some very important information, like a destroyed or a new entity in the scene. In this case we have to incorporate the reliability layer as described early.
On a client, we still have the delayed answer to the input, which becomes even bigger at lower update rates. One possible solution is to immediately update the local avatar. Then when the snapshot data is received, compare with the applied local simulation of the player's avatar, bringing corrections as necessary. In this case, depending on how the correction is applied it may lead to the so called rubber banding effect, which makes the player "teleport" to different locations.
The way we are encoding/decoding the data before sending across the network is potentially expensive and may become somewhat slow. Maybe it's a better idea to move this kind of code to GDNative.
Phew, that was a lot of work, and all of that only because we can't trust connected players over the internet! What we have brought here is means to synchronize the players while still trying to keep simulation control on the server. This does close some doors to cheaters, although not fully protected against them. Unfortunately, we can't completely avoid those players.
In any case, in the next part of this tutorial we will deal with two other things that are simpler than synchronizing the game: kicking players from the server and chat system.