Live Demo

Work Summary
- MMO-style multiplayer game in an endless world
- Server-authoritative: client only sends key inputs, both simulate and server corrects
- Grid-based culling system to only send relevant data to clients
- Built a custom serialization system using compile-time reflection to easily pack and unpack data for the network
- Cross-platform C++ game engine
- Live demo playable in browser!
- CI/CD pipeline for automatic builds and deployments
- Infinite procedurally generated world
Introduction
This project was made as part of my specialization at The Game Assembly. From the beginning, I knew I wanted to create something related to networking. I dug through some of my old game ideas and decided that Boat Game would be fun to build. It’s a simple game where you control a boat and go on delivery missions to different islands. I’ve always been fascinated by MMOs and persistent worlds, so this was my chance to make my own!
While the goal was to build a simple game to explore networking, I really wanted to make something that can be played immediately with no downloads. This meant that I couldn’t use the engines we used in our game projects, because they only support Windows with DirectX 11.
I briefly considered Unity, but felt networking in Unity wouldn’t give me the control I wanted. The natural next step for me was to build a custom engine in C++ because I love designing and building new systems. I also wanted to revise some of the systems I built for our game projects, so this would be a great testing ground for those ideas. Does this massively increase the scope of the project? Yes. However, I had built my portfolio early, which meant I had extra time to spend working on this project.
The Engine
While I enjoy writing cool systems, there wasn’t enough time to create the entire framework from scratch. Since I wanted it to be cross-platform, I opted to use Simple DirectMedia Layer (SDL) for window management, inputs, etc. and BGFX (a cross-platform rendering library) for rendering. This way, I can focus on building game systems without worrying about writing platform-specific code.
To save some time, I ported over some systems I wrote for our game projects, like my event bus and input system. I then reworked my Service Locator to make it more efficient. The original version used an unordered map to store services, which meant that every time you wanted to get a service, it had to perform a hash lookup. In the new version I use templates and static variables to store the services, which means that getting a service is a simple pointer dereference.
Actor System
I also wanted to build a more flexible actors system from what we had in our game projects.
We currently use a base Actor class with one list of components for the update loop in our game projects.
Here, I really wanted to apply some concepts I thought of during our game projects,
but haven’t had the opportunity to implement until now:
-
Separate lists for components that need to update and components that only hold data
I noticed that we had a bunch of actors that only had data components in our game projects, but their components were still being iterated over in the update loop. Hundreds of static actors with data components (like meshes) that never needed to be updated were being iterated over every frame!
After some template magic, I was able to create a system where components are split up into two lists. Components, which are included in the update loop, and containers which hold data. Both can be accessed via
GetElement<T>(), but only components are iterated over in the update loop.This could be further improved by making separate lists for actors (like elements), but this was good enough for my use case.
-
Central Actor management which allows for better control over the lifecycle of actors and easier access via ID
Instead of having to manually keep track of actors in the “disposable”
Gameclass, I decided to extend theActorclass with some static methods for creating, destroying, and getting actors by ID. The list is owned by the mainGameobject, while theActorclass manages it. -
Flag-style tags for components
Instead of storing tags as a string list and keeping a separate manager for it, I wanted to have just an enum class with bit flags that can be set on actors. I think it’s pretty clever, and I’m especially proud of how this one turned out.
c++
Actor.hEngineusing ActorTagType = uint32_t; class Actor {private: ActorTagType myFlags; } ActorTags.hGame enum class ActorTag : ActorTagType { None = 0, Player = 1 << 0, Local = 1 << 1, Dock = 1 << 2, };public: click to expand 7 lines
template<typename T> [[nodiscard]] bool HasFlags(const T someFlags) const { return (myFlags & static_cast<ActorTagType>(someFlags)) != 0; }// The helpers allow us to do things like this: const bool isLocalPlayer = Actor::GetActor(id)->HasFlags(ActorTag::Player | ActorTag::Local);// Helpers for bitwise operations on ActorTag click to expand 9 lines
constexpr ActorTagType operator|(const ActorTag aA, const ActorTag aB) { return static_cast<ActorTagType>(aA) | static_cast<ActorTagType>(aB); } constexpr ActorTagType operator|(const ActorTagType aA, const ActorTag aB) { return aA | static_cast<ActorTagType>(aB); }I would like to expand on this system in the future by making some kind of lookup feature where you can get all actors with certain tags.
Before building the actor system, I saw a video on YouTube by Pezzza’s Work called ”The magic container” where he talks about a vector alternative which keeps data contiguous in memory and allows for fast insertions and deletions while keeping consistent access via IDs. One downside is that it doesn’t keep things in order when iterating, but we should always assume that actors are in a random order anyway, so this isn’t really an issue. This container is most definitely overkill for this project, since we won’t have thousands of actors or performing a lot of insertions and deletions, but it sounded really interesting, so I implemented it.
Networking
After the engine had enough functionality for me to test more gameplay related features, I started working on the networking interface. When this project started, we were nearing the end of our network programming course. I already had many things I wanted to do differently here based on what I learned in that course.
For context, I pair programmed with my friend throughout the entire networking course, and we skipped writing a serializer because we thought it’d be much faster to just manually insert our data (xkcd 1319: Automation). Nearing the course’s end, we kept having issues caused by oversights in our manual serialization, and kept joking about how the time we saved by not writing a serializer was completely lost when debugging serialization issues. Key takeaway from the networking course: sometimes it’s worth automating something.
Serializer
This time around, I really wanted to write a proper serializer, and I wanted to use compile-time reflection to make it as easy to use as possible.
I discovered C++ concepts, and they are great for this kind of thing.
They basically allow you to replace typename with a more specific requirement, which can be checked at compile time.
I started off with two types: Trivial types and serializable types.
Trivial types was really exciting to implement, because it basically means “this thing can be directly copied with std::memcpy()”.
I then set up serializable types, which checks if a type has Serialize() and Deserialize() methods that returns the right types.
These types can then be used to write a serializer by using template <TrivialType T> or template <SerializableType T>.
c++
template <typename T>
concept TrivialType = std::is_trivially_copyable_v<T>;
template <typename T>
concept SerializableType = requires(const T& aPayload)
{
{ aPayload.Serialize() } -> std::convertible_to<std::vector<uint8_t>>;
};
template <typename T>
concept DeserializableType = requires(const std::vector<uint8_t>& aPayload)
{
{ T::Deserialize(aPayload) } -> std::same_as<T>;
};Inputs
One of the main goals of this project was to send as little data as possible over the network. So for inputs, I was going to just use some flags in a byte, but where’s the fun in that? I decided to add controller support, which changes input data from a single byte of flags to two floats for the joystick axes. But two floats is 8 bytes, which is a lot for just input data. We don’t even get that much data from the controller itself!
After some thinking, I realized that I could use bit manipulation to pack the two floats into a single byte.
You lose some precision, but it’s enough for boat inputs (0.13f per step).
c++
Solution 1: Flagsenum class Input : uint8_t
{
None = 0,
Forward = 1 << 0,
Backward = 1 << 1,
Left = 1 << 2,
Right = 1 << 3,
};
// Diagonal input:
uint8_t currentInput = Input::Forward | Input::Left;
Solution 2: Packed FloatsHelper constants click to expand 3 lines
constexpr float InputPackFactor = 7.0f;
constexpr float InputUnpackFactor = 1.0f / InputPackFactor;
constexpr uint8_t InputNullValue = 119;
struct PackedInput
{
PackedInput(const float aXInput, const float aYInput)
{
Input = static_cast<uint8_t>(QuantizeInput(aXInput) << 4 | QuantizeInput(aYInput));
}
[[nodiscard]] static uint8_t QuantizeInput(const float aValue)
{
const float remapped = aValue * InputPackFactor + InputPackFactor;
const int rounded = static_cast<int>(remapped + 0.5f);
return static_cast<uint8_t>(std::clamp(rounded, 0, static_cast<int>(InputPackFactor * 2.0f)));
}
[[nodiscard]] float GetXInputValue() const
{
return static_cast<float>(Input >> 4) * InputUnpackFactor - 1.0f;
}
[[nodiscard]] float GetYInputValue() const
{
return static_cast<float>(Input & 0x0F) * InputUnpackFactor - 1.0f;
}
uint8_t Input;
}
// Slight diagonal input:
uint8_t currentInput = PackedInput(0.5f, 0.5f).Input; // unpacks into (0.57f, 0.57f)Server-authoritative simulation
Since I didn’t want to send a bunch of state data, I made the simulation server-authoritative, which means that the client really only sends events (like input changes) to the server, and both the client and the server run the same simulation. This comes with the added benefit of anti-cheat and client-side prediction!
There isn’t much more to say about this, but it ties nicely with the serializer and packed inputs I mentioned earlier. Here’s how the server handles client input messages:
c++
case Net::MessageType::ClientInput:
{
const PackedInput input = Net::DeserializePayload<PackedInput>(aMessage.Payload);
const size_t socketClientIndex = std::distance(myClients.begin(), std::ranges::find(myClients, myRemoteEndpoint));
const Net::PlayerId boatId = static_cast<Net::PlayerId>(socketClientIndex);
if (const Actor* const actor = myWorld.GetActorByNetId(boatId))
{
MovementComponent* movementComponent = actor->GetElement<MovementComponent>();
movementComponent->SetInput(input.GetXInputValue(), input.GetYInputValue());
// Realization while writing this:
// maybe `break` here actually,
// since I don't think we should send inputs to clients if the server doesn't even have an actor for them.
}
const Net::ServerInput serverInput(boatId, input.Input);
const std::vector<uint8_t> buffer = Net::Serialize({
.Type = Net::MessageType::ServerInput,
.Payload = Net::SerializePayload(serverInput)
});
NearbyBroadcast(boatId, buffer);
break;
}Grid-based culling
Now, to make sure we don’t send things like input events to clients that are on the other side of the world, I built a simple grid for our world (this will come in handy for procedural generation later on as well). Positions are hashed and used with an unordered map to keep track of which actors are in what grid cell.
I built a simple NearbyBroadcast() function that uses the grid to only send messages to clients that are within a range of the relevant actor.
It uses a grid function to loop through nearby cells and gather clients that are within range, then the sockets are extracted from the server’s list using the actor IDs.
c++
void Server::NearbyBroadcast(const Net::PlayerId aClientId, const std::vector<uint8_t>& aBuffer, const bool aExcludeSelf, const CellUnitWide anExtent)
{
static std::vector<siv::ID> nearbyActors;
const CellCoord clientCell = myWorld.GetNetActorCell(aClientId);
myWorld.GetGrid().GetActorsInNeighborhood(clientCell, nearbyActors, anExtent);
for (const siv::ID neighborActorId : nearbyActors)
{
const Net::PlayerId neighborId = myWorld.GetNetIdByActorId(neighborActorId);
if (neighborId == Net::InvalidPlayer)
{
continue;
}
if (aExcludeSelf == true && neighborId == aClientId)
{
continue;
}
if (neighborId < myClients.size() && myClients[neighborId] != asio::ip::udp::endpoint())
{
SendBuffer(aBuffer, myClients[neighborId]);
}
}
}Conclusion
This project was a lot of fun to build. I got the opportunity to build a lot of systems that I had been wanting to build for a while, and I finally got to build an online multiplayer game, which has been one of my goals for many years.
I would love to continue expanding on both the engine and the game itself, because I think there’s a lot of potential in both.

(Work in progress article! Please check back later for the final version.)
