A Experiência do Hobbit 2014

Como adicionar jogabilidade WebRTC à experiência Hobbit

Daniel isaksson
Daniel Isaksson

A tempo do novo filme do Hobbit "O Hobbit: A Batalha dos Cinco Exércitos", trabalhamos para estender o experimento do Chrome do ano passado, Uma Viagem pela Terra-média, com conteúdo novo. O foco principal desta vez foi ampliar o uso do WebGL, à medida que mais navegadores e dispositivos podem visualizar o conteúdo e trabalhar com os recursos WebRTC no Google Chrome e no Firefox. Neste experimento deste ano, tínhamos três objetivos:

  • Jogabilidade P2P usando WebRTC e WebGL no Google Chrome para Android
  • Crie um jogo multiplayer fácil de jogar com base na entrada de toque
  • Hospedar no Google Cloud Platform

Como definir o jogo

A lógica do jogo é baseada em uma configuração em grade com as tropas se movendo em um tabuleiro. Isso facilitou o teste da jogabilidade no papel enquanto estávamos definindo as regras. O uso de uma configuração baseada em grade também ajuda na detecção de colisão no jogo para manter um bom desempenho, já que você só precisa verificar se há colisões com objetos nos mesmos blocos ou em blocos vizinhos. Desde o início, sabíamos que queríamos focar no novo jogo em torno de 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 Campos de Batalha no mapa da Terra-Média que servem como salas de jogos onde vários jogadores podem competir em uma batalha ponto a ponto. Mostrar vários jogadores na sala em uma tela de dispositivo móvel e permitir que os usuários selecionassem quem desafiava era um desafio por si só. Para facilitar a interação e a cena, decidimos ter apenas um botão para desafiar e aceitar e usar a sala apenas para mostrar eventos e quem é o atual rei da colina. Essa ordem também resolveu alguns problemas de combinação e nos permitiu escolher os melhores candidatos para uma batalha. No experimento anterior do Chrome, Cube Slam, aprendemos que é preciso muito trabalho para lidar com a latência em um jogo multiplayer caso o resultado dependa disso. Você sempre precisa fazer suposições sobre onde vai estar o estado do adversário, onde ele pensa que você está, e sincronizar isso com animações em dispositivos diferentes. Este artigo explica esses desafios com mais detalhes. Para ficar um pouco mais fácil, nós fizemos este jogo por turnos.

A lógica do jogo é baseada em uma configuração em grade com as tropas se movendo em um tabuleiro. Isso facilitou o teste da jogabilidade no papel enquanto estávamos definindo as regras. O uso de uma configuração baseada em grade também ajuda na detecção de colisão no jogo para manter um bom desempenho, já que é preciso verificar apenas se há colisões com objetos nos mesmos blocos ou nos vizinhos.

Partes do jogo

Para criar esse jogo multiplayer, precisamos criar 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 lidar com 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 gerencia a sincronização do estado e as mensagens RTC entre os dois jogadores/peering.
  • A visualização de jogo WebGL.

Gerenciamento de jogadores

Para aceitar um grande número de jogadores, usamos muitas salas de jogos paralelas por campo de batalha. O principal motivo para limitar o número de jogadores por sala de jogo é permitir que novos jogadores cheguem ao topo do placar em 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. Precisamos armazenar jogadores, salas, pontuações, sessões e suas relações no jogo. Para fazer isso, primeiro usamos o NDB para entidades e a interface de consulta para lidar com os relacionamentos. O NDB é uma interface para o Google Cloud Datastore. Usar o NBS funcionou muito bem no início, mas logo tivemos um problema de como precisávamos usá-lo. A consulta foi executada na versão "comprometida" do banco de dados (as gravações do NBS são explicadas detalhadamente neste artigo detalhado), o que pode ter um atraso de vários segundos. Mas as entidades em si não tiveram esse atraso, já que respondem diretamente do cache. Pode ser um pouco mais fácil de explicar com um código de exemplo:

// 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, foi possível ver o problema claramente e abandonamos as consultas para manter os relacionamentos em uma lista separada por vírgulas no memcache. Isso parecia um pouco de um hack, mas funcionou, e o Memcache do App Engine tem um sistema semelhante a transação para as chaves que usa o excelente recurso “comparar e definir”, e agora os testes foram aprovados novamente.

Infelizmente, o Memcache não é só arco-íris e unicórnios, mas tem alguns limites. Os mais notáveis são o tamanho de valor de 1 MB (não pode ter muitas salas relacionadas a um campo de batalha) e a expiração da chave, conforme explicado nos documentos:

Pensamos em usar outro ótimo armazenamento de chave-valor, o Redis. Mas, na época, configurar um cluster escalonável era um pouco complicado e, como preferimos nos concentrar em criar a experiência em vez de manter servidores, não seguimos esse caminho. Por outro lado, o Google Cloud Platform lançou recentemente um recurso simples click-to-deploy, com uma das opções sendo um cluster do Redis, o que seria uma opção muito interessante.

Por fim, encontramos o Google Cloud SQL e movemos as relações para o MySQL. Deu muito trabalho, mas funcionou muito bem. As atualizações agora são totalmente atômicas e os testes continuam sendo aprovados. Isso também tornou a implementação da combinação de jogadores e da pontuação muito mais confiável.

Com o tempo, mais dados foram migrados 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 as relações entre eles são armazenadas no SQL.

Também precisávamos saber quem estava jogando e criar pares com os jogadores usando um mecanismo de combinação que levava em consideração o nível de habilidade e a experiência dos jogadores. A criação de combinações é baseada na biblioteca de código aberto Glicko2.

Como este é um jogo multiplayer, queremos informar aos outros jogadores na sala sobre eventos como "quem entrou ou saiu", "quem ganhou ou perdeu" e se há um desafio a ser aceito. Para isso, incorporamos o recurso de receber notificações na API Player Management.

Como configurar o WebRTC

Quando dois jogadores são confrontados para uma batalha, um serviço de sinalização é usado para fazer com que os dois colegas conversem entre si e para ajudar a iniciar uma conexão entre os jogadores.

Há várias bibliotecas de terceiros que você pode usar 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 WebRTC do PubNub. O 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 ter instalado no Google Compute Engine, mas também teríamos que garantir que ela pudesse 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 é o escalonamento. O escalonamento dos recursos necessários para um projeto do App Engine é facilmente gerenciado pelo Google Developers Console e não é necessário nenhum trabalho extra para escalonar o serviço de sinalização ao usar a API Channels.

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

Como não escolhemos usar uma biblioteca de terceiros para ajudar com o WebRTC, tivemos que criar a nossa. Felizmente, conseguimos reutilizar muito do trabalho que fizemos para o projeto CubeSlam. Quando os dois jogadores entram em uma sessão, ela é definida como "ativa", e ambos usam o ID da sessão ativa para iniciar a conexão ponto a ponto por meio da API Channel. Depois disso, toda a comunicação entre os dois players será gerenciada 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 informações sobre a configuração do WebRTC no artigo WebRTC no mundo real do HTML5 Rocks: STUN, TURN, and flaging (em inglês).

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

A API Channel

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

Trabalhar com a API Channels tem alguns obstáculos. Um exemplo é que, como as mensagens podem vir desordenadas, tivemos que agrupar todas as mensagens em um objeto e classificá-las. Confira alguns exemplos de código que mostram 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 colocar tudo isso para funcionar no desenvolvimento, percebemos que a API Channel não funciona com módulos em produção. Em vez disso, passamos a usar instâncias separadas do GAE e tivemos problemas de CORS que nos obrigaram a usar uma ponte postMessage (link em inglês) de iframe.

Mecanismo do jogo

Para tornar o mecanismo de jogo o mais dinâmico possível, criamos o aplicativo de front-end usando a abordagem entity-component-system (ECS). Quando começamos o desenvolvimento, os wireframes e a especificação funcional não estavam definidos, então foi muito útil poder adicionar recursos e lógica à medida que o desenvolvimento avançava. Por exemplo, o primeiro protótipo usou um canvas-render-system simples para mostrar as entidades em uma grade. Algumas iterações depois, um sistema para colisões foi adicionado e outro para jogadores controlado por IA. No meio do projeto, poderíamos mudar para um sistema de renderizador 3D sem mudar o resto do código. Quando as partes da rede estavam funcionando, o ai-system poderia ser modificado para usar comandos remotos.

Portanto, a lógica básica do multiplayer é enviar a configuração do comando de ação ao outro ponto por meio de DataChannels e deixar a simulação agir como se fosse um jogador de IA. Além disso, existe uma lógica para decidir qual curva será usada, se o jogador pressionar botões de ataque/passar, colocar comandos em fila se eles entrarem enquanto o jogador ainda está olhando para a animação anterior etc.

Se apenas dois usuários trocam de turno, ambos podem compartilhar a responsabilidade de passar a rodada para o oponente quando terminarem, mas há um terceiro participante envolvido. O sistema de IA voltou a ser útil (não apenas para testes) quando precisávamos adicionar inimigos, como aranhas e trolls. Para que se encaixassem no fluxo baseado em turnos, eles tinham que ser gerados e executados exatamente da mesma forma em ambos os lados. Isso foi resolvido permitindo que um ponto controlasse o sistema de turnos e enviasse o status atual ao outro ponto remoto. Então, quando é a vez das aranhas, o gerenciador de turnos permite que o sistema de IA crie um comando que é enviado ao usuário remoto. Como o mecanismo do jogo atua apenas com base em comandos e "entity-id:s", o jogo será simulado da mesma forma nos dois lados. Todas as unidades também podem ter o componente de IA, que permite testes automatizados fáceis.

Era ideal ter um renderizador de tela mais simples no início do desenvolvimento, com foco 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 é fácil chegar a um estado jogável devido à arquitetura.

A posição do mouse é enviada com mais frequência ao usuário remoto e uma luz 3D dá dicas sutis sobre onde o cursor está no momento.