A Godot server + client game deployed on DigitalOcean Apps
Amir Eldor
Posted on October 16, 2020
I'm going to tell you how I built a Godot client/server game deployed on DigitalOcean Apps. Well, it's more of an experiment than a game at the moment, but I thought it's worthwhile sharing as Godot server resources on the net seem to be scarce, let alone deployment of such.
The game will be an online single-player persistent-universe game. The server should support multiple players playing on the same server, each in their sandbox.
Initially choosing Python and Phaser3 for development, my partner humbly asked me why don't we use an existing game engine, and I did not have a decent answer to give. Recalling a famous quote from somewhere around the web: "make games, not engines." I decided I have to try at least making a proof-of-concept for this kind of game, using a game engine. Enter Godot. Why not Unity? See further down this article.
Godot "universal" server/client Space Game
By leveraging the Godot's High-Level multiplayer networking API I can have the engine handle tasks such as syncing state between the server and client as well as issuing bidirectional commands between the peers. The client might send a "get star system data" or "fly a spaceship to planet X" and the server might report a random "space dragon attack" event or any other game-related data and logic.
A requirement for the game is running on a browser, so I chose the WebSocketMultiplayerPeer
class in the GDScript code.
Let's connect things
We are developers, I'll talk less and show more code.
func _ready():
var peer: WebSocketMultiplayerPeer
if OS.has_feature("Server") or "--server" in OS.get_cmdline_args():
peer = WebSocketServer.new()
peer.active_relay = false # see note below
peer.listen(4321, [], true)
else:
var server_url = 'ws://localhost:4321'
if OS.has_feature('release'):
server_url = "wss://this.will.be.your.digitalocean.apps.thing.soon/server"
peer = WebSocketClient.new()
peer.connect_to_url(server_url, [], true, [])
if peer == null:
OS.exit_code = 1
print("Failed initializing peer :( so can't network things")
get_tree().quit()
get_tree().network_peer = peer
if get_tree().is_network_server():
print("I am the mighty server")
var ok
ok = get_tree().connect("network_peer_connected", self, "_peer_connected")
assert(ok == OK)
ok = get_tree().connect("network_peer_disconnected", self, "_peer_disconnected")
assert(ok == OK)
else:
print("I am an unworthy client")
var ok
ok = get_tree().connect("connected_to_server", self, "_connected_to_server")
assert(ok == OK)
ok = get_tree().connect("connection_failed", self, "_connection_failed")
assert(ok == OK)
The peer.active_relay = false
command is so the server does not broadcast the connection of each peer to all the other connected peers (thanks LordDaniel09).
Forgive me for all the OK
assertions, I am rather new to Godot and a combination of not knowing what I'm doing and compiler warnings made me do that.
This code will initialize a server or connect to one depending on some feature or command-line argument. To run the server I just run this from the project's folder:
godot-server
You need the server build of Godot for that. This is what we'll deploy eventually with the Dockerfile on DigitalOcean Apps.
Running the client is simply running godot
or running the game from the editor. I also obviously want an HTML5 build so I exported the game rather early and made sure all this Websocket madness works.
Creating a new game or joining an existing one
On the client-side, when we connect to the server, we create a Game Scene locally and then ask the server (peer id 1) to create game 123
for us or let us join the one that is already running. Authentication, unique Game IDs, and game->player association is still something I'm thinking about. More on that soon.
We need the local scene so the hierarchy on the server which will soon create a similar node will be the same for the two peers. This makes Godot happy when doing the high-level multiplayer magic, or so I understand.
func _connected_to_server():
print("Connected to server!")
# TODO: Should explicitly ask to make a new game or join
var game_id = 123 # get from somewhere
var game = load("res://Game.tscn").instance()
game.set_name(str(game_id))
$Games.add_child(game)
rpc_id(1, "_join_game_or_new", 123)
The hierarchy will look something like this:
And on the server:
remote func _join_game_or_new(game_id: int):
var peer_id = get_tree().get_rpc_sender_id()
print("%d wants to join game %d" % [peer_id, game_id])
# TODO: make sure peer allowed to join this game
var game_node_path = "./Games/%d" % game_id
if has_node(game_node_path):
pass # What now?
else:
print("Creating game %d" % game_id)
var game = load("res://Game.tscn").instance()
game.set_name(game_id)
$Games.add_child(game)
So at this point, both the client and server have "agreed" on the hierarchy and the game can start (or resume as we'll see).
The Game
scene contains a Solar System
scene. In the future, we might have a Galaxy
scene or Planet
or any other screen that we might need.
Inside the Solar System
scene, we start having interesting fun. Let's look at the _ready()
of that scene.
func _ready():
if is_network_master():
print("Randomizing a solar system")
var rng = RandomNumberGenerator.new()
rng.randomize()
# Following is very specific mock-ish logic code just for the sake of having something on the screen
var radius = 200
for _index in range(rng.randi_range(2, 10)):
var planet = load('res://Planet.tscn').instance()
planet.set_planet_kind(rng.randi()%20 + 1)
var pos = Vector2(radius, 0)
pos = pos.rotated(rng.randf_range(0, 2*PI))
planet.position = pos
# this is stupid:
planet_angle_inc.append(rng.randf_range(min_angle_inc, max_angle_inc))
radius += 230
$Planets.add_child(planet)
else:
call_deferred('rpc_id', 1, "get_planets")
For the client, which is not the network master at any point for any node (besides GUI, which is not networked either way), I call get_planets
with call_deferred
. As you'll see in the implementation of get_planets
, the scene should exist for the client so _ready()
should have been run already when this call is made. Well, that's what I tell myself, as without call_deferred I had "Failed to get path from RPC
" errors.
remote func get_planets():
var peer_id = get_tree().get_rpc_sender_id()
print("Sending planets to peer %d" % peer_id)
# not sure if this is the best way to serialize things
# If you have Godot magic I'm unaware of please let me know
var serialized = {"planets": []}
var planets = $Planets.get_children()
for index in range(len(planets)):
serialized["planets"].append(
{
"position": [planets[index].position.x, planets[index].position.y],
"angle_inc": planet_angle_inc[index],
"kind": planets[index].get_planet_kind(),
"rotation": planets[index].get_rotation(),
"rotation_speed": planets[index].rotation_speed,
}
)
rpc_id(peer_id, "set_planets", serialized)
The planets are rotating around the star. As part of the POC, I wanted to make sure I get a consistent state between the client and server, even when I refresh or restart the client. So if for example a planet just passed "3 o'clock" and I'd reconnect briefly after, I'll see that planet a bit more towards "4 o'clock" and not in a completely new random position.
It's worth noting that the Solar System
scene is initialized on the server once, and I intend to keep it alive as long as the game lives. This will be needed for the whole universe simulation to keep running in the background. The client however will delete and create new Solar System
scenes as they go through the systems in the galaxy. There's no need for a client to know all the mysteries of the universe and it would be a waste of resources (think mobile browsers).
remote func set_planets(serialized):
for serial in serialized["planets"]:
var planet = load("res://Planet.tscn").instance()
planet.set_planet_kind(serial["kind"])
var pos = Vector2(serial["position"][0], serial["position"][1])
planet.position = pos
planet.rotate(serial["rotation"])
planet.rotation_speed = serial["rotation_speed"]
planet_angle_inc.append(serial["angle_inc"])
$Planets.add_child(planet)
first_sync_done = true
At this point, the server and client should have the same state. And the sweet part is that because we run the game on the same engine, then the simulation of the heavenly figures will be in sync without a need to continuously update the positions of the planets.
func _physics_process(delta):
var planets = $Planets.get_children()
for index in range(len(planets)):
var planet = planets[index]
var pos = planet.position
pos = planet.position.rotated(planet_angle_inc[index] * delta)
planet.position = pos
I will however add additional get_planets
call from the client to the server every, say, 10 seconds, just to be sure we are really in sync.
Deploying the server and a static HTML5 website to DigitalOcean Apps
So the only viable option for running this on the DigitalOcean Apps PaaS is with a Dockerfile. Actually two Dockerfiles. One for the main server and the other for building the HTML5 build of the game and hosting that on a static website in the platform.
Searching DockerHub, I found the barichello/godot-ci
repo which was a fantastic replacement to my flaky Docerfile build. Basically what my Dockerfile did and the repo's one do is download a copy of godot-headless
, which can build things e.g. the HTML5 build.
Let's start with what I called Dockerfile.html5
as it's a bit easier.
FROM barichello/godot-ci
WORKDIR /game
COPY . ./src/
RUN mkdir build && godot --path ./src/ --export HTML5 /game/build/index.html && rm -r src/
We copy the project, build things to some build folder, and then remove the code to keep the image leaner. The build is standalone and does not require the code at all even for HTML5.
For the game server, we also need to download godot-server
so the Dockerfile would look something like this:
FROM barichello/godot-ci
RUN wget https://downloads.tuxfamily.org/godotengine/3.2.3/Godot_v3.2.3-stable_linux_server.64.zip && \
unzip Godot_v3.2.3-stable_linux_server.64.zip && \
mv ./Godot_v3.2.3-stable_linux_server.64 /opt/godot-server
WORKDIR /game
COPY . ./src
RUN godot --path ./src --export-pack Linux /game/game.pck && rm -r ./src
EXPOSE 4321
CMD [ "/opt/godot-server", "--main-pack", "/game/game.pck" ]
It took a bit of trial and error to get the build paths right, also for DigitalOcean Apps to pick the static website folder, but I managed eventually.
Let's see some DigitalOcean Specifics
Deploying the server was pretty easy. For the static website, I needed to use doctl
to edit the spec for my App so I can provide a Dockerfile path for the static build.
The commands are more or less like:
doctl apps list
# take note of your app id
doctl apps spec get YOUR_APP_ID > do-apps-spec.yaml
# ...or any other filename, not sure how DO want you to call it
Then you'd edit the static website's section and add a Dockerfile_path
and the now-required output_dir
:
domains:
- domain: this.is.stil.unpublished.com
type: PRIMARY
name: your-repo-name
region: fra
services:
- dockerfile_path: Dockerfile
github:
branch: live
deploy_on_push: true
repo: me/repo-name
http_port: 4321
instance_count: 1
instance_size_slug: basic-xxs
name: nice-server
routes:
- path: /server
static_sites:
- envs:
- key: SERVER_URL
scope: BUILD_TIME
value: wss://the-digital-ocean-link-for-the-server/server
github:
branch: live
deploy_on_push: true
repo: me/repo-name
name: web
routes:
- path: /web
dockerfile_path: Dockerfile.html5
output_dir: /game/build
Update the App's spec:
doctl apps update $APPS_ID --spec do-apps-spec.yaml
And I think that did it!
It was a bit painful, but eventually, I got a game up. When I refresh the browser, I get the planets' state as they are on the server in my little persistent universe, which keeps on turning while I am not actively connected to it.
Show me the code
I showed you.
J/K.
I think the whole code as a "clone and run" repository is redundant. I gave you the ideas and snippets and you'll educate yourself better if you do some more hands-on on this one.
Authentication and Game IDs
I lean towards an external auth service such as Auth0 to generate some lovely JWTs for me. Then I would need a way for Godot to validate these JWTs.
When a client connects to the server, I could either use a C# or C++ module that handles JWT validation as suggested here or maybe use a cloud function of some sorts to validate the token before letting the peer know the secrets of the universe.
Why not Unity?
I chose Godot for 2 reasons.
One, it's open-source and I thought that if I manage to get this game somewhere then a "made with Godot" banner would be very nice for both projects.
Second, Unity's network stack is being re-written at the moment. I did not want to play around with a deprecated API. One could say I can use Mirror for Unity and they would be right, however, when trying this a few months ago I didn't get the HTML5 build to work properly and it's something I want for my game.
Posted on October 16, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.