← Aktualności

Dlaczego przepisaliśmy serwer do gry backgammon online w czasie rzeczywistym Gammon42 w Go

Jak przyspieszyliśmy naszego backgammona online pod obciążeniem — krytyczną ścieżkę czasu rzeczywistego przenieśliśmy na mały serwis w Go.

technical 1 lipca 2026

Każda gra online żyje albo umiera z jednego powodu: jak to jest, gdy dotykasz pionka i czekasz, aż plansza się poruszy. Jeśli ten moment jest natychmiastowy, gra wydaje się żywa. Jeśli się zacina — nawet o sekundę — całość wydaje się zepsuta, bez względu na to, jak dobre jest wszystko inne.

To historia o tym, jak sprawiliśmy, że ten moment stał się szybszy i bardziej stabilny w Gammon42 — naszej darmowej wieloosobowej grze w backgammon online — przenosząc część naszego backendu działającą w czasie rzeczywistym z frameworka obsługującego resztę aplikacji na mały, dedykowany serwis napisany w Go. Napisano to dla dwóch rodzajów czytelników: jeśli chcesz po prostu wiedzieć, dlaczego gra działa teraz lepiej, pierwsza połowa jest dla ciebie. Jeśli interesują cię szczegóły inżynieryjne — testy obciążeniowe, architektura, kompromisy — wszystko to znajdziesz w drugiej połowie, wyraźnie oznaczonej. A jeśli wolisz po prostu zagrać w backgammon online już teraz, śmiało — reszta będzie tu czekać na później.

Wersja skrócona

Nasz backend był pojedynczą aplikacją w Dart zbudowaną na frameworku Serverpod. Robił wszystko: konta, znajomych, turnieje, zakupy w aplikacji, czat — i samą rozgrywkę na żywo. Serverpod jest do tego naprawdę doskonały — pozwolił małemu zespołowi szybko dostarczyć pełny, typowany produkt klient-serwer i do dziś obsługuje większość Gammon42. Ale rozgrywka na żywo to zasadniczo inny rodzaj pracy niż reszta i wraz ze wzrostem liczby jednocześnie grających osób oba te obszary zaczęły konkurować o te same zasoby. Ta część systemu, która najbardziej wymaga szybkości — przesuwanie pionków w meczu na żywo — dzieliła pas ruchu ze wszystkim innym.

Wydzieliliśmy więc część odpowiedzialną za rozgrywkę na żywo do osobnego, dedykowanego serwisu. Obsługuje on wyłącznie gorącą ścieżkę czasu rzeczywistego: kości, ruchy, stan planszy i synchronizację obu graczy. Cała reszta pozostała dokładnie tam, gdzie była. W efekcie ruchliwy wieczór pełen kojarzenia przeciwników nie spowalnia już momentu, w którym wykonujesz ruch — a obie połowy systemu mogą teraz rozwijać się niezależnie.

Dlaczego oczekiwanie ma znaczenie

W wieloosobowym meczu backgammona online komunikaty przelatujące między twoim telefonem a naszym serwerem są maleńkie i ciągłe: rzuciłem kośćmi, oto co wypadło; przesunąłem tego pionka; czy ten ruch jest zgodny z zasadami; oto nowa plansza; twoja kolej. Dziesiątki takich na grę, wszystkie wrażliwe na opóźnienia, wszystkie muszące dotrzeć do obu graczy niemal natychmiast — właśnie tym jest gra wieloosobowa w czasie rzeczywistym, gdy rzucasz kośćmi przeciwko żywemu przeciwnikowi.

To zupełnie inny kształt pracy niż, powiedzmy, ładowanie listy znajomych czy przetwarzanie zakupu. Tamte zdarzają się od czasu do czasu i mogą tolerować kilkaset milisekund. Ruchy na żywo nie mogą — sekundowe opóźnienie ruchu to różnica między grą, która wydaje się responsywna, a taką, która wydaje się ospała.

Gdy jeden system obsługuje jedno i drugie, sporadyczna, ale ciężka praca może wchodzić w drogę ciągłej, ale lekkiej pracy. Dokładnie to zaczęliśmy obserwować.


Historia techniczna

Od tego miejsca robi się inżyniersko. Jeśli to nie twoja bajka, wniosek powyżej jest całą istotą sprawy: gra jest szybsza, bo jej najbardziej wrażliwa na czas praca działa teraz osobno.

Punkt wyjścia

Oryginalny backend to aplikacja Serverpod napisana w Dart i warto powiedzieć wprost: Serverpod to świetny framework. Dał nam wiele za darmo — generowane SDK klienta, typowany protokół współdzielony między aplikacją Flutter a serwerem, uwierzytelnianie, dostęp do bazy danych, strumienie WebSocket — i pozwolił małemu zespołowi poruszać się niezwykle szybko. Do dziś obsługuje większość produktu: uwierzytelnianie, użytkowników, znajomych, turnieje, zakupy w aplikacji, czat i narzędzia administracyjne, i nie planujemy tego zmieniać.

Rozgrywka w czasie rzeczywistym działała wewnątrz tego samego procesu. Serwer był autorytatywny: rzucał kośćmi, weryfikował każdy ruch względem zasad backgammona, przechowywał kanoniczny stan planszy i wysyłał aktualizacje do obu klientów przez strumienie WebSocket. Poprawnie i czysto — ale dzielił środowisko uruchomieniowe z każdym innym punktem końcowym w produkcie.

Znajdowanie ściany

Zanim cokolwiek zmieniliśmy, chcieliśmy liczb, a nie przeczuć. Zbudowaliśmy więc zestaw testów obciążeniowych, który tworzy syntetycznych graczy uwierzytelniających się, wchodzących do kojarzenia przeciwników, dobieranych do prawdziwych gier i grających losowe zgodne z zasadami ruchy od początku do końca — przechodząc przez dokładnie te same strumienie WebSocket, których używa prawdziwy klient Flutter. Bez skrótów, bez zaślepionych punktów końcowych.

Kilka celowych decyzji sprawiło, że wyniki były wiarygodne:

  • Osobny sprzęt w różnych regionach. Generator obciążenia działał w jednym centrum danych (Norymberga) i uderzał w serwer w innym (Helsinki), oddalone o mniej więcej 30 ms. To bliższe prawdziwemu użytkownikowi niż benchmark na tej samej maszynie i gwarantowało, że generator obciążenia nie mógł podkradać CPU serwerowi, który mierzyliśmy.
  • Automatyczne narastanie współbieżności. Zestaw testów podwajał liczbę jednoczesnych graczy według stałego harmonogramu i co pięć sekund zapisywał migawkę: opóźnienie uwierzytelniania, opóźnienie kojarzenia przeciwników oraz opóźnienie potwierdzenia ruchu w p50/p95/p99, a także liczbę błędów i przekroczeń limitu czasu gry.
  • Uczciwe progi awarii. “Zepsute” zdefiniowaliśmy z góry — p95 potwierdzenia ruchu powyżej 1,5 s lub wskaźnik błędów ruchu powyżej 2% — więc test obiektywnie orzekał porażkę, zamiast żebyśmy patrzyli na wykres na oko.

Kształt wyników był jasny. Przy niskiej współbieżności serwer był całkowicie zdrowy: przy kilkudziesięciu graczach potwierdzenia ruchów wracały w około 100–180 ms. Ale w miarę narastania obciążenia — 80, potem 160, potem 320 jednoczesnych graczy — ścieżka czasu rzeczywistego gwałtownie się pogarszała. P95 potwierdzenia ruchu wspiął się do zakresu wielosekundowego, ogon p99 rozciągnął się znacznie gorzej, kojarzenie przeciwników zwolniło, a gry zaczęły umierać na czterominutowym limicie czasu walkowera: najpierw garstka, potem dziesiątki. Gdzieś w okolicach 320 graczy progi awarii zostały przekroczone.

Ważny wniosek nie brzmiał “Dart jest wolny” ani “Serverpod sobie nie radzi” — żadne z tych nie jest prawdą, a oba do dziś spokojnie obsługują resztę Gammon42. Był prostszy: uniwersalny model obsługi żądań, dźwigający jednocześnie każdy ciężki punkt końcowy w produkcie, nie jest idealnym kształtem dla lawiny maleńkich, krytycznych pod względem opóźnień komunikatów gry. Dotyczy to niemal każdego frameworka typu wszystko-w-jednym — gorąca ścieżka wyrobiła sobie własny zestaw wymagań i potrzebowała zarówno modelu współbieżności dostrojonego dokładnie do tego, jak i własnego środowiska uruchomieniowego, żeby przestała konkurować ze wszystkim innym.

Decyzja: sidecar, a nie przepisywanie od nowa

Kusząca opcja — przepisanie całego backendu — była też tą błędną. Większość powierzchni Serverpod (konta, zakupy w aplikacji, turnieje, czat) działała dobrze i nie miała nic wspólnego z wąskim gardłem. Przepisanie jej byłoby ogromnym ryzykiem przy zerowej korzyści.

Zamiast tego zastosowaliśmy wzorzec sidecar. Zbudowaliśmy mały, osobny serwis, który posiada tylko gorącą ścieżkę gry w czasie rzeczywistym, i zostawiliśmy Serverpodowi całą resztę. Oba działają obok siebie na tym samym VPS i dzielą te same Postgres i Redis, więc nie ma między nimi problemu synchronizacji danych — jedynie czysty podział odpowiedzialności. Promień rażenia był mały, wdrożenie mogło być stopniowe, a gdyby coś poszło źle, mogliśmy się wycofać.

Do tego serwisu wybraliśmy Go, właśnie ze względu na to, jak obsługuje współbieżność.

Architektura: jedna goroutine na pokój

Głównym założeniem projektu jest jedna goroutine na pokój gry.

W Go goroutine to niezwykle lekka jednostka współbieżnego wykonania — kosztuje kilka kilobajtów, a nie pełny wątek systemu operacyjnego — a środowisko uruchomieniowe Go multipleksuje wiele tysięcy z nich na dostępnych rdzeniach CPU. Ta właściwość sprawia, że taki projekt jest możliwy: każdy aktywny mecz dostaje własną goroutine, która na wyłączność posiada stan tego meczu.

Daje to dwie rzeczy, które mają ogromne znaczenie dla serwera gry:

  • Brak blokad na gorącej ścieżce. Ponieważ pojedyncza goroutine posiada stan każdego pokoju, każdy rzut kośćmi, ruch i aktualizacja planszy dla tego meczu są przetwarzane pojedynczo przez tę goroutine. Inne części systemu rozmawiają z pokojem, wysyłając mu polecenie, a nie sięgając do środka i dotykając jego danych. Na gorącej ścieżce nie ma współdzielonego, zmienialnego stanu, o który trzeba by rywalizować, co oznacza brak rywalizacji o blokady i brak wyścigów danych — cała klasa błędów współbieżności jest projektowo wyeliminowana, a nie wydebugowana.
  • Naturalna izolacja. Jeden ruchliwy lub źle zachowujący się pokój nie może uszkodzić ani zablokować innego. Dziesięć tysięcy cichych pokojów kosztuje niemal nic, gdy czekają.

Wokół tych goroutine pokojów znajduje się cienka warstwa wspierająca:

  • Ogólnoprocesowy hub pokojów — prosta mapa za blokadą zoptymalizowaną pod odczyt — która daje wyszukiwanie pokoju po ID w czasie O(1). Blokada jest trzymana tylko przez mikroskopijne wyszukiwanie; cała realna praca per-pokój dzieje się za własną goroutine każdego pokoju, więc hub nigdy nie staje się wąskim gardłem.
  • Nieblokująca warstwa WebSocket. Pętla odczytu połączenia robi jak najmniej: dekoduje kopertę przychodzącego komunikatu i przekazuje ją do właściwej goroutine pokoju lub lobby. Ciężka praca nigdy nie dzieje się na ścieżce odczytu gniazda, więc wolna operacja nie może zablokować połączenia.
  • Pula połączeń do Postgres (przez pgx) oraz zwarty, wersjonowany protokół komunikacji, którego typy komunikatów celowo odzwierciedlają enumy Serverpod. Ten ostatni szczegół pozwolił aplikacji Flutter rozmawiać z nowym serwisem, używając tego samego współdzielonego słownika, który już znała — dodaliśmy dedykowanego klienta gry po stronie aplikacji i mogliśmy migrować ruch produkcyjny na serwis Go gra po grze, zamiast w jednym ryzykownym przełączeniu.

Całość buduje się do maleńkiego kontenera: wieloetapowy build Docker produkujący obraz uruchomieniowy distroless. Mały obraz, szybki start i skromny ślad pamięciowy — co ma znaczenie, gdy dzieli maszynę z głównym backendem.

Dlaczego to jest szybsze pod obciążeniem — uczciwie

Jesteśmy tu celowo ostrożni: mamy rygorystyczną linię bazową dla starej ścieżki (powyższe testy obciążeniowe) i nie będziemy podawać precyzyjnych liczb “po”, których danych nie opublikowaliśmy. Ale powody, dla których nowy projekt lepiej znosi współbieżność, są strukturalne, a nie życzeniowe:

  • Współbieżność dopasowana do obciążenia. Gra na żywo naturalnie modeluje się jako niezależny, długożyjący, sterowany komunikatami aktor. Goroutine-na-pokój odwzorowuje to jeden do jednego. Tysiące jednoczesnych meczów staje się tysiącami tanich goroutine, które środowisko uruchomieniowe rozdziela na wszystkie rdzenie — zamiast tysięcy żądań ustawiających się w kolejce przez model zbudowany dla innego kształtu ruchu.
  • Brak rywalizacji na gorącej ścieżce. Serializacja stanu każdego pokoju przez jego własną goroutine usuwa blokowanie z tej części systemu, która działa najczęściej i ma największe znaczenie. Mniej narzutu koordynacji oznacza bardziej przewidywalne opóźnienia, zwłaszcza w ogonie p99, gdzie stara ścieżka bolała najbardziej.
  • Izolacja zasobów. Serwis czasu rzeczywistego i główny backend nie dzielą już środowiska uruchomieniowego. Nagły wzrost kojarzenia przeciwników w ruchliwy wieczór nie może zagłodzić kodu, który weryfikuje twój ruch, i odwrotnie. Każdy można skalować, profilować i analizować osobno.
  • Skompilowane, przewidywalne środowisko uruchomieniowe. Natywny plik binarny Go z niskoopóźnieniowym garbage collectorem równomiernie obsługuje serie maleńkich, częstych komunikatów — dokładnie profil ruchu backgammona na żywo.

Co zachowaliśmy i co poradzilibyśmy skopiować

Częścią, z której jesteśmy najbardziej zadowoleni, nie jest wybór języka — to powściągliwość. Nie przepisaliśmy działającego systemu. Zidentyfikowaliśmy jeden komponent, którego wymagania odbiegły od wszystkiego wokół, daliśmy temu komponentowi środowisko uruchomieniowe, którego naprawdę potrzebował, i zostawiliśmy resztę w spokoju. Współdzielona baza danych, współdzielona pamięć podręczna, kompatybilny protokół, stopniowa migracja, łatwe wycofanie.

Jeśli masz wynieść z tego jedną rzecz: zanim sięgniesz po przepisywanie od nowa, przeprowadź testy obciążeniowe na tyle uczciwie, by znaleźć prawdziwą ścianę, a potem przenieś tylko to, co faktycznie w nią uderza.


Chcesz sam poczuć różnicę? Możesz zagrać w backgammon online za darmo — bez konta — przeciwko prawdziwym przeciwnikom w szybkich, niezawodnych meczach wieloosobowych, prosto w przeglądarce na play.table-online.ro. Wolisz telefon? Pobierz Gammon42, naszą grę w kości backgammon online, z App Store lub Google Play i rzucaj kośćmi przeciwko graczom z całego świata.

Dobrych rzutów. 🎲

Gotowy do gry?

Graj teraz
Graj teraz