Como adicionar a jogabilidade do WebRTC à experiência do Hobbit
A tempo para o novo filme do Hobbit "O Hobbit: A Batalha dos Cinco Exércitos", trabalhamos para ampliar o experimento do Google Chrome do ano passado, Uma Viagem pela Terra-média, com conteúdo novo. Desta vez, o foco principal foi ampliar o uso do WebGL, já que mais navegadores e dispositivos podem visualizar o conteúdo e trabalhar com os recursos WebRTC no Google Chrome e no Firefox. Nossos objetivos com o experimento deste ano eram:
- 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
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 precisa 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 solucionou alguns problemas no setor de combinação e nos permitiu encontrar 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. Assim, ficou mais fácil testar a jogabilidade no papel, já que 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 colisões com objetos no mesmo bloco ou nos blocos vizinhos.
Partes do jogo
Para criar esse 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 processa a sincronização do estado e das mensagens RTC entre os dois jogadores/peering.
- A visualização de jogos 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á relacionado 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 fazer isso, primeiro usamos o NDB para entidades e a interface de consulta para lidar com as relações. 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 em si não tiveram 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 click-to-deploy simples, 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 transferimos as relações para o MySQL. Deu muito trabalho, mas acabou funcionando muito bem. As atualizações agora são totalmente atômicas e os testes ainda são aprovados. Também tornou a implementação da criação de partidas e da manutenção de pontos muito mais confiável.
Com o tempo, mais dados foram lentamente movidos do NDK e do Memcache para o SQL. No entanto, em geral, as entidades do jogador, do campo de batalha e da sala ainda são armazenadas no NBS, enquanto as sessões e as relações entre todos são armazenadas 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, incorporamos a capacidade de receber notificações na API de gerenciamento do player.
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. 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. É fácil gerenciar o dimensionamento dos recursos necessários para um projeto do App Engine 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 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, 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 todas as comunicações de e para a sala de jogos no lado do cliente. A API Management do jogador usa a API Channel para as notificações sobre eventos de jogos.
O trabalho com a API Channels teve alguns problemas. Por exemplo, como as mensagens podem chegar 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 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 sistema de renderização de canvas 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 renderização 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 turno, os dois colegas poderiam compartilhar a responsabilidade de passar a vez para o adversário quando terminasse, mas há um terceiro participante. 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. Então, quando é a rotação dos indexadores, o gerenciador de turnos deixa o sistema de IA criar 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 de IA, que facilita testes automatizados.
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 de verdade quando a versão 3D foi implementada e as cenas ganharam vida com ambientes e animações. Usamos three.js como um 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.