Good game packet design is the first stone that decides whether a game server will scale and whether the client and server actually speak the same language. When you send data over TCP you are really sending a byte stream; there is no built-in notion of a "message." So you have to turn raw bytes into a structured protocol that both sides can read and write the same way. In this article I walk through building a solid binary protocol from scratch with the trio of header, serialization and byte order.
TCP is a stream, not a message
The first and most common mistake is to assume "one send() equals one recv()." TCP guarantees no such thing. The data you push with a single send() may arrive on the other side in two separate chunks; or two packets you send back to back may land merged inside a single recv() call (this is called "TCP coalescing"). In other words, splitting the data back into message boundaries is entirely your job.
There are two classic ways to solve this:
- Length-prefixed: you write how many bytes the body is at the start of every packet. The most common and most reliable approach.
- Delimiter-based: you separate messages with a special byte (for example
\n). This is risky for binary data because the delimiter can also appear inside the payload; it is preferred for text protocols.
Games almost always use a length-prefixed binary protocol, because it is fast, compact and free of ambiguity.
Header design: the identity of every packet
The header is a fixed-size block that comes before the body and tells you how to read the packet. A minimal yet useful header usually contains:
- length — the byte length of the body (or of the whole packet). Used to split the stream into messages.
- opcode / type — what this packet is (e.g. login, move, chat). Used to route it to the right handler.
- Optional: a sequence number, flags (compressed/encrypted?), protocol version.
A typical header can be expressed in C++ like this:
#include <cstdint>
#pragma pack(push, 1)
struct PacketHeader {
uint16_t length; // body length (bytes)
uint16_t opcode; // packet type
};
#pragma pack(pop)
#pragma pack(push, 1) is critical here: the compiler adds padding (alignment gaps) between struct fields for performance. That padding varies by compiler and platform, so if you send the struct directly over the wire the two sides may see a different byte layout. Turning padding off — or, more robustly, serializing the fields one by one — eliminates that problem.
Byte order: little-endian or big-endian?
When you write a multi-byte number (say a 4-byte uint32_t) to memory, the order of the bytes depends on the architecture. x86/x64 and ARM (usually) are little-endian; the convention in network protocols is big-endian ("network byte order"). If the two sides assume different orders, the number 1 is read on the other end as 16777216.
The fix: decide on a single byte order on the wire and apply it consistently on both ends. On POSIX systems the conversion functions ship out of the box:
#include <arpa/inet.h>
uint16_t net = htons(host_value); // host -> network (big-endian)
uint16_t host = ntohs(net); // network -> host
uint32_t net32 = htonl(host_value); // 32-bit versions
These functions are only for 16 and 32 bits; for 64-bit values you have to split into two 32-bit halves or write your own helper. As long as client and server always assume the same endianness (for example both being little-endian PCs), no conversion is technically needed; but pinning the protocol explicitly to one endianness saves you a headache later when you add a different platform.
Serialization: do not send the struct directly
A tempting but dangerous shortcut is: send(sock, &packet, sizeof(packet), 0). This sends the struct's memory layout as-is and breaks across platforms because of padding, endianness, even pointer/struct alignment. Instead, write the fields explicitly into a buffer:
void writeUint16(std::vector<uint8_t>& buf, uint16_t v) {
uint16_t net = htons(v);
buf.push_back(net & 0xFF);
buf.push_back((net >> 8) & 0xFF);
}
uint16_t readUint16(const uint8_t* data) {
uint16_t net = data[0] | (data[1] << 8);
return ntohs(net);
}
When serializing strings, do not forget the length prefix: first a uint16_t length, then that many bytes. That way the reading side knows where it ends. Instead of writing your own format you can also use a mature library — Protocol Buffers, FlatBuffers or Cap'n Proto provide schema-based, versionable serialization and reduce the maintenance burden especially as the protocol grows.
The read loop: reassembling fragmented data
Keep a receive buffer per connection. Append the bytes coming from recv() into it, then check in a loop "is there enough data?":
// rough logic (error handling trimmed)
buffer.append(recv_data);
while (buffer.size() >= sizeof(PacketHeader)) {
uint16_t len = readUint16(buffer.data()); // body length
size_t total = sizeof(PacketHeader) + len;
if (buffer.size() < total) break; // packet not complete yet
handlePacket(buffer.data(), total);
buffer.erase_front(total); // drop the processed part
}
This loop correctly handles both the fragmented case (one packet split across multiple recvs) and the merged case (multiple packets in a single recv). For production quality two more things are essential: put an upper bound on the length field (e.g. 64 KB) so a malicious client cannot send a huge length and exhaust your memory; and validate every incoming field before handing it to the handler.
Frequently Asked Questions
Should I use UDP instead of TCP?
It depends. TCP gives ordered, reliable delivery; it is ideal for loss-intolerant data like login, inventory and chat. For fast-paced action (FPS, fighting) games UDP is preferred for low latency, but you have to add reliability (ordering, retransmission) yourself. Many games use both together.
Should I encrypt the header?
Usually encrypting the body is enough, but basic fields like length should stay readable so you can split the stream. A common approach is to wrap all traffic after the handshake in a TLS-like layer; in that case your packet protocol runs inside the encrypted tunnel and the byte order/serialization logic stays unchanged.
How do I evolve the protocol later?
Put a version field in the header and add new opcodes without breaking the old ones. Schema-based serialization (like Protobuf) makes adding and removing fields backward-compatible; in hand-written protocols you have to keep that discipline yourself.
Need a solid game protocol? From Metin2-style servers to custom C++ network layers, I can help with packet design and networking — get in touch.