Portamos um jogo multiplayer de C ++ para a web com Cheerp, WebRTC e Firebase

1. Introdução


Nossa empresa, a Leaning Technologies, fornece soluções para transportar aplicativos de desktop tradicionais para a web. Nosso compilador C ++ Cheerp gera uma combinação de WebAssembly e JavaScript, que fornece fácil interação do navegador e alto desempenho.

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


Funciona no navegador Teeworlds

Decidimos usar esse projeto para experimentar soluções gerais para portar código de rede para a web . Isso geralmente é feito das seguintes maneiras:

  • XMLHttpRequest / fetch se a parte da rede consistir apenas em solicitações HTTP ou
  • WebSockets

Ambas as soluções exigem a hospedagem do componente do servidor no lado do servidor e nenhuma delas permite que você use o UDP como o protocolo de transporte. Isso é importante para aplicativos em tempo real, como videoconferência e software de jogos, porque as garantias de entrega e a solicitação de pacotes TCP podem interferir nas baixas latências.

Existe uma terceira maneira - use a rede a partir de um navegador: WebRTC .

O RTCDataChannel suporta transmissão confiável e não confiável (no último caso, se possível, tenta usar o UDP como protocolo de transporte) e pode ser usado com um servidor remoto e entre navegadores. Isso significa que podemos portar o aplicativo inteiro para o navegador, incluindo o componente do servidor!

No entanto, essa é uma dificuldade adicional: antes que dois pares de WebRTC possam trocar dados, eles precisam executar um procedimento de handshake relativamente complicado para conectar, o que exige várias entidades de terceiros (um servidor de sinal e um ou mais servidores STUN / TURN ).

Idealmente, gostaríamos de criar uma API de rede internamente usando o WebRTC, mas o mais próximo possível da interface UDP Sockets, que não precisa estabelecer uma conexão.

Isso nos permitirá tirar proveito do WebRTC sem a necessidade de divulgar detalhes complexos ao código do aplicativo (que queríamos mudar o mínimo possível em nosso projeto).

WebRTC mínimo


O WebRTC é um conjunto de APIs disponível em navegadores que fornece áudio ponto a ponto, vídeo e transferência de dados arbitrária.

A conexão entre os pares é estabelecida (mesmo se houver NAT em um ou nos dois lados) usando os servidores STUN e / ou TURN por meio de um mecanismo chamado ICE. Os pares trocam informações de ICE e parâmetros de canal por meio do protocolo de oferta e resposta do SDP.

Uau! Quantas abreviações por vez. Vamos explicar brevemente o que esses conceitos significam:

  • Utilitários transversais de sessão para NAT ( STUN ) - um protocolo para ignorar o NAT e receber um par (IP, porta) para trocar dados diretamente com o host. Se ele conseguir concluir sua tarefa, os colegas poderão trocar dados de maneira independente.
  • O uso transversal de retransmissões ao redor do NAT ( TURN ) também é usado para ignorar o NAT, mas faz isso redirecionando os dados por meio de um proxy que é visível para os dois pares. Ele adiciona atraso e é mais caro de executar do que STUN (porque é usado em toda a sessão de comunicação), mas às vezes essa é a única opção possível.
  • O ICE ( Interactive Connectivity Establishment ) é usado para selecionar a melhor maneira possível de conectar dois pares com base nas informações obtidas pela conexão direta de pares, bem como nas informações recebidas por qualquer número de servidores STUN e TURN.
  • O Session Description Protocol ( SDP ) é um formato para descrever os parâmetros do canal de conexão, por exemplo, candidatos a ICE, codecs de multimídia (no caso de um canal de áudio / vídeo), etc. ... Resposta ("resposta"). Depois disso, um canal é criado.

Para criar essa conexão, os pares precisam coletar as informações recebidas 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, portanto, deve haver um mecanismo fora de banda para trocar esses dados: um servidor de sinal.

Um servidor de sinal pode ser muito simples, porque sua única tarefa é redirecionar dados entre pares no estágio de "handshake" (como mostrado no diagrama abaixo).


Sequência simplificada de handshake do WebRTC

Visão geral do modelo de rede da Teeworlds


A arquitetura de rede do Teeworlds é muito simples:

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

Devido ao uso do WebRTC para troca de dados, podemos transferir o componente do servidor do jogo para o navegador em que o cliente está localizado. Isso nos dá uma grande oportunidade ...

Livre-se dos servidores


A falta de lógica do servidor tem uma boa vantagem: podemos implantar todo o aplicativo como conteúdo estático no Github Pages ou em nosso próprio equipamento atrás do Cloudflare, garantindo assim downloads rápidos e alto tempo de atividade gratuitamente. De fato, podemos esquecê-los e, se tivermos sorte e o jogo se tornar popular, a infraestrutura não precisará ser modernizada.

No entanto, para o sistema funcionar, ainda precisamos usar uma arquitetura externa:

  • Um ou mais servidores STUN: temos várias opções gratuitas.
  • Pelo menos um servidor TURN: não há opções gratuitas aqui, para que possamos configurar nossos próprios ou pagar pelo serviço. Felizmente, na maioria das vezes a conexão pode ser estabelecida através dos servidores STUN (e fornece p2p verdadeiro), mas o TURN é necessário como substituto.
  • Servidor de sinal: ao contrário dos outros dois aspectos, a sinalização não é padronizada. A responsabilidade pelo servidor de sinal depende da aplicação de alguma forma. No nosso caso, antes de estabelecer uma conexão, é necessário trocar uma pequena quantidade de dados.
  • Servidor principal da Teeworlds: é usado por outros servidores para notificar sua existência e por clientes para procurar servidores públicos. Embora não seja obrigatório (os clientes sempre podem se conectar a um servidor que conhecem manualmente), seria bom tê-lo para que os jogadores possam participar de jogos com pessoas aleatórias.

Decidimos usar os servidores STUN gratuitos do Google e implantamos um servidor TURN por conta própria.

Nos últimos dois pontos, usamos o Firebase :

  • O servidor principal da Teeworlds é implementado de maneira muito simples: como uma lista de objetos que contêm informações (nome, IP, mapa, modo, ...) de cada servidor ativo. Os servidores publicam e atualizam seu próprio objeto, e os clientes pegam a lista inteira e a exibem para o player. Também exibimos a lista na página inicial como HTML, para que os jogadores possam simplesmente clicar no servidor e ir direto ao jogo.
  • A sinalização está intimamente relacionada à nossa implementação de soquete, descrita na próxima seção.


Lista de servidores dentro do jogo e na página inicial

Implementação de soquete


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

Também queremos obter o mínimo necessário para a troca de dados mais simples na rede.

Por exemplo, não precisamos de roteamento real: todos os pares estão na mesma "LAN virtual" associada a uma instância específica do banco de dados Firebase.

Portanto, não precisamos de endereços IP exclusivos: para identificação exclusiva de pares, basta usar valores exclusivos das chaves do Firebase (semelhantes aos nomes de domínio), e cada par atribui localmente endereços IP “falsos” a cada chave que precisa ser convertida. Isso elimina completamente a necessidade de uma atribuição de endereço IP global, 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 do Posix Sockets, mas possui várias diferenças importantes: registrando retornos de chamada, atribuindo IPs locais e uma conexão lenta .

Registro de retorno de chamada


Mesmo se o programa de origem usar E / S sem bloqueio, o código precisará ser refatorado para ser executado em um navegador da web.

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

Em um ambiente nativo, podemos escrever código dessa maneira

 while(running) { select(...); // wait for I/O events while(true) { int r = readfrom(...); // try to read if (r < 0 && errno == EWOULDBLOCK) // no more data available break; ... } ... } 

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

 auto cb = []() { // this will be called when new data is available while(true) { int r = readfrom(...); // try to read if (r < 0 && errno == EWOULDBLOCK) // no more data available break; ... } ... }; recvCallback(cb); // register the callback 

Atribuição de IP local


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

Isso é conveniente porque não precisamos de um mecanismo para atribuir IP e verificar sua exclusividade (assim como sua eliminação após desconectar o cliente), mas geralmente é necessário identificar pares por um valor numérico.

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

Isso é semelhante a uma pesquisa de DNS, realizada apenas localmente no cliente.

Ou seja, os endereços IP não podem ser compartilhados entre clientes diferentes e, se você precisar de algum tipo de identificador global, precisará gerá-lo de uma maneira diferente.

Mistura preguiçosa


O UDP não precisa de uma conexão, mas, como vimos, antes de iniciar a transferência de dados entre dois pares, o WebRTC requer um longo processo de conexão.

Se quisermos fornecer o mesmo nível de abstração ( recvfrom / recvfrom com pares arbitrários sem primeiro conectar), devemos fazer uma conexão "preguiçosa" (atrasada) dentro da API.

Aqui está o que acontece durante a troca normal de dados entre o “servidor” e o “cliente” no caso de usar UDP, e o que nossa biblioteca deve fazer:

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

Em vez disso, publicaremos a porta aberta no Firebase sob a chave do servidor e ouviremos os eventos em sua subárvore.

  • O servidor chama recvfrom() , aceitando pacotes de qualquer host para esta porta.

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

Cada porta tem sua própria fila e adicionamos as portas de origem e de destino no início dos datagramas WebRTC para saber qual fila redirecionar quando um novo pacote chegar.

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

  • O cliente recebe, por alguns meios externos, o IP e a porta do servidor e chama sendto() . Além disso, uma chamada interna para bind() é executada; portanto, recvfrom() subsequente receberá uma resposta sem executar explicitamente a ligação.

No nosso caso, o cliente recebe externamente a chave da string e usa a função resolve() para obter o endereço IP.

Nesse ponto, iniciamos o "aperto de mão" do WebRTC se os dois pares ainda não estiverem conectados um ao outro. As conexões com portas diferentes do mesmo ponto usam o mesmo DataRannel WebRTC.

Também executamos bind() indireto bind() para que o servidor possa se reconectar no próximo sendto() caso seja sendto() por algum motivo.

O servidor é notificado sobre a conexão do cliente quando o cliente grava sua oferta SDP nas informações da porta do servidor no Firebase, e o servidor responde com sua própria resposta.



O diagrama abaixo mostra um exemplo do movimento de mensagens para um esquema de soquete e a transmissão da primeira mensagem do cliente para o servidor:


Diagrama completo das etapas de conexão entre cliente e servidor

Conclusão


Se você leu até o fim, provavelmente está interessado em examinar a teoria em ação. O jogo pode ser jogado em teeworlds.leaningtech.com , experimente!


Jogo amigável entre colegas

O código da biblioteca de rede está disponível gratuitamente no Github . Participe do bate - papo em nosso canal no Gitter !

Source: https://habr.com/ru/post/pt468031/


All Articles