Ir al contenido

Arquitectura Multijugador

El modo multijugador de En Parlant~ utiliza un servidor de retransmisión WebSocket para conectar a dos jugadores en tiempo real. No hay comunicación peer-to-peer: toda la comunicación pasa a través del servidor de retransmisión. Esto mantiene la red simple y funciona de manera fiable a través de firewalls y 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 ----------->|

El servidor de retransmisión es una capa de reenvío ligera. Mantiene las salas en memoria, enruta los eventos entre los dos jugadores de cada sala y gestiona la limpieza. No hay lógica de juego ni validación de movimientos en el servidor: los clientes son los que tienen la autoridad.

  • Frontend (cliente): socket.io-client — el cliente JavaScript estándar de Socket.IO
  • Backend (retransmisión): socketioxide — un servidor Socket.IO en Rust construido sobre Axum
  • Protocolo: Socket.IO sobre WebSocket (con respaldo automático a HTTP long-polling si es necesario)

Se eligió Socket.IO en lugar de WebSocket sin procesar porque proporciona reconexión automática, gestión de salas/namespaces y manejo estructurado de eventos de forma nativa.

El anfitrión hace clic en Multiplayer e introduce su nombre para mostrar. El cliente emite un evento create_game con el nombre. El servidor:

  1. Genera un código de sala único de 6 caracteres
  2. Crea una sala y añade al anfitrión como primer jugador
  3. Responde con game_created(code) para que el anfitrión pueda compartir el código

El jugador invitado introduce el código de sala y su nombre para mostrar. El cliente emite join_game(code, name). El servidor:

  1. Busca la sala por código
  2. Añade al invitado como segundo jugador
  3. Envía game_joined(name) a ambos jugadores: cada uno recibe el nombre para mostrar del otro

Una vez que ambos jugadores están en la sala:

  • El anfitrión siempre juega con blancas, el invitado siempre con negras. Esto se determina por el orden de conexión, no por negociación.
  • Los movimientos se envían en formato UCI (por ejemplo, e2e4) junto con los tiempos del reloj de ambos jugadores.
  • El servidor de retransmisión reenvía cada evento game_move al oponente. Ambos jugadores también reciben de vuelta sus propios movimientos como confirmación.

Varios eventos gestionan los escenarios de fin de partida:

  • Rendición — se reenvía al oponente para activar el diálogo de rendición
  • Oferta de tablas — se reenvía al oponente, quien puede aceptar o ignorar
  • Aceptación de tablas — se reenvía a ambos jugadores para finalizar la partida en tablas

Después de que termina una partida, cualquiera de los jugadores puede enviar un evento ready para indicar que quiere la revancha. Cuando ambos jugadores han enviado ready, los clientes reinician el tablero e intercambian colores (o los mantienen, según se determine en el cliente).

Los códigos de sala tienen 6 caracteres, formateados como XX-XX-XX para facilitar la lectura al compartirlos verbalmente. El conjunto de caracteres excluye caracteres visualmente ambiguos:

  • No se usa 0 ni O (cero frente a la letra O)
  • No se usa 1, I ni L (uno frente a I mayúscula frente a L mayúscula)

Esto evita el problema de “¿eso es un cero o una O?” cuando alguien lee un código por chat de voz o lo escribe a partir del mensaje de un amigo.

Las conexiones multijugador necesitan detectar cuándo un jugador se desconecta, ya sea por una caída de red, al cerrar la aplicación o porque su portátil entra en suspensión. En Parlant~ utiliza un sistema de heartbeat para esto:

  1. Cada cliente envía un evento heartbeat al servidor cada 5 segundos
  2. El servidor confirma el heartbeat y lo reenvía al oponente como peer_heartbeat
  3. El cliente registra cuándo recibió el último peer_heartbeat del oponente
  4. La función isPeerAlive(timeoutMs) comprueba si el último heartbeat del oponente está dentro del umbral aceptable

Esto controla el indicador de estado de conexión en la interfaz. Si los heartbeats dejan de llegar, el jugador ve que su oponente puede haberse desconectado y puede elegir esperar o abandonar la partida.

El servidor de retransmisión elimina automáticamente las salas inactivas para prevenir fugas de memoria:

  • Una sala se considera inactiva después de 30 minutos sin actividad
  • Una tarea de limpieza se ejecuta cada 60 segundos, eliminando cualquier sala que haya superado el umbral de inactividad
  • Cuando se limpia una sala, cualquier conexión restante se cierra

No hay almacenamiento persistente. Si el servidor se reinicia, todas las salas desaparecen. Esto es intencional: el servidor de retransmisión es sin estado y efímero. Las partidas en curso necesitarían reiniciarse, pero esto es poco frecuente en la práctica.

El servidor de retransmisión predeterminado se ejecuta en Fly.io, proporcionando conexiones WebSocket de baja latencia con TLS automático. Consulte la guía de configuración del servidor multijugador para obtener instrucciones sobre cómo ejecutar su propio servidor de retransmisión.

Para probar el modo multijugador durante el desarrollo:

  1. Clone y ejecute el servidor de retransmisión:

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

    El servidor se inicia en el puerto 3210.

  2. En En Parlant~, cambie la URL del servidor de retransmisión a ws://localhost:3210.

  3. Abra dos instancias de la aplicación (o una aplicación y otra en modo de desarrollo) para simular a ambos jugadores.

Los movimientos, heartbeats y todos los eventos funcionan de forma idéntica al servidor de retransmisión en producción: la única diferencia es la URL de conexión.