TankOn - Multiplayer Versus Topdown Shooter

Feb 21, 2025 min read
👥Solo passion project 💻Platforms: Windows

Introduction

TankOn! is a versus topdown shooter for 2-4 players and my first study into creating multiplayer games and networking! It uses SDL as a backend and Boost.Asio for networking.

All the code is available on my GitHub!

What I did

  • Networking using Boost.Asio
  • Patterns for gameplay messages between Server/Client
  • UI Framework and Widgets
  • SDL Rendering, Windowing, Input and Audio
  • Signals and Slots for UI

Networking using Boost.Asio

As my first study into multiplayer games, I used Boost.Asio as my main entry point to the domain.

I created a basic TCP connection class that handled message reading and writing from sockets asynchronously. Each message is comprised of a header describing the type and size of the message and the body, which is optional and is variable in size. This is already enough to implement some basic ping functionality:

Centered Image

With this I learned how asynchronous programming uses completion handlers to compose basic tasks into more complex ones.

Server-Client behaviour and gameplay

In order to go from pinging to server-client gameplay, I created abstractions for TCP acceptors and resolvers that accepted callbacks to handle connections. This gave me great flexibility when it came to implementing a lobby system and gameplay.

Centered Image

The gameplay was easy to implement, although there are some smart things I did about reducing the amount of message passing:

  • Each client updates their tank position and sends it to the server, that is then bounced to other clients.
  • Since bullets travel in a straight line, I only need to send their starting position and starting time to all the clients instead of sending messages every frame.
  • Tank to bullet collisions are done in the server side.

For serialization and deserialization of all this data, I used Cereal.

UI Widgets and Input

I explored using Boost.Signals2 to create a flexible UI framework. The entire system uses a tree data structure implementation to represent a Menu as a tree of multiple UI nodes (that can be widgets):

Centered Image

Scaling up on hovered buttons (with debug lines)

Sliders and toggles (Left), text input boxes (Right)

At a macro level, menus exist in a stack data structure where the top element is drawn (other systems might have it drawn on top of the previous ones). This is a very useful design pattern that I utilized for UI, since it fits nicely into the intuitive way of going into a menu and going back to the previous one active (pushing and popping).

// Initialization

main_menu = MakeMainMenu(*this);
settings_menu = MakeSettingsMenu(*this);
menu_stack.push(&main_menu);

// Drawing and updating

UICursorInfo ui_input {};
ui_input.cursor_position = mouse_pos;
ui_input.cursor_state = cursor;
ui_input.deltatime = deltatime;
ui_input.typed_characters = input_text;

if (!menu_stack.empty())
{
    ZoneScopedN("UI Drawing");
    menu_stack.top()->Draw(renderer, ui_input);
}

For drawing individual widgets, they are stored as a tree owned by the respective menu: drawing the UI is as easy as iterating the tree depth-first (Note: I did not need to do Z ordering for the simple framework I was creating):


// Defined aliases in the Menu class
using NodeTree = tr::tree<std::unique_ptr<UINode>>;
using NodeIterator = NodeTree::iterator;

void Menu::Draw(Renderer& renderer, const UICursorInfo& cursor_params)
{
    glm::vec2 frame_size = glm::vec2(renderer.GetFrameAspectRatio(), 1.0f);
    UIDrawInfo initial { frame_size * 0.5f, frame_size, colour::WHITE };

    // loop through all root ui elements
    for (auto it = elements.begin(); it != elements.end(); it = elements.next_sibling(it))
        DrawElement(renderer, it, initial, cursor_params);
}

void Menu::DrawElement(Renderer& renderer, NodeIterator iterator, const UIDrawInfo& parent_info, const UICursorInfo& cursor_params)
{
    auto& elem = **iterator;
    if (!elem.active) return;

    UIDrawInfo current_draw_info = parent_info * elem.local_transform;
    elem.Draw(renderer, current_draw_info, cursor_params);

    // Loops through all child elements
    for (auto it = elements.begin(iterator); it != elements.end(iterator); ++it)
        DrawElement(renderer, it, current_draw_info, cursor_params);
}