La experiencia El Hobbit 2014

Cómo agregar la jugabilidad de WebRTC a la experiencia de Hobbit

Daniel Isaksson
Daniel Isaksson

A tiempo para la nueva película de Hobbit, “El Hobbit: La batalla de los cinco ejércitos”, trabajamos en la extensión del experimento de Chrome del año pasado, Un viaje por la Tierra Media, con contenido nuevo. Esta vez, el enfoque principal fue ampliar el uso de WebGL, ya que más navegadores y dispositivos pueden ver el contenido y trabajar con las capacidades de WebRTC en Chrome y Firefox. Teníamos tres objetivos con el experimento de este año:

  • Juego P2P con WebRTC y WebGL en Chrome para Android
  • Crea un juego multijugador fácil de jugar y basado en la entrada táctil
  • Alojamiento en Google Cloud Platform

Definición del juego

La lógica del juego se basa en una configuración basada en cuadrículas con tropas que se mueven en un tablero de juego. Esto nos permitió probar la jugabilidad en papel mientras definíamos las reglas. El uso de una configuración basada en cuadrículas también ayuda con la detección de colisiones en el juego para mantener un buen rendimiento, ya que solo debes verificar si hay colisiones con objetos en las mismas tarjetas o en las adyacentes. Sabíamos desde el principio que queríamos enfocar el nuevo juego en una batalla entre las cuatro fuerzas principales de la Tierra Media: humanos, enanos, elfos y orcos. También tenía que ser lo suficientemente informal para poder jugarlo en un experimento de Chrome y no tener demasiadas interacciones para aprender. Primero, definimos cinco campos de batalla en el mapa de la Tierra Media que funcionan como salas de juego en las que varios jugadores pueden competir en una batalla entre pares. Mostrar a varios jugadores en la sala en una pantalla de dispositivo móvil y permitir que los usuarios seleccionen a quién desafiar fue un desafío en sí mismo. Para facilitar la interacción y la escena, decidimos tener un solo botón para desafiar y aceptar, y solo usar la sala para mostrar eventos y quién es el rey de la colina actual. Esta dirección también resolvió algunos problemas relacionados con el sistema de emparejamiento y nos permitió encontrar los mejores candidatos para una batalla. En nuestro experimento anterior de Chrome, Cube Slam, aprendimos que se necesita mucho trabajo para controlar la latencia en un juego multijugador si el resultado del juego depende de ella. Tienes que suponer constantemente dónde estará el estado del oponente, dónde cree que estás y sincronizarlo con animaciones en diferentes dispositivos. En este artículo, se explican estos desafíos con más detalle. Para que sea un poco más fácil, hicimos que este juego fuera por turnos.

La lógica del juego se basa en una configuración basada en cuadrículas con tropas que se mueven en un tablero de juego. De esta forma, pudimos probar fácilmente el juego en papel mientras definíamos las reglas. El uso de una configuración basada en cuadrículas también ayuda con la detección de colisiones en el juego para mantener un buen rendimiento, ya que solo debes verificar si hay colisiones con objetos en las mismas o adyacentes tarjetas.

Partes del juego

Para crear este juego multijugador, tuvimos que compilar algunas partes clave:

  • Una API de administración de jugadores del servidor controla los usuarios, la creación de partidas, las sesiones y las estadísticas del juego.
  • Servidores para ayudar a establecer la conexión entre los jugadores.
  • Es una API para controlar los indicadores de la API de AppEngine Channels que se usan para conectarse y comunicarse con todos los jugadores de las salas de juegos.
  • Un motor de juego de JavaScript que controla la sincronización del estado y los mensajes de RTC entre los dos jugadores o pares.
  • Vista del juego de WebGL.

Administración de jugadores

Para admitir una gran cantidad de jugadores, usamos muchas salas de juego paralelas por campo de batalla. El motivo principal para limitar la cantidad de jugadores por sala de juego es permitir que los jugadores nuevos lleguen a la cima de la tabla de clasificación en un tiempo razonable. El límite también está conectado con el tamaño del objeto JSON que describe la sala de juegos enviada a través de la API de canal y que tiene un límite de 32 KB. Tenemos que almacenar jugadores, salas, puntuaciones, sesiones y sus relaciones en el juego. Para ello, primero usamos NDB para las entidades y la interfaz de consulta para controlar las relaciones. NDB es una interfaz de Google Cloud Datastore. El uso de NDB funcionó muy bien al principio, pero pronto nos encontramos con un problema con la forma en que debíamos usarlo. La consulta se ejecutó en la versión "confirmada" de la base de datos (las operaciones de escritura de NDB se explican en detalle en este artículo detallado), que puede tener una demora de varios segundos. Sin embargo, las entidades en sí no tuvieron ese retraso, ya que responden directamente desde la caché. Es posible que sea más fácil explicarlo con un código de ejemplo:

// 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,
    }

Después de agregar pruebas de unidades, pudimos ver el problema con claridad y nos alejamos de las consultas para mantener las relaciones en una lista separada por comas en memcache. Esto fue un poco hack, pero funcionó, y el memcache de AppEngine tiene un sistema similar a una transacción para las claves que usa la excelente función "comparar y establecer", por lo que las pruebas volvieron a aprobarse.

Lamentablemente, memcache no es todo color de rosa, sino que tiene algunos límites, los más notables son el tamaño de valor de 1 MB (no se pueden tener demasiadas salas relacionadas con un campo de batalla) y el vencimiento de claves, o como se explica en la documentación:

Consideramos usar otro gran almacén de pares clave-valor, Redis. Sin embargo, en ese momento, configurar un clúster escalable era un poco abrumador y, como preferíamos enfocarnos en crear la experiencia en lugar de mantener servidores, no seguimos ese camino. Por otro lado, Google Cloud Platform lanzó hace poco una función sencilla de implementación en un clic, con una de las opciones de clúster de Redis, por lo que hubiera sido una opción muy interesante.

Por último, encontramos Google Cloud SQL y trasladamos las relaciones a MySQL. Fue mucho trabajo, pero al final funcionó muy bien, las actualizaciones ahora son completamente atómicas y las pruebas siguen pasando. También hizo que la implementación de la creación de partidas y la puntuación sea mucho más confiable.

Con el tiempo, más datos se trasladaron lentamente de NDB y memcache a SQL, pero, en general, las entidades de jugador, campo de batalla y sala aún se almacenan en NDB, mientras que las sesiones y las relaciones entre todas ellas se almacenan en SQL.

También teníamos que hacer un seguimiento de quién jugaba con quién y emparejar a los jugadores entre sí con un mecanismo de coincidencia que tuviera en cuenta el nivel de habilidad y la experiencia de los jugadores. Basamos el emparejamiento en la biblioteca de código abierto Glicko2.

Como se trata de un juego multijugador, queremos informar a los demás jugadores de la sala sobre eventos como "quién entró o salió", "quién ganó o perdió" y si hay un desafío para aceptar. Para controlar esto, incorporamos la capacidad de recibir notificaciones en la API de Player Management.

Cómo configurar WebRTC

Cuando dos jugadores se emparejan para una batalla, se usa un servicio de señalización para que los dos pares coincidentes se comuniquen entre sí y para ayudar a iniciar una conexión entre pares.

Existen varias bibliotecas de terceros que puedes usar para el servicio de señalización, lo que también simplifica la configuración de WebRTC. Algunas opciones son PeerJS, SimpleWebRTC y el SDK de PubNub WebRTC. PubNub usa una solución de servidor alojado y, para este proyecto, queríamos alojarlo en Google Cloud Platform. Las otras dos bibliotecas usan servidores node.js que podríamos haber instalado en Google Compute Engine, pero también tendríamos que asegurarnos de que puedan controlar miles de usuarios simultáneos, algo que ya sabíamos que la API de Channel puede hacer.

Una de las principales ventajas de usar Google Cloud Platform en este caso es la escalabilidad. El escalamiento de los recursos necesarios para un proyecto de App Engine se puede manejar fácilmente con Google Developers Console y no se necesita ningún trabajo adicional para escalar el servicio de señalización cuando se usa la API de Channels.

Teníamos algunas inquietudes sobre la latencia y la solidez de la API de Channels, pero la habíamos usado anteriormente para el proyecto CubeSlam y había demostrado funcionar para millones de usuarios en ese proyecto, por lo que decidimos volver a usarla.

Como no elegimos usar una biblioteca de terceros para ayudar con WebRTC, tuvimos que crear la nuestra. Por suerte, pudimos volver a usar gran parte del trabajo que hicimos para el proyecto CubeSlam. Cuando ambos jugadores se unen a una sesión, la sesión se establece como "activa" y ambos jugadores usarán ese ID de sesión activa para iniciar la conexión entre pares a través de la API del canal. Después de eso, toda la comunicación entre los dos jugadores se controlará a través de un RTCDataChannel.

También necesitamos servidores STUN y TURN para ayudar a establecer la conexión y controlar las NAT y los firewalls. Obtén más información sobre la configuración de WebRTC en el artículo de HTML5 Rocks WebRTC en el mundo real: STUN, TURN y señalización.

La cantidad de servidores TURN que se usan también debe poder escalar en función del tráfico. Para controlar esto, probamos el Administrador de implementaciones de Google. Nos permite implementar recursos de forma dinámica en Google Compute Engine y, además, instalar servidores TURN con una plantilla. Sigue en fase alfa, pero funciona a la perfección para nuestros fines. Para el servidor TURN, usamos coturn, que es una implementación de STUN/TURN muy rápida, eficiente y aparentemente confiable.

La API de Channel

La API de Channel se usa para enviar todas las comunicaciones hacia y desde la sala de juegos del lado del cliente. Nuestra API de administración de jugadores usa la API de Channel para sus notificaciones sobre eventos de juegos.

Trabajar con la API de Channels tuvo algunos inconvenientes. Un ejemplo es que, como los mensajes pueden llegar sin orden, tuvimos que unirlos todos en un objeto y ordenarlos. A continuación, se muestra un ejemplo de código sobre cómo 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
    }
  }
}

También queríamos mantener las diferentes APIs del sitio modulares y separadas del hosting del sitio, y comenzamos a usar los módulos integrados en GAE. Lamentablemente, después de que todo funcionara en el entorno de desarrollo, nos dimos cuenta de que la API de Channel no funciona con los módulos en producción. En su lugar, comenzamos a usar instancias de GAE independientes y nos encontramos con problemas de CORS que nos obligaron a usar un puente postMessage de iframe.

Motor del juego

Para que el motor de juego sea lo más dinámico posible, compilamos la aplicación del frontend con el enfoque de sistema de componentes de entidad (ECS). Cuando comenzamos el desarrollo, no se habían definido los esquemas de página ni la especificación funcional, por lo que fue muy útil poder agregar funciones y lógica a medida que avanzaba el desarrollo. Por ejemplo, el primer prototipo usaba un sistema de renderización de lienzo simple para mostrar las entidades en una cuadrícula. Un par de iteraciones más tarde, se agregó un sistema para las colisiones y otro para los jugadores controlados por IA. En medio del proyecto, podríamos cambiar a un sistema de renderización en 3D sin cambiar el resto del código. Cuando las partes de red estaban en funcionamiento, se podía modificar el sistema de IA para usar comandos remotos.

Por lo tanto, la lógica básica del modo multijugador es enviar la configuración del comando de acción al otro par a través de DataChannels y permitir que la simulación actúe como si fuera un jugador de IA. Además, hay una lógica para decidir qué turno es, si el jugador presiona los botones de pase o ataque, pone en cola los comandos si llegan mientras el jugador aún mira la animación anterior, etcétera.

Si solo fueran dos usuarios que cambiaran de turno, ambos pares podrían compartir la responsabilidad de pasar el turno al oponente cuando terminen, pero hay un tercer jugador involucrado. El sistema de IA volvió a ser útil (no solo para hacer pruebas), cuando necesitábamos agregar enemigos, como arañas y trolls. Para que se ajusten al flujo por turnos, se tuvieron que crear y ejecutar exactamente igual en ambos lados. Para resolver este problema, se permitió que un par controle el sistema de giro y envíe el estado actual al par remoto. Luego, cuando llega el turno de los spiders, el administrador de turnos permite que el sistema de IA cree un comando que se envía al usuario remoto. Dado que el motor de juego solo actúa en los comandos y los IDs de entidad, el juego se simulará de la misma manera en ambos lados. Todas las unidades también pueden tener el componente ai, que permite realizar pruebas automatizadas de forma sencilla.

Lo ideal era tener un renderizador de lienzo más simple al comienzo del desarrollo y enfocarse en la lógica del juego. Pero la verdadera diversión comenzó cuando se implementó la versión 3D y las escenas cobraron vida con entornos y animaciones. Usamos three.js como motor 3D, y fue fácil llegar a un estado jugable debido a la arquitectura.

La posición del mouse se envía con más frecuencia al usuario remoto y se muestran sugerencias sutiles de luz en 3D sobre dónde se encuentra el cursor en ese momento.