Online Multiplayer in Godot 4 with Firebase Realtime DB without addons (Part 3/4)

Flipflo Dev
7 min readJan 3, 2024

--

This is the third part of this series where I teach you how to implement Firebase Realtime DB in Godot 4 to create an online multiplayer game. Checkout Part 1 and Part 2 if you haven’t already to see how the project is setup so far.

In this part we will start by spawning, updating and removing the remote players in the game scene. In the final chapter we will then add some free game art to make it look pretty and add some finishing touches.

Randomize Local Player Spawn Point

But before we start with spawning all other players, it is a good time to randomize the spawn of the local player. Otherwise it will collide with the remote players when we create them if they have not moved. In the player_local.gd script add a new function _set_random_spawn and call it from the _ready function. We will generate a random point inside the viewport with some spacing from the screen border for the spawn point.

func _ready() -> void:
_set_random_spawn()

func _set_random_spawn() -> void:
var screen_size = get_viewport().size
var spawn_border = 64
var rand_x = randf_range(spawn_border, screen_size.x - spawn_border)
var rand_y = randf_range(spawn_border, screen_size.y - spawn_border)
global_position = Vector2(rand_x, rand_y)

Remote Player Node

Now we can start by creating the remote player scene. Duplicate the local Player node we created in part 1 and name it PlayerRemote. Remove the local_player script on this new node (only this one, leave it on the local player node) and add a new script to his node, called player_remote.gd.

Disable collisions of the remote players by opening the Collisions tab on the CharacterBody2D part in the inspector. Toggle the one selected layer in the Layer matrix to disable it.

Now right-click the PlayerRemote node and click on Save Branch as Scene. This will create a new packed scene file. I left the default name player_remote.tscn. After saving the scene to the file system, you can delete it from the world scene tree, as we will be spawning the remote players dynamically from our script.

In the remote_player.gd script add a class_name PlayerRemote to be able to use this script as type. Add the properties for the player_id and the color of the player. Create a function update_from_event that takes the player_data Dictionary from the event data as input. The player id has to be parsed to a string because it gets an automatic type from the JSON parser and since it only contains numbers, the parser thinks it’s a float. For the color we can use the html function from the built-int Color class to convert the hex representation back to a Godot color. Pass the position coordinates to a new function move_to_target that for now just sets the global_position to the new value. In the end we will add some interpolation to smooth out the remote player movement.

extends CharacterBody2D

class_name PlayerRemote

var player_id: String
var color: Color

func update_from_event(player_data: Dictionary) -> void:
player_id = str(player_data["player_id"])
color = Color.html(player_data["color"])
_move_to_target(player_data["position_x"], player_data["position_y"])

func _move_to_target(target_pos_x, target_pos_y) -> void:
global_position.x = target_pos_x
global_position.y = target_pos_y

Remote Player Scene Reference

Moving back to the player_remote_sync.gd script we can add a property for the reference to the player_remote scene. One way to reference this scene is using the load or preload function and specifying the path to the file. I prefer to add an @export annotation and assign the scene in the editor.

@export var player_remote_scene: PackedScene

In the editor, you can now select the PlayerRemoteSync node in the World scene tree and in the properties under Player Remote Scene click on Quick Load. Selecet the player_remot.tscn scene.

Tracking Remote Players

To know when to spawn a new remote player, we need to keep track of the currently spawned players, as well as who is our local player. Add two more properties, one for tracking the local player and one to keep track of the remote players in our scene. The local_player property is of type LocalPlayer, while the remote_players are of type Dictionary, where the keys are the player ids and the values will be the PlayerRemote node instances.

@export var player_local: PlayerLocal
var players_remote: Dictionary = {}

You also need to assign the player_local variable in the editor:

Also add two more empty functions for creating/updating a player and deleting a player, we will implement them later.

func _create_or_update_player(player_id: String, player_data: Dictionary):
print("creating/updating player %s" % player_id)

func _delete_player(player_id: String):
print("deleting player %s" % player_id)

Handle Player Event

Now in the read loop of the player_remote_sync.gd script we need to add a new function _handle_player_event. This function is responsible for deciding what to do with the received events, for example if we should spawn a new node or update an existing player node position.

func _start_listening() -> void:
...

while true:
var response = await _read_stream_response(stream)
var events = _parse_response_event_data(response) as Array[Dictionary]
for event in events:
_handle_player_event(event)

func _handle_player_event(event: Dictionary):
pass

Now let’s implement the handling. Each event data dictionary will again contain two parts: the path and the data.

put

The JSON-encoded data is an object with two keys: path and data. The path key points to a location relative to the request URL. The client should replace all of the data at that location in its cache with data.

We can parse the event dictionary into its path and data parts, where the path is always a String. The data can be either null or a dictionary, that represents either one player or all players depending on the path.

func _handle_player_event(event: Dictionary):
var path = event["path"] as String
var data = event["data"]

There are two cases we need to consider. If the path is single slash (/) the data will contain a dictionary of player_id and player_data key-value pairs. Every key of the dictionary corresponds to a live player and together with the data these will be created or updated. In a second step we have to delete every cached remote player if they are not in the list of player_id keys.

func _handle_player_event(event: Dictionary):
...

if path == "/":
if data != null:
for player_id in data.keys():
_create_or_update_player(player_id, data[player_id])
for player_id in players_remote.keys():
if data == null or player_id not in data.keys():
_delete_player(player_id)

On the other hand, if the path is a path to a player_id (/123456) the dictionary is directly the player_data. To get the player id from this path, we can split the path by the slash into its components and take the last one (-1 indexing wraps around to size-1). If the data is not null we can create or update the player. If the data is null, we have to delete the player.

func _handle_player_event(event: Dictionary):
...

if path == "/":
...
else:
var path_parts = path.split("/")
var player_id = path_parts[-1]
if data != null:
_create_or_update_player(player_id, data)
else:
_delete_player(player_id)

Spawn and Delete Players

Now let’s implement the _create_or_update_player function. First we need to make sure that we don’t do anything with our local player, as we don’t want a ghost clone of our local player lagging behind us. Then we check if we already have an instance with the given player_id. If we do, we just need to update this instance. Otherwise we have to create a new instance, add it to the scene and add it to our dictionary. Then we can also update the new instance from the event.

func _create_or_update_player(player_id: String, player_data: Dictionary):
if player_id == str(player_local.player_id):
return

var player: PlayerRemote
if player_id in players_remote:
player = players_remote[player_id]
else:
player = player_remote_scene.instantiate()
get_parent().add_child(player)

player.update_from_event(player_data)
players_remote[player_id] = player

The delete function is even shorter. We also require the check for the local player_id to prevent deleting our local player and we also have to check if we have spawned an instance of that player id. Once we get the instance from our dictionary we can call queue_free on this node to remove it from the scene tree.

func _delete_player(player_id: String):
if player_id == str(player_local.player_id):
return

if player_id not in players_remote:
return

var player = players_remote[player_id] as PlayerRemote
player.queue_free()

Testing

Now we can test the game already. In the editor under Debug — Run Multiple Instances you can select to run up to 4 instances of the game. Select the option to Run 4 instances and start the game. You will get 4 game windows, and all players are synchronized between the game windows. You could also export the game now to any platform and play online.

Outlook

In the next and final part we will cleanup the project and add some free game art to add a finishing touch to the game.

Source Code for Part 3

--

--

Flipflo Dev

A hobby game developer coming from a robotics and electrical engineering background, hoping to provide fun and interesting content for like minded people.