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

Flipflo Dev
10 min readJan 2, 2024

--

This is the second part in the series. If you haven’t already, checkout Part 1 to get an overview of the project and the setup of Firebase/Godot project.

In the first part we setup the Firebase and Godot project and successfully synchronized the local player to the Realtime DB in Firebase. In this second part, I will show you how to synchronize the remote players state.

Godot Scene Setup

To start, add a new plain Node as a child to the world scene root node and name it RemotePlayerSync. Attach a new script called player_remote_sync.gd. For now leave it as is, we will get back to this script in the next section. We first have to do some code cleanup.

Node setup for PlayerRemoteSync

Currently our Firebase URL is hardcoded in the local_player_sync script. Since we will also use the host url for the synchronizing of the remote players, let’s create a common script that holds the urls to the firebase endpoints. Create a new script in the FileSystem without attaching it to a node in the world scene and call it firebase_urls.gd.

In the script add the host const that contains your Firebase Realtime Database host address, as well as a host_url variable with the added HTTPS protocol prefix. Add a function get_player_url to get the url for a specific player_id. Finally add a function get_players_url to get the address to all players.

extends Node

const host = "godot-multiplayer-firebase-default-rtdb.europe-west1.firebasedatabase.app"
const host_url = "https://" + host

func get_player_url(player_id) -> String:
var path_player = "/players/%s.json" % player_id
return host_url + path_player

func get_players_url() -> String:
return host_url + "/players.json"

Next we need to add this script to the autoload of the game, that way we can access these variables and functions from anywhere. Go to Project — Project Settings and then select the Autoload tab. Click on the folder icon next to the path and select the firebase_urls script. Leave the autogenerated name FirebaseUrls and click on Add. The script should now be added to the autoload list.

Now we can go back to our player_local_sync script from Part 1 and remove the _get_player_url function. Replace its usages with the newly generated function in FirebaseUrls. There are two places, in the _send_local_player and in the _delete_local_player functions the url can now be loaded from the FirebaseUrls script.

func _send_local_player() -> void:
var url = FirebaseUrls.get_player_url(player.player_id)
...

func _delete_local_player() -> void:
var url = FirebaseUrls.get_player_url(player.player_id)
...

Now we are ready to move on to the interesting part: the synchronization of the remote players.

Server-Sent Events

To receive updates for the other players we could poll the REST API of the firebase realtime DB and ask for all players position every frame. This approach would lead to a lot of traffic all the time, even when nothing has changed on the server.

Instead, we will use so called Server-sent events (SSE). The SSE protocol is an event streaming protocol that allows the server to notify its connected clients of events, instead of having the clients request data themselves. We can use this protocol to let the Firebase Realtime DB send events to our game if anything has changed in the player data, e.g. a new player connected or a player has moved.

In a first approach to implement the SSE protocol in Godot, I tried to use the HTTPClient to send an initial request and check its chunked response for streamed events. However, in the HTTPClient node we do not have direct access to the underlaying stream and we therefore have to rely on the read_response_body_chunk function. I was able to get it working by calling this function in a read loop in a separate thread, but the game was just too laggy. Something in the implementation of this node did not like keeping the HTTP connection alive and using it as a stream.

In the end, I resorted to using a raw TCP connection with a TLS layer on top to communicate with the Firebase SSE endpoint using HTTPS. The TLS layer is needed as a plain HTTP request will not work, it will simply get redirected to the HTTPS port. There is quite some code to setup the TCP/TLS Stream, but bare with me.

TCP Stream

First let’s create a function in the player_remote_controller.gd to create the TCP stream. Create a new StreamPeerTCP variable. Connect to the Firebase host URL we defined in the previous step using the conncet_to_host function. To send HTTPS requests, we will use the port 443, which is the standard port for the TLS protocol (standard for plain HTTP is port 80).

func _setup_tcp_stream() -> StreamPeerTCP:
var tcp = StreamPeerTCP.new()

var err_conn_tcp = tcp.connect_to_host(FirebaseUrls.HOST, 443)
assert(err_conn_tcp == OK)

We now need to wait for the connection to establish. Because there’s no built-in signal for the connection state we have to poll the status of the TCP stream every frame until the status is STATUS_CONNECTED. Between each poll, we yield the frame by awaiting the process_frame, meaning the game can continue to run one frame while the stream is connecting. Without this line, the game would shortly freeze before starting up. Finally we return the tcp stream from the function, we will use this in a moment to create the TLS layer on top.

func _setup_tcp_stream() -> StreamPeerTCP:
...

tcp.poll()
var tcp_status = tcp.get_status()
while tcp_status != StreamPeerTCP.STATUS_CONNECTED:
await get_tree().process_frame
tcp.poll()
tcp_status = tcp.get_status()

return tcp

TLS Stream

Next we need to setup the TLS stream to be able to send requests via HTTPS. The structure is very similar to the creation of the TCP stream, except the function will receive a TCP stream as input and setup the TLS stream on top of this existing connection.

func _setup_tls_stream(tcp: StreamPeerTCP) -> StreamPeerTLS:
var stream = StreamPeerTLS.new()

var err_conn_stream = stream.connect_to_stream(tcp, FirebaseUrls.HOST)
assert(err_conn_stream == OK)

stream.poll()
var stream_status = stream.get_status()
while stream_status != StreamPeerTLS.STATUS_CONNECTED:
await get_tree().process_frame
stream.poll()
stream_status = stream.get_status()

return stream

Initialize SSE Stream

To initialize the SSE stream we need to send an initial request to open the stream. Create a new function called _start_sse_stream that will receive a StreamPeer as an argument. We can get the URL from our FirebaseUrls class. We need to build the request according to HTTP 1.1 with a request line, headers, an empty line and an optional body, which we leave blank:

GET $URL HTTP/1.1
Host: $URL
Accept: text/event-stream

The headers need to include the host URL and the Accept content type. By specifying text/event-stream, we signal the Firebase server that we are ready to receive the event stream on this connection.

func _start_sse_stream(stream: StreamPeer) -> void:
var url = FirebaseUrls.get_players_url()
var request_line = "GET %s HTTP/1.1" % url
var headers = [
"Host: %s" % FirebaseUrls.HOST,
"Accept: text/event-stream",
]

No we need to concatenate the request to a single string and then encode it as bytes. Since we only use ascii characters, we can use to built-in to_ascii_buffer function for strings.

func _start_sse_stream(stream: StreamPeer) -> void:
...

var request = ""
request += request_line + "\n" # request line
request += "\n".join(headers) + "\n" # headers
request += "\n" # empty line
stream.put_data(request.to_ascii_buffer())

Read Stream Response

After starting the stream, we won’t be sending anymore requests. The script will listen to any new data from the opened stream. To implement this behavior, let’s create a function _read_stream_response that awaits a new response from the stream. The function will continuously poll the stream to check if new bytes are available and if nothing is available it will yield the process until the next frame by awaiting the process_frame signal. Using the get_string method from the stream we directly parse the raw bytes using the ASCII encoding to a string. The resulting response string will be returned from the function.

func _read_stream_response(stream: StreamPeer) -> String:
stream.poll()
var available_bytes = stream.get_available_bytes()
while available_bytes == 0:
await get_tree().process_frame
stream.poll()
available_bytes = stream.get_available_bytes()

return stream.get_string(available_bytes)

First Response

Let’s put the pieces together and check if we can establish a connection to Firebase. Create a function _start_listening and call it at the start of the script in the _ready method.

func _ready() -> void:
_start_listening()

func _start_listening() -> void:
pass

First create the TCP stream, then the TLS stream and then send the request to start the event stream. Finally read the response from the server and print it to the console.

func _start_listening() -> void:
var tcp = await _setup_tcp_stream()
var stream = await _setup_tls_stream(tcp)

_start_sse_stream(stream)

var initial_response = await _read_stream_response(stream)
print(initial_response)

If everything is setup correctly, the Firebase server will respond with a message similar to the following one:

HTTP/1.1 200 OK
Server: nginx
Date: Tue, 02 Jan 2024 17:17:49 GMT
Content-Type: text/event-stream; charset=utf-8
Connection: close
Cache-Control: no-cache
Access-Control-Allow-Origin: *
Strict-Transport-Security: max-age=31556926; includeSubDomains; preload

Read Loop

Now we can add a loop and for now print all events from the server.

func _start_listening() -> void:
...

while true:
var response = await _read_stream_response(stream)
print(response)

When you now start moving the player, you should see the events from the Firebase Database in the console. Neat!

Event Parsing

The events are structured in two lines as colon separated key-value pairs, where the first line is the event type and the second line is the data. The main event we are interested in is the put event. There are other events such as the keep-alive which we don’t need to process. Let’s write a parser for these events. First create an inner class for the event data that contains a type that represents the event type (put, keep-alive, etc.) and a dictionary representing the JSON data.

class EventData:
var type: String
var data: Dictionary

Now we can create a function that parses two lines of an event returns an instance of this event data.

func _parse_event_data(event_str: String) -> EventData:
pass

First split the event_str into its two lines. An event will always contain exactly two lines, therefore we can return null otherwise.

 var event_lines = event_str.split("\n")
if event_lines.size() != 2:
return null

Then we need to check if the first line starts with event followed by a colon and then a whitespace, and similarly for the second line we need to check if it starts with data followed by a colon and a whitespace.

event: put
data: {...}

Let’s define these two prefixes as constants at the top of our script.

const EVENT_TYPE_PREFIX = "event: "
const EVENT_DATA_PREFIX = "data: "

Now we can check the two lines for theses prefixes

 var event_type_line = event_lines[0]
if !event_type_line.begins_with(EVENT_TYPE_PREFIX):
return null
var event_data_line = event_lines[1]
if !event_data_line.begins_with(EVENT_DATA_PREFIX):
return null

Then to get the part on the right side of the prefixes, we use the substring function on the string, starting after the prefix string length.

 var event_type_str = event_type_line.substr(EVENT_TYPE_PREFIX.length())
var event_data_str = event_data_line.substr(EVENT_DATA_PREFIX.length())

To parse the event data to a dictionary from the JSON representation, we can use the JSON parse_string function. This will return null if the data cannot be parsed, in which case we return an empty Dictionary.

 var event_data_json = JSON.parse_string(event_data_str)
if event_data_json == null:
event_data_json = {}

Now we can create our event data object and assign the two properties. Finally we can return the event data instance.

 var event = EventData.new()
event.type = event_type_str
event.data = event_data_json
return event

Response Parsing

To parse the response stream, we need to analyze the output a bit closer. Each event line-pair is separated by one completely empty line, or in other words two new line feeds. We have to be careful, as in the HTTP 1.1 the header/content line feeds contain a carriage return \r as well as a new line \n character, but the body/event data from Firebase only contains one new line character. The simplest way to eliminate this problem is to remove the carriage return characters all together. Then we can split the response into chunks/parts separated by two new line characters \n\n. Create a new function _parse_response_event_data that receives a response string and returns an Array (there may be multiple events per response) of event datas, represented as Dictionaries.

func _parse_response_event_data(response: String) -> Array[Dictionary]:
var response_parts = response.replace("\r", "").split("\n\n")

Each response part has to be parsed by our previously created function and if the event is a relevant put event, we add it to our event_data array which we will eventually return.

func _parse_response_event_data(response: String) -> Array[Dictionary]:
var response_parts = response.replace("\r", "").split("\n\n")
var event_data: Array[Dictionary] = []
for response_part in response_parts:
var event = _parse_event_data(response_part)
if event == null:
continue
if event.type != "put":
continue
event_data.append(event.data)

return event_data

Now we can adjust the read loop to parse the events and print out the parsed event data:

func _start_listening() -> void:
var tcp = await _setup_tcp_stream()
var stream = await _setup_tls_stream(tcp)

_start_sse_stream(stream)

while true:
var response = await _read_stream_response(stream)
var events = _parse_response_event_data(response)
for event in events:
print(event)

Now when running the game, you should see the the events in the console.

Outlook

In the next part of this tutorial series, I will show you how we can spawn new players into the scene when they connect, synchronize the existing players when not joining as first player, updating the position to visually see the movement of the remote players in the game and finally removing the player if he leaves the game.

Source Code for Part 2

--

--

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.