← Portfolyoya dön

Yazılar · Ağustos 2026 · 10 dk okuma

Üç düzlem ve 4 €'luk bir sunucu: MoonLite'ın netcode'u gerçek bir sunucu olmadan 480 oyuncuyu nasıl taşıyor

Bu yazı İngilizce aslından çevrilmiştir.

Tasarım notu, Ağustos 2026. MoonLite (moonlite.gg) tarayıcıda çalışan, çok oyunculu, 3B bir “gece parkı”. Tek başıma geliştirdim; bu, tasarımın en çok savunacağım ve ilk seferde yanlış yaptığım kısmı.

Gerçekte çözdüğüm problem

İncelediğim her “tek bir 3B odada çok sayıda insan” projesi aynı şekilde ölüyor ve sebep paket boyutu değil. Birbirini büyüten iki şey:

  1. Olay başına dağıtım O(n²). Bir oyuncu hareket eder, sunucu herkese söyler; herkes hareket eder, sunucu yine herkese söyler. 20 oyuncuda bu hiçbir şey. 200’de bütün işlemciyi yer.
  2. Kalabalığın trafiği, asla aksamaması gereken şeyle aynı kanalı paylaşır. MoonLite’ta o şey müzik: oyuncular ortak bir zaman çizelgesinde ses kanallarını karıştırıyor ve herkes aynı vuruşu aynı anda duyuyor. Bir emoji fırtınası bir mikser komutunu geciktirebiliyorsa ürün bozuktur.

Bu yüzden ilk gün yazdığım kısıt şuydu: sunucunun olay başına işi, istemcilerin ne kadar yüklendiğine değil, tick hızına bağlı olmalı. Aşağıdaki her şey bu cümleden çıkıyor.

Asla aynı tamponu paylaşmayan üç düzlem

Trafik, farklı garantileri olan üç düzleme ayrılıyor. Aynı WebSocket üzerinden gidiyorlar ama kodlamaları, hız limitleri ve dağıtımları tamamen ayrı.

Kontrol düzlemi. Güvenilir, sıralı, seyrek. JSON. “3. kanalın kazancı artık 0,7”, “oturum temposu 124”, “o eşyanın sahibi bu oyuncu”. Sunucu yetkili: relay mesajı odanın oturum durumuna uygular ve sonucu yayınlar. Oyuncu başına saniyede 30 mesajlık token bucket; meşru hiçbir kullanıcı yaklaşmıyor bile.

Konum düzlemi. Kayıplı, ikili, toplu. Her istemci konumunu kare hızından bağımsız olarak sabit 12 Hz’te, 8 baytlık bir paketle gönderiyor: bir tip baytı, int16 x, int16 z, tek baytlık yön (voksel bir avatar için 256 yön fazlasıyla yeter) ve zıplamalar için int16 y. Dünya 500 metre kare, yani int16 yaklaşık 8 mm hassasiyet veriyor; bunu kimse göremez. Sunucu bu paketleri iletmiyor. Topluyor ve kendi 15 Hz tick’inde (66 ms bütçe) her oyuncuya tek bir snapshot gönderiyor: 8 bayt başlık artı görünen her komşu için 9 bayt. “Görünür” olmaya bir ilgi alanı (area-of-interest) uzamsal hash’i karar veriyor: 40 m yarıçap, en fazla 80 komşu. Bunun ötesindeki birinin konumunu hiç almıyorsun. İstemciler snapshot’lar arasında enterpolasyon yapıyor, böylece 15 Hz, 60 fps’te akıcı görünüyor.

İlk ana kareden sonra snapshot’lar delta: sadece nicemlenmiş durumu değişen oyuncular dahil ediliyor; kendini onarması için her 30 tick’te (2 sn) bir tam ana kare gidiyor. Deltaları eklediğimde, çıkardıkları değişmeyen veriyi ölçtüm: dolu bir odada istemci başına saniyede yaklaşık 10,8 KB. “Her şeyi gönder”in, çıkış trafiği faturası gelene kadar kimsenin fark etmediği bedeli bu.

Tepki düzlemi. Emojiler, zıplamalar, el sallamalar. Kayıplı, tick başına toplu, her biri 2 bayt. İlgi yarıçapının ötesinde toplam sayılara dönüşüyorlar (“orada 14 kişi el salladı”); böylece tezahürat eden bir kalabalık bin mesaj değil, tek küçük bir mesaj.

Konum ve tepki düzlemlerindeki her şeyin soket başına 256 KB’lık bir geri basınç limiti var: istemci yetişemiyorsa kayıplı trafiği kuyruğa alınmıyor, düşürülüyor. Kontrol düzlemi asla düşürülmüyor. Bütün mesele bu asimetri.

Protokolde yaklaşık 62 mesaj tipi var; hepsi için elle yazılmış Reader/Writer kodlayıcıları ve gidiş-dönüş testleri var. Relay tarafında sevdiğim bir hile de var: bir istemci paketinin sadece gönderenin kimliğiyle yeniden etiketlenip iletilmesi gerekiyorsa spliceRelay, çözüp yeniden kodlamak yerine baytları yerinde yamalıyor.

Mini oyunlar: ağda hiç kare yok

Aynı ilke on iki etkinliği de (satranç, bilardo, bowling, pinball, go-kart ve diğerleri) yönetiyor. Hiçbiri durum akıtmıyor. Her biri, tarayıcının ve relay’in birlikte içe aktardığı ortak bir paketteki saf, determinist bir step(durum, girdiler) fonksiyonu. Ağda sadece tick damgalı girdi işlemleri ve sonradan katılanlar için bir snapshot gidiyor. Köstebek vurma oyununun sonradan katılan biri için tüm durumu 54 bayt; köstebek takvimi tohum, delik ve tick’in saf bir fonksiyonu olduğu için gönderilecek başka bir şey yok. Lunapark araçları daha da ucuz: bir hız treni koltuğunun konumu, ağ üzerinden paylaşılan bir startedAt zaman damgasının ve ortak saatin fonksiyonu; yani bir tur çalışırken sıfır bant genişliği harcıyor.

Yan kazanç, simülasyonların kolayca test edilebilmesi; testler de hakkını verdi. Go-kart pistinde yanlış bir Catmull-Rom katsayısı (eğri salınıyordu ve depo merkez çizgisinden yarım metre kaymıştı), bir sapan tarafından sonsuza kadar yeniden enerji verilen bir pinball topu ve rastgele direksiyon girdisinin bir kartı bariyerlere sıkıştırabildiği “direksiyonda bebek” senaryosunu yakaladılar. Kart testi, direksiyonu simüle edilmiş 45 saniye boyunca rastgele çeviriyor ve kartın pistten asla çıkmadığını doğruluyor.

Rakamlar ne diyor

Gerçek ikili protokolü konuşan bir yük botu yazdım (misafir girişi, 12 Hz rastgele yürüyüş, ara sıra tepkiler) ve onu üretimle aynı bir makineye yönelttim: Hetzner CX22, 2 vCPU / 4 GB, ayda yaklaşık 4 €, gerçek Cloudflare ve Caddy yolu üzerinden.

YükOda düzeniEn kötü tick (bütçe 66 ms)Kopan
200 bot10 lobi odası~1,5 ms0
200 bot1 etkinlik odası, hepsi hareket halinde~26 ms (relay kendini “boşaltma” moduna aldı)0
350 bot1 etkinlik odası, hepsi hareket halinde48–60 ms, döngü en fazla 135 ms. Kırılma noktası.0
500 bot24 lobi odası (tam makine)480/480 oyuncuda 1,5–2,5 msLimit üstündeki 20’si düzgünce reddedildi

İkisi de ürünü değiştiren iki sonuç:

  • Lobiler işlemciyle değil, oda sayısıyla sınırlı. 24 odaya dağılmış 480 oyuncu yaklaşık 25 kat tick payı bırakıyor. Makine işlemcisi bitmeden çok önce ayarlanmış oda yuvaları bitiyor.
  • Herkesin aktif olduğu tek bir etkinlik odası yaklaşık 300 oyuncuyu kaldırıyor (gerçekçi boşta durma oranıyla daha fazlasını). Başta 1.000 oyunculu etkinlikler planlamıştım. Veri, tek odada 1.000 kişi için bambaşka sınıfta bir makine gerektiğini ve her ~500 oyuncu için bir ucuz makinenin daha doğru bir yapı olduğunu söyledi. Ben de odalara limit koydum (lobi 20, etkinlik 100) ve tek büyük makine yerine çok sayıda ucuz makine için tasarladım.

Relay kendini izliyor: her 5 saniyede bir yeni oyuncu kabul edip etmediğini (accepting) yayınlıyor (çekirdek başına yük ortalaması 0,8’i ya da en kötü oda tick’inin hareketli ortalaması 25 ms’yi ya da bellek 1,2 GB’ı geçerse “sıcak”, histerezisle). Uçtaki küçük bir yönlendirici her makineyi yokluyor ve yeni oyuncuları kabul eden en dolu makineye yerleştiriyor; böylece küçük kalabalıklar dağılmak yerine buluşuyor. Bir makine ısınınca yeni oda almayı bırakıyor ve elindekilere hizmet etmeye devam ediyor. Canlı oda taşıma yok ve hiç olmayacak; odalar geçici, dolayısıyla “taşıma, boşalt” hem daha basit hem oyunculara daha nazik. Yerel bir denemede tek bir etkinlik odasına 800 bot yükledim: gerçek aşırı yükte boşaltma refleksi devreye girdi ve 800’ünün hepsine sıfır kopmayla hizmet edilmeye devam edildi.

Gerçek oyuncularla, en yoğun elle kullanımda 24 tarayıcı sekmesiyle yaptığım test terazinin öbür kefesini gösterdi: dolu bir 20 kişilik lobi tick başına ortalama 1,07 ms, en kötü 11,8 ms harcadı; VPS’in işlemcisi %15’in altında kaldı ve bunun çoğu simülasyon değil, TLS yapan Caddy’ydi.

Yanlış yaptığım kısım: P2P üzerinden medya

Ses kanalları ağır baytlar ve baştan beri kural şuydu: medya baytları asla relay’den geçmez. İlk tasarımım bunları eşler arası taşıyordu: WebRTC veri kanalları, çakışma geri alımlı “perfect negotiation”, bir istek/sahiplik/parça protokolü, geri basınçlı bir sürü. Hepsini yazdım ve avatar görünümleri için yayına aldım. Üç gün sonra sildim.

Ne kadar acıttıklarına göre sırayla üç sebep:

  1. NAT aşma, gerçek oyuncularımda nadiren çalıştı. Mobil operatörlerdeki CGNAT ve simetrik NAT’lar, çoğu eşleşmenin zaten yedeğe düşmesi demekti.
  2. Eşlerin IP adreslerini sızdırıyordu. Yabancıların aynı odayı paylaştığı sosyal bir üründe bu, belgelemem ve savunmam gereken bir güvenlik ve gizlilik sorunu.
  3. Hiçbir şey kazandırmıyordu. Yedek, çıkış ücreti sıfır olan Cloudflare R2’ydi. R2 her eksiği taşımaya başlayınca P2P, zaten harcamadığım parayı tasarruf ediyordu.

Zincir artık yerel IndexedDB önbelleği → R2; anahtar, temizlenmiş dosyanın SHA-256 özetinin ilk 16 baytı (meta veri sızmasın diye PCM’den yeniden kodlanıyor). Bir ses kanalı, dinleyici başına bir kez indirilen bir dosya; sonra her istemci kendi yerel kopyasını ortak saatte çalıyor. Asla 1’den N’e bir akış değil. İndirmeler 40 metrelik kontrol yarıçapına değil, 14 metrelik dinleme yarıçapına bağlı; yani sadece gerçekten duyacağını indiriyorsun.

Çıkardığım ders: optimize edilecek şey hiçbir zaman bant genişliği değildi. Sunucunun olay başına ne yapmasına izin verildiğiydi. Bu sınırlandığında ucuz makine yetti ve o akıllıca P2P katmanı, atabileceğim bir karmaşıklık oldu.

Aynı şeyi yapan birine söyleyeceklerim

  • Hangi trafiğin düşürülebileceğine, hangisinin asla düşürülemeyeceğine karar ver ve onlara ayrı borular ver. İkisi bir kuyrukta buluşmasın.
  • Erken nicemle. int16 konumlar ve sabit bir girdi hızı sonraki her kararı ucuzlattı.
  • Simülasyonu saf bir fonksiyona koy ve bir kütüphane gibi test et. Netcode hatalarının çoğu, üstüne gecikme binmiş simülasyon hatalarıdır.
  • Daha büyük bir makine almadan önce gerçek makinede, gerçek yoldan ölç. Benim “ölçek” sorunum bir oda limiti ayarı çıktı.
  • En gurur duyduğun parçayı silmeye hazır ol. P2P katmanı projenin en ilginç koduydu ve onu kaldırmak ürünü daha iyi yaptı.

Protokol, nicemleme, saat senkronu ve tüm oyun simülasyonları açık kaynak: github.com/lectorsalamanca/moonlite-netcode. Yük botu ve bu sonuçlar: github.com/lectorsalamanca/ws-loadbot.

Detayları oku: MoonLite →

İletişim

Aklınızda bir proje ya da pozisyon mu var?

Freelance projelere ve tam zamanlı rollere açığım, uzaktan ya da İstanbul'da. İhtiyacınızı birkaç cümleyle yazın; genelde bir gün içinde dönerim.