A Experiência do Hobbit 2014

Como adicionar a jogabilidade do WebRTC à experiência do Hobbit

Daniel Isaksson
Daniel Isaksson

Para comemorar o lançamento do novo filme "O Hobbit: A Batalha dos Cinco Exércitos", trabalhamos para estender o Experimento do Chrome do ano passado, A Journey through Middle-earth, com novos conteúdos. O foco principal desta vez foi ampliar o uso do WebGL, já que mais navegadores e dispositivos podem visualizar o conteúdo e trabalhar com os recursos do WebRTC no Chrome e no Firefox. Tivemos três objetivos com o experimento deste ano:

  • Jogo P2P usando WebRTC e WebGL no Chrome para Android
  • Criar um jogo multijogador fácil de jogar e baseado em entrada por toque
  • Hospedar no Google Cloud Platform

Como definir o jogo

A lógica do jogo é criada em uma configuração baseada em grade com tropas se movendo em um tabuleiro. Isso facilitou a definição das regras e a experimentação do jogo no papel. O uso de uma configuração baseada em grade também ajuda na detecção de colisões no jogo para manter uma boa performance, já que você só precisa verificar colisões com objetos no mesmo bloco ou em blocos vizinhos. Desde o início, sabíamos que queríamos focar o novo jogo em uma batalha entre as quatro principais forças da Terra-média: humanos, anões, elfos e orcs. Ele também precisava ser casual o suficiente para ser jogado em um experimento do Chrome e não ter muitas interações para aprender. Começamos definindo cinco Battlegrounds no mapa da Terra-média, que servem como salas de jogos em que vários jogadores podem competir em uma batalha entre pares. Mostrar vários jogadores na sala em uma tela de smartphone e permitir que os usuários selecionem quem desafiar foi um desafio em si. Para facilitar a interação e a cena, decidimos ter apenas um botão para desafiar e aceitar e usar apenas a sala para mostrar eventos e quem é o rei da colina. Essa direção também resolveu alguns problemas no lado do matchmaking e nos permitiu combinar os melhores candidatos para uma batalha. No nosso experimento anterior do Chrome, o Cube Slam, aprendemos que é preciso muito trabalho para lidar com a latência em um jogo multijogador se o resultado dele depende disso. Você precisa constantemente fazer suposições sobre onde o estado do oponente vai estar, onde o oponente pensa que você está e sincronizar isso com animações em diferentes dispositivos. Este artigo explica esses desafios com mais detalhes. Para facilitar, criamos um jogo por turnos.

A lógica do jogo é criada em uma configuração baseada em grade com tropas se movendo em um tabuleiro. Isso facilitou a definição das regras e a experimentação do jogo no papel. O uso de uma configuração baseada em grade também ajuda na detecção de colisões no jogo para manter uma boa performance, já que você só precisa verificar colisões com objetos no mesmo bloco ou em blocos vizinhos.

Partes do jogo

Para criar este jogo multijogador, precisamos de algumas partes importantes:

  • Uma API de gerenciamento de jogadores do lado do servidor lida com usuários, partidas, sessões e estatísticas de jogos.
  • Servidores para ajudar a estabelecer a conexão entre os jogadores.
  • Uma API para processar a sinalização da API AppEngine Channels usada para se conectar e se comunicar com todos os jogadores nas salas de jogos.
  • Um mecanismo de jogo JavaScript que lida com a sincronização do estado e das mensagens RTC entre os dois jogadores/pares.
  • Visualização do jogo do WebGL.

Gerenciamento de jogadores

Para oferecer suporte a um grande número de jogadores, usamos muitas salas de jogo paralelas por Battleground. O principal motivo para limitar o número de jogadores por sala de jogo é permitir que novos jogadores alcancem o topo da tabela de classificação em um tempo razoável. O limite também está conectado ao tamanho do objeto JSON que descreve a sala de jogos enviada pela API Channel, que tem um limite de 32 KB. Temos que armazenar jogadores, salas, pontuações, sessões e as relações deles no jogo. Para isso, primeiro usamos o NDB para entidades e a interface de consulta para lidar com relacionamentos. O NDB é uma interface do Google Cloud Datastore. O uso do NDB funcionou muito bem no início, mas logo encontramos um problema com a forma como precisávamos usá-lo. A consulta foi executada na versão "confirmada" do banco de dados. As gravações NDB são explicadas detalhadamente neste artigo detalhado, que pode ter um atraso de vários segundos. Mas as entidades não têm esse atraso, porque respondem diretamente do cache. Talvez seja mais fácil explicar com um exemplo de código:

// example code to explain our issue with eventual consistency
def join_room(player_id, room_id):
    room = Room.get_by_id(room_id)
    
    player = Player.get_by_id(player_id)
    player.room = room.key
    player.put()
    
    // the player Entity is updated directly in the cache
    // so calling this will return the room key as expected
    player.room // = Key(Room, room_id)

    // Fetch all the players with room set to 'room.key'
    players_in_room = Player.query(Player.room == room.key).fetch()
    // = [] (an empty list of players)
    // even though the saved player above may be expected to be in the
    // list it may not be there because the query api is being run against the 
    // "committed" version and may still be empty for a few seconds

    return {
        room: room,
        players: players_in_room,
    }

Depois de adicionar testes de unidade, identificamos o problema claramente e deixamos de usar as consultas para manter as relações em uma lista separada por vírgulas no memcache. Isso parecia um pouco de hack, mas funcionou, e o memcache do AppEngine tem um sistema semelhante a uma transação para as chaves usando o excelente recurso "comparar e definir". Agora os testes foram aprovados novamente.

Infelizmente, o memcache não é perfeito, mas tem alguns limites, sendo os mais importantes o tamanho de valor de 1 MB (não é possível ter muitos rooms relacionados a um campo de batalha) e a expiração da chave, ou como a documentação explica:

Consideramos usar outro ótimo armazenamento de chave-valor, o Redis. Mas, na época, configurar um cluster escalonável era um pouco assustador. Como preferimos nos concentrar na criação da experiência em vez de manter servidores, não seguimos por esse caminho. Por outro lado, o Google Cloud Platform lançou recentemente um recurso simples de Clique para implantar, e uma das opções era um cluster Redis.

Finalmente encontramos o Google Cloud SQL e transferimos as relações para o MySQL. Foi muito trabalho, mas acabou funcionando muito bem, as atualizações agora são totalmente atômicas e os testes ainda são aprovados. Isso também tornou a implementação do sistema de matchmaking e de pontuação muito mais confiável.

Com o tempo, mais dados foram transferidos lentamente do NDB e do memcache para o SQL, mas, em geral, as entidades de jogador, campo de batalha e sala ainda são armazenadas no NDB, enquanto as sessões e relacionamentos entre elas são armazenados no SQL.

Também precisávamos acompanhar quem estava jogando com quem e formar duplas usando um mecanismo de correspondência que levava em consideração o nível de habilidade e a experiência dos jogadores. Baseamos a combinação na biblioteca de código aberto Glicko2.

Como este é um jogo multijogador, queremos informar os outros jogadores na sala sobre eventos como "quem entrou ou saiu", "quem ganhou ou perdeu" e se há um desafio para aceitar. Para lidar com isso, criamos a capacidade de receber notificações na API Player Management.

Como configurar o WebRTC

Quando dois jogadores são pareados para uma batalha, um serviço de sinalização é usado para fazer com que os dois pares correspondentes se comuniquem entre si e para ajudar a iniciar uma conexão entre pares.

Há várias bibliotecas de terceiros que podem ser usadas para o serviço de sinalização, o que também simplifica a configuração do WebRTC. Algumas opções são PeerJS, SimpleWebRTC e SDK do PubNub WebRTC. A PubNub usa uma solução de servidor hospedado, e para este projeto queríamos hospedar no Google Cloud Platform. As outras duas bibliotecas usam servidores node.js que poderíamos instalar no Google Compute Engine, mas também precisaríamos garantir que elas pudessem lidar com milhares de usuários simultâneos, algo que já sabíamos que a API Channel pode fazer.

Uma das principais vantagens de usar o Google Cloud Platform nesse caso é a escalabilidade. É possível dimensionar os recursos necessários para um projeto do App Engine com facilidade pelo Google Developers Console, e não é necessário fazer mais nada para dimensionar o serviço de sinalização ao usar a API Channels.

Havia algumas preocupações sobre a latência e a robustez da API Channels, mas já a usamos no projeto CubeSlam e ela funcionou para milhões de usuários, então decidimos usá-la novamente.

Como não escolhemos usar uma biblioteca de terceiros para ajudar com o WebRTC, tivemos que criar a nossa própria. Felizmente, pudemos reutilizar muito do trabalho que fizemos para o projeto CubeSlam. Quando os dois jogadores entram em uma sessão, ela é definida como "ativa", e os dois jogadores usam esse ID de sessão ativo para iniciar a conexão ponto a ponto pela API Channel. Depois disso, toda a comunicação entre os dois jogadores será processada por um RTCDataChannel.

Também precisamos de servidores STUN e TURN para ajudar a estabelecer a conexão e lidar com NATs e firewalls. Leia mais sobre a configuração do WebRTC no artigo do HTML5 Rocks WebRTC no mundo real: STUN, TURN e sinalização.

O número de servidores TURN usados também precisa ser escalonável, dependendo do tráfego. Para lidar com isso, testamos o Google Deployment Manager. Ele permite implantar recursos dinamicamente no Google Compute Engine e instalar servidores TURN usando um modelo. Ele ainda está na versão Alfa, mas funcionou perfeitamente para nossos propósitos. Para o servidor TURN, usamos o coturn, que é uma implementação muito rápida, eficiente e aparentemente confiável do STUN/TURN.

A API Channel

A API Channel é usada para enviar toda a comunicação de e para a sala de jogos no lado do cliente. Nossa API de gerenciamento de jogadores está usando a API Channel para notificações sobre eventos do jogo.

O trabalho com a API Channels teve alguns problemas. Por exemplo, como as mensagens podem vir sem ordem, tivemos que agrupar todas em um objeto e classificá-las. Confira um exemplo de código sobre como isso funciona:

var que = [];  // [seq, packet...]
var seq = 0;
var rcv = -1;

function send(message) {
  var packet = JSON.stringify({
    seq: seq++,
    msg: message
  });
  channel.send(packet);
}

function recv(packet) {
  var data = JSON.parse(packet);

  if (data.seq <= rcv) {
    // ignoring message, older or already received
  } else if (data.seq > rcv + 1) {
    // message from the future. queue it up.
    que.push(data.seq, packet);
  } else {
    // message in order! update the rcv index and emit the message
    rcv = data.seq;
    emit('message', data.message);

    // and now that we have updated the `rcv` index we 
    // will check the que for any other we can send
    setTimeout(flush, 10);
  }
}

function flush() {
  for (var i=0; i<que.length; i++) {
    var seq = que[i];
    var packet = que[i+1];
    if (data.seq == rcv + 1) {
      recv(packet);
      return; // wait for next flush
    }
  }
}

Também queríamos manter as diferentes APIs do site modulares e separadas da hospedagem do site e começamos a usar os módulos integrados ao GAE. Infelizmente, depois de fazer tudo funcionar no desenvolvimento, percebemos que a API Channel não funciona com módulos na produção. Em vez disso, passamos a usar instâncias separadas do GAE e encontramos problemas de CORS que nos forçaram a usar uma ponte postMessage de iframe.

Mecanismo do jogo

Para tornar o mecanismo de jogo o mais dinâmico possível, criamos o aplicativo front-end usando a abordagem entidade-componente-sistema (ECS). Quando começamos o desenvolvimento, os wireframes e a especificação funcional não estavam definidos. Por isso, foi muito útil adicionar recursos e lógica à medida que o desenvolvimento avançava. Por exemplo, o primeiro protótipo usava um sistema de renderização de tela simples para mostrar as entidades em uma grade. Algumas iterações depois, um sistema de colisões foi adicionado, assim como um para jogadores controlados por IA. No meio do projeto, podemos mudar para um sistema de renderizador 3D sem mudar o restante do código. Quando as partes de rede estavam ativas, o sistema de IA podia ser modificado para usar comandos remotos.

Portanto, a lógica básica do modo multijogador é enviar a configuração do comando de ação para o outro participante por meio de DataChannels e deixar a simulação agir como se fosse um jogador de IA. Além disso, há uma lógica para decidir qual é a vez, se o jogador pressiona os botões de ataque/passe, enfileira comandos se eles chegam enquanto o jogador ainda está olhando para a animação anterior etc.

Se fossem apenas dois usuários trocando de vez, os dois colegas poderiam compartilhar a responsabilidade de passar a vez para o oponente quando terminassem, mas há um terceiro jogador envolvido. O sistema de IA voltou a ser útil (não apenas para testes), quando precisamos adicionar inimigos como aranhas e trolls. Para que elas se encaixassem no fluxo por turnos, era necessário gerar e executar exatamente da mesma forma nos dois lados. Isso foi resolvido permitindo que um peer controlasse o sistema de turnos e enviasse o status atual para o peer remoto. Quando chega a vez dos spiders, o gerenciador de turnos permite que o sistema de IA crie um comando que é enviado ao usuário remoto. Como o mecanismo do jogo está agindo apenas em comandos e IDs de entidade, o jogo será simulado da mesma forma nos dois lados. Todas as unidades também podem ter o componente ai, que permite testes automatizados fáceis.

Era ideal ter um renderizador de tela simples no início do desenvolvimento, enquanto se concentrava na lógica do jogo. Mas a diversão começou quando a versão 3D foi implementada e as cenas ganharam vida com ambientes e animações. Usamos o three.js como mecanismo 3D, e foi fácil chegar a um estado jogável devido à arquitetura.

A posição do mouse é enviada com mais frequência para o usuário remoto, e uma luz 3D sutil indica onde o cursor está no momento.