Part 3 - Game World
May 17, 2019

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

In the previous part we have implemented the code necessary to either create or join a server. At this point, while we can do this, we don't have much to test, since the game world scene, which is the one opened when the server is created or joined, is completely empty. We will add something into it so we can test things.

The first thing we will do is work on a bare bones heads up display (HUD) that will show the list of connected players. In order to do that we will need some internal peer management code.

After that we will create a "player scene" that will be used to represent a player avatar in the game world.

Internal Player List

As mentioned, we will need internal peer management in order to display some information in the "HUD". This peer management will be done mostly from the network.gd script file. It will hold a list of connected players. This list will be dictionary of dictionary entries, where each entry will hold some information regarding each player and the key to each player will be the unique network ID. Through the various events we will keep this list updated across all the connected peers. Which means, we will see how to use some of the remote/rpc functions provided by the Godot engine.

The first thing we will do is create a dictionary that will hold a single player info entry, for the local player. Since this data can be used elsewhere besides the networking code, we will add this into the gamestate.gd script. We will work with some basic information for this tutorial, but it should be pretty easy to further expand based on the project needs. Nevertheless, we will hold player name, network id, the path to the "actor" scene and the dominant icon color:

gamestate.gd
var player_info = {
	name = "Player",                   # How this player will be shown within the GUI
	net_id = 1,                        # By default everyone receives "server ID"
	actor_path = "res://player.tscn",  # The class used to represent the player in the game world
	char_color = Color(1, 1, 1)        # By default don't modulate the icon color
}

Although in the tutorial we won't deal with different player classes, the code shown here will give the possibility to easily choose one, by just changing the value of the gamestate.player_info.actor_path dictionary field. One thing to notice is the default net_id value. The server will always have unique network ID equal to 1 so, in a way, we assume the player will be the server.

Ok, now, whenever a client joins a server, we will want everyone to get this information. Also, the new player has to receive the information of everyone already connected in the server. So, the first thing is to declare the list of players within the network.gd:

network.gd
var players = {}

We want to populate this list with every connected player and keep this list equal on all machines. To do that we need a function that can be called from a different peer, meaning a remote function. In this function we add a single argument, which should be the player_info dictionary. The intention is to get that information and append into the player list.

As soon as the server is created, we "register" the player itself into the list, which, at that moment, will only contain that player. Once a new player connects into the server, that player must "register" the server's player into its list and itself. The server's list, already has itself, but must include the new player into its dictionary.

In a way, we have to synchronize the lists between the peers. For that, we add some code that will be executed only on the server, which will iterate through the already registered players and send the new player's information to them. Taking advantage of the loop, we also take the iterated player's info and send to the newly connected player. Remember, this iteration is done only on the server, which will "distribute" the information throughout the clients.

After leaving this "server only section of the code", we enter the "general section" which will actually append the new player info into the player list. This is a matter of just assigning the received dictionary into the correct key ID. As mentioned early, we want to display on the HUD the list of connected players. We will use this players dictionary in order to populate the HUD with widgets. But, we also need means to indicate that we have to update that widget list because it has changed. In order to do that, we crate yet another signal named player_list_changed that will be emitted every time we change the contents of the list. Its declaration:

network.gd
signal server_created                          # when server is successfully created
signal join_success                            # When the peer successfully joins a server
signal join_fail                               # Failed to join a server
signal player_list_changed                     # List of players has been changed

Now let's work on the function that will maintain the player list. Again, we have some code that will be executed only on the server, meant to distribute the player information across the connected players, by calling the function itself but on a remote machine with the rpc_id() function. The distribution itself occurs through iterating the players that are already in the list. Within this iteration, notice the fact that we "skip the server" when sending the new player information to the remote machine. The reason for that is that we are already in the server and we get an error if trying to "remote call the local machine". Moreover, once in the second part of the function, in the "general section", the server will update its internal list with the received data, which is the new player info anyway. After the list is updated we then emit the signal telling about this event. When a peer gets into this function, it will skip the "server only section" and execute only the "append the new player to the player list". The code to perform those tasks:

network.gd
remote func register_player(pinfo):
	if (get_tree().is_network_server()):
		# We are on the server, so distribute the player list information throughout the connected players
		for id in players:
			# Send currently iterated player info to the new player
			rpc_id(pinfo.net_id, "register_player", players[id])
			# Send new player info to currently iterated player, skipping the server (which will get the info shortly)
			if (id != 1):
				rpc_id(id, "register_player", pinfo)
	
	# Now to code that will be executed regardless of being on client or server
	print("Registering player ", pinfo.name, " (", pinfo.net_id, ") to internal player table")
	players[pinfo.net_id] = pinfo          # Create the player entry in the dictionary
	emit_signal("player_list_changed")     # And notify that the player list has been changed

Obviously we have to initially call this function. The way we will do this follows. Once the client successfully gets connected and after emitting the signal indicating the success, we update its internal gamestate.player_info dictionary and then send the information to the server through the register_player() function. If you pay close attention to the register_player() code, you will probably notice the fact that after the server finishes broadcasting the new player_info the new player has got everyone in his/her local list, but not him/herself. To "fix" that, we call this function locally right after requesting the server to distribute the information across the players:

network.gd
func _on_connected_to_server():
	emit_signal("join_success")
	# Update the player_info dictionary with the obtained unique network ID
	gamestate.player_info.net_id = get_tree().get_network_unique_id()
	# Request the server to register this new player across all connected players
	rpc_id(1, "register_player", gamestate.player_info)
	# And register itself on the local list
	register_player(gamestate.player_info)

The way we are building the list requires that everyone contains themselves on their local player lists. But with this code the server's player is not part of any list, not even the local one! Of course we have to fix that. Luckily, it's very simple. We just update the create_server() function to call the register_player() just after emitting the server_created signal:

network.gd
func create_server():
	# Initialize the networking system
	var net = NetworkedMultiplayerENet.new()
	
	# Try to create the server
	if (net.create_server(server_info.used_port, server_info.max_players) != OK):
		print("Failed to create server")
		return
	
	# Assign it into the tree
	get_tree().set_network_peer(net)
	# Tell the server has been created successfully
	emit_signal("server_created")
	# Register the server's player in the local player list
	register_player(gamestate.player_info)

Player List HUD

At this moment we have no means to know if this is working or not. So let's add some very basic HUD into the game world scene that we will use to display the list of players. So, open the game_world.tscn scene and first rename the root node to GameWorld then add a CanvasLayer into it, renaming to HUD. Then add a Panel node into the HUD, naming it PanelPlayerList. With the panel selected, expand the Visibility property under the CanvasItem category. In there change the SelfModulate alpha to 0 so the panel itself becomes fully transparent but not its children.

Now, add a label into the PanelPlayerList naming it lblLocalPlayer. This label will hold the name of the player on the local machine. Just for the sake of layout testing you can enter any temporary text like LocalPlayerName.

Next add one VBoxContainer into the PanelPlayerList naming it boxList. Each remote player will have one label inside of this box, which we will dynamically spawn/remove. The VBoxContainer node is a nice choice here because widgets are placed one bellow the other. We could have used the ItemList node but for this tutorial we don't need it. Alternatively, we could have created a new scene that would be instanced for each player in the list. Again, no need for that in this tutorial.

Now we can move to the scripting. For that, a new one must be created and attached to the GameWorld node. Name the file game_world.gd. The first thing we will do is use the _ready() function to connect an event handler to the player_list_changed signal and also update the lblLocalPlayer label widget:

game_world.gd
func _ready():
	# Connect event handler to the player_list_changed signal
	network.connect("player_list_changed", self, "_on_player_list_changed")
	
	# Update the lblLocalPlayer label widget to display the local player name
	$HUD/PanelPlayerList/lblLocalPlayer.text = gamestate.player_info.name

Of course, we have to create the _on_player_list_changed() function! So, let's do that. In it we first delete all child nodes from the boxList then repopulate it. This will be a lot easier than trying to remove/append only the relevant data, which would be the ideal (hint if you want to do so: it becomes almost trivial if you add extra arguments to the player_list_changed event, namely the player_info of the affecting player and a flag indicating if it's a new player or someone leaving). In the iteration loop notice the fact that we skip the local player since we have a different spot to display that information, which is above the list itself. Also notice how easy it would be to add extra information from the player_info dictionary if we wanted to! The code:

game_world.gd
func _on_player_list_changed():
	# First remove all children from the boxList widget
	for c in $HUD/PanelPlayerList/boxList.get_children():
		c.queue_free()
	
	# Now iterate through the player list creating a new entry into the boxList
	for p in network.players:
		if (p != gamestate.player_info.net_id):
			var nlabel = Label.new()
			nlabel.text = network.players[p].name
			$HUD/PanelPlayerList/boxList.add_child(nlabel)

There is one last detail before we can test this. We have to update the player_info dictionary with the correct data. Since we want to update this data from the server creation and server joining code places, we first create a new function meant to perform this task, so we avoid duplicating logic code:

main_menu.gd
func set_player_info():
	if (!$PanelPlayer/txtPlayerName.text.empty()):
		gamestate.player_info.name = $PanelPlayer/txtPlayerName.text
	gamestate.player_info.char_color = $PanelPlayer/btColor.color

Again, ideally we should disable both the btCreate and btJoin buttons if some data is not given, although in this tutorial we are only assigning something to the player_info.name if the text field is properly filled. Anyway, on the pressed event of both join and create buttons, prior to dealing with the networking code, we call this new set_player_info() function:

main_menu.gd
func _on_btCreate_pressed():
	# Properly set the local player information
	set_player_info()
	
	# Gather values from the GUI and fill the network.server_info dictionary
	if (!$PanelHost/txtServerName.text.empty()):
		network.server_info.name = $PanelHost/txtServerName.text
	network.server_info.max_players = int($PanelHost/txtMaxPlayers.value)
	network.server_info.used_port = int($PanelHost/txtServerPort.text)
	
	# And create the server, using the function previously added into the code
	network.create_server()


func _on_btJoin_pressed():
	# Properly set the local player information
	set_player_info()
	
	var port = int($PanelJoin/txtJoinPort.text)
	var ip = $PanelJoin/txtJoinIP.text
	network.join_server(ip, port)

It's time to test the code, which means exporting the project into binary format and opening multiple instances of it. With 3 instances, from the point of view of the "second player", this is how the list should look like:

Player List 1

"Unregistering"

During the test you may have noticed that closing an instance (other than the server) will not update the player list on any of the peers. Granted, we didn't add any code to deal with that. For this task we create another remote function. In this case there is no need to separate the code into "server only" and "everyone" sections. All we have to do is remove the relevant player from the players list, which will be done based the network ID, which is the key to access it on the dictionary. After that, we emit a signal indicating that the player list has been changed:

network.gd
remote func unregister_player(id):
	print("Removing player ", players[id].name, " from internal table")
	# Remove the player from the list
	players.erase(id)
	# And notify the list has been changed
	emit_signal("player_list_changed")

We already have the event handler that deals with players being disconnected from the server. Once the server gets this notification we will then call the unregister_function() locally (which will update the server's list) and then we use the rpc() function to call this function on all remaining connected players:

network.gd
func _on_player_disconnected(id):
	print("Player ", players[id].name, " disconnected from server")
	# Update the player tables
	if (get_tree().is_network_server()):
		# Unregister the player from the server's list
		unregister_player(id)
		# Then on all remaining peers
		rpc("unregister_player", id)

Since we are dealing with disconnection events, let's also work on the one that is emitted to the player that got disconnected. When that happens we have to clear that local player list since that machine is no longer in a multiplayer environment. Also, we reset the player_info.net_id to 1:

network.gd
func _on_disconnected_from_server():
	print("Disconnected from server")
	# Clear the internal player list
	players.clear()
	# Reset the player info network ID
	gamestate.player_info.net_id = 1

And this is the player list from the point of view of Player 4 after Player 2 has been disconnected from the server (you can simulate that just by closing the relevant game instance window):

Player List 2

Player Scene

We are now synchronizing the player list between the connected peers. But how about we add player controlled actors into the game world and also synchronize them? Let's do it. First we create a new scene named player.tscn. Later on we will create instances of this scene in the game world, one for each connected player. Choose the Node2D as root and rename it PlayerRoot. Now drag the default Godot's icon.png into the scene and then make sure its transform->position property is x = 0, y = 0. This icon will probably be a bit too big in the game world, so change the scale to x = 0.6, y = 0.6.

Ok, now we attach an script to the PlayerRoot. Name this new file player.gd. At this point its entire content should be a single line, extends Node2D. We will use this script for two things:

  1. Apply the "dominant" color that was chosen at the main menu
  2. Handle movement based on player input

Dealing with 1 is extremely simple, as all we have to do is access the icon node and apply a color to the modulate property. We do this through a new function:

player.gd
func set_dominant_color(color):
	$icon.modulate = color

As for 2, we first have to create the input bindings. So open the project settings, then the Input Map tab. Add 4 actions named move_up, move_down, move_left and move_right. Assign the desired keys for those movements (in my case I have chosen "WASD"). Ok, now we can go back to the script. We use the _process(delta) function to poll the action key states on every loop iteration. Whenever a key is pressed we will change a movement vector that, in the end, will be used to move the actor in its direction. For that we also need a "movement speed". So, let's first declare this variable:

player.gd
var move_speed = 300

Of course, tweak this value to whatever you desire. Anyway, let's work on the _process(delta) function now. First we declare the movement vector, initializing it with (0, 0), so no movement will be performed. Then we poll each one of the action keys. If it's down, we add/subtract from the vector based on the desired movement direction. When all direction keys are polled, we normalize the vector and then we apply the P2=P1+(movedirspeedtime)\small P_2 = P_1 + (movedir * speed * time), where P1\small P_1 is the current position and P2\small P_2 the resulting position:

player.gd
func _process(delta):
	# Initialize the movement vector
	var move_dir = Vector2(0, 0)
	
	# Poll the actions keys
	if (Input.is_action_pressed("move_up")):
		# Negative Y will move the actor UP on the screen
		move_dir.y -= 1
	if (Input.is_action_pressed("move_down")):
		# Positive Y will move the actor DOWN on the screen
		move_dir.y += 1
	if (Input.is_action_pressed("move_left")):
		# Negative X will move the actor LEFT on the screen
		move_dir.x -= 1
	if (Input.is_action_pressed("move_right")):
		# Positive X will move the actor RIGHT on the screen
		move_dir.x += 1
	
	# Apply the movement formula to obtain the new actor position
	position += move_dir.normalized() * move_speed * delta

If you want to quickly test this, add one instance of this object into the game world scene and try out the movement. Before proceeding remember to remove the player instance from the scene since we will dynamically spawn it.

Spawning Players

Now that we have a controllable player actor, we want to spawn it, right? Before doing so, let's see some of the needed tasks by analyzing the events and what must be done.

As you can see, when a player joins the server, all of the already connected players only get a single new instance of the player scene, while the new player must get an instance for each of the already connected players plus its own one. We will surely use that information to design the function but there are three other things that we have to take into account.

The first thing is relatively simple, which relates to the spawning position. We need to give an initial position to the player's avatar otherwise it will be placed at the top-left corner of the screen. We could obtain a random position within the screen size and position the player there. Or we could create some predetermined locations that can be placed when designing the level. With a list of spawning points we can then choose one of them and place the actor at that location.

That said, open the game world scene and add into the root a new Node2D node, named SpawnPoints which will be used solely for organizing the tree hierarchy. Then add one Position2D node into the SpawnPoints. This node does not have any visual representation in the game itself, but it does provide a "cross widget" in the editor. With this widget we can reposition the node, becoming an excellent tool to spread several spawn points throughout the level when designing it. Anyway, rename this node to 1. Now select it and duplicate (ctrl+d). Godot will automatically rename the new node to 2. Excellent, repeat the duplication until you have 16 spawn points. Then spread those nodes throughout the scene according to your desires. In the editor, this is how mine looks like:

Ok, now we can move into the second thing that we have to take into account. This is a bit more related to how you want to organize your game code. The question here is where will we place the actor spawning code? We could stuff that code directly into the network.gd, which surely works great. Well, when first testing the networking that's where I placed this spawning stuff. In a way this is the first place to come to mind because we want the spawning function to be remote. More about this shortly.

But then, the actor spawning code is somewhat more game specific than "general network". Maybe we can then add the code within the gamestate.gd class. That will certainly work and from the point of view of "game-specific-code" thing, the game state is somewhat like that and can definitely hold this kind of game logic.

The thing is, we are meant to spawn the player scenes within the game world scene. This means that we have to obtain the parent node of the game world. This is also true if we decide to add the spawning code into the network.gd class. So, why not add this code directly into the game_world.gd? This probably helps a lot with the code since with this we could avoid code to try to obtain and check the validity of the game world scene node as we will already be in it. We also have direct access to the spawn points.

But then again, this is very much related to how you want to organize your game code, so you are free to place the spawning function wherever you prefer, really! In this tutorial, though, we will place the code within the game_world.gd.

Now to the third thing that we have to take into account. This relates to the function itself. Does it have to be a remote one? We can surely rely entirely on the local player list in order to spawn the individual player avatars but we have a few problems here, although all of them are solvable.

The first problem comes from the fact that we have to use the player_list_changed event in order to help keep the spawned actors in sync. To do that, we also would have to add a few parameters to this event so management becomes easier. By itself that's not really a problem, but by the time we effectively connect a function to this event there is a chance the list will already have some entries but will be incomplete. This happens because by the time we are changing from the main menu into the game world the server is already remotely calling the player registration function. In other words, we can't fully rely on the list being complete when entering the game world scene (in the _ready() function) nor fully rely on the player_list_changed event to populate the game world. We would have to use both.

While it is still possible to not make a remote function and save some few bandwidth data, this brings some complications that are not really the intention to be shown in this tutorial. Besides that, the amount of data here is not really problematic, unless you are dealing with a massive multiplayer game. That said, we will work with a remote function that will be very similar to the player registration code. That is, the server will remotely call the peers to perform the actor spawning. We do this because the server is always the first to update the player list.

That said, let's begin working on the function to spawn the player scene for each connected player. As mentioned, we need the player_info data to setup the spawned player so we add this as an argument to the function. An additional very helpful argument would be the index of the spawn point so from the server the spawn point will be correctly sent to everyone.

Regarding the spawn point, there is one caveat to be mentioned before showing the code. When spawning the connected player's avatars into the new peer's scene, we will use their default spawning points which will probably be incorrect. But later on, when we synchronize their locations those will be corrected. Depending on the latency a player may experience a visual warping of the other players. This can be avoided if we keep track of each player's position within the server, but that's not exactly in the scope of this tutorial. Nevertheless, if you want to directly place everyone in the correct place for the new player, from the server code it would be necessary to locate the iterated player's node and obtain its position property.

Ok, on the function's body, we have a "server only section" that is meant to iterate through the registered players and spread the spawning function call throughout the connected players. In this section we remotely call the spawning function to add each connected player into the new player's scene as well as adding the new player into everyone else. In this iteration we have to skip the server since the function is already being executed there, not to mention the "remote calling itself" thing that will result in an error message in the console. In the code we use a variable that will be incremented during the loop, which will be used as the spawn index. In a way, the algorithm shown associates each player with an spawn point based on the joining order. Yes, this will potentially spawn multiple players on the same place but then again, this kind of refinement is meant to the polishing phase.

As for the spawning code itself, we first take the player scene path stored in the received player_info dictionary. This is where it becomes really easy to customize players avatars, as all we have to do is change the value of the actor_path field. Once we have the loaded scene we create an instance of it and then setup the new object, which includes setting the dominant color and its position.

There are two more things that we must do. The first thing relates to the network master. This information is necessary to tell who owns that object. By default this is set to 1, which corresponds to the server. But if the spawned actor does not belong to the server's player, we have to change it. This is done through the set_network_master() function. Shortly we will se how this will affect the object in the game. The second thing relates to the name of the node within the tree. The name is how we will locate the actor at a later moment when retrieving from the tree. And we will need to do that when removing actors from the scene. We will set the name of the node using set_name() function and giving the network ID converted into string. Finally, we add the new node object as a child of the game world's root. The code:

game_world.gd
# Spawns a new player actor, using the provided player_info structure and the given spawn index
remote func spawn_players(pinfo, spawn_index):
	# If the spawn_index is -1 then we define it based on the size of the player list
	if (spawn_index == -1):
		spawn_index = network.players.size()
	
	if (get_tree().is_network_server() && pinfo.net_id != 1):
		# We are on the server and the requested spawn does not belong to the server
		# Iterate through the connected players
		var s_index = 1      # Will be used as spawn index
		for id in network.players:
			# Spawn currently iterated player within the new player's scene, skipping the new player for now
			if (id != pinfo.net_id):
				rpc_id(pinfo.net_id, "spawn_players", network.players[id], s_index)
			
			# Spawn the new player within the currently iterated player as long it's not the server
			# Because the server's list already contains the new player, that one will also get itself!
			if (id != 1):
				rpc_id(id, "spawn_players", pinfo, spawn_index)
			
			s_index += 1
	
	# Load the scene and create an instance
	var pclass = load(pinfo.actor_path)
	var nactor = pclass.instance()
	# Setup player customization (well, the color)
	nactor.set_dominant_color(pinfo.char_color)
	# And the actor position
	nactor.position = $SpawnPoints.get_node(str(spawn_index)).position
	# If this actor does not belong to the server, change the node name and network master accordingly
	if (pinfo.net_id != 1):
		nactor.set_network_master(pinfo.net_id)
	nactor.set_name(str(pinfo.net_id))
	# Finally add the actor into the world
	add_child(nactor)

Next we have to call this function. Once in the _ready() function we check if we are in the server and, if so, locally call this function. Otherwise, we request the server to execute it which, in turn, will spread the spawning throughout the connected players. Because we are somewhat associating each player with an spawn point based on the join order, when calling the spawn_players on the server, we specify 1 as index. Then, we use -1 for the rest, since we may not have the complete player list at that moment. In this case, the server will take care of correcting the index value and spread it to the players:

game_world.gd
func _ready():
	# Connect event handler to the player_list_changed signal
	network.connect("player_list_changed", self, "_on_player_list_changed")
	
	# Update the lblLocalPlayer label widget to display the local player name
	$HUD/PanelPlayerList/lblLocalPlayer.text = gamestate.player_info.name
	
	# Spawn the players
	if (get_tree().is_network_server()):
		spawn_players(gamestate.player_info, 1)
	else:
		rpc_id(1, "spawn_players", gamestate.player_info, -1)

Synchronizing the Actors

If you test the game now, we indeed get the correct amount of player actors spawned within each game window instance. However, if you test the movement, you will be able to control every single icon in the scene! And, there is more, the positions of the actors are not synchronized across the peers. As it turns out, this happens because we are not actually synchronizing those yet! Let's fix.

Back in part 1 I have mentioned the slave variable as well as the rset*() functions. We will use those to "replicate" the desired values across the networked players. We want to synchronize the position of the avatars, so we declare one slave variable meant to do this replication:

player.gd
slave var repl_position = Vector2()

Next we have to update the _process() function. In there we can't blindly update the actor based on the input. We have to check if that actor belongs to the player! This is done by checking if the local player is the network master, by using is_network_master() function. If that's the case then we can poll the input actions, otherwise we take the position based on the replicated value, repl_position:

player.gd
func _process(delta):
	if (is_network_master()):
		# Initialize the movement vector
		var move_dir = Vector2(0, 0)
		
		# Poll the actions keys
		if (Input.is_action_pressed("move_up")):
			# Negative Y will move the actor UP on the screen
			move_dir.y -= 1
		if (Input.is_action_pressed("move_down")):
			# Positive Y will move the actor DOWN on the screen
			move_dir.y += 1
		if (Input.is_action_pressed("move_left")):
			# Negative X will move the actor LEFT on the screen
			move_dir.x -= 1
		if (Input.is_action_pressed("move_right")):
			# Positive X will move the actor RIGHT on the screen
			move_dir.x += 1
		
		# Apply the movement formula to obtain the new actor position
		position += move_dir.normalized() * move_speed * delta
		
		# Replicate the position
		rset("repl_position", position)
	else:
		# Take replicated variables to set current actor state
		position = repl_position

Despawning

Fantastic, now we are synchronizing the player's positions across the connected peers! But, when a player disconnects (other than the server), while we are updating the player list, we are not updating the spawned actors. To fix that we create yet another remote function, meant to remove player scene instances from the game world. Basically, after a player disconnects from the server, we have to iterate through the list of remaining players removing the disconnected player's actor from their scenes. Yet again we separate the function into a "server only section" and one "everyone section". From the server we "replicate" the call across all connected players. Then, we perform the actual task of removing the player instance from the scene. To remove the actor, we first have to locate its node, which can be done through the name. In this case, the network ID that we have set when spawning the players:

game_world.gd
remote func despawn_player(pinfo):
	if (get_tree().is_network_server()):
		for id in network.players:
			# Skip disconnecte player and server from replication code
			if (id == pinfo.net_id || id == 1):
				continue
			
			# Replicate despawn into currently iterated player
			rpc_id(id, "despawn_player", pinfo)
	
	# Try to locate the player actor
	var player_node = get_node(str(pinfo.net_id))
	if (!player_node):
		print("Cannoot remove invalid node from tree")
		return
	
	# Mark the node for deletion
	player_node.queue_free()

But, we have to call this function when a player disconnects from the server. We are only dealing with this event from the network.gd file. What we can do here is this:

  1. Create a new signal that will be meant to give the disconnected player_info as argument.
  2. If we are the server, we connect to this event and then call the despawn_player() function.
  3. The despawn_player() is coded in a way to replicate the call across all the connected players.

With this, we don't have to deal with the case of a player being disconnected while the other is just getting into the game world scene and didn't have time to connect into the event. All that said, let's declare the new signal, in the network.gd:

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

It's not necessary to add the argument in the signal declaration, but it makes things a lot clearer when studying the code. Now, in the unregister_player() function we have to emit this signal. The problem here comes from the fact that our new event requires the player_info of the disconnected player, which we have erased. If we invert the call order, we may get errors because our functions are assuming the player list is in the correct state, that is, without the player that has left. So, before erasing the player from the list, we cache its info and send that to the new event:

network.gd
remote func unregister_player(id):
	print("Removing player ", players[id].name, " from internal table")
	# Cache the player info because it's still necessary for some upkeeping
	var pinfo = players[id]
	# Remove the player from the list
	players.erase(id)
	# And notify the list has been changed
	emit_signal("player_list_changed")
	# Emit the signal that is meant to be intercepted only by the server
	emit_signal("player_removed", pinfo)

Next we connect a function to this new event only if we are in the server:

game_world.gd
func _ready():
	# Connect event handler to the player_list_changed signal
	network.connect("player_list_changed", self, "_on_player_list_changed")
	# If we are in the server, connect to the event that will deal with player despawning
	if (get_tree().is_network_server()):
		network.connect("player_removed", self, "_on_player_removed")
	
	# Update the lblLocalPlayer label widget to display the local player name
	$HUD/PanelPlayerList/lblLocalPlayer.text = gamestate.player_info.name
	
	# Spawn the players
	if (get_tree().is_network_server()):
		spawn_players(gamestate.player_info, 1)
	else:
		rpc_id(1, "spawn_players", gamestate.player_info, -1)

Yes, I know we could have reused the server check and connect the function before spawning the actor. I have separated this in the sample code mostly to keep it clear about the different things that are being done within the logic.

We have to create the function, which should only call the despawn_player() function. Since we are connecting this function to the event only on the server, we can get away with checking this fact before calling the despawning function:

game_world.gd
func _on_player_removed(pinfo):
	despawn_player(pinfo)

At this point, there is only one small problem that must be mentioned. If you test the game and watch the output messages in the console, whenever a new player above the 2 count joins the server, a bunch of errors will be given. That happens because there will be quite a few replication code happening but the new peers are still initializing things, not containing the correct actors in the scene yet.

In this tutorial we will ignore this error because it won't affect the synchronization between the players, however I have to mention it so you know that it's not your error. Nevertheless, if you really want to remove this error, then things have to be done a little bit differently. First, the replication code must be done individually using the rset_id() function, so a lot more control is present when dealing with this part. Next a message or flag must be set to people telling that everything has been fully initialized so that the peer can now receive the replication packets.


In this longer part we have done quite a lot, internal player list management, displaying the connected players within the HUD, updating that once something changes, spawning and despawning player actors according to the various connect/disconnect events... all of that synchronized between the connected players, which is the main objective of this tutorial!

We are not done yet, however. In the next part we will go a little further and incorporate some "rudimentary" bot spawning in order to fill the maximum players that can be connected in the game. With that code, we will remove a bot when a new player comes in and (re)add bots when players disconnect from the server.

EDIT: Thanks to NameBrandCerreal for pointing out a typo.

Introduction
Previous12
3
4Next