Pular para o conteúdo

Arquitetura Multijogador

O modo multijogador do En Parlant~ utiliza um servidor de retransmissão WebSocket para ligar dois jogadores em tempo real. Não existe ligação peer-to-peer — toda a comunicação passa pelo servidor de retransmissão. Isto mantém a rede simples e funciona de forma fiável através de firewalls e 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 ----------->|

O servidor de retransmissão é uma camada fina de encaminhamento. Mantém salas em memória, encaminha eventos entre os dois jogadores de cada sala e trata da limpeza. Não existe lógica de jogo nem validação de jogadas no servidor — os clientes são a autoridade.

  • Frontend (cliente): socket.io-client — o cliente JavaScript padrão do Socket.IO
  • Backend (retransmissão): socketioxide — um servidor Socket.IO em Rust construído sobre Axum
  • Protocolo: Socket.IO sobre WebSocket (com fallback automático para HTTP long-polling, se necessário)

O Socket.IO foi escolhido em vez de WebSocket puro porque oferece reconexão automática, gestão de salas/namespaces e tratamento estruturado de eventos de forma nativa.

O anfitrião clica em Multiplayer e introduz o seu nome de exibição. O cliente emite um evento create_game com o nome. O servidor:

  1. Gera um código de sala único com 6 caracteres
  2. Cria uma sala e adiciona o anfitrião como primeiro jogador
  3. Responde com game_created(code) para que o anfitrião possa partilhar o código

O segundo jogador introduz o código da sala e o seu nome de exibição. O cliente emite join_game(code, name). O servidor:

  1. Procura a sala pelo código
  2. Adiciona o segundo jogador como o segundo participante
  3. Envia game_joined(name) a ambos os jogadores — cada um recebe o nome de exibição do outro

Assim que ambos os jogadores estão na sala:

  • O anfitrião joga sempre com as Brancas, o segundo jogador joga sempre com as Pretas. Isto é determinado pela ordem de entrada, não por negociação.
  • As jogadas são enviadas em formato UCI (por exemplo, e2e4) juntamente com os tempos do relógio de ambos os jogadores.
  • O servidor de retransmissão encaminha cada evento game_move ao adversário. Ambos os jogadores também recebem de volta as suas próprias jogadas como confirmação.

Vários eventos tratam dos cenários de fim de jogo:

  • Desistência — encaminhada ao adversário para acionar o diálogo de desistência
  • Oferta de empate — encaminhada ao adversário, que pode aceitar ou ignorar
  • Aceitação de empate — encaminhada a ambos os jogadores para terminar o jogo como empate

Após o fim de um jogo, qualquer jogador pode enviar um evento ready para sinalizar que pretende uma revanche. Quando ambos os jogadores tiverem enviado ready, os clientes reiniciam o tabuleiro e trocam as cores (ou mantêm-nas — determinado do lado do cliente).

Os códigos de sala têm 6 caracteres, formatados como XX-XX-XX para facilitar a leitura ao partilhar verbalmente. O conjunto de caracteres exclui caracteres visualmente ambíguos:

  • Sem 0 ou O (zero vs. letra O)
  • Sem 1, I ou L (um vs. I maiúsculo vs. L maiúsculo)

Isto evita o problema “isto é um zero ou um O?” quando alguém lê um código por chat de voz ou o digita a partir da mensagem de um amigo.

As ligações multijogador precisam de detetar quando um jogador se desliga — seja por uma falha de rede, ao fechar a aplicação ou ao adormecer o portátil. O En Parlant~ utiliza um sistema de heartbeat para isso:

  1. Cada cliente envia um evento heartbeat ao servidor a cada 5 segundos
  2. O servidor confirma o heartbeat e encaminha-o ao adversário como peer_heartbeat
  3. O cliente regista quando recebeu o último peer_heartbeat do adversário
  4. A função isPeerAlive(timeoutMs) verifica se o último heartbeat do adversário está dentro do limiar aceitável

Isto alimenta o indicador de estado da ligação na interface. Se os heartbeats deixarem de chegar, o jogador vê que o adversário pode ter-se desligado e pode optar por aguardar ou abandonar o jogo.

O servidor de retransmissão remove automaticamente salas inativas para evitar fugas de memória:

  • Uma sala é considerada inativa após 30 minutos sem atividade
  • Uma tarefa de limpeza executa a cada 60 segundos, eliminando quaisquer salas que tenham excedido o limiar de inatividade
  • Quando uma sala é limpa, quaisquer ligações restantes são encerradas

Não existe armazenamento persistente. Se o servidor reiniciar, todas as salas desaparecem. Isto é intencional — o servidor de retransmissão é stateless e efémero. Jogos em curso precisariam de ser reiniciados, mas isto é raro na prática.

O servidor de retransmissão predefinido corre no Fly.io, proporcionando ligações WebSocket de baixa latência com TLS automático. Consulte o guia de configuração do Servidor Multijogador para instruções sobre como executar o seu próprio servidor de retransmissão.

Para testar o multijogador durante o desenvolvimento:

  1. Clone e execute o servidor de retransmissão:

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

    O servidor inicia na porta 3210.

  2. No En Parlant~, altere o URL do servidor de retransmissão para ws://localhost:3210.

  3. Abra duas instâncias da aplicação (ou uma aplicação e outra em modo de desenvolvimento) para simular ambos os jogadores.

Jogadas, heartbeats e todos os eventos funcionam de forma idêntica ao servidor de retransmissão de produção — a única diferença é o URL de ligação.