İyi bir oyun packet tasarımı, bir oyun sunucusunun ölçeklenip ölçeklenmeyeceğini ve istemci ile sunucunun aynı dili konuşup konuşmadığını belirleyen ilk taştır. TCP üzerinden veri gönderdiğinizde aslında bir bayt akışı (stream) gönderiyorsunuz; "mesaj" diye bir kavram yoktur. Bu yüzden ham baytları, her iki tarafın da aynı şekilde okuyup yazabileceği yapılandırılmış bir protokole dönüştürmeniz gerekir. Bu yazıda header, serialization ve byte order üçlüsüyle sıfırdan sağlam bir binary protokolün nasıl kurulduğunu anlatıyorum.
TCP bir akıştır, mesaj değil
İlk ve en sık yapılan hata, "bir send() = bir recv()" varsaymaktır. TCP bunu garanti etmez. Tek bir send() ile gönderdiğiniz veri karşı tarafa iki ayrı parçada gelebilir; ya da peş peşe gönderdiğiniz iki packet tek bir recv() çağrısında birleşmiş halde (bu olaya "TCP coalescing" denir) düşebilir. Yani veriyi mesaj sınırlarına bölmek tamamen sizin işiniz.
Bunu çözmenin iki klasik yolu vardır:
- Uzunluk ön ekli (length-prefixed): her packet'in başına gövdenin kaç bayt olduğunu yazarsınız. En yaygın ve en güvenilir yöntem.
- Ayraç tabanlı (delimiter): mesajları özel bir bayt (örneğin
\n) ile ayırırsınız. Binary veride ayraç verinin içinde de geçebileceği için risklidir; metin protokollerinde tercih edilir.
Oyunlarda neredeyse her zaman uzunluk ön ekli binary protokol kullanılır, çünkü hızlı, kompakt ve belirsizlikten uzaktır.
Header tasarımı: her packet'in kimliği
Header, gövdeden önce gelen sabit boyutlu bir bloktur ve packet'i nasıl okuyacağınızı söyler. Minimal ama işe yarar bir header genelde şunları içerir:
- length — gövdenin (veya tüm packet'in) bayt uzunluğu. Akışı mesajlara bölmek için.
- opcode / type — bu packet'in ne olduğu (örn. login, move, chat). Doğru handler'a yönlendirmek için.
- İsteğe bağlı: sequence numarası, flags (sıkıştırılmış/şifreli mi), protokol version.
Tipik bir header'ı C++'ta şöyle ifade edebilirsiniz:
#include <cstdint>
#pragma pack(push, 1)
struct PacketHeader {
uint16_t length; // gövde uzunluğu (bayt)
uint16_t opcode; // packet türü
};
#pragma pack(pop)
#pragma pack(push, 1) burada kritik: derleyici performans için struct alanlarına padding (hizalama boşluğu) ekler. Bu padding derleyiciye ve platforma göre değişir, dolayısıyla struct'ı doğrudan tel üzerinden (over the wire) gönderirseniz iki taraf farklı bayt düzeni görebilir. Padding'i kapatmak ya da — daha sağlamı — alanları tek tek serialize etmek bu sorunu ortadan kaldırır.
Byte order: little-endian mi big-endian mi?
Çok baytlı bir sayıyı (örneğin 4 baytlık uint32_t) belleğe yazarken baytların sırası mimariye bağlıdır. x86/x64 ve ARM (genelde) little-endian; ağ protokollerinde geleneksel olan ise big-endian'dır ("network byte order"). İki taraf farklı sırayı varsayarsa 1 sayısı karşıda 16777216 olarak okunur.
Çözüm: tel üzerinde tek bir byte order'a karar verin ve her iki uçta da tutarlı uygulayın. POSIX sistemlerde dönüşüm fonksiyonları hazır gelir:
#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 sürümler
Bu fonksiyonlar yalnızca 16 ve 32 bit içindir; 64 bit değerler için iki 32 bitlik parçaya bölmek veya kendi yardımcınızı yazmak gerekir. İstemci ve sunucu hep aynı endianlığı varsaydığı sürece (örneğin ikisi de little-endian PC ise) teknik olarak dönüşüme ihtiyaç olmaz; ama protokolü açıkça bir endianlığa sabitlemek, ileride farklı bir platform eklediğinizde başınızı ağrıtmaz.
Serialization: struct'ı doğrudan göndermeyin
Cazip ama tehlikeli bir kestirme yol şudur: send(sock, &packet, sizeof(packet), 0). Bu, struct'ın bellek düzenini olduğu gibi gönderir ve padding, endianlık, hatta pointer/struct hizalaması yüzünden platformlar arasında bozulur. Bunun yerine alanları açık biçimde bir tampona (buffer) yazın:
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);
}
String'leri serialize ederken de uzunluk ön ekini unutmayın: önce uint16_t bir uzunluk, sonra o kadar bayt. Böylece okuyan taraf nerede biteceğini bilir. Kendi formatınızı yazmak yerine olgunlaşmış bir kütüphane de kullanabilirsiniz — Protocol Buffers, FlatBuffers veya Cap'n Proto şema tabanlı, sürümlenebilir serialization sağlar ve özellikle protokol büyüdükçe bakım yükünü azaltır.
Okuma döngüsü: parçalı veriyi birleştirmek
Her bağlantı için bir alım tamponu (receive buffer) tutun. recv()'den gelen baytları bu tampona ekleyin, sonra "yeterli veri var mı?" diye döngüyle kontrol edin:
// kaba mantık (hata kontrolü kısaltıldı)
buffer.append(recv_data);
while (buffer.size() >= sizeof(PacketHeader)) {
uint16_t len = readUint16(buffer.data()); // gövde uzunluğu
size_t total = sizeof(PacketHeader) + len;
if (buffer.size() < total) break; // packet henüz tam değil
handlePacket(buffer.data(), total);
buffer.erase_front(total); // işlenen kısmı at
}
Bu döngü hem parçalanmış (bir packet birden çok recv'e bölünmüş) hem de birleşmiş (birden çok packet tek recv'de) durumları doğru ele alır. Üretim kalitesi için iki şey daha şart: length alanına bir üst sınır koyun (örn. 64 KB) ki kötü niyetli bir istemci dev bir uzunluk gönderip belleğinizi tüketmesin; ve gelen her alanı handler'a vermeden önce doğrulayın.
Sık Sorulan Sorular
TCP yerine UDP kullanmalı mıyım?
Duruma göre. TCP sıralı ve güvenilir teslimat verir; login, envanter, sohbet gibi kaybı affetmeyen veriler için idealdir. Hızlı tempolu aksiyon (FPS, dövüş) oyunlarında düşük gecikme için UDP tercih edilir, ancak güvenilirliği (sıra, yeniden gönderim) kendiniz eklemeniz gerekir. Birçok oyun ikisini birlikte kullanır.
Header'ı şifrelemeli miyim?
Genelde gövdeyi şifrelemek yeterlidir, ama length gibi temel alanlar okunabilir kalmalı ki akışı bölebilesiniz. Yaygın yaklaşım: el sıkışma (handshake) sonrası tüm trafiği TLS benzeri bir katmanla sarmak; bu durumda packet protokolünüz şifreli tünelin içinde çalışır ve byte order/serialization mantığı değişmez.
Protokolü ileride nasıl değiştiririm?
Header'a bir version alanı koyun ve yeni opcode'ları eskileri kırmadan ekleyin. Şema tabanlı serialization (Protobuf gibi) alan ekleyip çıkarmayı geriye dönük uyumlu kılar; el yazımı protokollerde bu disiplini kendiniz korumalısınız.
Sağlam bir oyun protokolüne mi ihtiyacınız var? Metin2 türevi sunuculardan özel C++ ağ katmanlarına kadar packet tasarımı ve networking konusunda yardımcı olabilirim — benimle iletişime geçin.