Powrót do Bloga

Co się dzieje, gdy 700 tys. graczy jednocześnie wchodzi do Twojej indie gry multiplayer (i jak to przetrwać)

Opublikowano 26 lipca 2026
Co się dzieje, gdy 700 tys. graczy jednocześnie wchodzi do Twojej indie gry multiplayer (i jak to przetrwać)

W skrócie

Poznaj sprawdzone wzorce backendowe, które pomogą przetrwać wiralowy wzrost graczy w Twojej indie grze multiplayer.

Każdy indie deweloper marzy o nocnym wiralowym hicie. Twoja gra eksploduje na Twitchu, liczba jednoczesnych graczy na Steam skacze z 200 do 200 000 w tydzień i nagle jesteś tematem rozmów w branży. Czego nikt nie mówi w tej fantazji, to jak wygląda twój backend o 3 nad ranem, gdy twój serwis matchmakingowy płonie, twoja baza danych lobby rzuca błędami write contention, a na Discordzie pełno graczy, którzy nie mogą połączyć się z żadną grą.

To nie jest hipotetyczne. Gdy Goose Goose Duck stało się wiralowe pod koniec 2022 roku, Gaggle Studios – samozwańczy mały zespół bez wcześniejszego doświadczenia z mega-hitami – obserwowało, jak liczba jednoczesnych graczy przekracza 700 000. Ich backend wytrzymał. Nie dlatego, że mieli nieskończone zasoby, ale dlatego, że podjęli konkretne decyzje architektoniczne na wczesnym etapie, które pozwoliły im przetrwać skok.

Ten post szczegółowo opisuje, jakie to były decyzje, co psuje się jako pierwsze, gdy gra multiplayer staje się wiralowa, oraz konkretne wzorce, które możesz zastosować we własnym projekcie, zanim nadejdzie ruch.

Anatomia wiralowego skoku w grze multiplayer

Co właściwie się psuje (w kolejności)

Gdy gra multiplayer osiąga 10x–100x swojego oczekiwanego obciążenia, awarie kaskadowo układają się w przewidywalnej sekwencji. Zrozumienie tej kolejności jest kluczowe, ponieważ musisz utwardzić swój system we właściwej kolejności.

1. Uwierzytelnianie i logowanie (5–15x normalne obciążenie w pierwszych 48 godzinach)

Każdy gracz, który chce grać, musi się najpierw uwierzytelnić. Backend Steama wykonuje ciężką robotę dla gier uwierzytelnionych przez Steam, ale twój serwer nadal musi walidować tickety, tworzyć lub pobierać profile graczy i zwracać tokeny sesyjne. Jeśli każde żądanie uwierzytelnienia dotyka twojej podstawowej bazy danych, masz problem. Nagły wzrost do 50 000 żądań logowania na minutę, każde wykonujące zapis PostgreSQL w celu utworzenia sesji, nasyci twoją pulę połączeń w mniej niż 90 sekund.

2. Odkrywanie lobby i matchmaking (10–50x normalne obciążenie)

To jest pierwsze domino, które faktycznie zabija wrażenia graczy. Gdy 200 000 graczy jednocześnie przegląda lobby, wzorzec zapytań do listy lobby zmienia się z „setek odczytów na sekundę” na „dziesiątki tysięcy odczytów na sekundę”. Jeśli stan lobby znajduje się w twojej podstawowej relacyjnej bazie danych, walczysz teraz z replikami do odczytu, które nie nadążają za opóźnieniem replikacji, zwracając nieaktualne dane lobby, które pokazują pokoje jako dostępne, gdy są już pełne.

3. Tworzenie lobby i operacje dołączania (skok obciążenia zapisem)

Każde nowe lobby gry to zapis. Każdy gracz dołączający do lobby to zapis (aktualizacja listy graczy). Każdy gracz opuszczający to zapis. Przy szczytowym ruchu Goose Goose Duck oznaczało to tysiące mutacji stanu lobby na sekundę. Gaggle Studios użyło modelu peer-to-peer do rzeczywistej rozgrywki, ale koordynacja lobby nadal wymagała centralizacji – gracze muszą się znaleźć, zanim będą mogli połączyć się bezpośrednio.

4. NAT traversal i ustanawianie połączeń P2P

Tutaj architektura peer-to-peer napotyka swój sufit. Nawet z infrastrukturą STUN/TURN, połączenia P2P zawodzą. Średnia branżowa sukcesu połączeń P2P bez zapasowego przekaźnika wynosi około 75–85% par graczy. Pozostałe 15–25% potrzebuje serwerów przekaźnikowych TURN. Przy 700 000 jednoczesnych graczy próbujących tysiące połączeń na sekundę potrzebujesz infrastruktury przekaźnikowej, której większość indie zespołów nigdy nie budowała.

Dlaczego P2P było słuszną decyzją (dopóki nie przestało być)

Model kosztów P2P

Spójrzmy na matematykę. Architektura z dedykowanymi serwerami dla meczu 16 graczy trwającego 15 minut na skromnej instancji w chmurze (~0,04 USD/godz. za współdzielone vCPU) kosztuje około 0,01 USD za mecz. Pomnóż przez 500 000 jednoczesnych meczów w godzinach szczytu, a otrzymasz 5 000 USD/godz. tylko za moc obliczeniową. To 120 000 USD dziennie.

P2P przenosi ten koszt obliczeniowy na maszynę gracza-hosta. Koszt infrastruktury spada do warstwy koordynacji: serwery matchmakingu, stan lobby, uwierzytelnianie i przekaźnik STUN/TURN. Dla Goose Goose Duck oznaczało to, że rachunek za infrastrukturę pozostał zarządzalny, nawet gdy liczba graczy gwałtownie wzrosła.

Sufit niezawodności P2P

Jednak P2P wprowadza tryby awarii, których nie mają dedykowane serwery:

  • Migracja hosta: Gdy gracz-host się rozłącza, sesja musi przenieść autorytet na innego peera. W grze dedukcyjnej społecznościowej nieudana migracja hosta oznacza utratę stanu głosowania, desynchronizację przydziału ról i zepsuty mecz. Typowa sekwencja migracji hosta wygląda tak:
// Simplified peer-to-peer host migration logic
// When the current host becomes unreachable

void OnHostUnreachable(float timeoutSeconds = 3.0f) {
    // 1. All peers detect host disconnect via heartbeat timeout
    // 2. Each peer independently evaluates whether it should become the new host
    
    TArray<FPlayerInfo> remainingPeers = GetConnectedPeers();
    FPlayerInfo newHost = SelectNewHost(remainingPeers);  // Lowest latency, highest bandwidth
    
    if (newHost.PlayerId == GetLocalPlayerId()) {
        // This peer becomes the new host
        BecomeHost();
        
        // Reconstruct authoritative game state from local cache
        GameState = ReconstructFromLastKnownState();
        
        // Tell all other peers to connect to the new host
        BroadcastHostMigration(newHost.Address);
        
        // Resume gameplay - votes, timers, and role assignments must survive this transition
        ResumeSessionWithReconciledState();
    } else {
        // Wait for migration signal, then connect to new host
        ConnectToNewHost(newHost.Address, timeoutSeconds);
    }
}

Każdy z tych kroków to potencjalny punkt awarii. Jeśli dwóch peerów zdecyduje, że to oni powinni być hostem (scenariusz split-brain), otrzymujesz dwa rozbieżne stany gry, których nie da się uzgodnić.

  • Błędy NAT traversal: Gracze za symetrycznymi NAT lub carrier-grade NAT nie mogą nawiązać bezpośrednich połączeń. Twoja infrastruktura przekaźnikowa TURN musi obsłużyć tych graczy. Przy skali 15% z 700 000 jednoczesnych graczy to 105 000 graczy wymagających ruchu przekaźnikowego – a przepustowość przekaźnika jest droga, zwykle 0,05–0,10 USD za GB.

  • Podatność na oszustwa: Maszyna gracza-hosta jest autorytatywna. Wszelkie dane po stronie klienta mogą być manipulowane. Dla casualowej gry imprezowej jest to mniej katastrofalne niż dla konkurencyjnej strzelanki, ale wciąż pogarsza wrażenia. Projekty serwerowo-autorytatywne (takie jak te stosowane w propozycjach optymalizacji serwerów Fortnite) eliminują całe kategorie exploitów, ale wymagają dedykowanej mocy obliczeniowej.

Warstwa matchmakingu: Budowanie na 10x spodziewanego szczytu

Architektura zarządzania stanem lobby

System lobby Goose Goose Duck musiał obsługiwać te operacje na dużą skalę:

  • Przeglądanie lobby (intensywny odczyt): Gracze filtrują i wyświetlają dostępne lobby
  • Tworzenie lobby (zapis): Nowy rekord lobby z ustawieniami gry, regionem i pojemnością
  • Dołącz do lobby (zapis warunkowy): Operacja atomowa – sprawdź pojemność, dodaj gracza lub zakończ niepowodzeniem
  • Opuść lobby (zapis + możliwe usunięcie): Usuń gracza, usuń lobby, jeśli puste
  • Aktualizuj ustawienia lobby (zapis): Host modyfikuje parametry gry

Oto uproszczony menedżer lobby obsługujący atomową operację dołączania, która jest najbardziej podatna na awarie pod obciążeniem:

import asyncio
from dataclasses import dataclass, field
from typing import Optional
import uuid

@dataclass
class Lobby:
    lobby_id: str
    host_id: str
    max_players: int
    players: list = field(default_factory=list)
    region: str = "us-east"
    game_settings: dict = field(default_factory=dict)
    created_at: float = 0.0

class LobbyManager:
    def __init__(self, cache_client, db_client):
        self.cache = cache_client    # Redis or similar
        self.db = db_client           # PostgreSQL or similar
        self.MAX_LOBBIES_PER_REGION = 10000
        self.LOBBY_TTL_SECONDS = 3600  # Auto-cleanup stale lobbies

    async def join_lobby(self, lobby_id: str, player_id: str) -> dict:
        """
        Atomic join operation using Redis optimistic locking.
        Prevents the race condition where two players simultaneously
        join a lobby that has one slot remaining.
        """
        cache_key = f"lobby:{lobby_id}"
        
        # Use a Lua script for atomic check-and-modify in Redis
        # This is the critical path—under viral load, this single 
        # operation runs thousands of times per second
        lua_script = """
        local key = KEYS[1]
        local player_id = ARGV[1]
        local max_players = tonumber(ARGV[2])
        
        local lobby_data = redis.call('HGETALL', key)
        if #lobby_data == 0 then
            return {-1, "lobby_not_found"}
        end
        
        -- Parse the player count from the hash
        local current_players = tonumber(redis.call('HGET', key, 'player_count'))
        if current_players == nil then
            return {-1, "corrupted_state"}
        end
        
        if current_players >= max_players then
            return {0, "lobby_full"}
        end
        
        -- Atomic increment and add player
        redis.call('HINCRBY', key, 'player_count', 1)
        redis.call('SADD', key .. ':players', player_id)
        redis.call('EXPIRE', key, 3600)
        
        return {1, "joined"}
        """
        
        result = await self.cache.eval(
            lua_script,
            keys=[cache_key],
            args=[player_id, str(self.MAX_PLAYERS)]
        )
        
        status_code, message = result
        
        if status_code == -1:
            raise LobbyNotFoundException(message)
        elif status_code == 0:
            raise LobbyFullException(message)
        
        # Async write to persistent DB (non-blocking, eventual consistency is fine here)
        asyncio.create_task(self._persist_join(lobby_id, player_id))
        
        return {"status": "joined", "lobby_id": lobby_id}

    async def _persist_join(self, lobby_id: str, player_id: str):
        """Background persistence—lobby state in Redis is the source of truth for joins.
           DB only lags by milliseconds but is not on the critical path."""
        await self.db.execute(
            "UPDATE lobbies SET player_count = player_count + 1, "
            "updated_at = NOW() WHERE lobby_id = $1",
            lobby_id
        )
        await self.db.execute(
            "INSERT INTO lobby_players (lobby_id, player_id, joined_at) "
            "VALUES ($1, $2, NOW()) ON CONFLICT DO NOTHING",
            lobby_id, player_id
        )

Kluczowym szczegółem jest tutaj skrypt Lua w Redis. Naiwna implementacja, która wykonuje GET, sprawdza pojemność w kodzie aplikacji, a następnie wykonuje POST, tworzy okno wyścigu, w którym 15 graczy może jednocześnie dołączyć do lobby 16-osobowego, co daje 17 graczy i zepsutą logikę gry. Skrypt Lua wykonuje się atomowo wewnątrz Redis – brak wyścigu, brak utraconych dołączeń, nawet przy tysiącach operacji na sekundę.

Przekazanie połączenia: Lobby do rozgrywki

Gdy lobby jest pełne, gra musi przejść od scentralizowanej koordynacji lobby do rozgrywki peer-to-peer. To przekazanie jest miejscem, w którym większość indie gier multiplayer wprowadza skoki opóźnień lub całkowite awarie.

Wzorzec, który działa:

  1. Gracz-host otwiera gniazdo nasłuchowe WebSocket lub UDP
  2. Serwer (system lobby) rozsyła IP i port hosta do wszystkich peerów
  3. Peerowie próbują bezpośredniego połączenia P2P przez STUN
  4. Jeśli STUN nie powiedzie się w ciągu N sekund, następuje powrót do przekaźnika TURN
  5. Gdy wszyscy peerowie zgłoszą połączenie, host sygnalizuje rozpoczęcie gry

Do komunikacji w czasie rzeczywistym podczas tego przekazania, połączenia WebSocket są znacznie bardziej niezawodne niż HTTP polling, zwłaszcza gdy trzeba wysyłać aktualizacje statusu połączenia do 8–16 klientów jednocześnie.

Kształtowanie ruchu podczas wiralowego skoku

Wzorce łagodnej degradacji

Kolejkowanie połączeń: Zamiast odrzucać graczy, gdy serwery lobby są na pełnej wydajności, umieść ich w wirtualnej kolejce z licznikiem pozycji w czasie rzeczywistym. Gracze poczekają 2 minuty. Nie tolerują enigmatycznego komunikatu „błąd serwera”.

// C# connection queue with position feedback
public class ConnectionQueue
{
    private readonly ConcurrentQueue<string> _queue = new();
    private readonly SemaphoreSlim _admissionGate;
    private readonly int _maxConcurrentSessions;
    
    public ConnectionQueue(int maxConcurrentSessions)
    {
        _maxConcurrentSessions = maxConcurrentSessions;
        _admissionGate = new SemaphoreSlim(maxConcurrentSessions, maxConcurrentSessions);
    }
    
    public async Task<QueueResult> TryEnterQueue(string playerId)
    {
        int position = _queue.Count + 1;
        _queue.Enqueue(playerId);
        
        // Estimate wait time: assume ~30 second average session search time
        // at current throughput
        int estimatedWaitSeconds = (position / _maxConcurrentSessions) * 30;
        
        if (_admissionGate.CurrentCount > 0)
        {
            await _admissionGate.WaitAsync();
            _queue.TryDequeue(out _);
            return new QueueResult { Admitted = true, Position = 0 };
        }
        
        return new QueueResult 
        { 
            Admitted = false, 
            Position = position, 
            EstimatedWaitSeconds = estimatedWaitSeconds 
        };
    }
}

Regionalne zrzucanie obciążenia: Jeśli US-East jest przeciążone, ale EU-West ma moc, przekieruj nowych graczy z USA do EU z ostrzeżeniem o opóźnieniu, zamiast całkowicie odmawiać połączenia. Ping 120 ms w grze dedukcyjnej społecznościowej jest praktycznie niezauważalny – to nie są gry walki wymagające perfekcyjnych klatek.

Ograniczanie tempa tworzenia lobby: Przy szczytowym obciążeniu ogranicz tworzenie lobby do jednego na gracza na 30 sekund. Zapobiega to spamowaniu lobby przez boty (co było realnym problemem w Goose Goose Duck) i zmniejsza presję zapisu na bazę danych lobby.

Podział kosztów: Ile faktycznie kosztuje skala wiralowa

Przełóżmy to na realne liczby. Oto przybliżony model kosztów wiralowego wydarzenia na skalę Goose Goose Duck przy użyciu różnych architektur backendu:

P2P z scentralizowaną koordynacją lobby (to co zrobiło Goose Goose Duck):

Komponent Miesięczny koszt (przy 700K szczytowych CCU)
Serwery lobby/matchmaking (12 instancji c5.2xlarge, auto-skalowanie) $3,500–$5,000
Klaster Redis dla stanu lobby (3 węzły, r6g.xlarge) $1,800
Serwery przekaźnikowe TURN (dla 15% ruchu, ~100K graczy) $8,000–$15,000
PostgreSQL dla stanu trwałego (RDS Multi-AZ) $600
Przepustowość (koordynacja lobby, ~2 TB/dzień) $1,200
Suma $15,100–$23,600/miesiąc

W pełni dedykowane serwery (każdy mecz na maszynie w chmurze):

Komponent Miesięczny koszt (przy 700K szczytowych CCU)
Serwery gry (~50 000 jednoczesnych meczów × 0,04 USD/godz.) $1,440,000/miesiąc
Matchmaking i lobby $5,000
Infrastruktura bazy danych $2,000
Suma ~$1,447,000/miesiąc

Różnica kosztów wynosi dwa rzędy wielkości. Dla gry free-to-play zarabiającej na kosmetykach model dedykowanych serwerów to bezpośrednia droga do bankructwa, chyba że monetyzacja jest agresywna od pierwszego dnia. P2P to nie leniwa architektura – to świadoma decyzja finansowa.

Jednak oszczędności wiążą się z kompromisami. Wzrasta skala oszustw. Jakość połączeń różni się w zależności od hosta. A twoja infrastruktura koordynacyjna musi być niezniszczalna, ponieważ jest pojedynczym punktem awarii dla każdego meczu w twojej grze.

Jeśli oceniasz te kompromisy dla własnego projektu, horizOn zajmuje się warstwą koordynacji – zarządzaniem lobby, matchmakingiem, uwierzytelnianiem graczy i stanem sesji – dzięki czemu możesz skupić się na rozgrywce, a nie na infrastrukturze. Platforma została zbudowana specjalnie dla tego przypadku użycia: indie zespołów, które muszą skalować się bez poświęcania miesięcy na inżynierię backendu.

5 wzorców architektury backendu do przetrwania wiralowego wzrostu

1. Oddziel stan lobby od stanu gry

Twój system koordynacji lobby i rzeczywista sieć rozgrywki to różne systemy o różnych profilach skalowania. Stan lobby to dużo odczytów, umiarkowane zapisy, i korzysta z cache'owania (Redis). Stan gry to wysoka częstotliwość, niskie opóźnienie i należy do maszyny hosta lub dedykowanego serwera. Mieszanie ich w jednej bazie danych to pułapka śmierci skalowania.

2. Używaj operacji atomowych dla zapisów wrażliwych na pojemność

Operacja dołączania do lobby, którą pokazałem powyżej, jest atomowa dzięki Redis Lua. Nie polegaj na blokadach na poziomie aplikacji w przypadku czegokolwiek, co decyduje o przepełnieniu pokoju gry. Przy 2000 dołączeń na sekundę, nawet okno wyścigu 10 ms oznacza 20 przepełnionych lobby.

3. Zaimplementuj kolejkowanie połączeń, zanim będzie potrzebne

Kolejka z szacowanym czasem oczekiwania 60 sekund zatrzymuje 70–80% graczy. Ogólny błąd „połączenie nieudane” zatrzymuje około zera. Wbuduj system kolejki w swoją początkową architekturę. Możesz go wyłączyć przy niskim ruchu, ale nie jesteś w stanie zbudować go wystarczająco szybko, gdy ruch gwałtownie wzrośnie.

4. Monitoruj przejścia lobby-gra oddzielnie

Większość systemów monitorowania śledzi „łączną liczbę graczy online” i „wskaźnik błędów”. Potrzebujesz konkretnych metryk dla punktu przejścia: jaki procent pełnych lobby z powodzeniem przechodzi do rozgrywki? Jeśli ta liczba spadnie poniżej 95%, twoja infrastruktura STUN/TURN lub logika dziurkowania P2P zawodzi. Jest to metryka, która przewiduje odpływ graczy dokładniej niż jakakolwiek inna.

5. Zbuduj drabinę łagodnej degradacji

Zdefiniuj warunki degradacji z wyprzedzeniem:

  • Zielony (poniżej 80% wydajności): Pełna funkcjonalność, brak ograniczeń
  • Żółty (80–95% wydajności): Włącz ograniczenia tempa tworzenia lobby, preferuj dołączanie graczy do istniejących lobby
  • Pomarańczowy (95–100% wydajności): Aktywuj kolejkowanie połączeń, wyłącz filtry matchmakingu, akceptuj mecze międzyregionowe
  • Czerwony (powyżej wydajności): Pełne kolejkowanie, statyczna strona zapasowa dla nowych połączeń, priorytet dla istniejących sesji

Zapisz te progi w konfiguracji infrastruktury. Ustaw alerty na każdej granicy. Różnica między „wiralowym momentem” a „wiralową katastrofą” polega na tym, czy osiągniesz Pomarańczowy, zanim osiągniesz Czerwony.

Większa lekcja

Historia Goose Goose Duck pokazuje coś fundamentalnego o architekturze gier multiplayer: decyzja między P2P a dedykowanymi serwerami nie jest decyzją jakościową – to decyzja ekonomiczna i architektoniczna z kaskadowymi konsekwencjami. P2P zaoszczędziło Gaggle Studios potencjalnie miliony na kosztach serwerów, ale wymagało solidnej warstwy koordynacji, starannego zarządzania lobby i gotowości do zaakceptowania pewnych kompromisów jakościowych.

Dla indie deweloperów planujących swoją architekturę multiplayer wniosek jest jasny: projektuj na swój szczyt, a nie na średnią. Twój backend doświadczy 50–100x normalnego obciążenia w dniu, gdy twoja gra stanie się wiralowa. Jeśli nie testowałeś przy tej skali, nie jesteś gotowy.

Zacznij od warstwy lobby i matchmakingu. Dopracuj atomowe operacje lobby. Zbuduj kolejkowanie połączeń. Wdróż przełączanie regionalne. To są komponenty, które decydują, czy twój wiralowy moment będzie historią sukcesu, czy pośmiertną analizą.

Jeśli chcesz pominąć miesiące inżynierii backendu i dostarczyć grę z warstwą koordynacji już sprawdzoną w boju na dużą skalę, horizOn zapewnia zarządzanie lobby, matchmaking i stan sesji od razu po wyjęciu z pudełka – dzięki czemu możesz skupić się na tworzeniu zabawnej gry, zamiast zastanawiać się, czy twoje serwery wytrzymają. Sprawdź dokumentację API, aby zobaczyć, jak pasuje do twojej architektury.


Źródło: Staying Lean: Jak zbudowaliśmy największą na świecie grę dedukcyjną społecznościową