7 puan yazan GN⁺ 2023-10-19 | 1 yorum | WhatsApp'ta paylaş
  • Reflect, Figma, Notion ve Google Sheets gibi iş birliği uygulamalarını hızlıca oluşturmak için geliştirilmiş bir framework; Replicache’in oyun tarzı senkronizasyon motoruna tam yönetilen bir sunucu eklenerek yayımlandı
  • İş birliği arayüzleri, sunucu yanıtını beklemeden yerel değişiklikleri anında göstermeli; bu yüzden aynı veriler eşzamanlı düzenlendiğinde çakışmaların nasıl ele alındığı ürün deneyimini belirler
  • Reflect, CRDT yerine Transactional Conflict Resolution yaklaşımını seçer: istemci ve sunucu aynı mutator çağrı geçmişini çalıştırır, sunucu da geliş sırasına göre otoritatif durumu oluşturur
  • Yjs gibi sequence CRDT’ler metin, liste ve map yapılarında güçlüdür; ancak sayaç gibi ayrı birleştirme kuralları gerektiren durumlarda artışlar kaybolabilir. Reflect, mutator’ları yeniden çalıştırarak aritmetik işlemleri, liste manipülasyonlarını ve üst seviye değişmezleri ele alır
  • Sunucu yalnızca mutation adını ve argümanlarını alıp sonucu yeniden hesapladığı için, istemcinin hesapladığı sonuca güvenmeden yetki kontrolü, şema doğrulama ve migration süreçlerini tasarıma dahil etmek kolaydır

Reflect’in sunduğu geliştirici deneyimi

  • Reflect, Figma, Notion ve Google Sheets gibi çok oyunculu web uygulamaları oluşturmak için yeni bir yaklaşımdır
  • Mevcut istemci tarafı senkronizasyon framework’ü Replicache’in evrimleşmiş hâlidir ve aynı oyun tarzı senkronizasyon motorunu kullanır
  • Replicache’ten farklı olarak tam yönetilen bir sunucu içerir; amacı, yüksek kaliteli çok oyunculu uygulamaların dakikalar içinde oluşturulmasını sağlamaktır
  • Reflect ilk kez herkese açık olarak sunuluyor; reflect.net üzerinden tanıtımı görebilir, hello.reflect.net üzerinden başlayabilirsiniz

İş birliğine dayalı düzenlemede çakışmaların ortaya çıkma yapısı

  • İş birliğine dayalı düzenlemede kaçınılmaz olarak çakışmalar oluşur
  • Anında tepki veren bir arayüz oluşturmak için sunucu beklenemez; değişiklikler önce istemcide yerel olarak gerçekleşmelidir
  • Birden fazla kullanıcı aynı öğeyi aynı anda düzenleyebileceği için, tüm kullanıcıların aynı sonucu görmesini sağlayacak şekilde çakışmaların senkronize edilmesi ve doğal biçimde çözülmesi gerekir
  • Senkronizasyon motoru; geliştirici deneyimini, kullanıcı deneyimini, mümkün olan performansı ve yapılabilecek uygulama türlerini bile belirler

CRDT ve Yjs sayaç örneği

  • Web ekosisteminde CRDT, veri senkronizasyon yöntemi olarak yaygın kullanılır
  • CRDT, iş birliği yapanlar arasında değişikliklerin tamamı paylaşıldığında aynı değere yakınsayan bir veri yapısıdır; Yjs ve Automerge başlıca açık kaynak CRDT kütüphaneleridir
  • Reflect bir CRDT değildir; video oyun sektöründe uzun süredir kullanılan Server Reconciliation yaklaşımının bir varyasyonu olan Transactional Conflict Resolution kullanır
  • Yjs’te basit bir sayacın neden bozulduğu

    • Yjs Map içinde count değerini saklayıp prev + 1 sonucunu tekrar yazmak, eşzamanlılık durumunda artışların kaybolmasına neden olabilir
    • Yjs dokümantasyonundaki doğru sayaç örneği, sayıları bir diziye ekleyip toplamı hesaplama yöntemidir
    • Yjs bir sequence CRDT olduğu için listeler, metin parçaları ve map yapılarında güçlüdür; ancak sayaçları doğal biçimde modellemek zordur
    • Yjs Map’in birleştirme algoritması anahtar bazında last-write wins yaklaşımını kullanır; iki kullanıcı aynı anda artırma yaptığında değişikliklerden biri kaybolabilir
    • CRDT belirli problemlere iyi uyar, ancak o problem sınıfının dışında genişletilmesi zor olan bir sınıra sahiptir

Transactional Conflict Resolution nasıl çalışır?

  • Reflect’te değişiklikler, mutator adı verilen özel JavaScript fonksiyonlarıyla uygulanır
  • Her mutator’ın bir kopyası tüm istemcilerde ve sunucuda bulunur
  • Kullanıcı bir değişiklik yaptığında Reflect, mutator çağrı kaydı olan bir mutation oluşturur
    • Mutation içinde yalnızca increment(delta: 1) gibi mutator adı ve argümanlar yer alır
    • Ortaya çıkan değişiklik içeriği mutation’a dahil edilmez
  • Reflect, mutation’ı anında yerelde uygular ve arayüzü günceller; kullanıcı kendi değişikliğini hemen görebilir
  • Sunucu doğrusal hâle getirme ve yeniden çalıştırma

    • Her istemci sunucuyu beklemeden mutation eklemeye devam eder
    • Mutation’lar sunucuya stream edilir; sunucu, mutation’ları geliş zamanına göre doğrusal hâle getirir ve ardından bir sonraki otoritatif durumu oluşturur
    • Örneğin istemci 1’in increment(1) ve istemci 2’nin increment(2) çağrıları aynı anda gerçekleşirse, sunucu çalıştırmasında nihai sayaç değeri geliş sırasına göre oluşur
    • Sunucu, increment’in ne yaptığına veya nasıl birleştirileceğine dair ek bilgiye ihtiyaç duymadan yürütme geçmişini doğrusal hâle getirerek çakışmaları birleştirir
    • En güncel otoritatif durum her istemciye sürekli olarak stream edilir
    • İstemci, bekleyen kendi mutation’ının otoritatif duruma uygulandığını anladığında onu yerel kuyruktan kaldırır
    • Kalan bekleyen mutation’lar, en güncel otoritatif durumun üzerinde mutator kodu yeniden çalıştırılarak rebase edilir
    • Bu döngünün tamamı istemci başına saniyede en fazla 120 kez gerçekleşir

Uygulama maliyeti ve genelleşen avantajlar

  • Bu yaklaşımı uygulamak için geri sarma, fork ve branch oluşturabilen hızlı bir veri deposu gerekir
  • Sunucu tarafında da gelen mutation’ları işleyebilen hızlı bir depolama gerekir
  • Mutator senkronizasyonu yöntemi ve istemci ya da sunucunun senkronizasyon sırasında çakıştığında toparlanmasını sağlayan işlemler gerekir
  • Buna karşılık, keyfi fonksiyonların doğrusal hâle getirilmesi oldukça genel amaçlı bir senkronizasyon stratejisi olarak çalışır
  • Ayrı senkronizasyon kodu olmadan ele alınan örnekler

    • Aritmetik işlemler doğal biçimde ele alınır
    • setHighScore, mevcut high-score ile aday skor arasından büyük olanı saklar
    • Liste manipülasyonlarının çoğu da çalışır
    • append, alışveriş listesinin sonuna öğe ekler
    • insertAt, belirtilen konuma öğe ekler; splice() konumu düzeltir
    • remove, indeks değişebileceği için argüman olarak öğeyi veya kararlı bir ID’yi almalıdır
    • Daha üst seviye değişmezler de zorunlu kılınabilir
    • addChild, ebeveynin childIDs değeri ile çocuğun parentID değerinin her zaman tutarlı kalması için ikisini birlikte günceller
    • Bu örnekler, senkronizasyonu dikkate alan ayrı bir kod olmadan da makul biçimde birleştirilir

Sunucu otoritesi ve yetki kontrolü

  • Reflect’te sunucu otoritedir
  • İstemcinin değişiklik sonucunu nasıl gördüğü sunucuya veya diğer istemcilere paylaşılmaz
  • Sunucuya gönderilenler yalnızca mutation adı ve argümanlarıdır; sunucu mutation sonucunu doğrudan yeniden hesaplar
  • Sunucunun istemciyle aynı kodu çalıştırması da gerekmez; harici servisleri sorgulayabilir veya rastgele sayı kullanabilir
  • İnce taneli yetkilendirme

    • Bu tasarımda ince taneli yetki kontrolleri doğal biçimde eklenebilir
    • İş birliğine dayalı bir tasarım programında konuklara yorum ve vurgulama izni verilip gerçek tasarım değişikliklerinin yasaklanması buna örnektir
    • CRDT’de yetkisiz değişiklikleri reddedecek mantığın konumlandırılacağı bir yer olmadığı için bunu uygulamak zordur
    • Reflect’te sunucuda çalışan mutator, tx.user.canEdit gibi değerleri kontrol edip yetki yoksa unauthorized hatası fırlatabilir
    • Mutator’ın sunucuda istemciden farklı kod çalıştırması sorun değildir; nihai kararı sunucu verir

Şema doğrulama ve kullanım yönlendirmesi

  • Reflect’in yaklaşımında şema doğrulama ve migration süreçleri de tasarıma doğal biçimde dahil edilebilir
  • Senkronizasyon stratejisi seçimi, çok oyunculu sistemlerin merkezindedir; Reflect, oyun sektöründen öğrenilen Transactional Conflict Resolution’ı basit, esnek ve güçlü bir yaklaşım olarak görür
  • Çok oyunculu bir uygulama geliştiriyorsanız Reflect başlangıç sayfası üzerinden deneyebilirsiniz
  • Geliştirme ekibiyle konuşmak için discord.reflect.net veya @hello_reflect kullanılabilir

1 yorum

 
GN⁺ 2023-10-19
Hacker News yorumları
  • Ana sayfanın üst kısmındaki demo (https://reflect.net/) oldukça eğlenceli
    İzlerken, bulmaca her tamamlandığında insanların “başardık!” der gibi imleçlerini sallayıp sevindiğini görüyorsunuz

    • Parçalarla FUCK kelimesini oluşturmaya başladılar; başkaları fark edip katılınca beklenmedik şekilde iç ısıtan bir deneyime dönüştü
    • Tamamlanmış e kısmının arkasına e parçasını saklayabildiğiniz görsel bir bug var
      Diğer harflerde dış çizgi görünmeye devam ettiği için aynı yöntem işe yaramıyor
    • Bu demonun kütüphanenin yeteneklerini iyi gösteren bir örnek olduğunu sanmıyorum
      Bir parçayı tuttuğunuzda o parça o kullanıcıya kilitlenmiş gibi görünüyor; bu yüzden çözülecek neredeyse hiç çakışma yok, en fazla iki kişi aynı anda aldığında önce alana verilmesi kalıyor
      Gerçekten çakışma çözümlemenin gerçekleştiği daha iyi bir demo görmek isterdim
    • Gerçekten eğlenceli bir deneyim
      Anlatılan sahneyi gösteren video burada: https://streamable.com/asu261
  • Daha önce Replicache adıyla bir iki kez gündeme geldiğini hatırlıyor olabilirsiniz
    Reflect, buna tamamen yönetilen ve çok hızlı bir senkronizasyon sunucusu eklenmiş hâli
    Yerel öncelikli/gerçek zamanlı alanı bugünlerde kalabalık, ama Replicache/Reflect veri modeli ve kodlama modeli açısından zarif biçimde basit olduğu için bakmaya değer
    CRDT’ye kıyasla avantajı, çakışmaları basit sıralı kodla doğrudan ele alabilmeniz; yerleşik kurallar uymadığında CRDT’ye uygulamaya özel çakışma çözümleme eklemenin karmaşık olabileceğini düşünüyorum

  • Yaklaşık 15 yıl önce büyükannemin evinde can sıkıntısından kaçmak için bu konuyu epey derinlemesine kurcalamış, bir JavaScript uygulaması da yazmıştım
    “Aritmetik işlemler kendiliğinden çalışır” demek elbette idempotent işlemler hâline getirmek açısından çok kolay; ama “liste işlemleri de kendiliğinden çalışır” ifadesine katılmak zor
    Örneğin [1, 2, 3, 4, 5] gibi bir dizide A’nın 2~4 aralığını sildiğini, B’nin 3~5 aralığını sildiğini, C’nin de 3 ile 4 arasına bir şey eklediğini düşünün; üç güncelleme sunucuya aynı anda ulaştığında çözüm belirsizleşiyor
    Zaman damgasına ya da son yazan kazanır (Last Write Wins) yaklaşımına yaslanırsanız işlem modeli bozulur; birini kazanan seçerseniz diğer kullanıcıların ne göreceği ve bunun nasıl aktarılacağı da sorun olur
    Yanıt “tüm diziyi yeniden gönder” ise yine işlem modeli bozulur

    • Tespit yerinde
      Aslında kastettiğimiz ifade daha çok “birçok liste işlemi kendiliğinden çalışır” idi
      Reflect’te düzenleme/silme tanımlayıcısı olarak liste indeksi kullanamazsınız; çünkü indeksler kararlı değildir. Atomikse öğenin kendisini, genellikle de kararlı bir ID kullanın dememizin nedeni bu: https://i.imgur.com/IKzmf0q.png
      C’nin eklediği şeyin silinmesi sorunu da var; ancak gerçek zamanlı işbirliği bağlamında hiçbir protokol bunu tamamen çözemez
      C açısından az önce yazdığı içeriğin kaybolması üzücü olabilir; bu, aynı alanda eşzamanlı çalışan insanların niyetlerinin farklı olmasından doğan bir durumdur ve geri alma ile o anda kimin çalıştığını gösterme gibi sosyal mekanizmalar yardımcı olur
    • Silme işlemini işin içine katmasak bile, “aynı anda” A liste L’ye “a”, B “b”, C de “c” eklerse sunucu eklemeleri keyfi bir sırayla uygular ve sonucu istemcilere gönderir
      Bu sırada her istemci kendi eklemesini yerelde uygulamış olabilir; A [“a”], B [“b”], C [“c”] görür. Sunucu [“c”, “b”, “a”] durumunu gönderdiğinde istemciler bekleyen değişikliklerini atıp sunucu durumunu dünyanın gerçeği olarak kabul edecek gibi görünüyor
      Ancak her ekleme “benim değişikliğim önce uygulanırsa kazanırım” gibi bir etki yaratıyorsa, sunucu güncellemesini beklenen 300 ms boyunca herkesin “you win” görüp görmeyeceğini merak ediyorum
    • Bu tür sistemlerin çoğu silmeyi tombstone olarak ele alır
      Böylece silinmiş öğelerin arkasına ya da arasına güvenle ekleme yapabilirsiniz
    • İşbirlikçi uygulamalarda sunucu üç işlemi herhangi bir sırayla çözebilir
      Üç kullanıcı sonucu görüp aynı veriye aynı anda dokunduklarını ve durumun karıştığını fark eder, sonra düzeltir
      Belge düzenlemeyi düşünün
      Birbirinizin çalıştığını göremiyorsanız şaşırabilirsiniz, ama sonunda istediğiniz duruma getirebilirsiniz
      Sonucu kontrol etmiyor ya da edemiyorsanız durum doğru olmayabilir; ancak işbirlikçi düzenleme veya oyun gibi etkileşimli uygulamalarda akış genellikle böyle olmaz
  • Bu projede yer alan kişilerden biriyim; sorularınız varsa yanıtlayabilirim

    • Aaron mütevazı biridir, onun yerine söyleyeyim: Greasemonkey’i o yaptı ve yıllar boyunca tarayıcı tarafındaki birçok çığır açıcı yenilikte rol aldı
    • Geçmişte kapalı kaynak kod tabanlarında birkaç çok oyunculu senkronizasyon sistemi geliştirirken bu alanı takip ettim
      Rebase ve sunucu otoritesine sahip bir “redux-pubsub” kütüphanesi de yapmıştım; anladığım kadarıyla TCR’ye benziyor
      Bu modelde hoşuma giden çok şey var ve bağlantı verilen yazı da çok net
      “Şema doğrulama ve migration’lar tasarımdan doğal olarak neredeyse bedavaya gelir” denmiş; migration’larda pratikte hangi yöntemin iyi çalıştığını merak ediyorum
      Ayrıca TCR sisteminde ciddi miktarda paylaşımlı metin düzenlemeyi kapsayan bir kullanım senaryosunda genelde akla varsayılan olarak Yjs ve Tiptap/ProseMirror geliyor; CRDT dokümanı ile TCR dokümanını paralel tutmanın en iyi yol olup olmadığını merak ediyorum
    • Replicache geliştirmesi bundan sonra nasıl ilerleyecek, merak ediyorum
      İstemci tarafındaki kod tabanı büyük ölçüde ortak olduğu için güncellemeleri beklemeye devam edebilir miyiz, yoksa Reflect’in ana odak haline gelip onun yerini alması daha mı olası, bilmek isterim
    • ElectricSQL hakkında ne düşündüğünüzü merak ediyorum
    • Bir odaya aynı anda girebilecek maksimum kullanıcı sayısı yaklaşık ne kadar, merak ediyorum
  • Terminoloji biraz kafa karıştırıcı
    Oyunlarda “oyuncu” olduğu için senkronizasyon sistemine “çok oyunculu” deniyor, ama genel yazılımlarda “kullanıcı” var; bu yüzden çok kullanıcılı demek daha doğru görünüyor
    Sayfada “kullanıcı” ve “çok oyunculu” ifadeleri birlikte kullanıldığı için garip okunuyor

    • Olabilir, ama “kullanıcı” tüketici gibi duyuluyor
      HN çok kullanıcılı bir servis, ama HN’ye çok oyunculu demek tuhaf olur
      Gerçek zamanlı eşzamanlı etkileşim, çok kullanıcılıdan daha güçlü bir şey; birden fazla kullanıcının eylemde bulunup etkileştiği anlamındaki çok oyunculu bence yerinde bir kullanım
    • Bu mantık, onlarca yıldır çok oyunculu oyunlarda kullanılan mantığa dayanıyor
      Öte yandan tüm web yazılımları çok kullanıcılıdır; bu ifade tek başına hiçbir bilgi vermez
    • Günümüzde terim zaten böyle yerleşti
      Çok oyunculu, diğer kullanıcıların her an ne yaptığını görselleştiren canlı çok kullanıcılı deneyim anlamına geliyor
  • Yaklaşık 2 yıldır CRDT’lere yüzeysel olarak bakıyorum ve hep yetkilendirme nasıl oluyor diye merak ettim
    Bu yazı, Y.js gibi CRDT kütüphanelerinin tek başına değişikliklerin yetki kontrolünden geçmesi gereken uygulamaları düzgün şekilde ele almakta zorlandığını ima ediyor gibi
    Bunun nedeni merkezi bir otoritenin, yani sunucunun olmaması; Reflect ise sunucunun istemci etkileşimlerine aracılık ettiğini varsayıyor gibi görünüyor. Bu anlayış doğru mu, merak ediyorum

    • “Yapamaz” demek ağır olur
      Uğraşılırsa CRDT’lerde de yetki yönetimi yapılabilir; örneğin her şeyin arasına bir sunucu koyup, sunucunun gördüğü yetkisiz değişiklikleri geri aldırabilirsiniz
      Ama uygulama büyüdükçe bakım yükü artar ve kırılgan hale gelir; üstelik CRDT’nin avantajlarının bir kısmı da baştan kaybolur
      Sunucu zaten aradaysa, en baştan sunucunun mesajları reddedebildiği bir protokol kullanmak çok daha basittir
    • CRDT’ler de sunucu içerebilir; sadece sunucunun mutlaka her şeyin tam ortasında olması gerekmez
      Örneğin bağlantı kimlik doğrulamasını sunucu üstlenebilir
      Birisi P2P bağlanıp belirli bir yetkiye sahip olduğunu iddia ederse, bu iddia sunucuyla doğrulanabilir
      Ayrıca CRDT mutlaka P2P anlamına gelmez; merkezi sunucu üzerinden mesaj iletimi yaparken de mevcut durumu hem sunucu hem istemci tarafında çözen CRDT modelini koruyabilirsiniz
    • Her operasyonu ya da mesajı imzalayıp, erişim kontrol listesindeki açık anahtarla eşleşip eşleşmediğini doğrulayarak yetki yönetimi yapılabilir
      Bu yöntem CRDT’nin P2P bağlamında da çalışmasını sağlar
      Ancak sunucu otoriteyse, yetkisiz istemcinin mesajını doğrudan reddetmek yeterlidir
  • mutator yükseltmeleri nasıl ele alınıyor, merak ediyorum
    İstemci eski kodu çalıştırıyorsa operasyon sunucudakinden farklılaşacaktır; bariz cevap increment_v1, increment_v2 gibi bağımsız sürümlemek, ama daha iyi bir yöntem var mı merak ediyorum

    • Şimdilik, beklemede değişikliği olan hiçbir istemci kalmadığını bilene kadar eski mutator’ı kaldırmamaktan daha iyi bir cevap yok
      Henüz kalıcılığı açmadığımız için şu anda bu süre oldukça kısa
  • Lansman hayırlı olsun
    https://partykit.io/ ile benzer bir kategoride mi, merak ediyorum

    • Aynı alandalar
      Temel fark, her birinin ne kadar kanaatli bir tasarıma sahip olduğu
      PartyKit çok az kural koyan bir yapı; daha çok hızlı başlanabilen ve otomatik ölçeklenen hafif bir JavaScript sunucusuna yakın
      Görünüşe göre çoğu kişi PartyKit üzerinde yjs çalıştırıyor, ama automerge veya Replicache de çalıştırılabilir
      Reflect ise mümkün olan en iyi çok oyunculu deneyimi sunmaya tamamen odaklanıyor; çok oyunculunun kendiliğinden çalışmasını sağlamak ve uygulama geliştirmeye odaklanmanızı mümkün kılmak için yığın genelinde birçok tercihi güçlü biçimde entegre etmeyi planlıyor
  • Bu arada, bu strateji oyun geliştirme tarafında deterministik lockstep olarak adlandırılır ve durumu senkronize etmesi gereken çok sayıda entity bulunan oyunlarda özellikle sık kullanılır.
    Tipik örnek, neredeyse tamamı bu yöntemi kullanan gerçek zamanlı strateji oyunlarıdır.

    • Daha doğrusu, istemci yerel değişikliği göstermeden önce sunucu yanıtını beklemediği için deterministik rollbacke daha yakındır.
      Mortal Kombat ve Injustice 2 uygulamasını derinlemesine anlatan mükemmel bir GDC sunumu var: https://youtu.be/7jb0FOcImdg
    • Bu deterministik lockstep değildir.
      Deterministik lockstep, her katılımcının oyun simülasyonunu ilerletmeden önce diğer tüm oyuncuların girdilerini beklediği bir P2P oyun algoritmasıdır.
      “Deterministik” denmesinin nedeni simülasyon sonucunun değil girdilerin paylaşılması ve aynı girdiler için simülasyonun deterministik olmasıdır; “lockstep” denmesinin nedeni ise tüm istemcilerin koordine edilmiş bir hızda ilerlemesidir.
      Age of Empires serisi bu yöntemi kullandığı için tıkladığınızda birimler hemen hareket etmez; StarCraft da bunu kullanır, ancak oynanış hissini daha akıcı kılmak için bazı hilelere sahiptir.
      Reflect, P2P değil, sunucu otoriteli simülasyona daha yakındır.
      İstemci girdiyi sunucuya gönderir ama sonucu beklemeden yerelde tahmin yapar; sunucu ise tek tek istemci gecikmelerini telafi etmek için zamanı geri alır ve girdileri yeniden oynatır.
      Ardından istemci, kendi girdisini içeren sunucu sonucunu aldığında yerel simülasyonu düzeltir.
      Bu algoritmanın anahtar terimleri sunucu otoritesi, tahmin, gecikme telafisi ve tahmin düzeltmesidir.
      Reflect'te var mı bilmiyorum, ancak FPS oyunlarında dünya güncellemesi alındığında entity'leri belirli bir süre boyunca yeni konum ve dönüşlerine enterpole eden istemci tarafı enterpolasyon da yaygındır.
      Tek otorite sunucu olduğu için determinizm çok kritik değildir, fakat istemci tahmininde hatalı tahminleri azaltmada faydalıdır.
      Hatalı tahminler, diğer istemci girdilerinin dünya durumunu büyük ölçüde değiştirmesi veya rastgele sayı üretiminin senkronize olmaması gibi simülasyonun deterministik olmadığı durumlarda ortaya çıkar.
      Counter Strike'ta “nospread” hilesini engellemek için mermi saçılımı rastgeleliğini senkronize etmeyen örnekler de vardır.
      https://www.gabrielgambetta.com/client-side-prediction-live-...
      https://developer.valvesoftware.com/wiki/Latency_Compensatin...
      https://developer.valvesoftware.com/wiki/Source_Multiplayer_...
  • Reflect harika.
    Şu anda prodüksiyonda alfa sürümünü kullanıyoruz; yalnızca sistemden değil, Aaron ve ekipten de çok memnunuz.
    Bir müşteri olarak sorularınız varsa yanıtlayabilirim.