Skip to content

Packets and Serialization

irrld edited this page Jul 30, 2026 · 4 revisions

Packets and Serialization

A message is three pieces: a Packet subclass holding the data, a PacketSerializer that converts it to and from bytes, and a registration in a Codec that maps the id to the serializer.

Defining a packet

enum : PacketId { kChatMessage = 1 };

class ChatMessage : public Packet {
 public:
  ChatMessage() : Packet(kChatMessage) {}

  std::string author;
  std::string text;
};

class ChatMessageSerializer : public PacketSerializer<ChatMessage> {
 public:
  std::shared_ptr<Buffer> SerializeTyped(
      std::shared_ptr<ChatMessage> packet,
      std::shared_ptr<Buffer> buffer) override {
    buffer->WriteString(packet->author);
    buffer->WriteString(packet->text);
    return buffer;
  }

  std::shared_ptr<ChatMessage> DeserializeTyped(
      std::shared_ptr<Buffer> buffer) override {
    auto packet = std::make_shared<ChatMessage>();
    packet->author = buffer->ReadString();
    packet->text = buffer->ReadString();
    return packet;
  }
};

std::shared_ptr<Codec> MakeCodec() {
  auto codec = std::make_shared<Codec>();
  codec->Add(kChatMessage, std::make_unique<ChatMessageSerializer>());
  return codec;
}

Read in the same order you wrote. Nothing checks the field layout for you — the codec verifies the length the serializer declared, not its contents.

Ids are yours to assign and must match on both ends. Zero is fine. znet's own handshake runs on a codec it installs itself and which yours replaces once the session is ready, so the two never coexist; its packets also sit at the top of the id space (PacketId(-2) and PacketId(-3)), which is the only range worth avoiding.

One codec, many sessions

Serializers hold no per-connection state, so build the codec once and hand the same shared_ptr to every session. Building one per connection works but allocates a serializer set per client for nothing.

The serializer contract

Write into the buffer you were given and return it. The codec has already written the frame header into that buffer, and Buffer grows on write, so there is no size to respect and no reason to allocate.

A serializer that already holds the bytes — a cached encoding, a payload being forwarded — may instead return a buffer of its own. The codec copies its readable range in behind the header, so the frame comes out identical either way. That costs one copy, which is why writing in place is still the default.

Returning nullptr refuses the packet: it is dropped, logged, and nothing goes on the wire.

Buffer

Buffer is a growable byte buffer with separate read and write cursors, so a buffer being filled and one being drained use the same type without interfering.

WriteInt<T> / ReadInt<T> Fixed-width integers
WriteVarInt<T> / ReadVarInt<T> Variable-length, smaller for small values
WriteString / ReadString Length-prefixed
WriteBool, WriteFloat, WriteDouble, WriteChar And their Read counterparts
WriteBitset<N> / ReadBitset<N> std::bitset
WriteInetAddress / ReadInetAddress Addresses
Write(const T*, size_t) Raw arrays

Reads never throw

A read past the end sets an error flag and returns a default value instead of throwing. Check it when the buffer came from the network:

auto value = buffer->ReadInt<uint32_t>();
if (buffer->GetAndClearLastError() != BufferError::None) {
  return nullptr;  // truncated or malformed, refuse it
}

GetAndClearLastError() clears as it reads, so call it once per thing you want to check rather than once at the end.

The codec limits each serializer to its own frame while deserializing, so a serializer that reads too far hits the limit rather than the next packet. It still returns garbage for that message — the limit protects the stream, not the message.

Handling packets

PacketHandler takes the handler type and the packet types it accepts, then one OnPacket overload each:

class GameHandler : public PacketHandler<GameHandler, Ping, Pong> {
 public:
  explicit GameHandler(std::shared_ptr<PeerSession> session)
      : session_(std::move(session)) {}

  void OnPacket(std::shared_ptr<Ping> packet) {
    (void)packet;
    session_->SendPacket(std::make_shared<Pong>());
  }

  void OnPacket(std::shared_ptr<Pong> packet) { (void)packet; }

 private:
  std::shared_ptr<PeerSession> session_;
};

Dispatch is by type, resolved once at construction, so adding packet types does not add per-message branching.

A session's handler can be replaced at any time, which is the usual way to model connection state: a login handler that accepts two packet types, swapped for a gameplay handler once authenticated. A packet the current handler has no OnPacket overload for is dropped, so an unauthenticated client cannot reach gameplay messages.

That drop is silent. An id with no serializer registered on the codec warns, but a packet that deserialized fine and simply found no handler does not, so a handler missing an overload looks exactly like a peer that never sent it. If you are debugging a message that seems not to arrive, check the handler's type list before suspecting the network.

Sending

session->SendPacket(packet);

SendPacket queues and returns; it does not block and it does not encode on your thread. Check the return value. false means the queue was full, which is how you learn you are producing faster than the link drains. The packet is still yours, so retrying or dropping it are both fine.

Queue depth is send_queue_capacity, 512 by default. See Configuration Reference.

The second argument controls delivery and only means anything on ZDT — see Choosing a Transport.

Versioning between builds

Two builds that disagree about a packet's fields will misread each other, since nothing on the wire describes the layout. The multiversion example shows the usual fix: exchange a version during the handshake, then register a different serializer for the same id depending on what the peer reported.

Clone this wiki locally