-->
Networked multiplayer games. This is a huge, not straightforward, thing to be done. That's true if we want to close some obvious doors for cheaters while also still keeping the game at a reasonable playable state because of inherent latency that can also be added on top of the physical lag caused by the network infrastructure.
I have published two tutorials (here and here) on this web site regarding how to setup networked multiplayer games with Godot. The overall necessary steps to achieve a synchronized multiplayer game through the network with snapshot interpolation is shown throughout both tutorials. Yet, just a little bit of network latency results in a somewhat unplayable experience because the shown method makes the client wait for the response from the server in order to update its state.
But there is also one rather problematic thing with that entire system. Adapting to a custom project may not be that difficult, however small changes in the game requires a bunch of updates in pretty much the entire networking system which obviously will lead to not so fun to hunt bugs. If one were to add client side prediction to hide the latency, it also brings another thing that must be verified whenever the game is updated.
This addon was born from the desire to automate most of the process to achieve a synchronized networked multiplayer game. Most of the design decisions behind it are meant to result in a less intrusive as possible system, that should not interfere (too much) with the game logic and, if possible, don't force any node hierarchy whatsoever.
When you see the code samples and/or the how to use tab, you will probably notice the fact that there isn't a node meant to be attached into your game object which will perform the synchronization. While this kind of node would be really nice, reality is that it would be really difficult to implement something generic enough to be useful. The problem is that often it's not enough to replicate only the "physical states" of the game objects (orientation, position and velocities). Anything that results in a visual display (player health for GUI, color of a banner and so on) will require replication, at least at some point in time.
The system consists in basically performing some initialization, creating a few short scripts meant to work as a bridge between the game logic and the snapshot system and perform some per game loop calls that are mostly telling which objects are part of the snapshots. In this most basic usage the result will be a client/server game that is in a similar state to the one that is achieved by following my other tutorials. In other words, absolutely no mechanism to hide latency.
Client side prediction is a technique used to try to hide network latency and consists in running the same game logic no only on the server, but also on the client machine. One may think that game logic should not be run on the client, but that's not really the case, provided the client does not directly tell the server anything about the game state. With this technique the client tries to stay ahead of the last received snapshot data, which represents a past state of the server (remember there is latency). When a new snapshot is received, the client must then compare with a corresponding internal prediction state, which is also client's past. If there are differences, then the client must perform the corrections accordingly. Because each project has to deal with this mechanism based on its code, the entire prediction and correction system cannot be part of the addon. Still, this tutorial describes how to achieve this technique using this addon. Also check the network and the complete examples in the demo project.
Two things to remember about this addon:
encdecbuffer.gd
script.There are a few limitations with this addon:
Once this addon is activated, it will not only create a few entries in the project settings main tab, it will also automatically add the network.gd
script into the project's auto-load (singletons) list. If you have any other singleton script and intend to access the network addon from it, ensure your script is loaded after the network.
The additional project settings are shown in the next image:
Additional Project Settings
Those settings will be described throughout this tutorial's tab, so don't worry too much about them at this moment.
The very first thing that we must deal with is, obviously, the structure to open the server as well as means to connect to them as clients. Those operations can fail and the result is rarely immediate. Because of that, the operations that must be performed based on success (change scene) or failure (display error message) must be done through signal handling. For that, in the main menu script we have to listen to a few signals defined in the network.gd
script.
Signal | Usually For |
---|---|
server_created | Since this will be given when the server is created, this should be a pretty good place to transition into the game scene |
server_creation_failed | This is given when the creation fails. Because it may take some time before this signal is given, it would be a good idea to temporarily disable the create and join buttons to avoid further requests while this signal is not given. |
join_fail | This is given when joining a server has failed. Maybe the connection is not correctly working, or the server does not exist or even the port is not correct. This behaves much like the server_creation_failed , so maybe some buttons should be temporarily disabled until the signal is dealt with. |
join_accepted | Joining the server has been successful. Perhaps this is a good place to transition into the game scene |
join_rejected | Joining the server has actually happened but the system allows for some other kinds of checking which may allow manual rejections. In that case this signal will be received. Again, it may take some time so further UI code may be desired. |
Note that if you don't intend to use the connection rejection (typically for a banning system or wrong credentials), then you can safely ignore the join_rejected
signal since it will only be given if you explicitly deal with this. Shortly I will show how.
In any way, once the desired signals are properly handled, it's a matter of calling, from button clicks, the functions that may trigger those events. Namely, in order to create a server it's simply calling the function create_server()
, which requires the connection port, the name of the server and then the maximum number of connections:
func _on_create_server_clicked() -> void:
# Try to create the server on port 1223, named "TestServer" and with max of 5 connections
network.create_server(1223, "TestServer", 5)
# ... perhaps some UI buttons can be disabled in order to avoid multiple clicks while
# the network system is performing the necessary tasks, which may even fail
# Another option is to display a modal window with a message like "Creating server..."
Once that is called, the addon will try to create the server. In case it's successful, then the signal server_created
will be given and provided there is a proper handler, the game may change into the game world scene. Obviously, if the creation has failed (server_creation_failed
), then it's a good idea to show a message box and, if there was any UI disabling, re-enable them after the message box is dealt with.
Now to join a server. This is done by calling the join_server()
function, which requires the IP address and the connection port. In the following snippet we try to connect to the local machine (which is represented by the special IP 127.0.0.1
), which should have a server window running:
func _on_join_server_clicked() -> void:
# Try to join the server on port 1223, on the local machine
network.join_server("127.0.0.1", 1223)
# ... perhaps some UI buttons ca be disabled in order to avoid multiple clicks while
# the network system is performing the necessary tasks, which may even fail
# Another option is to display a modal window with a message like "Joining server..."
Again, provided there are proper signal handlers, it may be a good place to change into the game world when the join_accepted
signal is given. Obviously, in the same fashion that was done with the server creation, deal with the UI if the join_fail
has been given.
Perhaps you may want to create login or a ban system. For that, there is a way to reject connections. Using this system consists in handling a signal named credentials_requested
and setting a function reference property named credential_checker
. The signal handling is something that is run on the client, while the checking is done on the server. Note that if the checker function reference is not set the entire system will be ignored and new connections will be automatically accepted by the addon, provided the ENet system didn't reject because of maximum amount of connections.
Basically, if the credential checker function reference is set then the server will request credentials to the client trying to connect. On the client this will result in the credentials_requested
signal being given. Since this is part of the attempt to connect to the server, the proper location to deal with this is still in the main menu script. So let's first deal with the request to give credentials. Suppose we have the following function connected to the credentials_requested
signal. It will create a credential dictionary that will be sent to the server:
func on_credentials_requested() -> void:
# This dictionary can surely change from project to project
var credential: Dictionary = {
"user_name": get_user_name(),
"password": get_salted_password(),
}
# Dispatch the credentials to the server
network.dispatch_credentials(credential)
Now that the credentials have been sent to the server, the authority must deal with it. We need a function that receives a dictionary as argument (the credentials dispatched by the client) and return an string, empty to accept the connection or a text that will be given with the rejection, which may give the reason for the fact.
func on_credential_received(cred: Dictionary) -> String:
# Assume the function check_db_for_user checks a database and returns true if the
# provided user name exists and the password matches
if (check_db_for_user(cred.user_name, cred.password)):
if (!is_user_banned(cred.user_name)):
# The user exists and is not banned. So accept the connection by returning empty string
return ""
else:
# Reject the connection because user is banned. It would be a good idea to also check the
# ban time and attach that into the returned string
return "You have been banned"
else:
# The user name does not exist in the data base or the password does not match
# So, reject the connection
return "Invalid user name and/or password"
As you can see, the credentials system gives space for any kind of checking, being open for the requirements of pretty much most projects because the actual credential contents can be stuffed into a dictionary, which will be sent to the server and dealt accordingly.
Part of the game synchronization mechanism when dealing with client/server architecture consists in the client sending input data to the server, which will then compute the state based on that. The networking addon pretty much automates the process of sending input data to the server, but it must "know" what data is meant to be gathered. If the requirements are relatively simple like any movement that directly responds to the input keys, then the process consists of registering within the network object all of the relevant input mappings while also specifying if that map should be dealt as boolean or analog.
So, suppose we have in the input map (project settings window) the following actions (the actual buttons are irrelevant here):
Action | Analog? |
---|---|
jump | No |
sprint | No |
shoot | No |
move_forward | Yes |
move_backward | Yes |
move_left | Yes |
move_right | Yes |
During the initialization of the game we then have to tell the network system that we want to deal with these action keys, through the register_action()
function. The first argument should be the name of the input map, while the second specifies if the value should be gathered as analog or not:
func _setup_network_input() -> void:
network.register_action("jump", false)
network.register_action("sprint", false)
network.register_action("shoot", false)
network.register_action("move_forward", true)
network.register_action("move_backward", true)
network.register_action("move_left", true)
network.register_action("move_right", true)
At a later moment, when the input is necessary, regardless of server or client, it should be retrieved through the network system. This will be shown later in this tutorial, but for now, focus on the initialization. With those lines we are essentially telling the network system that we have those input mappings to be encoded/decoded when sending data through the network.
Important:
The system has a limitation of only 32 booleans that can be sent through the encoding because it packs all of the boolean input actions into a single integer (either 8, 16 or 32 bit). In this regard, each analog (floating point) input action will take 32 bit of data when being sent through the network.
Ok, but, what if we need the mouse relative (or even mouse speed) as part of the input data? In a first person shooter, for instance, the mouse relative is rather necessary! Obviously we have to tell the network system that we need this information when gathering input data. There are two options here to enable this to be part of encoded/decoded data. The first method has already been indirectly shown, in the additional project settings image. The relevant properties in there are Use Input Mouse Relative
and Use Input Mouse Speed
which, if enabled, will attach the mouse relative and mouse speed, respectively, into the encoded/decoded input data. The other option is to do that from code, with the rest of the input setup:
func _setup_network_input() -> void:
# Enable mouse relative data in the input objects
network.set_use_mouse_relative(true)
# Rest of the setup
Important:
Mouse relative and mouse speed are vectors, containing 2 floating points each. This means that each one of those will add 64 bit to the input objects. The shown initialization, which contains 3 booleans (fit in a single byte), 4 analog and just the mouse relative results in 200 bits (25 bytes) per input object.
How about dealing with input that is based on clicking the game world. As an example, suppose you have multiple units that can be selected by dragging the mouse, or even single selections by just clicking in the unit. Right clicking would then move the selected units into the clicked location. The way this is typically done is by first obtaining the world coordinates of the clicked locations and performing the relevant actions based on that information. Obviously this can't exactly be done with the shown action registrations because, generally speaking, converting from screen space into world space requires the camera which most likely will be in different states for each player.
Because of that, we have to deal with input in a completely different way for this use case. With this addon, this basically consists in using custom input data. First it's necessary to "register" this custom data just so the encoding and decoding can retrieve that information from the InputData
objects. Custom input data can be boolean/analog actions (but wont be automatically retrieved through device polling) or vectors (Vector2
or Vector3
). Regardless of the type, at some point those custom data must be filled within the obtained InputData
object.
To register the custom input data:
# Register a custom boolean input data
network.register_custom_action("custom_boolean", false)
# Register a custom analog input data
network.register_custom_action("custom_analog", true)
# Register a custom vector2 input data
network.register_custom_input_vec2("custom_vector2")
# Register a custom vector3 input data
network.register_custom_input_vec3("custom_vector3")
Following the example of selecting units, we would then register a few custom vectors to represent the world space coordinates of the clicked locations (check the network example in the demo project, does exactly that). Shortly in this tutorial I will show how to fill the input objects with this custom data.
This is a rather short topic but must be mentioned either way. In order for the server to start sending snapshot data to the client, a "ready" signal must be given by the client. This is done by calling the notify_ready()
function. A good place to perform this is at the end of the _ready()
function of the game world script. Granted, at this point the game world should be initialized and ready to begin the simulation:
func _ready() -> void:
# ... some code
# If this is client, notify the server that snapshot data can come this way
if (!network.has_authority()):
network.notify_ready()
At each game loop iteration the snapshot must be created and, obviously, finished. To start the snapshot creation, it's a matter of calling the init_snapshot()
function. This will create all of the internal state in order to fill it with the necessary data. This call will also setup an internal state which will automatically finish the snapshot once entering the first idle process call (IE.: the finishing is deferred).
So, in the game world, the usual code becomes something like this:
func _physics_process(_dt: float) -> void:
# Start building the new snapshot
network.init_snapshot()
# Perform any necessary update in the game code
If you check the demos in the repository project, right after initializing the snapshot, each connected player is iterated through and the corresponding characters are spawned, provided the code is running on the server. Something like this:
func _physics_process(_dt: float) -> void:
# Start building the new snapshot
network.init_snapshot()
if (network.has_authority()):
# Create character(s) for the local player
spawn_player_avatar(network.player_data.local_player)
# Now for each connected player
for pid in network.player_data.remote_player:
spawn_player_avatar(network.player_data.remote_player[pid])
Obviously this assumes the spawn_player_avatar()
function exists somewhere in the script and receives an instance of the PlayerNode
, which is defined in the playernode.gd
file.
As mentioned early in this tutorial, a few short scripts are necessary in order to bridge data between game logic and snapshots. Don't worry too much about this because, really, those are very simple. The most important thing to note is that they must follow four "rules":
SnapEntityBase
._init()
function in order to relay data to the parent class, which does have an init.apply_state()
function, which is used by the snapshot system to take data and apply into the specified node.During initialization the network addon will scan all scripts derived from SnapEntityBase
and automatically perform the registration of the classes. Think about them as representations of your game world objects within the snapshots. I strongly recommend that you add a class_name SomeUsefulName
line just bellow the extends SnapEntityBase
line because this "name" will be used as identification when referencing objects in the snapshot.
By default, each object of this class will contain two properties, id
and class_hash
. The first property is used to identify the game object within the game world, specially when sending data through the network. This will be used to match things on all peers. Understand the class_hash
as the class identification which can be used to specify which node path should be used. Both of those properties are integers and, when encoding/decoding, are considered as unsigned 32 bit integers.
The class_hash
property can be disabled, which in turn will result int 32 bit less data encoded per snapshot object represented by the class. To do that just add a meta named class_hash
and set its value to 0
within the _init()
function.
Each additional property added to the script will be automatically "registered" as data to be encoded/decoded into/from snapshots. During the scanning process the code requires information regarding the type of each property, so it becomes imperative that they are static typed in the script. Note that not every type can be used here, just a subset of them, which are:
But, wait, Godot does not have unsigned integer (16 and 32 bit) types nor specifically byte (8 bits) type. Indeed, they do not exist, but it's possible to tell the network system that an integer property is meant to be interpreted as an unsigned integer of either 16 or 32 bits or even a single byte. To do that, in the _init()
function just add a meta with the name of the integer property while setting its value to one of the following constants (defined in the EntityInfo
class):
Meta value | Result |
---|---|
CTYPE_UINT | The property will be considered as a 32 bit unsigned integer |
CTYPE_USHORT | The property will be considered as a 16 bit unsigned integer |
CTYPE_BYTE | The property will be considered as a single byte |
As for the _init()
function itself, it must call the parent's initialization by giving both unique id and class hash, even if the second property has been disabled. The thing is, disabling it only prevents the data from being encoded in the snapshots but the property is still there.
Finally, there is the apply()
function that requires one argument of type Node
. The idea of this function is to relay the state in the snap entity base object into the game node.
Now for a simple example here. Let's suppose a 3D game where we have kinematic body objects. Minimally we need position (vector3) and orientation (quaternion). However, in order to make it easier to perform corrections if we deal with client side prediction, it's a good idea to also have the accumulated velocity (vector3). With that in mind, the script would become something like this:
extends SnapEntityBase
class_name SnapPlayerCharacter
var position: Vector3 = Vector3()
var orientation: Quat = Quat()
var velocity: Vector3 = Vector3()
func _init(uid: int, h: int).(uid, h) -> void:
# No extra initialization needed.
pass
func apply_state(to_node: Node) -> void:
# Ensure the node is actually a KinematicBody - we could, if desired, take
# an specific class corresponding to the player character if so desired
assert(node is KinematicBody)
# Assumes the player character script contains a function named apply_state()
# that receives a dictionary with the fields corresponding to the properties
node.apply_state({
"position": position,
"orientation": orientation,
"velocity": velocity,
})
With this script we now have means to represent player characters within the snapshot objects. At a later moment in this tutorial there will be some details related to the player character itself. That is, how to deal with the received state data and also how to generate it in order for the snapshot to be sent to the clients.
Before moving to the next topic, the next snippet is given to exemplify how to use the custom types:
extends SnapEntityBase
class_name SomeExample
var some_uint: int
var some_byte: int
var some_ushort: int
func _init(uid: int, h: int).(uid, h) -> void:
# some_uint is meant to be unsigned int
set_meta("some_uint", EntityInfo.CTYPE_UINT)
# some_byte is meant to be a single byte
set_meta("some_byte", EntityInfo.CTYPE_BYTE)
# some_ushort is meant to be unsigned int of 16 bits
set_meta("some_ushort", EntityInfo.CTYPE_USHORT)
... rest of the code
While game nodes can be described by classes derived from SnapEntityBase
and represented in the snapshots by those objects, spawning the game nodes must be done through the network system. The reason for this is so the addon can properly associate the nodes with the snapshot objects and then perform the synchronization.
In order to do so (and somewhat automate part of the process), the network system requires objects named node spawners, which are basically classes derived from NetNodeSpawner
(defined in the nodespawner.gd
file). Note, however, that there is a default node spawner that should be enough for the vast majority of cases, so usually there is no need to create a new one. Spawners are used in order to "bridge" the network system internal data and the game world node hierarchy. With that said, the spawners must be registered before being able to create any game node that will be synchronized through the network addon.
Spawner registration is done through the snapshot data function named register_spawner()
. This function requires the reference to the snapshot object type, a class hash (again, think about it as a class identification), the spawner object, the node that will be used as a parent when the new object is spawned and an optional function reference that will be called when the node is spawned. In here, basically each object type (those that are derived from SnapEntityBase
) can have multiple spawners associated with. The spawners are found by the class hash. The spawner is the object that performs the instantiation of the game node.
The default spawner that is provided with the addon, NetDefaultSpawner
(defined in the defaultspawner.gd
file) holds a single property, which is the packed scene of the node that must be instantiated. So, suppose we have 3 scenes for player characters and we want spawners for all of them, associated with the SnapPlayerCharacter
that has been shown early. In the game world there is also one spatial node named player_characters
meant to hold all spawned characters. Also suppose we want to perform some extra setup whenever the nodes are spawned. The registration (in the game world script) would look something like this:
func setup_network_spawners() -> void:
# Function to be called whenever the nodes are spawned
var extra_setup: FuncRef = funcref(self, "on_extra_pchar_setup")
# Gather data for the barbarian player character
var pack_barbarian: PackedScene = load("res://scenes/pchar_barbarian.tscn")
var barb_class_hash: int = pack_barbarian.resource_path.hash()
# Perform the actual registration, using the default spawner
network.snapshot_data.register_spawner(SnapPlayerCharacter, barb_class_hash, NetDefaultSpawner.new(pack_barbarian), $player_characters, extra_setup)
# Gather data for the wizard player character
var pack_wizard: PackedScene = load("res://scenes/pchar_wizard.tscn")
var wiz_class_hash: int = pack_wizard.resource_path.hash()
# Perform the actual registration, using the default spawner
network.snapshot_data.register_spawner(SnapPlayerCharacter, wiz_class_hash, NetDefaultSpawner.new(pack_wizard), $player_characters, extra_setup)
# Gather data for the rogue player character
var pack_rogue: PackedScene = load("res://scenes/pchar_rogue.tscn")
var rogue_class_hash: int = pack_rogue.resource_path.hash()
# Perform the actual registration, using the default spawner
network.snapshot_data.register_spawner(SnapPlayerCharacter, rogue_class_hash, NetDefaultSpawner.new(pack_rogue), $player_characters, extra_setup)
In this script note something very important. The class hash has been calculated by taking the resource path and hashing the string. You can use whatever identification you want, it's just that by using this hashing the ID becomes automatically calculated.
Some things to note regarding the class hash:
Just to complete the example, let's see the on_extra_pchar_setup()
function, which will be called whenever one of the player characters is spawned in the game world:
func on_extra_pchar_setup(new_pchar: Node) -> void:
new_pchar.set_color_list(color_list)
# And any other additional initialization that you may need.
Now that node spawners have been registered, spawning then becomes possible. Note that ideally "manual" node spawning should happen only on the server, which will then "tell the clients" about this through the snapshots. To spawn new nodes in the game there is the snapshot data function named spawn_node()
, which requires reference to the snapshot object type, the unique id and the class hash.
When the spawn node function is called, the network system will use the registered spawner to perform the scene instantiation, attaching it into the parent node specified when registering the spawner itself. Additionally to this, two meta data entries are also created in the spawned node, id
and class_hash
. Later you will see how that can be extremely useful, specially the unique id.
Nevertheless, if you go back a little bit, into the Snapshot Cycle
topic, the second snippet calls the function spawn_player_avatar()
. Shortly we will see a little bit of this function. There are a few things to consider first, though. In this snippet the player character unique ID will be the exact same network ID. This can be done only if each player owns a single character. In a game where each player controls multiple characters (think a real time strategy game, for example) the unique ID must be something else.
Then, before spawning the game node we have to check if it already exists. For that, we can first obtain the node using get_game_node()
function and checking if the returned value is valid or not. The inputs to this function are the node unique ID and the associated snapshot object type reference.
The following snippet is hard coding the barbarian class and assumes its class hash is cached in a variable named cached_barb_chash
.
func spawn_player_avatar(pnode: NetPlayerNode) -> void:
var char_node: KinematicBody = network.snapshot_data.get_game_node(pnode.net_id, SnapPlayerCharacter)
if (!char_node):
char_node = network.snapshot_data.spawn_node(SnapPlayerCharacter, pnode.net_id, cached_barb_chash)
char_node.global_transform.origin = get_character_initial_location()
Later in this tutorial I will show how to deal with player selection through the network. Indeed, when a client selects the desired class, it must be given to the server so the correct node is instantiated.
Up to this point, the server will indeed create player character nodes. But that is it. If a client connects, nothing will happen on its end. That's because the snapshots must be filled with data, which I haven't shown how to do so yet.
Any object that must be part of the snapshot must take its state, set that into a new instance of the corresponding class derived from SnapEntityBase
and add that object into the snapshot. So, suppose there is a base player character scene with an script attached into it. In there a function can be defined in order to generate the snapshot object:
func create_snapshot_object() -> SnapPlayerCharacter
var uid: int = get_meta("uid")
var chash: int = get_meta("chash")
var ret: SnapPlayerCharacter = SnapPlayerCharacter.new(uid, chash)
ret.position = global_transform.origin
ret.orientation = Quat(global_transform.basis)
ret.velocity = velocity
return ret
Now, in the _physics_process()
function, near its end, we call this function and add the return value to the snapshot, using the function snapshot_entity()
:
func _physics_process(dt: float) -> void;
# ... some code - the usual character logic
# Now add the state to the snapshot if running on the server
if (network.has_authority()):
network.snapshot_entity(create_snapshot_object())
The previous snippet adds the state into the snapshot only if running on the server. This is perfectly valid for the case when there is no desire to incorporate client side prediction in order to hide the latency. However, in the case of client side prediction, the client do need to fill local snapshots (the reason for this is explained in the how it works tab). The function in this case would look like this:
func _physics_process(dt: float) -> void;
# ... some code - the usual character logic
# Now add the state to the snapshot
network.snapshot_entity(create_snapshot_object())
The data is being added to the snapshot. Now, when the client receives it, the system will compare with local data. When necessary, it will call the apply_state()
function within the snapshot object. Just as a quick reminder, this is the function defined in the class derived from SnapEntityBase
in order to take the snapshot state and transfer it into the actual game. Going back to that topic (Bridging Snapshot and Game Logic
) the shown snippet assumes the corresponding game node implements a function also named apply_node()
that uses a Dictionary
as input. So, continuing with the example up to this point, let's see how it would look like in the player character script:
func apply_state(state: Dictionary) -> void:
global_transform.origin = state.position
global_transform.basis = Basis(state.orientation)
velocity = state.velocity
With this code, the state is immediately applied into the game node. This is valid if there is no client side prediction method, however it will result in some repeated movements if prediction is applied. The problem is that the state may be applied in between physics updates, where the actual prediction may happen. In order to perform client side prediction, it becomes necessary to cache the received state, which should be applied at a later moment. So, assuming the player character script declares a property named correction_data
, meant to be a dictionary, this function can be changed to:
func apply_state(state: Dictionary) -> void:
correction_data.has_correction = true
correction_data.position = state.position
correction_data.orientation = state.orientation
correction_data.velocity = state.velocity
Notice in this script that a flag is part of the local dictionary (and set in the function). This will be used to determine if the stored data must be applied or not during the physics updates. The next snippet exemplifies this:
func _physics_process(dt: float) -> void:
if (correction_data.has_correction):
# Reset the flag so it doesn't trigger the correction multiple times
correction_data.has_correction = false
# Apply to the game node
global_transform.origin = correction_data.position
global_transform.basis = Basis(correction_data.orientation)
velocity = correction_data.velocity
At this point correction data is applied to the game node but there is still some work that must be done if this is an entity directly controlled by player input. The reason follows:
The necessary steps to perform the correction in a client will be mentioned in the next topic.
For now let's assume that our input directly affects the character. That is, pressing the "move_forward" button will move the character forward and so on. Normally in this case, the relevant piece of code does something like Input.is_action_pressed("some_mapping")
and perform something in case it's true. Instead of doing that we must request one InputData
object from the network system, which is done by calling the get_input()
function. This function requires the network player ID as argument and will return null if the player is not supposed to deal with the input. As an example said case, suppose the code is running on the client and the specific instance of the player character corresponds to the server's player.
This InputData
object contains a few functions that can be easily used to determine the state of the relevant buttons/keys:
get_mouse_relative()
→ Returns a Vector2
with the mouse relative data if mouse relative is enabled in the network system.get_mouse_speed()
→ Returns a Vector2
with the mouse speed data if mouse speed is enabled in the network system.get_analog("some_mapping")
→ Returns a floating point number indicating the state of the specified mapped input key/button.is_pressed("some_mapping")
→ Returns true if the specified mapped input key/button is pressed, false otherwise.As you can see, the game code to deal with input doesn't change that much, just how you obtain the state! When the get_input()
function is called, the network system automatically deal with the generation of the data to be sent to the server when running on a client. And, if running on the server, it will get data that was received from the corresponding client.
With all that in mind, a function can be created in order to calculate the simulation based on the input:
func handle_input(input: InputData) -> void:
# If the input object is not valid, bail as this machine is not meant to do anything
if (!input):
return
# ... some code
if (input.is_pressed("jump")):
# Perform the jump operations
if (input.is_pressed("sprint")):
# Perform the sprinting operations
# And so on...
Obviously this is a rather simplistic example, but this is mostly to show the overall pattern! Nevertheless, I have mentioned previously that if client side prediction is desired, input data must be replayed when running in the client and corrections are applied. This means the client must store the input objects in a local cache and use those when correcting the state. Once input is confirmed, clean up only the relevant data. The network addon already deals with this cache management so the code will mostly have to request an array of this non confirmed data!
With the list of non acknowledged input data in one array, when correction is applied they can be replayed. With this in mind, the physics update function can be updated. Note in the code that we only replay the input data if the character belongs to the local player, otherwise remote players will incorrectly replay their own input data:
func _physics_process(dt: float) -> void:
# Obtain the unique ID of this character
var uid: int = get_meta("uid")
if (correction_data.has_correction):
# ... apply the state code
# Replay the stored input objects
if (network.is_id_local(uid)):
var inputlist: Array = network.player_data.local_player.get_cached_input_list()
for idata in inputlist:
handle_input(idata)
# Because the unique ID corresponds to the network unique ID, this can be used
var input: InputData = network.get_input(uid)
# Handle the input
handle_input(input)
This code assumes that all of the simulation code for the node object is done within the handle_input()
function, including gravity and so on.
The code shown in the previous topic does indeed allow the client to predict and correct its state. However it still does one problem. The local snapshots are still incorrect and this fact will result in the correction code to be triggered multiple times. Depending on the nature of the correction this can result in some visual glitches that obviously are not desired. To fix that the re-simulated entities must corrected within the corresponding snapshots. To do that there is the network.correct_in_snapshot()
function, which requires two arguments, the snapshot entity object with the correct state and the input object.
The two arguments are already easily obtained. In a previous topic, State to Snapshot, I have shown the create_snapshot_object()
, which will provide the snapshot entity object. The input data is then the one we used to re-simulate the entity. That said, we can update the previous snippet to something like this:
func _physics_process(dt: float) -> void:
# Obtain the unique ID of this character
var uid: int = get_meta("uid")
if (correction_data.has_correction):
# ... apply the state code
# Replay the stored input objects
if (network.is_id_local(uid)):
var inputlist: Array = network.player_data.local_player.get_cached_input_list()
for idata in inputlist:
handle_input(idata)
network.correct_in_snapshot(create_snapshot_object(), idata)
# Because the unique ID corresponds to the network unique ID, this can be used
var input: InputData = network.get_input(uid)
# Handle the input
handle_input(input)
Early in this tutorial I mentioned about custom input data, which can be created in order to send additional custom data to the server. When that data is registered, it becomes responsibility of the game logic to fill in when relevant. Basically, in the client code this data must be set while in the server it is retrieved. The relevant functions are:
get_custom_vec2(name)
set_custom_vec2(name, value)
get_custom_vec3(name)
set_custom_vec3(name, value)
get_custom_bool(name)
set_custom_bool(name, value)
get_analog(name)
set_analog(name, value)
These names should be self explanatory so I won't get into any explanation about them, only that the name
argument on those functions must match the registered custom input data, including the corresponding types, otherwise the encoding/decoding will fail or not do anything at all. Nevertheless, when input is required, the object can be retrieved exactly like shown in the previous topic, with network.get_input(id)
. Based on wether the code is running on server or client, the custom data can be "get" or "set". One possible way here is to perform this from within the handle_input()
function:
func handle_input(idata: InputData) -> void:
# ... some code - also assume this declares some variables:
# custom_analog, custom_bool, custom_vec2 and custom_vec3
if (network.has_authority()):
# This is server code
custom_bool = input.get_custom_bool("custom_boolean")
custom_analog = input.get_analog("custom_analog")
custom_vec2 = input.get_custom_vec2("custom_vector2")
custom_vec3 = input.get_custom_vec3("custom_vector3")
else:
# This is client code
custom_bool = calculate_custom_bool...
custom_analog = calculate_custom_analog...
custom_vec2 = calculate_custom_vector2...
custom_vec3 = calculate_custom_vector3...
input.set_custom_bool("custom_boolean", custom_bool)
input.set_custom_analog("custom_analog", custom_analog)
input.set_custom_vec2("custom_vector2", custom_vec2)
input.set_custom_vec3("custom_vector3", custom_vec3)
# ... some more code
perform_something_with_the_custom_data...
Note that the names of the custom data match those registered early in this tutorial! Also note one specific pattern here. In the server section the custom values are retrieved from the input object. In the client section those custom values are first calculated (and set within the variables) then assigned to the input object. Later in the code those variables containing the custom data are used to perform whatever is necessary.
While this example is very simplistic, this is, in a way, what is done within the network example in the demo project (look into the cam_control.gd
file).
In the Gathering Input - The Simple Case topic it's mentioned how client side prediction is performed. In that case, the amount of input data objects stored in the container is used to perform multiple simulations of the game node in order to "catch up" with the most recent local snapshot. What if there is any desire to perform this kind of re-simulation on entities that don't need input data, like a projectile for instance? This can still be done, through the network.snapshot_data.get_prediction_count()
function. It requires two arguments, the entity unique ID and the snapshot entity class that represents the entity itself. It returns a number, which should be how many times the simulation of the entity should run.
So, suppose we have a projectile class, represented in snapshots by SnapEntityProjectile
. In the projectile's script there is a function called _simulate()
, which performs the movement simulation. There's also a property named _uid
, caching the unique ID of the projectile. From the _physics_process()
, among other possible code, the _simulate()
is called. We can incorporate prediction code, similar to what has been done with the character script, by storing the correct data somewhere and, during the physics update, check if there is any correction. If there is any, then the prediction counter can be used:
func _physics_process(dt: float) -> void:
# ... some code
if (correction_data.has_correction):
# ... Apply the correct state code
# Then re-simulate the movement
for _i in network.snapshot_data.get_prediction_count(_uid, _SnapEntityProjectile):
_simulate(dt)
# ... some other code
# Simulate current frame
_simulate(dt)
# ... even more code
When comparing server and client data, by default this addon uses "ordinary" comparison, that is, if value1 == value2
returns false, then the objects are considered in different state and will trigger the apply_state()
function. Unfortunately comparing floating point numbers can be rather finicky. The most important thing to know about all this is the fact that operations with floats are not guaranteed to yield the same result when performed on different machines. This becomes a problem because the client side prediction works best if the simulation gives the same result. Triggering the apply_state()
way too often on predicted objects may lead to some visual glitches.
Because of that, often it's recommended to perform floating point comparisons with a certain tolerance. And Godot does offer that, through the is_equal_approx()
function. It receives only two arguments (the two values to be compared) and a tolerance will be automatically calculated. Under the hood, the core of the comparison is performed by abs(value1 - value2) < tolerance
. If this expression is true, then the two value are considered "equal".
With this in mind, this addon gives the possibility to perform floating point comparisons through the is_equal_approx()
or even by using a custom fixed tolerance value. To do that, when defining the snapshot entity class (those derived from SnapEntityBase
), add a meta with a name matching that of the floating point property, including compound ones like Vector2
, Vector3
and so on. Assign the tolerance to this meta or, if you prefer the is_equal_approx()
assign 0
or any negative number.
If you decide to use a custom fixed tolerance value, the only thing to keep in mind is that it will use the same expression abs(value1 - value2) < tolerance
to test for "equality".
The snippet bellow showcases how to declare 3 properties to use tolerance in their comparisons:
extends SnapEntityBase
class_name SomeSnapshotEntity
var prop1: Vector3
var prop2: Quat
var prop3: float
func _init(uid: int, hash: int).(uid, hash) -> void:
# Configure prop1 and prop2 to use is_equal_approx()
set_meta("prop1", 0)
set_meta("prop2", 0)
# Configure prop3 to use 0.001 as tolerance
set_meta("prop3", 0.001)
Things to keep in mind:
Early I have shown how to spawn player character nodes from within the game world script. In there, the character class is hard coded. I have also mentioned that I would show how to deal with player choice. The thing is, whenever a player chooses a character class, most likely the game is not even connected yet, still somewhere in the main menu.
When the character selection is done, it will obviously change some internal variable and most likely some visual feedback within the UI. All of that still in the main menu script. With all that in mind, it would be a good idea for this script (or even a singleton script) to have all of the class hash values stored somewhere, preferably associated with the UI option in order to make things simpler.
Yet, this information must be given to the server. Enter custom player properties. This is a feature that is part of this network addon in order to allow some additional values to be automatically synchronized through the network. If a custom property is changed while still not connected, after connecting that property will be sent to the server. Besides that, if the value is changed during the game, it will be updated. The most important thing to note is that custom properties are synchronized using the reliable method, which can be slower than the rest of the system. This fact should not keep you from using this system, just that you should avoid constantly updating values in it.
A custom property must be registered within the network system. This is done by calling the add_custom_property()
function, which is part of the player_data
. The function needs 3 parameters. The first one is the name of the custom property, which is how the value can be obtained at a later moment. The second argument is the default value of this property. Lastly, is how this property should be replicated, which is basically chosen from the enum ReplicationMode
that is defined in the NetCustomProperty
class. The options are:
Option | Result |
---|---|
None | The property will not be replicated at all. |
ServerOnly | The value will be sent only to the server. |
ServerBroadcast | The value will be sent to the server, which then will broadcast to the other clients. |
In the main menu script we can register a custom property that will hold the class hash of the selected player character. There is no need for this property to be broadcast to all clients, since the snapshot system will take care of spreading the correct class hash value through the snapshot data. In the following snippet it's assumed the barb_hash
holds a cached value of the class hash corresponding to the barbarian scene, which was described early in this ongoing series of examples:
network.player_data.add_custom_property("character_class", barb_hash, NetCustomProperty.ReplicationMode.ServerOnly)
The property is now registered and can be used! When the player selects a character class in the menu, this property can be changed, with the set_custom_property()
function. Note that to update the property the specific player node must be accessed and obviously ideally this should be done only for the local player. With all that in mind, suppose there is a function meant to handle the player character selection. During the signal connection, a payload has been added into it, which is the class hash corresponding to the selected character class:
func on_character_selected(class_hash: int) -> void:
network.player_data.local_player.set_custom_property("character_class", class_hash)
Now, to get this property the function is get_custom_property()
which is also part of the player node. This function requires the name of the custom property and a default value in case this property is not found.
If you recall that spawn_player_avatar()
function, which was previously shown, it does receive an instance of the player node as argument. So let's update that function to use this custom property instead of the hard coded cached hash:
func spawn_player_avatar(pnode: NetPlayerNode) -> void:
var char_node: KinematicBody = network.snapshot_data.get_game_node(pnode.net_id, SnapPlayerCharacter)
if (!char_node):
var chash: int = pnode.get_custom_property("character_class", cached_barb_chash)
char_node = network.snapshot_data.spawn_node(SnapPlayerCharacter, pnode.net_id, chash)
char_node.global_transform.origin = get_character_initial_location()
And that's it! The custom property is registered, assigned and used to indicate which player character scene to be used by an specific player!
Now there is one important thing to note here. This spawning function is defined in the game world script. The custom property is probably being registered in the main menu script. In this case, directly testing the scene (by default, F6 Key), means the property does not exist when get_custom_property()
is called. That's the reason the cached_barb_chash
is added as default value.
An alternative to the default value is to perform the custom property registration from a singleton (auto-load) script, as long as the script is loaded after the network addon.
Because the demo project is meant to give several examples and at least two of them are using the networking system, you will probably notice the fact that there is no extra auto-load script performing any kind of operation. Indeed, this is meant to avoid "polluting" the overall game code environment so everything in the example code is done to be "as confined as possible".
On certain cases, synchronizing the game state becomes a lot easier through events that are sent through the network. Think, for example, a player picking up an item from the game world. While it's possible to synchronize the action purely through snapshots (specially when the pickup just changes the character state), using events in this case is still a lot easier. To that end, this addon offers means to dispatch events, with a few things that must be kept in mind:
As many of the other features of the addon, event types must be registered before being used, which consists of specifying the event type ID and a list of parameter types (in an array).
Regarding the event type ID, it must be a number, smaller or equal to 65535 (that is, 16 bits). In other words, do not consider the possibility of creating named events and hashing those strings as those will result in 32 bit numbers.
As for the parameter types, those must be supported by the encoding system (the list is in the Bridging Snapshot and Game Logic topic).
The registration itself can be done a single time in the game so a good place for this is either in the main menu script or a singleton (auto-load) script. The following snippet registers 2 event types. One that doesn't receive any parameter and the other that uses two (a floating point and one 16 bit integer):
# Register an event (code = 0) that doesn't receive any parameter
network.register_event_type(0, [])
# Register an event (code = 1) that receives two parameters (float and 16 bit integer)
network.register_event_type(1, [TYPE_REAL, NetEventInfo.CTYPE_USHORT])
Obviously event handlers are necessary in order to perform anything useful when the event does happen. To that end, those must be attached to the event type. Note that multiple handlers can be added into a single event type. Because events are (frequently) more useful when the game is already running, often those will be part of the game world script. The function to attach event handlers (attach_event_handler()
) requires 3 arguments, which are the event type code, a valid object and the name of the function that must be part of that object. This specified function will be called in order to handle the event when the event is decoded on clients and when processed on the server.
The next snippet attaches one event handler for each of the previously registered events. The event type = 0
will be handled by a function called on_simple_net_event
while the event type = 1
will be handled by a function called on_another_event
, both being part of the same script performing the attachment:
# Attach an event handler for the event code = 0
network.attach_event_handler(0, self, "on_simple_net_event")
# Attach an event handler for the event code = 1
network.attach_event_handler(1, self, "on_another_event")
Those event handlers must implemented. Those are simple functions, which should have parameters matching the requested ones when registering:
func on_simple_net_event() -> void:
print("Received a network event without arguments")
func on_another_event(p1: float, p2: int) -> void:
print("Received a network event with two arguments: ", p1, " and ", p2)
The last thing about this system is in how to actually send one event. This is done through the function send_event()
, which requires two arguments. The first one is the event type ID code, while the second is the list of parameters stuffed into an array. Notice that this function can be called regardless of being on client or server as the check will be performed from within. In other words, if calling on a client, this function will do nothing. With this in mind, the game logic code can become slightly simpler when it becomes necessary to send an event through the network. The following snippet sends an event of each of the previously shown event types:
# Send an event of type = 0
network.send_event(0, [])
# Send an event of type = 1
network.send_event(1, [3.14, 314])
Right after sending snapshot data, those two events will be encoded and sent to all connected clients with a single remote call. During the event processing on the server, it will locally call the event handlers just so the server can use the same code to handle the events that clients do.
This addon provides means to exchange chat messages. To send a message, there is send_chat_message()
function. It requires two arguments, the message itself and the network ID of the destination. If set to 0, then the message will first be sent to the server which will then broadcast to every connected player, obviously skipping the sender. So, supposing there is an event that once handled, takes the text from a LineEdit
control stored in a variable named txt_chat_input
to be sent as a chat message. The following snippet performs the operation while also clearing that control for further chat message inputs:
# Broadcast this message to everyone connected
network.send_message(txt_chat_input.text, 0)
txt_chat_input.text = ""
When a chat message is received, the signal chat_message_received
will be given by the network system. The signal contains two arguments, the message itself and the network ID of the person who sent the message, in this order. With that in mind, a possible event handler can look something like this:
func on_chat_message_received(msg: String, sender: int) -> void:
var output: String = format_chat_message(msg, sender)
some_control.add_chat_line(output)
And that's it for the chat system!
One thing to note: for the moment there is no (easy) way to whisper a message to the specified player in a way that the message passes through the server first. This use case may be desired and this feature is in the TODO list (once incorporated this topic will be updated).
This addon automatically measures latency (ping) of connected players. By default the server will take the measured value and broadcast to all connected players. This can be disabled and only the relevant player will get the measured value through the project settings. The specific property is the Broadcast Measured Ping
, which should be set to false if you don't want the ping value to be given to every connected player.
In any way, from wherever you want to deal with this value, all that must be done is connect a function to the network ping_updated
signal, which receives two arguments, the ID of the player and the measured ping, in milliseconds. So, an example of event handler would look something like this:
func on_ping_updated(player: int, value: float) -> void:
var hud: Control = get_hud_for_player(player)
hud.set_ping(value)
Obviously this is a rather simplistic example and assumes a bunch of things. But the gist of how to deal with the ping values measured by the addon is in there.
As mentioned early, the most important property to this entire snapshot system is the unique ID of the game objects. It's the value that is used to identify and perform the "connection" between the game logic and snapshot objects. Without it, sending data to a remote machine would have no easy means to know what the data corresponds to. Because of that, dealing with the unique ID for the various object types becomes necessary. Still, often this ID can be simply an incrementing integer.
Thinking about this, one really good place to hold this kind of incrementing value is a singleton (auto-load) script. Taking advantage of the fact that the network.gd is set as one, there is one incrementing id system that has been added into it. Obviously this must be registered first, with the register_incrementing_id()
function:
# Register incrementing ID for projectiles
network.register_incrementing_id("projectiles")
# Register incrementing ID for every object that will be represented by SnapMonster
network.register_incrementing_id("monsters")
As you can see, the ID must be unique per object type, not project wide! In other words, if you have 4 classes derived from the SnapEntityBase
, you may have four incrementing IDs, one for each of those classes.
Then, at a later moment, when dynamically spawning a projectile, you may want to take the ID:
# ... some code
var uid: int = network.get_incrementing_id("projectiles")
# ... perform the rest of spawning code
Please note that this incrementing ID thing is provided mostly for convenience and in many cases will not be enough for the correct identification, which will often require other means to generate the number.
Up to this point the core of the network addon is in use and provided everything was done right, the synchronization through the network should be working. Yet, there are a few signals that can be used in order to bring some extra features/polishing to the overall behavior of the game. As an example, when a new player joins (or leaves) the game, we can update the HUD. With that in mind, the following table shows the signals that can be used to perform some operation. Events that have been previously discussed are not shown in this table.
Signal | Description |
---|---|
player_added(id) | This is given when a new player is registered within the network system. The argument is a simple integer corresponding to the network id of the new player |
player_removed(id) | This is given when a player is removed from the internal network system (normally due to disconnection). The argument is a simple integer corresponding to the network id of the player. |
disconnected | A client will get this signal whenever disconnected from the server. No argument is given |
kicked(reason) | When a client gets kicked by the server, this signal is given. The argument is an string with the reason for the kick. |
If a level is designed in a way that contains movable objects, chances are high that one would like those to be synchronized. To that end, there is the add_pre_spawned_node()
function, which is used to perform the registration of a node that is part of the tree but was not dynamically spawned through the network system (with network.snapshot_data.spawn_node()
). This functions takes, as arguments, the snapshot entity type, the unique ID of the entity and the reference to the node itself. If you take a look into the clutter_base.gd
script (in the demo project), near the end of the _ready()
function this "registration" function is called in order to associate itself (the "clutter" object) within the network system. In that demo project the snapshot entity class is named MegaSnapClutter
. With that in mind, the relevant code is performed like this:
network.snapshot_data.add_pre_spawned_node(MegaSnapClutter, uid, self)
The rest of the synchronization happens like normal, that is, during the _physics_process()
an object of the MegaSnapClutter
for each instance of the clutter base is created, holding current state, then added into the snapshot with the network.snapshot_entity()
function.
Before wrapping this how to use, I would like to take some time to discuss some special synchronization use cases, that may require some different way of thinking.
SnapEntityBase
containing the environment variables that control the weather state. An spawner is not necessary in this case so there is no need to register one. Then use the game world script as the node to take the environment state.To create one (very simple) example, the snapshot representation of the environment can be called SnapEnvironment
(remember, this is derived from SnapEntityBase
). In it two variables are added, time_of_day
(as a float) and weather
(as a short integer). The first variable will be used to tell if the game is in daytime or night time (or something in between). And the second variable will tell, through an enumeration, the weather state. Those could be clear, cloudy, raining and so on. The script would look like this:
extends SnapEntityBase
class_name SnapEnvironment
var time_of_day: float
var weather: int
func _init(uid: int, chash: int).(uid, chash) -> void:
set_meta("weather", EntityInfo.CTYPE_USHORT)
func apply_state(node: Node) -> void:
var state: Dictionary = {
"time": time_of_day,
"weather": weather
}
# Assume the game world script contains the apply_state function
node.apply_state(state)
Next, the game world needs to "register" itself as the node associated with the environment entity type. This is done by using the add_pre_spawned_node()
function, which was explained in the Level Designed Synchronization topic (the previous one). Because there will be only one game world, we can hard code its unique ID. The snippet performing this registration:
func _ready() -> void:
#... some code
network.snapshot_data.add_pre_spawned_node(SnapEnvironment, 1, self)
Then, there are two final tasks to be done. The first one involves implementing the apply_state()
in the game world script, which will take the given dictionary and perform the necessary internal code to apply the state. Then, the other task is to create the SnapEnvironment
object within the _physics_process()
function.
When the item is picked from the game world, the action in itself must be replicated through the network just so every connected player can update the visual state and remove the "item on the ground" node. The easiest way here is to emit a network event telling that an specific item was removed from the ground, picked by the player. Still, depending on what the pickup actually is, events may not be necessary.
Now regarding the item itself. There are still a few cases which will determine the complexity of the problem. The easiest one is related to simple pickups, which just change the character state like healing or, in other words, pickups that are not added into an inventory. In this case, the changes given by the pickup can be incorporated into the snapshot entity, which will then replicate the effect to all other players.
When the pickup requires inventory, things can become a bit complex, specially if the items are dynamically generated through random rolls determining the properties of those items. Let's step back a little bit into items with fixed properties. Those can be part of a data base and identified by some unique ID, which should be the replicated value. On the client side, the same database must be queried in order to obtain the correct item.
If the item is fully randomized, then the easiest here is to have each of those properties be identified by a unique property id. Some kind of bit packing could be used in order to encode collections of property id's into a little bits as possible. This "collection" should then be replicated and on client side, use to rebuild the item.
Up to this point things should be pretty straightforward, mostly because it's all based in dealing with unique ID to query (static) data bases. However dealing with the inventory itself can become quite problematic. The easy implementation would be to allow each client to know whatever is in its inventory and be done with that. But when the player decides to drop the item on the ground, it means telling the server "hey, I'm dropping this item on the ground". This is a problem because in this case client is dictating the result of an action, meaning that a cheater can tamper with the data and tell about a different (maybe rarer) item and even the quantity if the item can be stacked.
For certain kinds of games this should be entirely acceptable, however when there is competition the server must handle everything, including the inventory management (and the stored persistent data - IE.: the save files). In this case, interacting with the inventory should be entirely replicated so the server can know about everything, including item positions within the inventory and obviously the properties of the items. This will potentially greatly increase the bandwidth requirements of the game, specially in the fact that this kind of replication requires knowledge about every single slot of the inventory.
But how to replicate the inventory becomes the next question. The inventory itself will probably be a node and most likely have a script attached to it. With this in mind, one possibility is to create a new snapshot entity class (something derived from SnapEntityBase
) to represent the inventory state. In this case, this new class would create an association with the inventory itself. Normally speaking, inventories are implemented using arrays to hold the slots, which is a type not supported by this network addon. Obviously manually adding a property for each slot is not an option, specially because it does not scale well, not to mention the limit of 30 additional properties that can be added into the class as it's handled by the network addon (please note here, a maximum of 30 replicable properties is a limitation in how the addon was implemented, not a Godot one).
The solution here becomes to create one snapshot entity class for each slot in the inventory rather than the inventory itself. This will result in one object of the new class for each slot within the snapshot data. This somewhat forces that each slot becomes a node. A script for this node is optional since the apply_state()
can directly deal with the slot rather than calling an extra function like I have been showing throughout this tutorial. In case this extra script is not implemented, the inventory itself can iterate through the slots in order to generate the snapshot entity object.
Finally, some cleanup steps. Suppose the player is going back to the main menu, maybe to restart the server, or connection was lost or... There is one function that can be used to reset most of the internal network system, the reset_system()
.
If the player did actively click a button to go back to the main menu, it's a good idea to forcefully close the server (if server) or disconnect from it (if client). Those can be done with close_server()
and disconnect_from_server()
functions. Note that there is no harm in calling both, regardless if the code is running as server or client or none! So, with that in mind, it would be a good idea to incorporate the following in the game world script:
func _exit_tree() -> void:
network.close_server()
network.disconnect_from_server()
network.reset_system()
One very important thing here: the reset_system()
call will also cleanup any event handler that has been attached to the various event types.
And that is it! Yes, I know, this addon is pretty big and maybe the snippets were not enough to explain how to completely use this system. Indeed, again, these tutorials are primarily meant to give a grasp to understand the code examples that can be found in the project repository. If you are still in doubt, feel free to contact me!
Ok, here things become a bit complicated. If you take a look into this addon directory (keh_network
) you will find multiple script files in it. Indeed, in depth explanation of everything that is going on would require a huge amount of text. So, instead of that, this will focus more on the "theory" of the techniques which hopefully should give enough knowledge to understand at least the organization of the code. Note that in this text there will be some aspects that were mentioned in my previous networking tutorials with the intention of avoiding the need to read them.
The network addon is built on top of the NetworkedMultiplayerENet
, which is a PacketPeer
implementation on top of the ENet Library. This high level networking is designed in a way that games must follow the client-server architecture. With that in mind, everything that is discussed bellow assumes this kind of methodology.
Once a client machine connects to the server, the synchronization can occur and that can be done in various ways. Obviously, each one with its set of "pros and cons".
The easiest architecture works in the following way:
The described method is a non authoritative client-server. This means, the server does not dictate the entire state of the game. Because clients can send world state to the server, which will have to accept, a lot of doors are open to cheating. Granted, nothing stops a cheater from sending data that results in the character teleporting around, moving faster or whatever would give advantages in the game.
Still, this method gives a very good response time to any client, not to mention the fact that this is relatively simple to be implemented. Certain games can be implemented in this way. Think about cooperative games without any kind of competition for example.
But then, even in non competitive games, maybe there is a desire to close some doors for cheating (remember, completely avoiding cheats is an almost impossible task). From that comes the authoritative server methodology. The overall flow works like this:
With this method, the client works like what is often described as dumb terminal. Indeed, all it does is collect player input data, send to the server and then render what the server sends back. This is a rather interesting functionality since most of the computing power will happen only on the server, allowing client machines to be less powerful. And because client can't tell anything about the world state, a bunch of doors to cheating are now closed (again, relentless cheaters will find ways to obtain advantages).
But what if the client sends a lot more input data than expected? Well, ideally the server should deal with a single input data set per frame. If input data was missing, then connection to the client is possibly going through data loss, which is a problem slightly beyond what the game code can do. And if there is an excess of input data, the server can either ignore or create an internal cache of what was received. With this, clients can't try to attack the game (cheat) by sending more data than expected.
Still, this imposes another problem. Response time is directly tied to the round trip time. In this case, the client must send the input data to the server, which will process the data and then send back. Only then client will see what happened as a result of a button/key press. So, if the packet takes 50 milliseconds to reach the server, then another 50 milliseconds for the response to arrive, the client would experience at least 100 milliseconds delay in the character's movement. I say at least because it's very well possible that interpolation method is in used in order to smooth the animation, which will result in extra delay, not to mention the fact that once the input data is received by the server, it may take a short time to compute the result, pack it and send back to the client.
Response time can be greatly improved if the client also simulates the game, but the world state is still dictated by the server. The overall flow works like this:
This is the so called client side prediction method and it's really nice in the sense that it combines the response time of non authoritative server with the "closed doors" of the complete authoritative server method. However, the client is not a "dumb machine" anymore and starts to perform full simulation again. Worse, when correction must be applied, several "ticks" will have to be performed in a single frame, increasing the required computing power to run the game.
The way the data will be built in order to perform the actual synchronization can vary, from exchanging event codes to creating objects that contain the entire state of the game world. The network addon uses the "entire state of the game world" method, or snapshot. This method is quite robust because if there is data loss during the the game session a subsequent snapshot data will basically fix client state, albeit with some probable visual anomalies. The important thing is that synchronization is restored without having to implement other methods.
The major downside of snapshots is the fact that they may become rather large (in bytes). In a game running at 60 frames per second the required bandwidth to exchange snapshot data can become rather prohibitive if not careful enough. This means it's a good idea to monitor network data usage as the project evolves.
There are multiple strategies that can be used in order to save on bandwidth requirements, always with tradeoffs of course:
There are other methods of compressing data which may help saving some more bandwidth requirement, but let's focus on the 5 listed. The first has huge tradeoffs and only become useful if client side prediction is added as it may hide some of the lag.
Delta compression is a rather interesting method and can be "transparently implemented". Indeed, this addon initially implemented it although because of a bunch of bugs, it was temporarily removed and will be incorporated back at a later moment.
While eliminating entities from snapshots pretty much depends on the project (thus cannot be "transparently implemented"), means to do so can be added. For the moment there is no way to perform this because it may "clash" with delta compression without the proper internal upkeep. To understand why, suppose a chest entity, which is too far away from a client (Bob). This triggers the culling method and doesn't get sent. Another client (Alice), on the other hand, is close enough, spots the chest, comes and open it. The chest's state has changed from closed to opened and the delta information about this state has also changed but not sent to Bob because the character was too far. At some point in time, Bob get close enough to spot the chest. Without knowing the initial state of the chest, delta will not help. Still, there are ways to combine both methods and once the delta system is restored in the addon, there will be a way to "cull" objects from the snapshots.
Data compression is something that "comes for free" in Godot. Indeed, when creating the NetworkedMultiplayerENet
we can specify which kind of compression we want. While the default in Godot is that no compression is enabled, this addon uses the Range Coder
by default, which is provided by the ENet library. When testing which method to use, it becomes recommended to test not only the bandwidth saving, but also how much processing power becomes used.
As for the quantization methods, those are project specific and requires special code. Note, however, that there are plans to (re)incorporate one addon containing a few functions to perform this kind of operation. Remember that this kind of compression is lossy and will bring some extra error into the floating point computations. Often this error is small enough to be used without any noticeable problems.
The entire addon was built on top of snapshot data to perform the synchronization. The problem comes in the fact that each project will certainly need a completely different set of data to be added to the snapshots. Because of that a method to generalize this became necessary. Thanks to the fact that Godot offers reflection meta data related to the various classes defined by scripts, creating a generic system became possible.
With that in mind, a base class (SnapEntityBase
- defined in snapentity.gd
) is used as a "bridge" between the game logic and the snapshots. Well, the snapshots actually hold instances of classes derived from this! To help with the rest of the text, let's call those objects snapshot entities. Those objects hold states of corresponding game nodes. But, how does that make things "generic"?
First, each project must create classes derived from the SnapEntityBase
, which are basically meant to represent the states of the various game objects. The moment the network.gd
script is initialized (added into the tree through the auto-load system), it also initializes an internal instance of the NetSnapshotData
(snapshotdata.gd
) class, which in turn grabs a list of script classes and "scan" each one that is derived from SnapEntityBase
. During this scanning, every single defined property is analyzed and if it's a supported type, it's added into an internal entity description. This description is later used to perform encoding and decoding of snapshot data into raw bytes.
Because of this automatic scanning it becomes rather necessary that those scripts become static typed, otherwise it will fail to interpret certain properties.
A single snapshot is represented by an instance of the NetSnapshot
(snapshot.gd
) class. Those objects know nothing about the data, only the instances of the snapshot entities (which are derived from SnapEntityBase
). Indeed, this class is meant to be as minimal as possible because multiple instances of it will be generated and cached (for a while) during the game.
Provided there are no errors, each class derived from SnapEntityBase
will generate an instance of the EntityInfo
(entityinfo.gd
) class, which contains internal data necessary to describe and operate over the data in a generic way. Because it holds a list of properties (and their types), it can provide means to perform a bunch of operations, like fully encoding the entity into raw bytes, comparing their values and so on.
So when it's time to encode snapshots into raw byte arrays, the addon iterates through the contents of the snapshot object. The entries are keyed by the identification of the snapshot entities which, in turn, lead to the corresponding EntityInfo
instance. With this, the instance of the snapshot entity is given to the entity description, which performs the encoding using the EncDecBuffer
addon.
When a player joins a server, some internal data is often necessary, which will greatly help with a lot of upkeep. To that end, each player will generate an instance of the NetPlayerNode
(playernode.gd
) class which extends the Node
class.
The player node was created not only to serve as a way to provide game code with some data related to an specific player, but also help automate a bunch of the internal operations. One such example is dealing with input on the correct player data. Granted, remote calling functions (rpc*()
) require the exact same node, with exact same hierarchy, exists on the called remote machine. With that in mind, when a client gathers input, its node sends the data to the server, which will receive on the corresponding (and correct) player node.
The various instances of the player nodes are managed by the NetPlayerData
(playerdata.gd
) class which, in turn, is internally used by the network singleton.
As with the snapshot data, each project will certainly have its own needs regarding how to deal with input. Because of that, a generic system to deal with input had to be created. Is mostly consists of describing the necessary input mappings through the NetInputInfo
(inputinfo.gd
) class. It basically contains information about the data itself. Then, when input is required, objects of InputData
(inputdata.gd
) are generated. When it's moment to encode/decode input data, the description is used to obtain what (and how) to extract information from the data InputData
objects.
Normally the input description performs registration (association) of the various input mappings with which kind of data those are meant to represent (analog, boolean etc). So when the input data requirement is rather simple, it becomes possible to directly request polling through the networking system, which will automate the entire process of encoding and sending data to the server.
Still, there are cases where the actual desired input data is not exactly what is obtained from polling the input devices (using input mappings), but some values computed after those input states are used. As an example, consider a game where the player can "drag" to select multiple units, then click somewhere in the world to move those selected to that location. In this case sending the input state isn't exactly useful since the world location (in 3D) requires a series of ray casts that are based on the camera (and what it's seeing). In this case it becomes necessary for the client to compute the world locations locally and send those values instead. While there are cases in which this could be used for cheating (like "clicking" on a location that shouldn't), in this case it's relatively easy to perform tests and if necessary completely block the action. For this kind of interaction there is the custom input data feature.
Internally the custom input data are just additional entries in the input description. Because those entries are flagged as custom, they will not be used during the polling stage. And because those entries are only telling the data type, encoding/decoding deal with the custom data in the very same way the normal input mappings. When custom input data is required, the game code will have to manually fill with this extra data since there is no way to generalize this kind of information.
The client will send all input objects that were not acknowledged by the server yet. By doing so, the server can build an internal buffer, which can provide some small window for data loss. Input acknowledgement is indirectly done by attaching the signature of the used input into the snapshot data. The input signature is the first identification used to "match" snapshots for comparison, which is the necessary requirement to perform client side prediction.
Delta compression is probably the technique that would bring the best bandwidth savings since the frequency in which the state of the input devices changes are not too high. However, it would also require a bunch of extra checks in order to ensure everything is delivered. To explain this, consider the case of the player pressing a "move forward" button. The information reaches the server, which starts moving the character in that direction. However, due to massive data loss, the "released" state didn't reach the server. The character will continue moving forward even when the client player released the button.
While there are means to deal with this kind of situation, the method used in this addon is easier to implement and basically consists in simply not encoding data for analog/vectors that are 0. A bit mask is attached into the raw data indicating which input names are present or not. This bit mask will use 8, 16 or 32 bits, depending on the amount of each kind of data needed to be encoded.
When encoding the boolean data, those will always be packed into a single integer of either 8, 16 or 32 bits. This means that dealing with boolean input data should always be rather cheap when comparing to any other kind of input data.
Unfortunately there is no way to perform a true ping with pure GDScript. The fact is that ping requests use a completely different protocol (not to mention a different network layer). Still, we can sort of measure the latency (maybe with an small error margin). The idea is to remote call a peer while at that moment start counting time. The peer answer back and once this packet arrives, measure the time it took.
In this addon the NetPingInfo
(pinginfo.gd
) class was created in order to help with the initialization of the "ping loop", which is basically a system, based on timers, to continuously remote call peers in order to constantly measure the network latency. On the server each player node corresponding to a client will get an instance of this class.
The basic flow of this "ping loop" begins when the remote call is given. At this moment a timeout timer (5 seconds) is started. If it expires before the answer arrives then the request is considered lost and another one is sent. However, if the answer arrives before it expires, then it's used in order to calculate how much time has elapsed (in milliseconds) while also starting an interval timer (1 second) which, once expired, send a new remote call.
While this method is not perfect it at least gives an idea of how much time the packets are taking to reach the peers.
At certain times it may be desired to have custom player properties that are also synchronized. Things like player name, character class selection and so on are perfect examples of custom properties that can be used with this addon. Each player node also contains an internal collection of custom data, which will be automatically synchronized through the network.
Each custom property gets an instance of the NetCustomProperty
(customproperty.gd
) class, which holds the value of the property and the replication mode. This mode can be set to None
, ServerOnly
or ServerBroadcast
. The first option completely disable replication of the property. The second option will give the property value only to the server while the third will make the property be sent to the server which will then broadcast to the rest of the clients.
Ok, but, why would one want to disable the replication of a property? Consider the situation where the server may need some extra data associated with each client but those peers don't need to know anything about that data. When that client leaves the game, its corresponding player node is also removed, meaning that this extra data is automatically cleaned up.
The important thing here is that custom properties are manipulated through the player node interface, set_custom_property()
and get_custom_property()
. The other functions related to the custom properties are internally used by the system.
Chance are high that custom properties are set before connecting to the server. However, once a client is accepted on the server, the internal code will automatically call a function (initial_custom_sync()
) to perform the initial synchronization of the properties. And then, when a new client connects, the server will call a function (sync_custom_with
) to perform the synchronization of every property set to ServerBroadcast
.
One last thing related to the custom property system is that all properties will be synchronized using the reliable channel.
The event system consists in replicating simple event codes (as 16 bit integers) plus the parameters that might be necessary to give more information regarding the event. The parameters must be supported by the EncDecBuffer
.
In this subsystem everything begins with the registration of the event type, which is done through the register_event_type()
function. All that happens here is the instantiation of one NetEventInfo
class (eventinfo.gd
), which is held in an internal dictionary named _event_info
.
The NetEventInfo
class contains internal data related to the types of the parameters that are expected by the described event. With that information encoding and decoding can be "automatically" done.
After the event type is registered, event handlers must be attached, which is done through the attach_event_handler()
function. The event handler is nothing more than a function name plus its object, which is then attached into an internal array of the corresponding NetEventInfo
instance.
An event in this system is generated by calling the send_event()
function, which basically accumulates the type code plus the parameter array into an internal variable (the _update_control
object). When the loop iteration ends, the deferred call to an internal network function not only deals with the snapshot encoding, it will also go through each accumulated event and initiate the encoding of the data into a single PoolByteArray
(but only if there is at least one connected player). Once the data is encoded it is then sent to all connected players through the reliable channel.
Note that as the events are processed on the server, the event handlers are also called just so the code meant to deal with the event can be the same on both the server and the client.
Another thing to note in the code is that if there is no accumulated event within the internal array then nothing will be sent to the clients.
There isn't much that need explanation here. The entire system consist of a function (chat_message()
) that is meant to be remote called giving the message and if it's meant to be broadcast or not. Obviously broadcast only happens from the server, so if there is any desire to send a message to everyone connected, it must be sent to the server with the broadcast flag set to true.
In order to make things easier to use and prevent the necessity to deal with remote calls from game code, there is "wrapper" function named send_chat_message()
. There are two argument in it, one that is the actual text message and the other is the destination (unique ID of the peer). If the destination is set to 0 then the message will automatically be broadcast, otherwise "whispered" to that player.
Ok, up to this point each topic has shown (from a very far away view) each subsystem of the network addon. Before wrapping up this tutorial page I would like to get a little more in depth with the snapshot synchronization mechanism.
At least for the moment, there is no way to set the frequency in which the snapshots will be sent, apart from changing the physics updates per second. With that said, as soon as the snapshot is "finished", the server will iterate through each registered remote player and if the is_ready
flag is true, will encode and send an snapshot data set to that client, attaching the signature of the input object used to simulate the relevant frame.
When the client receives the snapshot, obviously this data must be decoded back from raw byte array into a high level snapshot object. After that is done, client code tries to find a local snapshot that contains an input signature matching the one that was attached into the received snapshot data. Provided the client is using prediction in order to hide lag, most likely the snapshot found will be locally older than the received one. Now please attention here, the local state is ahead of the received data, but still behind the server (shortly I will expand on this timeline).
Having two snapshot objects to compare, one that came from the server, the other is a local one, with prediction state, each entity is compared, using the entity description system as a "tool". If there are any discrepancies then the apply_state()
of the snapshot entity will be called. In a way, the entity is placed in its correct server state. If the entity corresponds to a player character it is a good idea to re-simulate the input data, from one newer than what was attached to the incoming snapshot to the newest one in the local cache (more on this shortly when talking about the timeline).
Because the snapshot that arrived from the server contains the signature of an input object, it is then used locally as an acknowledgement that the input object was indeed received (and used) by the server. With this in mind, any input object older and up to this point can be removed from the local cache.
Right before the snapshots are compared, but after decoding the data that just arrived from the server, the client send a "signal" to the server, acknowledging the signature of the snapshot that has just been received. In its current code, this data is not really important since the server is always sending full data to the clients. However, this information is rather important when dealing with delta compression, as it must be used to tell the initial reference point from which to build the delta data. Think about this a way for the server to know which correct state the client knows about.
Finally, let's talk a little bit about the timeline, specially when client side prediction is used. When the first snapshot data arrives from the server, there are two things to keep in mind:
While obviously the tick updates are most likely not happening at the exact same time on client and server, the pace in which they are happening is, provided both machines can handle the computation demands of the game. What this means is that every time the client receives data, it will be older than the current time frame of the server and the client. However, the client is still behind the current time frame of the server, by approximately the amount of time it takes for the package to arrive from the server.
In order to (try) to help visualize that, suppose the simulation is running in a pace where each frame takes 16 milliseconds (rounding just to make things easier to represent). Also suppose it takes 48 milliseconds for a packet to go from server to client. This scenario is somewhat represented by the following image (at least I hope so):
Server/Client Timeline
What this is meant to show is that by the moment frame 1 is received and rendered by the client, the server is already running frame 4. Also notice that whenever frame 2 arrives from the server, client has already tried to predict what its state should be. Now there is one thing to note here. Because data is constantly being sent, this image can easily deceive us into thinking that this flow is enough to keep things in synchronization with about the supposed 48 milliseconds delay.
Problem is, this image is not taking client input into account, and that's the moment things become complicated. If we suppose it also takes 48 milliseconds for the packet to go from client to the server (there are chances for the times to be different depending on the direction), it would take 96 milliseconds for the snapshot containing the desired input to arrive and finally affect the player character. If you recall, the local snapshot used to be compared to the server data is the one that contains corresponding input signature, not snapshot signature.
Using the depicted scenario, the input data takes 3 frames to arrive on the server and it takes another 3 frames for the result to get back to the client. That means 5 frames of input on the client that weren't computed yet by the server. Supposing the predicted state was incorrect, the character must be corrected. At this point those 5 frames of input cannot simply be ignored otherwise the player will notice the player character warping around constantly during the corrections, which obviously is not good.
Unfortunately corrections will not be uncommon, specially on fast paced games. Depending on the nature of the correction, visual anomalies will happen. Depending on the world scale, small errors will be barely noticeable when dealing with position corrections. Rotations, however, may result in some problems if that directly affect the camera rotation (think first person shooters for example).
I truly hope this rather shallow description of the network tutorial is enough to help understand what is going on behind the scenes!