loading...

A Godot server + client game deployed on DigitalOcean Apps

amireldor profile image Amir Eldor ・9 min read

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

The hierarchy will look something like this:
Alt Text

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)
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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" ]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Update the App's spec:

doctl apps update $APPS_ID --spec do-apps-spec.yaml
Enter fullscreen mode Exit fullscreen mode

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.

Discussion

pic
Editor guide