Yet another copy of a famous game which will be probably not as polished and not as fun as the original. This time we try out: Battlestar Galactica Online.
The goal is to have a 2d top view space shooter where the user can travel from systems to systems and fight enemies while mining resources and upgrading their ship.
Multiple players can connect to a ever-running server and compete or band together to play the game. Below are a few screenshots of the progress achieved during the development.
The goal of this project is to take inspiration from BSGO and copy some of the patterns used in it and also incorporate some original elements when they make sense.
It is also a very good opportunity to actually write a multi-player game which is something we never did before. There are many learnings that will come during the course of the development.
As of the time of writing, the game offers:
- a persistent registration/log in system
- two factions which have similar capabilities but are able to fight each other in the game
- a persistent server to which clients can connect to
- a shop where the player can buy gear for their ships and equip them
- multiple systems where the players can travel
- some content in the systems in the form of outposts, asteroids and AIs
- a working health/power system to make fighting the other players/AIs slightly challenging
- a mining system where the player can accumulate resources to buy more gear for their ships
Some known limitations:
- 2D game
- no collisions
- no dynamic evolution of systems (once AIs or asteroids are killed they don't respawn until a server restart)
- limited content in the systems
- various edge cases where the server will crash
- client applications access the DB directly with the same user as the server
This projects uses:
- google test: installation instructions here, a simple
sudo apt-get install libgtest-dev
should be enough. - cmake: installation instructions here, a simple
sudo apt-get cmake
should also be enough. - eigen: installation instructions here for Ubuntu 20.04, a simple
sudo apt install libeigen3-dev
should be enough. - libpqxx for db connection. See installation instructions in the dedicated section of this README.
- golang migrate: following the instructions there should be enough.
- postgresql to define the database. See installation instructions in the dedicated section of this README.
- asio for networking. See installation instructions in the dedicated section of this README.
- Clone the repo:
git clone git@github.com:Knoblauchpilze/bsgalone.git
. - Clone dependencies:
- Go to the project's directory
cd ~/path/to/the/repo
. - Compile:
make run
.
Don't forget to add /usr/local/lib
to your LD_LIBRARY_PATH
to be able to load shared libraries at runtime. This is handled automatically when using the make run
target (which internally uses the run.sh script).
The README in the repo is not exactly working. You need to install libpq-dev
: while not indicated it is clear from the build process.
Then the commands are not as described here but rather:
mkdir build
cd build
cmake -DCMAKE_CXX_STANDARD=20 -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=1 ..
make -j 8
sudo cmake --install .
Those were taken from this tutorial and this Stack Overflow post. By default libpqxx generates a static library which is not really suited for our purposes. The option BUILD_SHARED_LIBS
forces to create them. This was indicated in the README. Also the c++20 standard comes from this issue as we encountered the same problem when building with the debug version.
Then we can attach the library as a dependency of the project as described in the rest of the install guide.
This Stack Overflow topic seems to indicate that there's a package for it under:
apt-get install libasio-dev
However this seems to be a relatively old version (1.18, from August 2020). Instead, we decided to download the newest revision from the official website. Click on the download link from the download page.
The download process gives a tar.bz2
file which can be extracted with (change the version as needed):
tar -xvjf asio-1.28.0.tar.bz2
Note: we used version 1.28
for the development.
Asio is a header only library so we then just need to copy this to usr/local/include
:
sudo mkdir /usr/local/include/asio
sudo cp -r asio-1.28.0/include/* /usr/local/include/asio
You can remove the Makefile.am
and Makefile.in
files from this directory to not pollute the includes.
After this we need to instruct cmake to find the library. This shouldn't be an issue as it is now installed on the system directories. However we faced another problem. When looking at asio.hpp
the includes look fine:
But when going into for example any_completion_executor.hpp
we see this:
This is a problem as relatively to the any_completion_executor.hpp
the config file is in the same directory. So it seems the library expects to have the /usr/local/include
directory as a -I
directive to correctly be set up.
Looking at a few topics on the internet it seems like this is usually handled by putting the library directly in the project's folder, or by adding some include pathes in the CMake files (see 1 or 2). Another way is to write manually a FindAsio.cmake
file to find the corresponding package and set the include path automatically.
We also used this approach in the CMakeLists.txt of the net library for example. This is sufficient to allow development, we might revisit this later on.
To install postgresql we can simply rely on the packages and run the following:
sudo apt-get install postgresql-14
The version may vary. 14
is the one we used for development but it should not be an issue to pick another one.
The application is built around the PixelGameEngine. We found it is a reliable engine to create 2D applications and display some graphics. However, there are a couple of compilation problems when we add the typical options we use (such as -Wall
and -Werror
) due to the #ifdef
directives to support multiple platforms.
We isolated the PixelGameEngine
into its own library in src/pge and abstracted the use of the graphic resources and rendering elements into it. This is not 100% enough as we still get some errors when compiling the library. We allowed unknown pragmas and fixed the unused parameters manually in fa73574.
Additionally the application does not define a very nice icon nor tooltip. This was changed directly in the PixelGameEngine file in 28590d1.
In case of a future update we can port those changes or adapt them.
The game uses data saved in a database to define elements of the gameplay. In order to do this we rely on a database being created and populated with data. The database is then accessed both by the client and the server through the same user.
In order to create the database and then perform the migration, we need to use at least once the default postgres
user to create a new one. This new user will then be used to perform all the rest.
On first inspection it seems like to do that we need to authenticate with the postgres
user. The first thing which was not working as easily as we thought is that running the default psql
command is trying to connect using Unix sockets which are apparently configured to use peer authentication. This can be checked in the file /etc/postgresql/14/main/pg_hba.conf
:
local all postgres peer
According to this Stack Overflow post a way to change this behavior is either to change the peer
value to something else or to use TCP for example by adding -h 127.0.0.1
or -h localhost
as an option of the command line.
The second problem that arised was that once we were able to communicate with TCP, we were prompted the postgres
user password. This is not known to us. This Stack Overflow post provided useful guidance to change it to something we know.
Once all of the above is done, we can successfully run the provided scripts to create a user (see create_user.sh) first and then use this user to create the database with create_database.sh.
We use migrate
to manage the database and perform the data migrations. Once the previous step is complete (so the user and the database both exist) one can simply go to the migrations folder and run:
make migrate
On a fresh database, this will trigger all the migrations in order. If the database is in an intermediate state then it will only run the missing migrations.
Once the database is created, it is easy to add migration files in the folder and run again make migrate
to apply them. If one of them needs to be reverted, make demigrateO
can be used to undo the last migration while make demigrate
will undo all the migrations and basically restore the database to its initial (empty) state.
In case it is required to drop the database completely, a convenience script is available under drop_database.sh. It is possible to recreate the database again by following the same procedure starting from the top section.
According to this Stack Overflow question it seems like we can directly use gen_random_uuid
with versions of postgres more recent than 14. This is our case.
Another approach as we need to deserialize the data from the database is to use plain integers for identifiers. This is probably enough for now. To this end it seems like the way to go about it is to use generated always as identity
(taken from this Stack Overflow post).
As of now each client will connect to the database and try to get information from it. This is mainly the case when processing messages and initializing the UI.
By default postgres does not allow incoming connections from remote host to come through. In order to allow the host machine to accept those we need to modify the configuration of the postgres a bit.
Once again we found a nice Stack Overflow post explaining what needs to be done. The steps are presented in the following sections.
Edit the file:
sudo nano /etc/postgresql/<version>/main/postgresql.conf
And enable or add:
listen_addresses = '*'
Edit the file:
sudo nano /etc/postgresql/<version>/main/pg_hba.conf
Register the network mask you want to allow connections from. A 0
stands for a wildcard and we chose to restrict to local network for now by adding the following:
host all all 192.168.1.0/0 scram-sha-256
The connection method was changed from md5
to scram-sha-256
as recommended by this DBA stackexchange post: it is supposed to be more secured than the old md5
approach.
With this we were able to connect from a remote computer to the database hosted on another machine.
The aim of the project is to provide a client server architecture where multiple players can connect to the game and play together. In order to achieve this we decided to go for an authoritative server and separate client.
From the code perspective there are elements and structures that will be used both by the client and the server.
It is not so clear cut whether writing the server and the client in the same language bring huge benefits. This article summarizes the research we did on this topic. As a summary: it depends™.
For now the client is written in C++ thanks to the PixelGameEngine
renderer. We also developed the rest of the game (the ECS and the interaction with the database) in C++ for this reason.
As we only started to have a look at the server a bit later on, we had a choice to make between continuing the implementation of the server also in C++ or using a different language.
Our initial idea was to use Go
as the server's language: we are more familiar with it and it handles the threading and networking quite well.
However the problem with it would be that we would have to essentially make some wrappers around the core game classes (the ECS system) as we don't want to rewrite it. Also the network part would probably involve some conversions between the data structure we receive in Go and the binding to communicate to the server in C++. This is a similar situation we faced in the past for other projects and it was usually a bit of a pain to handle. Moreover, the networking shouldn't be such an issue that we would consider writing the server in another language: as we anyway will have to handle some level of networking on the client, it seems a bit counterproductive to do it a second time in the server.
We divided the source folder into several directories to make it easy to segregate code that belongs to one application from the rest.
When adding new features it is important to think about whether they would benefit only to the client or to the server or both. This most of the times provides a strong hint as to where they should live.
It can also be that later on we realize that the bsgo
library is too big, or that some separate features (for example Data Transfer Objects) can be shared: this could be achieved by adding more top level folders in the src directory.
The bsgo folder contains the core library defining the Entity Component System and the interaction with the database. This is used both in the server and the client application.
The net folder contains the networking elements used to make the server and the client communicate with one another. This code is used in both applications and uses the asio
library.
Currently even though none of the internal of asio
are exposed in the interface of the library, we still require the library headers to be installed on the system. This could be changed in the future.
For an implementation overview of the network library please refer to the dedicated section over at networking.
The pge folder contains the code to interact with the PixelGameEngine
. It is used in the client to handle the rendering of the graphic interface.
By having it in a dedicated folder, we are able to minimize the surface we would have to replace if we want to use a different engine. This also allows to easily update it without having to change too much code: the rest of the modules (mainly the client) do not know that the underlying drawing routines are using the PixelGameEngine
.
The server folder defines all the server specific code. It defines all the structures needed to accept client connections and handle the persistent simulation of the game loop.
The client folder regroups all the code that is used exclusively by the client. It links against the core library and enriches it to present a compelling application to the player. It also serves the purpose of connecting to the server and updating the local data with what the server transmits.
In a client/server architecture it is necessary to define ways for both applications to communicate with each other through the network. Some changes are just relevant for one client, some are relevant for all players registered in a given system and some elements are relevant for every connected players.
Typically the clients will try to perform some actions which have an impact on the game. In an authoritative server we have to somehow validate these changes before they can be applied.
Throughout the project we rely on messages. Messages are atomic piece of information that can be interpreted by the server and the client alike. The typical scheme is that the client will send messages to the server and get in return and in an async way one or several messages responding to the initial request.
The server on the other hand will produce messages on its own (based on what happens in the game loop for example) but also after it validated requests from the clients. Each message will then be sent to connections which might be interested in the change.
For more details on how the messaging system works, please refer to the dedicated section in the implementation details.
Below are presented a few base types for the communication.
A NetworkMessage is a message associated to a client. A client is a unique identifier assigned by the server to a client application. This helps determining who sent a message and sending the response back to the right clients.
These messages are typically sent by the client applications as they know which client identifier they received from the server. The server is usually either propagating the input client id or erasing it in case the response to a message might be interesting for more than one client.
A ValidatableMessage is a message that can be either validated or not validated yet. This is typically used by clients to make request something from the server. They send an initially not validated message of a certain type to the server. The server will then process it and determine whether it can actually be processed as the client would hope for. If it succeeds the server sends back to the client the same message with a validated flag.
Note that not all messages need validation: for example messages that are produced by the server are by definition considered valid and will not be validated.
When the server is started, it will start listening to incoming connections. When one is received, the first thing that the server does is to send a ConnectionMessage: this message contains a client id assigned by the server.
The client application is expected to save this client identifier and use it in any subsequent communication with the server.
To start the game the client application will present the user with a login/sign up screen: in both cases the idea is to make the client select a username and password and try to log into the game. This will result in a message sent from the client to the server (either LoginMessage or SignupMessage) and which can be validated by the server.
On top of the validation the server will also associate the connection (and the client id) with the player id and its current system. This will help determine when a message produced by the server should be transmitted to this client. For example if an entity dies in a system (be it a player or an AI), we need to transmit this information to all clients currently playing in this system. To achieve this, the server has to loop through all the active connections and pick the ones that are associated with a player in the relevant system.
We provided a mechanism for a player to log out: in this case we send a LogoutMessage which will indicate to the server that the corresponding client's connection is no longer associated with the player's id and system: this will help making sure that we stop sending updates for the system to this connection.
In case the connection is lost without graceful termination, we automatically detect this in the server and simulate a logout process by sending a LogoutMessage
before terminating the connection. This allows to make sure that the player's ship is still sent back to the outpost and that other clients are made aware of the disconnection.
There's an asymmetry between how the server and the client applications process the messages. Regularly, the server will send updates to the clients about the current state of the entities. These updates are synchonization point for the clients which can then control their internal representation of the game and update it based on what the server reports.
In such cases, the client will just takes what the server says at face value and make the necessary changes to be up-to-date with what the server indicates. This is not the case for the server: the server always questions what the client applications are sending before making any changes.
Now even if the client should ultimately conform to what the server says, there are nice ways to do it so that it does not degrade too much the game experience of the players. There are a couple of nice resources that we found, the best of which being this article on Gabriel Gambetta's website. We did not implement any of this until now.
Ideally the server should process the events generated by the clients and return an answer to them, in the forms of one or multiple messages. If we designed the data structures right, we should be able to essentially instantiate the same game loop as for the clients and plug in the messages coming from the network into the internal game loop. Those messages will be picked up by the ECS and generate some more messages. In turn, the output messages should be broadcast to the clients that are interested in them.
In the context of this game, the server has to simulate multiple systems which are very similar: there are a bunch of ships (AIs and players) in them, and they can interact with one another.
By its nature, each system is independent: no action taken in one system can have impact on another system. This seems like a very nice simplification as it essentially means that we can easily parallelize and decouple the simulation of each system.
In order to achieve this, we created a SystemProcessor class: its role is to regroup all the structures needed to fully simulate what happens in a system and to make it run in its own thread.
This class contains:
- a Coordinator responsible to list all the entities for this system
- some services responsible to process the messages generated by the clients and the internal ECS in this system
- an EntityMapper responsible to keep track of a mapping between the database and the entities
This processor runs asynchronously in its own thread and handles the simulation in the following steps:
- processing the messages related to this system
- updating the entities
The output of this simulation is a bunch of messages that need to be dispatched to the various clients interested in them.
The messages are process through a consumer mechanism: please refer to the following section to learn more.
Messages are usually meant to be processed by some objects responsible to validate them and knowing what effects they might have. A popular approach to handle it is to use a Publish-Subscribe pattern: for each type of message, we have a corresponding consumer which deals with them and handle their consequences. They are registered in the consumers folder, segregated in their 'kind'.
Each consumer registers itself to a message queue and indicates its interest in receiving a specific type of messages (or several). When one is received, we can intercept the message and perform the necessary verification before processing it.
In the case of a DockMessage for example we can check that the ship concerned by the message is not already docked and that it is close enough to the outpost and that it does not try to dock to an outpost in a different system etc.
The consumers are usually using a dedicated service to process a message after doing some simple verification related to the messaging framework (for example that all fields are correctly populated). The services are where the business logic lives.
To come back to the dock example, the DockMessageConsumer relies on the ShipService to perform the docking.
This approach is quite flexible as it allows to easily keep messages which would fail to be processed for later analysis and make it easy to separate messages and make them processed by the most relevant consumers.
Consumers are separated in the server based on their kind:
- input consumers are responsible to handle user operation such as login or signup: anything that is not part of the game loop
- system consumers are responsible to handle messages specific to a system. We typically have multiple instances of each consumer in the server, one per system
- internal consumers are responsible to handle messages that are generated by the server and need additional processing before being sent out to client applications. A typical example is a jump message: when a client jumps from one system to another we need to handle some changes in the source and the destination system which is handled through an internal message
The server is responsible to handle a lot of messages from various sources. This includes:
- messages incoming from various client connections
- messages produced by the
SystemProcessor
s - messages produced internally and needing to be processed before being sent to clients
After several iterations we came up with the following design:
The main entry point of the server is the InputMessageQueue
: this sends messages to a triage consumer which only responsibility is to route messages to a consumer that can process them.
We have some interconnection between the system message consumers and the system processors: some messages will lead to changes in the systems such as logging out of the game where we need to remove the player ship's entity from its system.
We also allow system processors to send internal messages which need to be processed before they can either be sent to the clients or rerouted to be processed by a different system.
Finally the BroadcastMessageQueue is responsible to route the messages produced by the server to the right clients: this can be done by directly checking the client id if available or by checking in which system the messages belong to and broadcasting them to all connected clients.
In order to represent the various elements of the game and their properties, we decided to implement an entity component system.
The goal of this mechanism is to avoid the deeply nested class hierarchy common when developing a game. We already tried this approach when dealing with the agents (see the agents repository).
While researching how to implement it we found this blog codingwiththomas which presents the implementation of such a system from scratch. It is well documented and explains the concept in quite some details.
In comparison with the agents
project we decide to follow the paradigm a bit better and actually created some systems and a way to iterate through the entities.
The Entity is the base class and is basically just an identifier with a list of components. We added some convenience methods to assess the existence of some component and access them in a secure way.
A Component is the base class for all components. It just defines one basic method to update the component with some elapsed time. This might or might not be useful to the inheriting components but allows to have some quantity varying over time.
The goal of the implementation is to keep away the processing from the components in favour of putting all of them in the systems.
A System is iterating over the entities and only processing the ones that are interesting. Our implementation defines an AbstractSystem
which aims at iterating over the entities.
The constructor expects the inheriting classes to pass a callback which will be used to filter the entities and keep only the ones that are interesting for the system. This typically involves checking if an entity has some components which are handled by the system.
Typically if an entity defines a HealthComponent we expect it to be processed by the HealthSystem.
In order to keep together all properties of the ECS, we created (as advised in the initial link we got inspired from) a Coordinator class which is responsible to keep all the entities and their components along with systems.
This class has a convenient update
method which can be called to process all the systems in a consistent order and make the simulation advance one 'step' ahead in time.
In its simplest form, an ECS is initialized when creating it and then updated regularly to make changes to the entities within it. This is done through the Coordinator
and works pretty well.
However, when the user is playing, there are some interactions which will influence how the entities evolve: for example if the user tries to move its ship, or attacks another one, we need to make some modifications to some components of some entities.
Similarly, there are cases where the ECS, through its processing, will change the state of one of the component. For example when the countdown to jump to another reaches 0, the StatusComponent will need to notify the outside worl that there's some action required to move the entity it belongs to to another system.
A straightforward solution is to make the UI aware of the ECS and just let them update whatever data they assume is meaningful to it. This has the benefit that it is simple to implement. This however does not work at all for a distributed system. We could find a bunch of relevant links on how to deal with this.
In How to sync entities in an ecs game and Networking with entity component system the recommendation is to use a NetworkComponent
or something similar. The idea in each case is to have some dedicated NetworkSystem
which takes care of receiving data and updated the entities which have a network component. The network component can presumably be either very stupid (just flagging the entity as having some form of networking aspect) or a bit smarter and define for example the properties of other components needing to be synced.
The topic in How to network this entity system does not recommend to go for a 'smart' network component as this is essentially mirroring what already exists in other components. Instead it seems like the approach would then be to have the NetworkSystem
be able to deal with each component in a way similar to:
- check if the entity has a network aspect
- if yes iterates over all registered component
- for each component call an internal method that indicates which properties need to be updated and how
One drawback of this is that the NetworkSystem
will grow quite a bit due to handling all the components we have in the game. The plus is that there's a single place which should deal with network communication. We could possibly also include the various reconciliation mechanisms here.
Finally the Documentation of Space Station 14 indicates some sort of dirty
property to attach to component which would make the job of the NetworkSystem
easier: by checking this we know if it should send data to the network or not. This is quite interesting as it allows to not keep track internally in the system of the previous states of all components to detect modifications.
In the end we dediced to go with a mixture of both suggestions: we have a NetworkComponent
but it ultimately sends messages representing the changes to an entity's components.
The processing to bring information into the ECS is similar whether we're in the client or server application: the idea is that some external process needs to update the components of an entity.
This is done by using the IService mechanism: we consider this layer as the business logic of our project and its goal is to get the entity from the Coordinator
and then update manually its component to their desired value.
A limitation of this is that as we don't have concurrency protection mechanism the processing has to be synchronous with the processing of entities: this is ensured by the SystemProcessor
which processes first the messages (which in turn calls the consumers and then the services) and after this the Coordinator::update
method.
In the case of the jump example we have to somehow bring the information that the jump should happen outside of the ECS.
At first glance it seems like the best place to try to act on this would be to have a hook in the System
s: we know that they are responsible to make the components of each entity evolve and so any change that should trigger a notification will be detected at this point.
To solve this problem we made the System
s aware of a message queue: they can push messages to the queue if needed indicating what happened. These messages can't really have a client id or anything that attaches them to a client because they are generated internally by the ECS: we don't even know if there's any player attached to an entity.
It seems then clear that these messages will need some additional processing before being sent to client applications. This is the purpose of the internal message queue with its consumers so we need to make the System
s aware of the internal message queue.
A final note is that this system is quite flexible: as the System
s are called regularly for an update, it is quite easy to also have periodic updates sent to the queue so that client applications get a continuous stream of updates from the server and can be reasonably confident that they're in sync with the server. See the following section for more details.
Some changes in the ECS are one-off (e.g. a jump, an entity dying) and others which we want to trigger on a more regular schedule.
One general principle is that as the client applications are running essentially the same simulation as the server, we want to keep them in sync as much as possible. This usually means publishing regular updates of some aspects of the ECS to the clients.
Going a step further, we even have systems that are not present on the client applications such as the health system of the removal system: this is because such processes need to be validated by the server before any action is taken on the client's side.
In order to solve this problem we created a NetworkComponent: it defines a list of properties that need to be 'synced' and acts in the following way (with its companion NetworkSystem):
- each component defines an update interval
- the network system detects when a component has not been synced since long enough
- in this case it sends an update message with all the properties needing to be synced
We rely on the rest of the server to route these update messages to the clients that are interested in them (so the clients which are in the same system as the entity).
An important thing to note is that the ECS never interacts directly with the database: it's only way to communicate with the outside world is either through direct synchronous modification of its internal state (which is not under its control) or by pushing some messages to a queue to notify of a change.
The goal of the client's application is to provide a view of what's happening in the server for a specific player. On top of aiming at replicating what is happening on the server, it should also allow the player to transmit commands to the server so that a specific entity is following what the player wants.
The application has two main problems to solve:
- how to send updates to the server from the UI?
- how to apply the updates from the server to the local UI and ECS?
The client application splits the responsibilities to react to the user's input into several facets:
- the IRenderer takes care of rendering the visual elements
- the IUIHandler takes care of rendering the UI
- the IInputHandler takes care of interpreting the input of the user
In addition the client also defines the notion of a Screen: this is a way to represent which is the active view currently displayed in the application. Typically the panel of actions available to the user will not be the same whether they are in the outpost or not logged in or playing the game.
Typically each screen will have a specific renderer, a UI handler and an input handler. Some screens might only get some of these elements.
As described in the communication protocol, the client and the server have a set of predefined message allowing to start and close the communication channel between both.
When the client has successfully logged in to the server, the user will click on items in the UI and generally performed actions that should have an impact on the simulation.
In order to send these commands to the server, we use the concept of a IView: a view is the equivalent of the business layer (so a IService) but for the client: the idea is that each button of the UI (for example the dock button, or the button to purchase or equip an item) is binded to a method of a view. The view is then responsible to know which action should be triggered to accomplish this action.
Accomplishing an action usually means sending a message to the server. The message is sent once again through a message queue: the ClientMessageQueue: it is a bit of a special queue as it's only purpose is to send the messages it receives through the connection it is attached to.
We follow a send and forget approach when pushing messages: the idea being that if the event is valid the server will react in some way and send back some messages the client's way which will trigger a visual feedback for the player. If it is invalid no answer will be received and the UI will not change after the action (e.g. a button click).
The actions that the user takes in the UI usually have a goal to change some state of the player's account: for example buying an item, moving the ship somewhere else, etc. Assuming the server successfully processed a message sent by the client, it will send back an answer. Additionally, some messages will be generated and transmitted to the client because of the actions of other players.
In order to process those incoming messages, the client receives them in a NetworkMessageQueue in a very similar way to the server.
We then follow the same approach as in the server: the client defines some consumers which are responsible to handle the messages received from the server and update the local ECS. As described in the who's right section, the difference is that the client applies without check what the server sends.
A special consumer is the GameMessageModule: its role is to interpret messages which are changing the state of the UI and react to them. This typically includes messages indicating that the player died, or that it changed system.
While the ECS in the server is responsible for the whole simulation, there are some aspects that the client does not need to simulate. To this end, the Coordinator class (which is also used in the server) defines a way to deactivate some systems.
The client uses this in order to not instantiate systems dealing with events that need to be confirmed by the server. This includes for example the removal system: we don't want to remove an entity from the client's simulation unless the server says to do so: in this case the RemovalSystem is deactivated in all client's applications.
The content of this video series of Javidx9 was very informative. The resulting code can be found on github and it inspired the Connection class we created.
We use the asio library without boost to handle network communication in the project. We extracted all the logic to perform the calls, connect to the server and to the client in a dedicated net folder: similarly to what happens for the PixelGameEngine
wrapping, we want to be able to swap libraries or update relatively easily.
The networking revolves around the idea of an io_context
. This is wrapped by our own Context class which is instantiated both on the client and the server. The idea is that this context runs from the whole lifetime of the application and is used in the background by asio
to perform the network calls.
A connection is the central object allowing to communicate. It has two modes: SERVER
and CLIENT
. This defines which operations it will perform. The difference is mainly on the order with which operations are performed as both connections will (during the game) be receiving and sending data.
For the CLIENT
mode we expect to try to connect to the server. Conversely in SERVER
mode we get ready to accept connections.
Sending data is made easy through the send
method which takes a IMessage
as input, serializes it and transmit it through the network.
Receiving data is potentially more complex: depending on the type of data we might want to perform certain processes and relatively different depending whether we're on the client or the server. To this avail we provide a setDataHandler
which allows to pass a callback which will receive the raw data from the network.
A specific component of the server is the TcpServer class. In order to make it easy to extend it and possibly reuse it and also to hide the dependency to the asio
library we mainly work with callbacks.
The ServerConfig defines several to handle a new connection (when it's ready to receive data), a disconnection from a client and some data received. This last part is to make it easy to automatically assign the same data handler (for example a method of the server class) to all incoming connections.
While doing some research we found out that it seems there are two main ways to handle AI for NPCs: behavior trees and state machines.
During the research we gathered the following collection of articles:
- one on gamedeveloper.
- one on the root website of the approach (as it seems :D) at behaviortree.dev.
- and of course on wikipedia.
State machines seem pretty linked to Unity which probably has an implementation of them. The collection of articles below present the concept:
- one explaining how it works in Unity.
- this one without Unity over at tutsplus.
- this one is similar to the previous one but simpler.
For our purposes it seems like both things could work:
- state machines might be easier if it's easy to come up with all the states beforehand: then it's clear what are the transitions and what can happen in any state.
- decision trees might require a more careful definition of what can happen in which scenario. The fallback mechanisms are also not as clear as with the state machine where it's just transitioning to another state: here we would have to go up the hierarchy and see what to do.
After pondering a bit, we decided to go for behavior tree. The state machine seems similar to how we already approached AI in the past so maybe it's a good idea to try something new.
The idea behind the behavior tree is to have a list of nodes arranged in a graph. They are executed at each step which allows to easily react to new situations. Contraty to state machines, we don't have to think about switching from any state to any other state: at each frame we just start from the root again and see which state we end up with when the execution of the tree is finished.
Each node has an execution state which can be for example RUNNING
, FAILED
or FINISHED
. While a node is RUNNING
the scheduler will try to execute it. If it reaches one of the terminal state (FAILED
or FINISHED
) then the scheduler will continue the execution with the next node in the graph. This can either mean returning to the parent node (in case of a failure) or to the next in chain (for a finished node).
The following image shows an example of how the very simplistic AI works at the moment:
In this example the root node is a repeater: this is usually advised as then we have an infinite behavior. We then have two main modes: the attack mode and the idle mode. Both of them are composed of a succession of actions. These are composed together with a fallback node, meaning that if the first strategy fails we will go on with the second one.
In the attack mode, the first thing is to pick a target: to that purpose the AI will scan its surroundings for a valid target. Two main results: success or failure.
In case it succeeds, we continue to the next: the AI will try to follow the target. This action just takes into consideration the target and tries to come closer to it: it succeeds immediately but also work iteratively. After this the Fire mode will be triggered which will check if the AI is close enough to the target and try to shoot at it.
In case it fails, then the parent sequence node will also fail as one of its child couldn't succeed and the repreater will then go on to the next element.
At any point the attack mode can fail: for example if the target goes too far from the AI, or if there's no target in the first place. In this case we count on the fallback node to start the idle mode.
The idle mode is composed of a succession of node to go to a target. This defines a patrol for the AI to loop through while waiting for something to happen.
This mode can't fail: the AI will just loop indefinitely until something else happens.
At each loop of the game we just iterate over the whole tree again. Usually it is advised to keep the processing time of nodes small so that it's not an issue to iterate over them very often.
Due to the dynamic nature of the tree and the fact that we iterate over it all the time, we can very easily react to a change:
- we're in idle mode but a target arrives? The attack mode will trigger itself on its own because the
PickTarget
node will suddenly return a valid target. - we're shooting at the enemy but it dies? The next iteration will fail to find a target and we go back to idle mode.
This is much easier than having the AI in a certain state and then having at the very beginning of each state to do something like:
if (determineState() != m_currentState) {
return otherStateMethod();
}
It keeps the reactions of the AI dynamic by codifying them into the structure of the tree.
Most of the changes in the game trigger something in the internal state of the clients and the server. Here are a few examples:
- when an asteroid is scanned, we want to display a message indicating the result of the analysis
- when an entity is killed or an asteroid destroyed, we need to notify clients that this is no longer a valid target
Similarly, when the user clicks on interactive parts of the UI, we might need to make some changes to the entities:
- if the
DOCK
button is clicked, we need to remove the ship from the system and display the corresponding UI - when an item is purchased, we need to deduct some resources from the player's stash and add it to the list of items they possess
As we have a client/server architecture, these changes can't just be direct synchronous calls but have to be decoupled: we need some way to make the UI react asynchronously to changes that might have been triggered before.
These considerations made us realize that we needed some decoupling between what happens in the UI (respectively the ECS) and how is it applied to the ECS (respectively the UI).
In order to achieve this, we chose to use a messaging system. This allows communicating from the systems operating on the entities to the UI in a decoupled and asynchronous way, and plays nicely when being extracted to a distant server.
The IMessageQueue allows to keep track of all the messages needing to be processed. These messages can be registred through the interface method pushMessage
.
We have several implementations for this interface: the most basic one is a SynchronousMessageQueue which guarantees that there's no collision between enqueuing messages and processing them, but we also have specialization for the client and the server.
A building block is the NetworkMessageQueue which allows to receive messages from the network. Another important one for the server is the AsyncMessageQueue which processes messages in a dedicated thread.
The IMessageListener allows anyone to register to the message queue and get notified of the messages. We expect such objects to provide indications about which messages they are interested in so that we can feed them messages. It is guaranteed that the messages receieved through the onMessageReceived
method will be of the right type for the listener.
The meat of the messaging process is the IMessage class. This interface defines a type of message and we can add more type as we see fit. Most of the time a message corresponds to a flow in the game, such as jumping or scanning a resource.
You can find other examples of messages in the same source folder.
An important part of our approach is to provide de/serialization methods for the messages: this allows to easily send them through the network to the server and receive the response back. We added some unit tests for this behavior to make sure it does not break.
In the clone of ogame we put most of the logic to interact with the database in stored procedures. The problem was that it was quite cumbersome to make some validation on the input data and also to get back any error that might happen during the insertion.
For this project we wanted to revisit this hypothesis and see if it was relatively widely used or not. The findings can be summarized in these two articles:
- Stored Procedures a bad practice at one of worlds largest IT software consulting firms?
- Business Layer in Database logic system
It seems like this can be justified to sometimes have a bit of logic in the database but should (based on these two examples) be avoided.
In this context, we implemented a business logic layer which aims at separating the concerns to update the database and how to do it. Typically each action that the user can take usually involves multiple operations in the database. For example signing up means:
- registering the player
- adding some starting resources/gear
- register at least one ship
- set the current system of the ship to the starting system
This is probably why it's not popular to put all of this logic in a stored procedure: it is quite complex and can get messy really fast when writing it in the language of the procedure. It is also not easy to debug or validate parameters within the procedures. On the other hand we can very easily do this if we keep a business logic layer in the application.
We chose to use a concept of services, all inheriting from a base IService class: there's a service for each kind of action (for example signing up, purchasing an item, etc.). Each of them define the conditions to meet for an action to be allowed, and the logic to actually perform the action.
These services live in the server as they should not be tempered with: this is what guarantees the integrity of the actions in the game.
Note: for now in most systems we don't use a single transaction to perform a complete action (for example registering a player will spawn multiple transactions to create the player, register their ship, etc.). This probably needs to be changed in the future to not leave the database in an inconsistent state.
A collection of useful sql queries we used extensively during development process.
select ss.ship, p.id as player, ss.docked, ss.system, sj.system, ps.hull_points, p.name from ship_system ss left join ship_jump sj on sj.ship = ss.ship left join player_ship ps on ss.ship = ps.id left join player p on ps.player = p.id where p.name in ('colo', 'colo2');
update player_ship set hull_points = 370 where ship = 1;
update ship_system set docked=true where ship in ('1', '5', '4');
The nlohmann json is a quite famous library to handle json for C++. This might come in handy in the future if we need to introduce json in the project.
In Java there's a quite extensive framework to perform ORM: Hibernate. This allows to manipulate objects (entities) in an idiomatic way and have built-in persistence to a data source (for example a database). A similar library for C++ would be TinyORM. Alternatively ODB could also be a good choice. For now we didn't need spend some effort to include such a project (also considering that it seems to have a more limited feature set than what is available in Hibernate for example) and preferred to write our own queries in the repositories folder.
During the research on how to implement an ECS, we stumbled upon this link. It seems more advanced: it might be interesting to come back to it later if the current system does not bring satisfaction anymore.
Regarding the general topic of client/server architecture we found this medium article about building a game in less than a day. This youtube series by Game Development Center
also seems to cover a lot of ground even though it is tailored to creating a game in Godot.
Another interesting extension would be to include handling of the physics maybe for acceleration and moving the ship in general but also maybe for handling collisions. We found the Chipmunk2D library and got recommended the box2D project by a colleague.