Zum Inhalt springen

Multiplayer-Architektur

Der Multiplayer-Modus von En Parlant~ nutzt einen WebSocket-Relay-Server, um zwei Spieler in Echtzeit zu verbinden. Es gibt kein Peer-to-Peer – die gesamte Kommunikation läuft über den Relay-Server. Das hält die Netzwerktechnik einfach und funktioniert zuverlässig hinter Firewalls und NATs.

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 ----------->|

Der Relay-Server ist eine schlanke Weiterleitungsschicht. Er hält Räume im Speicher, leitet Events zwischen den beiden Spielern in jedem Raum weiter und übernimmt die Bereinigung. Es gibt keine Spiellogik oder Zugvalidierung auf dem Server – die Clients sind autoritativ.

  • Frontend (Client): socket.io-client – der Standard-Socket.IO-JavaScript-Client
  • Backend (Relay): socketioxide – ein Rust-Socket.IO-Server basierend auf Axum
  • Protokoll: Socket.IO über WebSocket (mit automatischem Fallback auf HTTP Long-Polling bei Bedarf)

Socket.IO wurde gegenüber rohen WebSockets bevorzugt, da es automatische Wiederverbindung, Raum-/Namespace-Verwaltung und strukturierte Event-Verarbeitung von Haus aus bietet.

Der Host klickt auf Multiplayer und gibt seinen Anzeigenamen ein. Der Client sendet ein create_game-Event mit dem Namen. Der Server:

  1. Generiert einen eindeutigen 6-Zeichen-Raumcode
  2. Erstellt einen Raum und fügt den Host als ersten Spieler hinzu
  3. Antwortet mit game_created(code), damit der Host den Code teilen kann

Der Beitretende gibt den Raumcode und seinen Anzeigenamen ein. Der Client sendet join_game(code, name). Der Server:

  1. Sucht den Raum anhand des Codes
  2. Fügt den Beitretenden als zweiten Spieler hinzu
  3. Sendet game_joined(name) an beide Spieler – jeder erhält den Anzeigenamen des anderen

Sobald beide Spieler im Raum sind:

  • Der Host spielt immer Weiß, der Beitretende immer Schwarz. Dies wird durch die Beitrittsreihenfolge bestimmt, nicht durch Verhandlung.
  • Züge werden im UCI-Format gesendet (z. B. e2e4) zusammen mit den Uhrzeiten beider Spieler.
  • Der Relay-Server leitet jedes game_move-Event an den Gegner weiter. Beide Spieler erhalten auch ihre eigenen Züge als Bestätigung zurück.

Mehrere Events behandeln Spielende-Szenarien:

  • Aufgabe – wird an den Gegner weitergeleitet, um den Aufgabedialog auszulösen
  • Remisangebot – wird an den Gegner weitergeleitet, der es annehmen oder ignorieren kann
  • Remisannahme – wird an beide Spieler weitergeleitet, um das Spiel als Remis zu beenden

Nach Spielende kann jeder Spieler ein ready-Event senden, um eine Revanche zu signalisieren. Wenn beide Spieler ready gesendet haben, setzen die Clients das Brett zurück und tauschen die Farben (oder behalten sie bei – wird clientseitig bestimmt).

Raumcodes bestehen aus 6 Zeichen und werden für die Lesbarkeit beim mündlichen Weitergeben im Format XX-XX-XX dargestellt. Der Zeichensatz schließt visuell mehrdeutige Zeichen aus:

  • Kein 0 oder O (Null vs. Buchstabe O)
  • Kein 1, I oder L (Eins vs. großes I vs. großes L)

Dies vermeidet das „Ist das eine Null oder ein O?”-Problem, wenn jemand einen Code per Sprachchat vorliest oder ihn aus der Nachricht eines Freundes abtippt.

Multiplayer-Verbindungen müssen erkennen, wenn ein Spieler die Verbindung verliert – sei es durch einen Netzwerkabbruch, das Schließen der App oder einen Laptop im Ruhemodus. En Parlant~ verwendet dafür ein Heartbeat-System:

  1. Jeder Client sendet alle 5 Sekunden ein heartbeat-Event an den Server
  2. Der Server bestätigt den Heartbeat und leitet ihn als peer_heartbeat an den Gegner weiter
  3. Der Client verfolgt, wann er den letzten peer_heartbeat vom Gegner erhalten hat
  4. Die Funktion isPeerAlive(timeoutMs) prüft, ob der letzte Heartbeat des Gegners innerhalb des akzeptablen Schwellenwerts liegt

Dies steuert die Verbindungsstatusanzeige in der Benutzeroberfläche. Wenn Heartbeats ausbleiben, sieht der Spieler, dass sein Gegner möglicherweise die Verbindung verloren hat, und kann entscheiden, ob er warten oder das Spiel verlassen möchte.

Der Relay-Server entfernt inaktive Räume automatisch, um Speicherlecks zu verhindern:

  • Ein Raum gilt nach 30 Minuten ohne Aktivität als inaktiv
  • Eine Bereinigungsaufgabe läuft alle 60 Sekunden und entfernt Räume, die den Inaktivitätsschwellenwert überschritten haben
  • Bei der Bereinigung eines Raumes werden alle verbleibenden Verbindungen getrennt

Es gibt keine persistente Speicherung. Wenn der Server neu startet, sind alle Räume verloren. Das ist beabsichtigt – der Relay-Server ist zustandslos und kurzlebig. Laufende Spiele müssten neu gestartet werden, was in der Praxis jedoch selten vorkommt.

Der Standard-Relay-Server läuft auf Fly.io und bietet WebSocket-Verbindungen mit niedriger Latenz und automatischem TLS. Siehe die Anleitung zur Einrichtung des Multiplayer-Servers für Anweisungen zum Betrieb eines eigenen Relay-Servers.

So testen Sie Multiplayer während der Entwicklung:

  1. Klonen und starten Sie den Relay-Server:

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

    Der Server startet auf Port 3210.

  2. Ändern Sie in En Parlant~ die Relay-Server-URL auf ws://localhost:3210.

  3. Öffnen Sie zwei Instanzen der App (oder eine App und eine im Entwicklungsmodus), um beide Spieler zu simulieren.

Züge, Heartbeats und alle Events funktionieren identisch zum Produktions-Relay – der einzige Unterschied ist die Verbindungs-URL.