A Godot server + client game deployed on DigitalOcean Apps
Tags: godot, digitalocean, gamedev
Published: 2020-10-16 23:30:51

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](#why-not-unity).

## Godot "universal" server/client Space Game

By leveraging the [Godot's High-Level multiplayer networking][godot-high-level] 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.

[godot-high-level]: https://docs.godotengine.org/en/stable/tutorials/networking/high_level_multiplayer.html 

### Let's connect things

We are developers, I'll talk less and show more code.

```gdscript
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](https://www.reddit.com/r/godot/comments/j5r5tb/network_peer_connected_not_to_notify_all_peers/g7u98al?utm_source=share&utm_medium=web2x&context=3)).

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](https://godotengine.org/download/server) 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](#auth).

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.

```gdscript
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:
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/8ql5zhcxdzc7c2rz0hn8.png)

And on the server:

```gdscript
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.

```gdscript
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.

```gdscript
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).

```gdscript
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.

```gdscript
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.

```dockerfile
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:

```dockerfile
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:

```bash
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`:

```yaml
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.

## <a name="auth">Authentication and Game IDs</a>

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](https://www.reddit.com/r/godot/comments/c2vso5/godot_jwt_cupport/) or maybe use a cloud function of some sorts to validate the token before letting the peer know the secrets of the universe.


## <a name="why-not-unity">Why not Unity?</a>

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][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.

[mirror]: https://mirror-networking.com/