Przejdź do głównej zawartości

Architektura trybu wieloosobowego

Tryb wieloosobowy En Parlant~ wykorzystuje serwer przekaźnikowy WebSocket do łączenia dwóch graczy w czasie rzeczywistym. Nie ma tu połączeń peer-to-peer — cała komunikacja przechodzi przez serwer przekaźnikowy. Dzięki temu warstwa sieciowa jest prosta i działa niezawodnie za firewallami i NAT-ami.

Player A (host) Relay Server Player B (joiner)
| | |
|--- create_game(name) ----------->| |
|<-- game_created(code) -----------| |
| |<--- join_game(code, name) -----|
|<-- game_joined(name) ------------|--- game_joined(name) -------->|
| | |
|--- game_move(uci, times) ------->|--- game_move(uci, times) --->|
|<-- game_move(uci, times) --------|<-- game_move(uci, times) ----|
| | |
|--- heartbeat ------------------>|--- peer_heartbeat ----------->|

Serwer przekaźnikowy to cienka warstwa przekierowująca. Przechowuje pokoje w pamięci, kieruje zdarzenia między dwoma graczami w każdym pokoju i obsługuje czyszczenie zasobów. Na serwerze nie ma żadnej logiki gry ani walidacji ruchów — autorytatywni są klienci.

  • Frontend (klient): socket.io-client — standardowy klient JavaScript Socket.IO
  • Backend (serwer przekaźnikowy): socketioxide — serwer Socket.IO napisany w Rust, oparty na Axum
  • Protokół: Socket.IO przez WebSocket (z automatycznym przejściem na HTTP long-polling w razie potrzeby)

Socket.IO został wybrany zamiast surowego WebSocket, ponieważ zapewnia automatyczne ponowne łączenie, zarządzanie pokojami/przestrzeniami nazw oraz ustrukturyzowaną obsługę zdarzeń od razu po wdrożeniu.

Gospodarz klika Multiplayer i wpisuje swoją nazwę wyświetlaną. Klient emituje zdarzenie create_game z nazwą. Serwer:

  1. Generuje unikalny 6-znakowy kod pokoju
  2. Tworzy pokój i dodaje gospodarza jako pierwszego gracza
  3. Odpowiada zdarzeniem game_created(code), aby gospodarz mógł udostępnić kod

Dołączający gracz wpisuje kod pokoju i swoją nazwę wyświetlaną. Klient emituje zdarzenie join_game(code, name). Serwer:

  1. Wyszukuje pokój po kodzie
  2. Dodaje dołączającego jako drugiego gracza
  3. Wysyła game_joined(name) do obu graczy — każdy otrzymuje nazwę wyświetlaną przeciwnika

Gdy obaj gracze są w pokoju:

  • Gospodarz zawsze gra białymi, dołączający zawsze gra czarnymi. Decyduje o tym kolejność dołączenia, a nie negocjacja.
  • Ruchy są wysyłane w formacie UCI (np. e2e4) wraz z czasami zegarów obu graczy.
  • Serwer przekaźnikowy przekazuje każde zdarzenie game_move do przeciwnika. Obaj gracze również otrzymują z powrotem swoje własne ruchy jako potwierdzenie.

Kilka zdarzeń obsługuje scenariusze zakończenia gry:

  • Poddanie — przekazywane do przeciwnika w celu wyświetlenia okna dialogowego poddania
  • Propozycja remisu — przekazywana do przeciwnika, który może ją zaakceptować lub zignorować
  • Akceptacja remisu — przekazywana do obu graczy w celu zakończenia gry remisem

Po zakończeniu gry każdy z graczy może wysłać zdarzenie ready, sygnalizując chęć rewanżu. Gdy obaj gracze wyślą ready, klienci resetują szachownicę i zamieniają kolory (lub je zachowują — o tym decyduje strona klienta).

Kody pokojów składają się z 6 znaków, sformatowanych jako XX-XX-XX dla czytelności przy ustnym przekazywaniu. Zestaw znaków wyklucza wizualnie niejednoznaczne znaki:

  • Brak 0 ani O (zero a litera O)
  • Brak 1, I ani L (jedynka a wielkie I a wielkie L)

Pozwala to uniknąć problemu „czy to zero, czy O?” gdy ktoś odczytuje kod przez czat głosowy lub przepisuje go z wiadomości od znajomego.

Połączenia wieloosobowe muszą wykrywać, kiedy gracz się rozłączy — czy to z powodu utraty połączenia sieciowego, zamknięcia aplikacji, czy uśpienia laptopa. En Parlant~ wykorzystuje do tego system heartbeat:

  1. Każdy klient wysyła zdarzenie heartbeat do serwera co 5 sekund
  2. Serwer potwierdza heartbeat i przekazuje go do przeciwnika jako peer_heartbeat
  3. Klient śledzi, kiedy ostatnio otrzymał peer_heartbeat od przeciwnika
  4. Funkcja isPeerAlive(timeoutMs) sprawdza, czy ostatni heartbeat przeciwnika mieści się w dopuszczalnym progu czasowym

To napędza wskaźnik stanu połączenia w interfejsie użytkownika. Jeśli heartbeaty przestaną przychodzić, gracz widzi, że jego przeciwnik mógł się rozłączyć, i może zdecydować, czy czekać, czy opuścić grę.

Serwer przekaźnikowy automatycznie usuwa nieaktywne pokoje, aby zapobiec wyciekom pamięci:

  • Pokój jest uznawany za nieaktywny po 30 minutach bez aktywności
  • Zadanie czyszczenia uruchamia się co 60 sekund, usuwając wszelkie pokoje, które przekroczyły próg nieaktywności
  • Gdy pokój jest usuwany, wszystkie pozostałe połączenia są zamykane

Nie ma trwałego przechowywania danych. Jeśli serwer zostanie zrestartowany, wszystkie pokoje znikają. Jest to celowe — serwer przekaźnikowy jest bezstanowy i efemeryczny. Gry w toku musiałyby zostać rozpoczęte od nowa, ale w praktyce zdarza się to rzadko.

Domyślny serwer przekaźnikowy działa na Fly.io, zapewniając połączenia WebSocket o niskim opóźnieniu z automatycznym TLS. Zapoznaj się z przewodnikiem konfiguracji serwera wieloosobowego, aby dowiedzieć się, jak uruchomić własny serwer przekaźnikowy.

Aby przetestować tryb wieloosobowy podczas pracy deweloperskiej:

  1. Sklonuj i uruchom serwer przekaźnikowy:

    Okno terminala
    git clone https://github.com/DarrellThomas/en-parlant-relay.git
    cd en-parlant-relay
    cargo run

    Serwer uruchamia się na porcie 3210.

  2. W En Parlant~ zmień adres URL serwera przekaźnikowego na ws://localhost:3210.

  3. Otwórz dwie instancje aplikacji (lub jedną aplikację i jedną w trybie deweloperskim), aby zasymulować obu graczy.

Ruchy, heartbeaty i wszystkie zdarzenia działają identycznie jak na produkcyjnym serwerze przekaźnikowym — jedyną różnicą jest adres URL połączenia.