Aller au contenu

Architecture multijoueur

Le mode multijoueur d’En Parlant~ utilise un serveur relais WebSocket pour connecter deux joueurs en temps réel. Il n’y a pas de pair-à-pair — toute la communication passe par le relais. Cela simplifie la couche réseau et fonctionne de manière fiable à travers les pare-feu et les NAT.

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

Le relais est une fine couche de transfert. Il conserve les salles en mémoire, achemine les événements entre les deux joueurs de chaque salle et gère le nettoyage. Il n’y a aucune logique de jeu ni validation de coups côté serveur — les clients font autorité.

  • Frontend (client) : socket.io-client — le client JavaScript standard de Socket.IO
  • Backend (relais) : socketioxide — un serveur Socket.IO en Rust construit sur Axum
  • Protocole : Socket.IO sur WebSocket (avec repli automatique vers le HTTP long-polling si nécessaire)

Socket.IO a été choisi plutôt que le WebSocket brut car il fournit la reconnexion automatique, la gestion des salles/espaces de noms et le traitement structuré des événements de manière native.

L’hôte clique sur Multijoueur et saisit son nom d’affichage. Le client émet un événement create_game avec le nom. Le serveur :

  1. Génère un code de salle unique de 6 caractères
  2. Crée une salle et ajoute l’hôte comme premier joueur
  3. Répond avec game_created(code) afin que l’hôte puisse partager le code

Le joueur entrant saisit le code de salle et son nom d’affichage. Le client émet join_game(code, name). Le serveur :

  1. Recherche la salle à partir du code
  2. Ajoute le joueur entrant comme second joueur
  3. Envoie game_joined(name) aux deux joueurs — chacun reçoit le nom d’affichage de l’autre

Une fois les deux joueurs dans la salle :

  • L’hôte joue toujours les Blancs, le joueur entrant joue toujours les Noirs. Cela est déterminé par l’ordre d’arrivée, et non par négociation.
  • Les coups sont envoyés au format UCI (par exemple, e2e4) accompagnés des temps de pendule des deux joueurs.
  • Le relais transfère chaque événement game_move à l’adversaire. Les deux joueurs reçoivent également leurs propres coups en retour comme confirmation.

Plusieurs événements gèrent les scénarios de fin de partie :

  • Abandon — transmis à l’adversaire pour déclencher la boîte de dialogue d’abandon
  • Proposition de nulle — transmise à l’adversaire, qui peut accepter ou ignorer
  • Acceptation de nulle — transmise aux deux joueurs pour terminer la partie par une nulle

Après la fin d’une partie, l’un ou l’autre joueur peut envoyer un événement ready pour signaler qu’il souhaite une revanche. Lorsque les deux joueurs ont envoyé ready, les clients réinitialisent l’échiquier et échangent les couleurs (ou les conservent — déterminé côté client).

Les codes de salle comportent 6 caractères, formatés en XX-XX-XX pour faciliter la lecture lors d’un partage oral. Le jeu de caractères exclut les caractères visuellement ambigus :

  • Pas de 0 ni de O (zéro vs. lettre O)
  • Pas de 1, I ni L (un vs. I majuscule vs. L majuscule)

Cela évite le classique « c’est un zéro ou un O ? » lorsque quelqu’un dicte un code en conversation vocale ou le tape à partir du message d’un ami.

Les connexions multijoueur doivent détecter quand un joueur se déconnecte — que ce soit à cause d’une coupure réseau, de la fermeture de l’application ou de la mise en veille de son ordinateur. En Parlant~ utilise un système de heartbeat pour cela :

  1. Chaque client envoie un événement heartbeat au serveur toutes les 5 secondes
  2. Le serveur accuse réception du heartbeat et le transfère à l’adversaire sous forme de peer_heartbeat
  3. Le client enregistre la date de réception du dernier peer_heartbeat de l’adversaire
  4. La fonction isPeerAlive(timeoutMs) vérifie si le dernier heartbeat de l’adversaire est dans le seuil acceptable

Cela alimente l’indicateur d’état de connexion dans l’interface. Si les heartbeats cessent d’arriver, le joueur voit que son adversaire s’est peut-être déconnecté et peut choisir d’attendre ou de quitter la partie.

Le serveur relais supprime automatiquement les salles inactives pour éviter les fuites de mémoire :

  • Une salle est considérée inactive après 30 minutes sans activité
  • Une tâche de nettoyage s’exécute toutes les 60 secondes, balayant les salles ayant dépassé le seuil d’inactivité
  • Lorsqu’une salle est nettoyée, toutes les connexions restantes sont fermées

Il n’y a pas de stockage persistant. Si le serveur redémarre, toutes les salles disparaissent. C’est intentionnel — le relais est sans état et éphémère. Les parties en cours devraient être relancées, mais cela est rare en pratique.

Le relais par défaut fonctionne sur Fly.io, offrant des connexions WebSocket à faible latence avec TLS automatique. Consultez le guide de configuration du serveur multijoueur pour les instructions sur l’hébergement de votre propre relais.

Pour tester le mode multijoueur pendant le développement :

  1. Clonez et lancez le relais :

    Fenêtre de terminal
    git clone https://github.com/DarrellThomas/en-parlant-relay.git
    cd en-parlant-relay
    cargo run

    Le serveur démarre sur le port 3210.

  2. Dans En Parlant~, changez l’URL du serveur relais en ws://localhost:3210.

  3. Ouvrez deux instances de l’application (ou une instance et une en mode développement) pour simuler les deux joueurs.

Les coups, les heartbeats et tous les événements fonctionnent de manière identique au relais de production — la seule différence est l’URL de connexion.