-
Notifications
You must be signed in to change notification settings - Fork 31
Backend concepts and architecture
This document outlines the high-level architecture and concepts you'll need in order to contribute to Sour's backend. As the page title implies, it ignores the web-based version of Saurbraten that Sour is typically known for. Sour is much more than that: it also includes a next-generation Sauerbraten server that brings modern functionality to the game such as ranked matchmaking, persistent player-created content, Discord authentication, and downloading assets to vanilla (meaning: unmodified) Sauerbraten clients.
All of Sour's services (the frontend, the cluster, the proxy) are collectively known as a Sour instance.
The cluster refers to the monolith backend, written in Go, that has a few primary roles:
- Accept connections from clients both via ENet (desktop) and WebSockets (web)
- Orchestrate Sauerbraten game servers
- Store and retrieve user state
- Provide access to locations in the Sourverse known as spaces
The cluster can start and stop Sauerbraten game servers and proxy traffic to them on demand. In effect, the cluster acts as a Sauerbraten server multiplexer, in that to a game client it appears as one "server" but actually gives access to arbitrarily many game servers. This is easily the most counterintuitive aspect of Sour. Check out the basic system diagram below:
An important caveat is that the game servers Sour runs are not connected to the network. The cluster starts them as separate processes and communicates with them over Unix domain sockets. Indeed, the network-facing functionality of the game servers is completely disabled. The Sour cluster defines a simple protocol by which it can send packets to the server on the user's behalf, receive packets to send to clients, issue server commands (including setting variables), and ensure the server is healthy.
It is worth emphasizing that the Sour cluster is not limited to a single point of ingress for ENet clients. Sour's configuration allows you to accept ENet connections on arbitrarily many UDP ports, all of which can have their own destination space in the cluster.
The Sour cluster starts game servers (in our case, a heavily modified version of QServ) and defines a Go API for moving users between them programmatically. Connections can come from either ENet or via WebSockets and packets sent on that connection are forwarded to the game server the user is actually connected to.
Sour can automatically migrate users to a new game server if their current one becomes unhealthy. The "health" of a game server refers to whether it is successfully running its event processing loop or not. QServ was not known for being a particularly stable project, but unfortunately for me it was the server code I was the most familiar with when I first started working on Sour. (Ideally Sour would just run game servers in pure Go, perhaps by forking waiter, but that's a story for another day.) Occasionally, the game servers crash. The cluster can detect this when it occurs and start a new one with the same configuration automatically. (Note: This is similar to how pods in a Kubernetes cluster function, which is why the "cluster" is called what it is.)
Most of the complexity in the cluster relates to defining tidy APIs for intercepting messages from users, moving them between game servers, and allowing them to make changes to cluster state while providing a seamless gameplay experience. The cluster is able to arbitrarily record, change, and forward messages it receives from the user and messages the server sends in reply. This lets it define advanced functionality without making changes to the game server.
The cluster does define mechanisms for identifying users regardless of whether they connect via the web or via the desktop game. It only stores state for users who are logged in with Discord authentication, however:
- The user's ELO ranking for any of the matchmaking modes the cluster has defined
- The user's "home" space and the state of its map (more on spaces in a subsequent section)
- A token used to make requests to the Discord API on the user's behalf (really just to fetch their avatar)
For debugging purposes, the cluster records user sessions in their entirety including all messages received from and sent to the client.
The Sourverse refers to the Sour functionality that allows users to create game worlds and explore them. The basic primitive of the Sourverse is the space, which is a location that a user can visit. Conceptually, a space is a game map (e.g. complex
), a set of game rules (e.g. ffa
), and a name by which users can refer to it (e.g. lobby
). Users can move between spaces using the #go
command (e.g. #go lobby
) and by using space links, which are teleporters that move users between spaces rather than just to other destinations on a game map.
There are two main kinds of spaces: preset spaces and user spaces.
Preset spaces are spaces defined in the cluster's configuration file. For example, the default Sour configuration defines a space called lobby
that desktop players connect to automatically when they join the server through its primary point of ingress. The lobby
space's default game mode is coop
and its map is xmwhub
, which was custom-made for the Sour project.
User spaces are spaces that are created, owned, and edited entirely by users. As of writing, every logged-in user is given a space (their "home") when they first connect to the Sour cluster. By default, that space is set to use a blank game map (ie one created using the /newmap
command), a UUID by which other users can refer to the space (e.g. 9d4c1
), and the coop
game mode. When the user makes edits to their space, those edits are persisted across gameplay sessions for all visitors to the space. Persistent map editing will be described in more detail in a subsequent section. Users are also able to set a human-readable "address" for their space using the #alias
command, after which other users can visit their space without typing in the underlying UUID.
For now, users are not able to (a) create new spaces other than their "home" nor (b) adjust the game rules of a space. There is no technical reason for (a), it was just a temporary simplification we will rectify when we switch to a better persistent data model (read: not Redis.) In the medium-term, allowing users to tweak the game rules of their space would be ideal. After all, the idea behind the Sourverse is that users could create novel game modes without needing to make modifications to the Sour project.
The Sour cluster supports server-side map editing. This means that any edits to a space that a user makes are immediately stored and visible to any other client that connects without requiring any user to run /sendmap
or /getmap
. For those accustomed to Sauerbraten's traditional model of collaborative map editing, this can be confusing, but it is extremely powerful. It is worth going into detail about how this works.
Consider the situation where two players are connected to a (traditional) Sauerbraten game server in coop mode and one player runs /newmap
followed by /sendmap
, after which the other player runs /getmap
. At this point the state of the Sauerbraten world should be identical on both players' game clients. When one player makes an edit, such as modifying a cube, that is translated into a game message and sent to the game server, which passes it on to the other client, whose world state is updated to reflect the change. In other words, so long as both clients started from the same world state, receive all edits, and those edits are applied in exactly the same way, they will stay in sync.
Sour's persistent map functionality works by pretending to be another (invisible) game client. When a space is in edit mode, the Sour cluster loads the world state in the same way that a normal game client would. When an authorized player makes an edit (= their client sends a message associated with editing), that edit is replayed on the cluster's world state for that map. Periodically, the cluster will save that world state to a file, hash it, and store it in its internal storage (for now, Redis, though support is planned for saving to the filesystem or cloud storage.) Importantly, when a new client joins, the server must save the map immediately so it can send the current version to the new client.
Every time the map is saved, the user space is updated to point at that new map.