Goed game-packetontwerp is de eerste steen die bepaalt of een gameserver schaalt en of de client en server echt dezelfde taal spreken. Wanneer je data over TCP verstuurt, verstuur je in werkelijkheid een bytestroom; het begrip "bericht" bestaat niet standaard. Je moet dus ruwe bytes omzetten naar een gestructureerd protocol dat beide kanten op dezelfde manier kunnen lezen en schrijven. In dit artikel loop ik door het bouwen van een degelijk binair protocol vanaf nul, met het drietal header, serialisatie en byte-volgorde.
TCP is een stroom, geen bericht
De eerste en meest gemaakte fout is aannemen dat "één send() gelijk is aan één recv()." TCP garandeert dat niet. De data die je met één enkele send() verstuurt, kan aan de andere kant in twee aparte stukken aankomen; of twee pakketten die je vlak na elkaar verstuurt, kunnen samengevoegd in één recv()-aanroep belanden (dit heet "TCP coalescing"). Met andere woorden: de data weer opdelen langs berichtgrenzen is volledig jouw taak.
Er zijn twee klassieke manieren om dit op te lossen:
- Lengte-geprefixt: je schrijft aan het begin van elk pakket hoeveel bytes de body is. De meest gangbare en betrouwbaarste aanpak.
- Scheidingsteken-gebaseerd: je scheidt berichten met een speciale byte (bijvoorbeeld
\n). Dit is riskant voor binaire data omdat het scheidingsteken ook binnen de payload kan voorkomen; het heeft de voorkeur bij tekstprotocollen.
Games gebruiken bijna altijd een lengte-geprefixt binair protocol, omdat het snel, compact en ondubbelzinnig is.
Headerontwerp: de identiteit van elk pakket
De header is een blok met vaste grootte dat vóór de body komt en je vertelt hoe je het pakket moet lezen. Een minimale maar bruikbare header bevat meestal:
- length — de bytelengte van de body (of van het hele pakket). Om de stroom in berichten op te delen.
- opcode / type — wat dit pakket is (bijv. login, move, chat). Om het naar de juiste handler te routeren.
- Optioneel: een sequence-nummer, flags (gecomprimeerd/versleuteld?), protocol-versie.
Een typische header druk je in C++ zo uit:
#include <cstdint>
#pragma pack(push, 1)
struct PacketHeader {
uint16_t length; // bodylengte (bytes)
uint16_t opcode; // pakkettype
};
#pragma pack(pop)
#pragma pack(push, 1) is hier cruciaal: de compiler voegt voor de prestaties padding (uitlijngaten) toe tussen de structvelden. Die padding verschilt per compiler en platform, dus als je de struct rechtstreeks over de lijn verstuurt, kunnen beide kanten een andere byte-indeling zien. Padding uitschakelen — of, robuuster, de velden één voor één serialiseren — neemt dat probleem weg.
Byte-volgorde: little-endian of big-endian?
Wanneer je een getal van meerdere bytes (bijvoorbeeld een 4-byte uint32_t) naar het geheugen schrijft, hangt de volgorde van de bytes af van de architectuur. x86/x64 en ARM (meestal) zijn little-endian; de conventie in netwerkprotocollen is big-endian ("network byte order"). Als beide kanten een andere volgorde aannemen, wordt het getal 1 aan de andere kant gelezen als 16777216.
De oplossing: kies één byte-volgorde op de lijn en pas die consistent aan beide kanten toe. Op POSIX-systemen worden de conversiefuncties standaard meegeleverd:
#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-bits versies
Deze functies zijn alleen voor 16 en 32 bits; voor 64-bits waarden moet je opsplitsen in twee helften van 32 bits of een eigen helper schrijven. Zolang client en server altijd dezelfde endianness aannemen (bijvoorbeeld beide little-endian pc's), is conversie technisch niet nodig; maar het protocol expliciet op één endianness vastpinnen bespaart je later hoofdpijn wanneer je een ander platform toevoegt.
Serialisatie: verstuur de struct niet rechtstreeks
Een verleidelijke maar gevaarlijke sluiproute is: send(sock, &packet, sizeof(packet), 0). Dit verstuurt de geheugenindeling van de struct zoals die is en breekt over platforms heen door padding, endianness, zelfs pointer-/structuitlijning. Schrijf in plaats daarvan de velden expliciet naar een 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);
}
Vergeet bij het serialiseren van strings de lengteprefix niet: eerst een uint16_t-lengte, dan dat aantal bytes. Zo weet de lezende kant waar het eindigt. In plaats van je eigen formaat te schrijven kun je ook een volwassen bibliotheek gebruiken — Protocol Buffers, FlatBuffers of Cap'n Proto bieden schema-gebaseerde, versioneerbare serialisatie en verlagen de onderhoudslast, vooral naarmate het protocol groeit.
De leeslus: gefragmenteerde data weer samenvoegen
Houd per verbinding een ontvangstbuffer bij. Voeg de bytes die uit recv() komen daaraan toe en controleer dan in een lus "is er genoeg data?":
// grove logica (foutafhandeling ingekort)
buffer.append(recv_data);
while (buffer.size() >= sizeof(PacketHeader)) {
uint16_t len = readUint16(buffer.data()); // bodylengte
size_t total = sizeof(PacketHeader) + len;
if (buffer.size() < total) break; // pakket nog niet compleet
handlePacket(buffer.data(), total);
buffer.erase_front(total); // verwerkte deel weggooien
}
Deze lus handelt zowel het gefragmenteerde geval (één pakket verspreid over meerdere recv's) als het samengevoegde geval (meerdere pakketten in één recv) correct af. Voor productiekwaliteit zijn nog twee dingen essentieel: zet een bovengrens op het length-veld (bijv. 64 KB) zodat een kwaadwillende client geen enorme lengte kan sturen en je geheugen kan uitputten; en valideer elk binnenkomend veld voordat je het aan de handler doorgeeft.
Veelgestelde vragen
Moet ik UDP gebruiken in plaats van TCP?
Dat hangt ervan af. TCP geeft geordende, betrouwbare levering; het is ideaal voor verliesgevoelige data zoals login, inventaris en chat. Voor snelle actiegames (FPS, vechtgames) heeft UDP de voorkeur vanwege de lage latentie, maar je moet de betrouwbaarheid (ordening, hertransmissie) zelf toevoegen. Veel games gebruiken beide samen.
Moet ik de header versleutelen?
Meestal is het versleutelen van de body genoeg, maar basisvelden zoals length moeten leesbaar blijven zodat je de stroom kunt opdelen. Een gangbare aanpak is om al het verkeer na de handshake in een TLS-achtige laag te wikkelen; in dat geval draait je pakketprotocol binnen de versleutelde tunnel en blijft de byte-volgorde-/serialisatielogica ongewijzigd.
Hoe laat ik het protocol later evolueren?
Zet een version-veld in de header en voeg nieuwe opcodes toe zonder de oude te breken. Schema-gebaseerde serialisatie (zoals Protobuf) maakt het toevoegen en verwijderen van velden achterwaarts compatibel; bij handgeschreven protocollen moet je die discipline zelf bewaken.
Een degelijk gameprotocol nodig? Van Metin2-achtige servers tot maatwerk C++-netwerklagen, ik kan helpen met packetontwerp en networking — neem contact op.