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

Flipflo Dev
9 min readDec 29, 2023

--

Starting a multiplayer game using the Godot Game Engine has never been easier. There are many different ways to structure your multiplayer backend and an increasing number of resources online to cover these. However, most of these approaches require some kind of server build that you need to upload to a paid hosting service or you need to create a network tunnel between your clients.

Project Demo

In this article, I will show you a simple way to implement the state synchronization of your multiplayer games, such that any player with an internet connection can directly join your game. There’s no need to setup port-forwarding or create a server build of your project. Furthermore, everything shown in this article is not platform specific, so you can export your godot project to web, mobile and desktop and you will be able to play cross-platform.

And the best part: it is free, up to a certain amount of traffic.

The tool we are going to use is Google Firebase. Firebase is a platform offering a range of cloud-based development tools with a generous free-tier. We will use a tool called Firebase Realtime DB to synchronize the state of the players.

This tutorial is divided into 4 sections:

If you prefer a video tutorial, here’s an updated guide similar to this written tutorial series on Youtube.

Architecture

Before we dive into how we are going to synchronize our data, we have to define what data we even want to synchronize between our players. To identify the players we will use an auto-generated player_id string. Because we build a 2D game we require a position vector with position_x and position_y coordinates. Finally, each player will have a different color, encoded as hex string.

To implement the communication of Godot with the Firebase Realtime DB, we are not using any addons. Instead we are using HTTP PUT requests to update the Firebase database with our player information. To receive updates from the Firebase database, we could just poll the Firebase Realtime Database all the time using HTTP GET requests. However, this leads to a lot of traffic, even if no player is moving and the state of the game stays the same, as the client cannot know this before sending the request. A better approach is opening a TCP Stream and listen to Server-Sent Events. That way, the server (aka the Firebase Realtime Database) will only notify the clients (our game) if the game state has changed.

Firebase Setup

Let’s get started with setting up the Firebase project. First, go to the Firebase Console and create a new project. Enter a name for your project, I will name this project godot-multiplayer-firebase. After naming your project, click on Continue.

Create a new Firebase project

We are not going to use Google Analytics, so you can disable this feature for this project. Then click on Create project.

Disable Google Analytics for this project

Once your project is ready, go to the Build tab on the left side panel and select Realtime Database.

Then click on Create Database.

Create Firebase Realtime Database

In the setup dialog select a location close to where you are, I will select europe-west1. Click on Next.

For the security rules, we are going to select Start in test mode. Note that this way, anyone will we able to access your realtime database for a month. If you plan to use this project in a production setup, make sure to check out the security rules documentation. Finally, click on Enable.

That’s it, your Firebase Realtime Database is setup. We will need the link to this realtime database in the next step. You can click the link icon to copy the url.

Godot Project

Finally it’s time to setup the Godot project. I am currently using Godot version 4.2.1, if you are using a newer version and anything breaks, please let me know.

Start by creating a new project and select your preferred renderer. I always aim to use the lowest renderer and since I want to create a web build I will use the Compatibility renderer.

Local Player

First, let’s create the character that the player controls locally with their keyboard. Create a Node2D root for the scene and add a CharacterBody2D node for the player. Add a a Sprite2D and a CollisionShape2D node as children to the player node. For the sprite select the Godot icon.svg for now, we will make the game pretty later. On the collision shape, set the shape to a New RectangleShape2D and set its size to 128x128 pixels, that way it fits to the selected sprite. Save the scene as world.tscn.

To make the player move, we need to define an input map. Go to Project — Project Settings — Input Map. There you can configure 4 input actions, one for each movement direction (up, down, left, right)

Now we can add a script to the player, let’s call it player_local.gd. Let’s add some movement code in the _physics_process function. We can get the movement in the x/y direction by using the Input.get_axis on the left/right and the up/down axis. To prevent the player from moving faster diagonally, we normalize the movement vector and as long as the player is moving, the direction vector will have a length of 1. We then multiply the direction with a fixed movement speed to get the final velocity.

To move the player in the world, we need to apply the velocity. Because we are using a CharacterBody2D we can simply call the move_and_slide function.

extends CharacterBody2D

const MOVE_SPEED: float = 200

func _physics_process(delta: float) -> void:
var dx = Input.get_axis("move_left", "move_right")
var dy = Input.get_axis("move_up", "move_down")

if dx != 0 or dy != 0:
var dir = Vector2(dx, dy).normalized()
velocity = dir * MOVE_SPEED
else:
velocity = Vector2.ZERO

move_and_slide()

Now you can already run the game (F5), if you haven’t already selected a default scene you will be prompted to do so. Click on Select Current.

You should be able to move your godot icon character around using the ASDW or arrow keys.

Sync local player to Firebase

Now we are ready to start syncing the local player data to Firebase. Let’s first add two more properties to the existing player script, the player_id which will be a random 6 digit number and the player_color, which will also be generated at random using the from_hsv function.

...
const MOVE_SPEED: float = 200

var player_id: int = randi_range(100000, 999999)
var player_color: Color = Color.from_hsv(randf(), 1.0, 1.0)

func _physics_process(delta: float) -> void:
...

Let’s also add a class_name to this script. This way we will be able to reference these properties from another script.

extends CharacterBody2D

class_name PlayerLocal

...

Next, create a new HTTPRequest node as a child of the world node, called PlayerLocalSync. Add a new script to this node, I called the script player_local_sync.gd. This script will send the local player data to the Firebase Realtime Database. Start by declaring a reference to the player node that we can assign in the editor.

extends HTTPRequest

@export var player: PlayerLocal
Assign the player nodeto the exported variable

We will send requests to update the player all the time, but we don’t want the requests to build up a backlog, aka we want to make sure that we only send a new request when the previous request is done. Therefore we keep track of whether a request is pending in a bool.

var is_request_pending: bool = false

Let’s create the structure of our script, where each frame we check if a request is pending and if not, we send a new one.

...

func _ready() -> void:
request_completed.connect(_on_request_completed)

func _on_request_completed(
result: int,
response_code: int,
_headers: PackedStringArray,
_body: PackedByteArray,
) -> void:
if result != RESULT_SUCCESS:
printerr("request failed with response code %d" % response_code)
is_request_pending = false

func _process(delta: float) -> void:
if !is_request_pending:
_send_local_player()

func _send_local_player() -> void:
# TODO
pass

Now we can implement the _send_local_player function. First we need to compose the url to which we will send the request for our local player. Replace the host url in the following snippet with the url copied from the Firebase setup in the previous step.

 const host = "https://godot-multiplayer-firebase-default-rtdb.europe-west1.firebasedatabase.app"
var path_player = "/players/%s.json" % player.player_id
var url = host + path_player

We can define the player data as a dictionary. The position will be defined by the global_position of the player node and for the color string we will use the to_html function, that will return the hex code for the color without the leading # symbol.

 var player_data = {
"player_id": player.player_id,
"position_x": player.global_position.x,
"position_y": player.global_position.y,
"color": player.player_color.to_html(false)
}

We then need to convert this data to json. We can use the built-in JSON.stringify to get the json string.

 var player_data_json = JSON.stringify(player_data)

Now the final part of the function is to send the request using the player data as body to the defined url. Also we need to set the request to pending.

 is_request_pending = true
request(url, [], HTTPClient.METHOD_PUT, player_data_json)

Let’s add a small optimization to only send the json data if it has changed. To do this, we create a property that keeps the previously sent json data.

...

var prev_player_data_json: String

...

func _send_local_player() -> void:
...
var player_data_json = JSON.stringify(player_data)

if player_data_json == prev_player_data_json:
return
prev_player_data_json = player_data_json

is_request_pending = true
request(url, [], HTTPClient.METHOD_PUT, player_data_json)

Now we are ready to test. Run the game and open the Firebase Console. You should be able to see your player data synced and if you move the player, the position will change.

One thing you will notice, is that the players stay in the database, even if the game is closed. And upon a new startup, there will be two instances in the player json in the realtime database. To fix this, let’s add a new method that will delete the local player when the game is closed.

But before we do that, let’s extract the url composing into a separate function:

func _get_player_url() -> String:
const host = "https://godot-multiplayer-firebase-default-rtdb.europe-west1.firebasedatabase.app"
var path_player = "/players/%s.json" % player.player_id
return host + path_player

func _send_local_player() -> void:
var url = _get_player_url()
...

Now we need to disable the default quit behaviour of Godot. That way we can wait for our player deletion request to complete.

func _ready() -> void:
get_tree().set_auto_accept_quit(false)
request_completed.connect(_on_request_completed)

And now let’s override the quit behaviour. First cancel the ongoing request for updating the player. Then we can send a delete request to the endpoint for the local player. Only after waiting for the request_completed signal we can finally let the game quit.

func _notification(what) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST:
_delete_local_player()

func _delete_local_player() -> void:
var url = _get_player_url()
cancel_request()
request(url, [], HTTPClient.METHOD_DELETE, "")
await request_completed
get_tree().quit()

Now if you check the Firebase Database, you will see that players that quit the game will also be removed.

Outlook

In the next part, we will continue by adding the remote players synchronization to the game. In the final part we will make the game look pretty using some free game art.

Source Code for Part 1

--

--

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.