DEV Community

Cover image for [C++ 2D Arena Shooter Server #2] Packet Structure
MinBapE
MinBapE

Posted on

[C++ 2D Arena Shooter Server #2] Packet Structure

What I did today

Designing the packet header

I defined the format for data exchanged between the client and server.

enum class PacketType : uint16_t
{
    ENTER_GAME = 1,
    // ...
};

#pragma pack(push, 1)
struct PacketHeader
{
    PacketType type;
    uint16_t size; // payload size, not including the header
};
#pragma pack(pop)
Enter fullscreen mode Exit fullscreen mode

The reason I applied #pragma pack(1) to PacketHeader is that compilers insert padding between struct members by default. For example, if a uint8_t is followed by a uint32_t, the compiler may insert 3 bytes of padding in between. If the struct is transmitted over the network in that state, the layout will differ depending on the compiler settings on each side. Since packet headers must align at the byte level, I used #pragma pack(1) to eliminate the padding.

The same reasoning applies to PacketType. A plain enum has an implementation-defined size that can vary by compiler, so I explicitly fixed it to 2 bytes using enum class PacketType : uint16_t.

The resulting format is always 4 bytes for the header (2 for type, 2 for size), followed by size bytes of payload.


PacketBuffer: serialization utility

PacketBuffer is the class responsible for reading and writing packet data at the byte level.

template<typename T>
void Write(T value)
{
    static_assert(std::is_trivially_copyable_v<T>, "T must be trivially copyable");

    if constexpr (std::is_arithmetic_v<T>)
        value = detail::to_le(value);

    const char* ptr = reinterpret_cast<const char*>(&value);
    _buffer.insert(_buffer.end(), ptr, ptr + sizeof(T));
}
Enter fullscreen mode Exit fullscreen mode

The static_assert(std::is_trivially_copyable_v<T>) is a compile-time safety check. If someone accidentally passes a type like std::string, which internally holds a pointer, the code will fail to compile rather than silently producing garbage bytes at runtime.

reinterpret_cast<const char*>(&value) reinterprets the address of the value as a byte pointer, giving a byte-level view of T's memory regardless of its type. The subsequent _buffer.insert copies exactly sizeof(T) bytes to the end of the buffer. This is equivalent to memcpy, but integrated directly into the std::vector.


Endian handling

When communicating over a network, there is no guarantee that both sides use the same byte order. I chose little-endian as the wire format for this project. On x86 and ARM, which are already little-endian, no actual byte swapping occurs. However, the code is written to handle big-endian environments correctly as well.

namespace detail {
    template<typename T>
    T to_le(T value) {
    #if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__
        // byte swapping only happens on big-endian machines
        ...
    #else
        return value; // no-op on x86/ARM
    #endif
    }
}
Enter fullscreen mode Exit fullscreen mode

Because the branching is done with if constexpr at compile time, there is no runtime overhead.


Data models: Vector3, PlayerSnapshot, MatchSnapshot

I defined the basic game data structures for player positions and state.

struct Vector3 { float x, y, z; };

struct PlayerSnapshot {
    uint32_t playerId;
    Vector3  position;
    float    yaw, pitch;
    int32_t  hp;
    bool     isAlive;

    void Serialize(PacketBuffer& buffer) const;
    static PlayerSnapshot Deserialize(PacketBuffer& buffer);
};
Enter fullscreen mode Exit fullscreen mode

PlayerSnapshot is the DTO sent to the client. It is conceptually different from a Player class that would hold authoritative server state — PlayerSnapshot exists solely to serialize that state for transmission. I defined it at this stage because I wanted to test the packet format before implementing the game loop. The fields may change once the game loop is in place.

The reason Serialize writes each Vector3 field individually rather than the whole struct at once is explained in the debugging section below.


Session packet parsing: TCP framing

The original Session::handle() was a simple echo loop that sent received data back as-is. I replaced it with actual packet parsing.

TCP is a stream protocol, which means a single recv call does not guarantee that exactly one packet has arrived. Multiple packets may arrive together, or a single packet may arrive split across multiple calls. To handle this, I accumulate received bytes into _recvBuf and process complete packets one at a time.

void Session::handle()
{
    char temp[RECV_BUFFER_SIZE];
    ssize_t bytesRead;

    while ((bytesRead = recv(_clientFd, temp, sizeof(temp), 0)) > 0)
    {
        _recvBuf.insert(_recvBuf.end(), temp, temp + bytesRead);
        processPackets();
    }
}

void Session::processPackets()
{
    constexpr size_t HEADER_SIZE = sizeof(PacketHeader);

    while (_recvBuf.size() >= HEADER_SIZE)
    {
        PacketBuffer headerBuf(_recvBuf.data(), HEADER_SIZE);
        auto rawType    = headerBuf.Read<uint16_t>();
        auto payloadLen = headerBuf.Read<uint16_t>();

        if (_recvBuf.size() < HEADER_SIZE + payloadLen)
            break; // payload not fully received yet, wait for more data

        PacketBuffer payload(_recvBuf.data() + HEADER_SIZE, payloadLen);
        _recvBuf.erase(_recvBuf.begin(), _recvBuf.begin() + HEADER_SIZE + payloadLen);

        _dispatcher.dispatch(*this, static_cast<PacketType>(rawType), payload);
    }
}
Enter fullscreen mode Exit fullscreen mode

Reading the header through PacketBuffer::Read rather than a raw memcpy ensures that endian conversion is applied consistently. Reading raw bytes on a big-endian machine would produce flipped type and size values.


PacketDispatcher: packet router

PacketDispatcher looks up the handler registered for a given packet type and calls it.

class PacketDispatcher {
public:
    using Handler = std::function<void(Session&, PacketBuffer&)>;
    void registerHandler(PacketType type, Handler handler);
    void dispatch(Session& session, PacketType type, PacketBuffer& payload) const;
private:
    std::unordered_map<uint16_t, Handler> _handlers;
};
Enter fullscreen mode Exit fullscreen mode

I used uint16_t as the map key instead of PacketType because the C++ standard does not explicitly require a std::hash specialization for enum class, and behavior can vary across compilers.

Handlers are owned by TcpListener and registered before the accept loop starts.

void TcpListener::registerHandlers()
{
    _dispatcher.registerHandler(PacketType::ENTER_GAME, [](Session& session, PacketBuffer& payload) {
        auto nickname = payload.ReadString(MAX_NICKNAME_LEN);
        std::cout << "[ENTER_GAME] nickname=" << nickname << std::endl;
    });
}
Enter fullscreen mode Exit fullscreen mode

Session receives a PacketDispatcher& in its constructor and stores it as a member. This makes the dependency explicit: a Session cannot function without a dispatcher.


Next

  • Session Manager: tracking connected clients and broadcasting to all sessions

Advice and feedback are welcome.

Top comments (0)