Portando o jogo multiplayer de C++ para a web com Cheerp, WebRTC e Firebase

Introdução

Nossa empresa Leaning Technologies fornece soluções para a portabilidade de aplicativos desktop tradicionais para a web. Nosso compilador C++ Cheerp gera uma combinação de WebAssembly e JavaScript, o que assegura tanto uma interação simples com o navegador, quanto um alto desempenho.

Como exemplo de sua aplicação, decidimos portar um jogo multiplayer para a web e escolhemos Teeworlds. Teeworlds é um jogo retro 2D multiplayer com uma comunidade de jogadores pequena, mas ativa (incluindo eu!). É pequeno tanto em termos de recursos para download quanto em requisitos de CPU e GPU — um candidato ideal.

Portando o jogo multiplayer de C++ para a web com Cheerp, WebRTC e Firebase
Teeworlds rodando no navegador

Decidimos usar este projeto para experimentar com soluções gerais para portar código de rede para a web. Normalmente, isso é realizado das seguintes maneiras:

  • XMLHttpRequest/fetch, se a parte de rede consiste apenas em requisições HTTP, ou
  • WebSockets.

Ambas as soluções exigem hospedar um componente de servidor do lado do servidor, e nenhuma delas permite usar como protocolo de transporte. UDPIsso é importante para aplicações em tempo real, como softwares para videoconferências e jogos, pois as garantias de entrega e ordem dos pacotes do protocolo TCP podem interferir em baixas latências.

Há também um terceiro caminho — usar a rede a partir do navegador: WebRTC.

RTCDataChannel suporta tanto transmissão confiável quanto não confiável (neste último caso, ele tenta usar como protocolo de transporte o UDP sempre que possível), e pode ser utilizado tanto com um servidor remoto quanto entre navegadores. Isso significa que podemos portar toda a aplicação para o navegador, incluindo o componente do servidor!

No entanto, isso traz uma dificuldade adicional: antes que dois pares WebRTC possam trocar dados, eles precisam passar por um procedimento relativamente complexo de "aperto de mão" (handshake) para se conectar, o que requer várias entidades externas (servidor de sinalização e um ou mais servidores STUN/TURN).

Idealmente, gostaríamos de criar uma API de rede que dentro utilize WebRTC, mas o mais próximo possível da interface de Sockets UDP, que não requer uma conexão estabelecida.

Isso nos permitirá aproveitar as vantagens do WebRTC sem a necessidade de expor os detalhes complexos ao código da aplicação (que em nosso projeto gostaríamos de modificar o mínimo possível).

WebRTC mínimo

WebRTC é um conjunto de APIs disponível nos navegadores que permite a transmissão de áudio, vídeo e dados arbitrários de peer-to-peer.

A conexão entre os pares é estabelecida (mesmo com NAT em um ou ambos os lados) através de servidores STUN e/ou TURN por meio de um mecanismo chamado ICE. Os pares trocam informações de ICE e parâmetros de canais através do protocolo SDP com oferta e resposta.

Uau! Quantas siglas de uma só vez. Vamos explicar brevemente o que esses conceitos significam:

  • Session Traversal Utilities for NAT (STUN) é um protocolo para contornar o NAT e obter um par (IP, porta) para trocar dados diretamente com o host. Se conseguir realizar sua função, os pares poderão trocar dados entre si.
  • Traversal Using Relays around NAT (TURN) também é usado para contornar o NAT, mas o faz redirecionando os dados através de um proxy, visível para ambos os pares. Isso adiciona latência e é mais custoso em termos de execução do que o STUN (porque é utilizado durante toda a sessão de comunicação), mas às vezes é a única opção possível.
  • Interactive Connectivity Establishment (ICE) é utilizado para selecionar a melhor maneira possível de conectar dois pares com base nas informações obtidas através da conexão direta dos pares, bem como as informações adquiridas por qualquer número de servidores STUN e TURN.
  • Session Description Protocol (SDP) é um formato para descrever os parâmetros do canal de conexão, como os candidatos de ICE, codecs de mídia (no caso de um canal de áudio/vídeo), etc. Um dos pares envia uma oferta SDP ("offer"), e o outro responde com uma resposta SDP ("answer"). Após isso, o canal é criado.

Para criar tal conexão, os pares precisam coletar as informações que conseguiram dos servidores STUN e TURN e trocá-las entre si.

O problema é que eles ainda não têm a capacidade de trocar dados diretamente, então deve existir um mecanismo de sinalização off-band: o servidor de sinalização.

O servidor de sinalização pode ser muito simples, pois sua única tarefa é redirecionar dados entre os pares durante a etapa de "aperto de mãos" (como mostrado no diagrama abaixo).

Portando o jogo multiplayer de C++ para a web com Cheerp, WebRTC e Firebase
Diagrama simplificado da sequência de "aperto de mãos" do WebRTC

Visão geral do modelo de rede Teeworlds

A arquitetura de rede do Teeworlds é muito simples:

  • Os componentes do cliente e do servidor são dois programas diferentes.
  • Os clientes entram em jogo conectando-se a um dos vários servidores, cada um dos quais hospeda apenas um jogo de cada vez.
  • Toda a transmissão de dados no jogo é feita através do servidor.
  • Um servidor-mestre especial é utilizado para coletar a lista de todos os servidores públicos, que são exibidos no cliente de jogo.

Graças ao uso do WebRTC para troca de dados, podemos transferir o componente de servidor do jogo para o navegador, onde está o cliente. Isso nos oferece uma oportunidade maravilhosa…

Desfazer-se dos servidores

A ausência de lógica de servidor tem a vantagem agradável: podemos implantar toda a aplicação como conteúdo estático no Github Pages ou em nosso próprio hardware atrás do Cloudflare, garantindo assim downloads rápidos e alta disponibilidade de forma gratuita. Basicamente, podemos esquecer deles, e se tivermos sorte e o jogo se tornar popular, não será necessário atualizar a infraestrutura.

No entanto, para que o sistema funcione, ainda teremos que usar uma arquitetura externa:

  • Um ou mais servidores STUN: temos várias opções gratuitas à escolha.
  • Pelo menos um servidor TURN: aqui não há opções gratuitas, então podemos ou configurar o nosso ou pagar pelo serviço. Felizmente, a maior parte do tempo a conexão pode ser estabelecida através de servidores STUN (assegurando p2p verdadeiro), mas o TURN é necessário como uma alternativa de reserva.
  • Servidor de sinalização: ao contrário dos outros dois aspectos, a sinalização não é padronizada. O que o servidor de sinalização realmente fará depende do aplicativo. No nosso caso, é necessário trocar um pequeno volume de dados antes de estabelecer a conexão.
  • Servidor-mestre Teeworlds: ele é usado por outros servidores para notificar sua existência e pelos clientes para encontrar servidores públicos. Embora não seja obrigatório (os clientes sempre podem se conectar manualmente a um servidor que conhecem), seria bom tê-lo para que os jogadores possam participar de jogos com pessoas aleatórias.

Decidimos usar os servidores STUN gratuitos da Google, e implantamos um servidor TURN nós mesmos.

Para os dois últimos pontos, utilizamos Firebase:

  • O mestre servidor Teeworlds é implementado de forma muito simples: como uma lista de objetos contendo informações (nome, IP, mapa, modo, ...) de cada servidor ativo. Os servidores publicam e atualizam seu próprio objeto, enquanto os clientes capturam toda a lista e a exibem para o jogador. Também exibimos a lista na página inicial como HTML, para que os jogadores possam simplesmente clicar no servidor e entrar no jogo diretamente.
  • A sinalização está intimamente relacionada à nossa implementação de soquetes, descrita na próxima seção.

Portando o jogo multiplayer de C++ para a web com Cheerp, WebRTC e Firebase
Lista de servidores dentro do jogo e na página inicial

Implementação de soquetes

Queremos criar uma API o mais próximo possível dos Soquetes UDP Posix, para minimizar o número de alterações necessárias.

Além disso, queremos implementar o mínimo necessário para a troca simples de dados pela rede.

Por exemplo, não precisamos de roteamento real: todos os peers estão em uma 'LAN virtual' ligada a uma instância específica do banco de dados Firebase.

Portanto, não precisamos de endereços IP exclusivos: para a identificação única dos peers, basta usar valores de chave exclusivos do Firebase (semelhante a nomes de domínio), e cada peer localmente atribui endereços IP 'falsos' a cada chave que precisa ser convertida. Isso nos livra completamente da necessidade de atribuição global de endereços IP, o que é uma tarefa não trivial.

Aqui está a API mínima que precisamos implementar:

// Create and destroy a socket
int socket();
int close(int fd);
// Bind a socket to a port, and publish it on Firebase
int bind(int fd, AddrInfo* addr);
// Send a packet. This lazily create a WebRTC connection to the 
// peer when necessary
int sendto(int fd, uint8_t* buf, int len, const AddrInfo* addr);
// Receive the packets destined to this socket
int recvfrom(int fd, uint8_t* buf, int len, AddrInfo* addr);
// Be notified when new packets arrived
int recvCallback(Callback cb);
// Obtain a local ip address for this peer key
uint32_t resolve(client::String* key);
// Get the peer key for this ip
String* reverseResolve(uint32_t addr);
// Get the local peer key
String* local_key();
// Initialize the library with the given Firebase database and 
// WebRTc connection options
void init(client::FirebaseConfig* fb, client::RTCConfiguration* ice);

A API é simples e semelhante à API Posix Sockets, mas possui algumas diferenças importantes: registro de callbacks, atribuição de IPs locais e conexão 'preguiçosa'.

Registro de callbacks

Mesmo que o programa original utilize E/S não bloqueante, para execução em um navegador, o código precisa ser refatorado.

A razão para isso é que o loop de eventos no navegador está oculto para o programa (seja JavaScript ou WebAssembly).

No ambiente nativo, podemos escrever o código desta forma

while(running) {
  select(...); // aguardar eventos de E/S
  while(true) {
    int r = readfrom(...); // tentar ler
    if (r < 0 && errno == EWOULDBLOCK) // não há mais dados disponíveis
      break;
    ...
  }
  ...
}

Se o loop de eventos está oculto para nós, precisamos transformá-lo em algo como:

auto cb = []() { // isso será chamado quando novos dados estiverem disponíveis
  while(true) {
    int r = readfrom(...); // tentar ler
    if (r < 0 && errno == EWOULDBLOCK) // não há mais dados disponíveis
      break;
    ...
  }
  ...
};
someCallback(cb); // registrar o callback

Atribuição de IPs locais

Os identificadores de nós em nossa "rede" não são endereços IP, mas chaves do Firebase (são strings que se parecem com: -LmEC50PYZLCiCP-vqde ).

Isso é conveniente, pois não precisamos de um mecanismo para atribuir IPs e verificar sua exclusividade (bem como descartá-los após a desconexão do cliente), mas muitas vezes é necessário identificar pares por um valor numérico.

É exatamente para isso que são utilizadas as funções resolve e reverseResolve: o aplicativo de alguma forma obtém o valor da chave em string (por meio da entrada do usuário ou por meio de um servidor mestre) e pode convertê-lo em um endereço IP para uso interno. O restante da API também recebe, por simplicidade, esse valor em vez da string.

Isso é semelhante a uma consulta DNS, exceto que é realizada localmente no cliente.

Ou seja, os endereços IP não podem ser compartilhados entre diferentes clientes, e se um identificador global for necessário, ele precisará ser gerado de outra maneira.

Conexão preguiçosa

UDP não requer conexão, mas, como vimos, antes de começar a transmitir dados entre dois pares, o WebRTC exige um longo processo de conexão.

Se quisermos manter o mesmo nível de abstração, (sendto/recvfrom com pares arbitrários sem conexão prévia), devemos realizar uma conexão "preguiçosa" (delayed) dentro da API.

Isso é o que acontece durante a troca típica de dados entre um "servidor" e um "cliente" ao usar UDP, e o que nossa biblioteca deve executar:

  • O servidor chama bind(), para informar ao sistema operacional que deseja receber pacotes na porta especificada.

Em vez disso, vamos publicar uma porta aberta no Firebase sob a chave do servidor e escutar os eventos em seu subárvore.

  • O servidor chama recvfrom(), recebendo nesse porta pacotes provenientes de qualquer host.

No nosso caso, precisamos verificar a fila de entrada de pacotes enviados para essa porta.

Cada porta tem sua própria fila, e adicionamos nos datagramas WebRTC as portas de origem e destino, para saber em qual fila redirecionar quando um novo pacote chegar.

A chamada é não bloqueante, portanto, se não houver pacotes, simplesmente retornamos -1 e definimos errno=EWOULDBLOCK.

  • O cliente obtém IP e a porta do servidor por meios externos e chama sendto(). Também ocorre uma chamada interna, bind(), portanto, o subsequente recvfrom() receberá uma resposta sem a execução explícita de bind.

Neste caso, o cliente recebe externamente uma chave de string e utiliza a função resolve() para obter o endereço IP.

Neste estágio, começamos o 'handshake' do WebRTC, caso os dois pares ainda não estejam conectados entre si. Conexões em diferentes portas de um mesmo par utilizam o mesmo DataChannel do WebRTC.

Além disso, realizamos uma indireta bind(), para que o servidor possa restaurar a conexão na próxima sendto() caso ela tenha sido encerrada por algum motivo.

O servidor é notificado da conexão do cliente quando o cliente registra sua oferta SDP sob a informação da porta do servidor no Firebase, e o servidor responde lá mesmo com sua resposta.

No esquema mostrado abaixo, é apresentado um exemplo do fluxo de mensagens para o esquema de sockets e a transmissão da primeira mensagem do cliente para o servidor:

Portando o jogo multiplayer de C++ para a web com Cheerp, WebRTC e Firebase
Esquema completo da etapa de conexão entre o cliente e o servidor.

Conclusão

Se você chegou até aqui, deve estar interessado em ver a teoria em ação. O jogo pode ser jogado em teeworlds.leaningtech.com, experimente!


Partida amigável entre colegas.

O código da biblioteca de rede está disponível livremente em Github. Junte-se à conversa no nosso canal em Gitter!

Fonte: habr.com

Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS 🔥 Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS | ProHoster