La experiencia El Hobbit 2014

Cómo agregar la dinámica de juego de WebRTC a Hobbit

Daniel Isaksson
Daniel Isaksson

A tiempo para la nueva película de Hobbit “El hobbit: La batalla de los cinco ejércitos”, trabajamos para extender el experimento de Chrome del año pasado, Un viaje por la Tierra Media, con contenido nuevo. En esta ocasión, el enfoque principal ha sido 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. Tuvimos tres objetivos con el experimento de este año:

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

Define el 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 facilitó la prueba de juego en papel mientras definíamos las reglas. Usar 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 mosaicos vecinas. Desde el principio, sabíamos que queríamos centrar el nuevo juego en torno a una batalla entre las cuatro fuerzas principales de la Tierra Media: los humanos, los enanos, los elfos y los orcos. También tenía que ser lo suficientemente informal para jugarlo en un experimento de Chrome y no tener demasiadas interacciones para aprender. Comenzamos definiendo cinco campos de batalla en el mapa de la Tierra Media que funcionan como salas de juego donde varios jugadores pueden competir en una batalla entre pares. Mostrar a varios jugadores en la sala en una pantalla móvil y permitir que los usuarios seleccionen a quién desafiar era un desafío en sí mismo. Para facilitar la interacción y la escena, decidimos tener solo un botón para desafiar y aceptar, y solo usar la sala para mostrar eventos y quién es el rey actual de la colina. Esta instrucción también resolvió algunos problemas respecto a la creación de partidas y nos permitió unir a los mejores candidatos para una batalla. En nuestro experimento anterior de Chrome, Cube Slam, descubrimos que se necesita mucho trabajo para controlar la latencia en un juego multijugador si el resultado depende de esta. Constantemente, debes hacer suposiciones sobre dónde estará el estado del oponente, dónde cree que eres tú y sincronizar esa información 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 este juego 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. Esto nos facilitó la prueba de juego en papel mientras definíamos las reglas. Usar 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 tienes que verificar si hay colisiones con objetos en el mismo mosaico o en mosaicos cercanos.

Partes del juego

Para este juego multijugador, tuvimos que desarrollar 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 de los juegos.
  • Servidores que permiten establecer la conexión entre los jugadores
  • Una API para controlar la señalización de la API de AppEngine Channels que se usa para conectarse y comunicarse con todos los jugadores en las salas de juegos.
  • Un motor de juego de JavaScript que controla la sincronización del estado y la mensajería RTC entre los dos jugadores o pares.
  • La vista de juego de WebGL

Administración del jugador

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 parte superior 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 del canal, cuyo límite es de 32 KB. Tenemos que almacenar jugadores, salas, puntuaciones, sesiones y sus relaciones en el juego. Para hacerlo, primero usamos NDB para las entidades y la interfaz de consulta para tratar las relaciones. NBS es una interfaz para Google Cloud Datastore. El uso de NDB funcionó muy bien al principio, pero pronto tuvimos un problema con la forma en que necesitábamos usarlo. La consulta se ejecutó en la versión “confirmada” de la base de datos (las escrituras de NoSQL se explican en profundidad en este artículo detallado), que puede tener un retraso de varios segundos. Sin embargo, las entidades en sí no tuvieron ese retraso, ya que responden directamente desde la caché. Puede ser un poco más fácil de explicar con algún 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, en su lugar, nos alejamos de las consultas para mantener las relaciones en una lista separada por comas en memcache. Esto parecía un truco, pero funcionó y la Memcache de AppEngine tiene un sistema similar a la transacción para las claves que usa la excelente función de “comparar y establecer”, por lo que ahora las pruebas pasaron de nuevo.

Desafortunadamente, Memcache no es solo arcoíris y unicornios, pero tiene algunos límites. Los más notables son el tamaño de 1 MB (no puede tener demasiadas salas relacionadas con un campo de batalla) y el vencimiento de la clave, o como lo explica los documentos:

Consideramos usar Redis, otro excelente almacén de pares clave-valor. Sin embargo, en ese momento, la configuración de un clúster escalable era un poco abrumadora y, como preferimos centrarnos en desarrollar la experiencia que en mantener los servidores, no seguimos ese camino. Por otro lado, Google Cloud Platform recientemente lanzó una función sencilla de implementación en un clic, con una de las opciones como un clúster de Redis, de modo que habría sido una opción muy interesante.

Por último, encontramos Google Cloud SQL y trasladamos las relaciones a MySQL. Fue mucho trabajo, pero con el tiempo el resultado fue excelente: las actualizaciones ahora son completamente atómicas y las pruebas siguen siendo exitosas. También hizo que la implementación de la creación de partidas y el mantenimiento de puntuaciones fuera mucho más confiable.

Con el paso del tiempo, la mayor parte de los datos se han transferido lentamente a SQL, pero, en general, las entidades de jugador, campo de batalla y sala se siguen almacenando en NBS mientras que las sesiones y relaciones entre ellas se almacenan en SQL.

También teníamos que hacer un seguimiento de quiénes jugaban y emparejar a los jugadores con un mecanismo de emparejamiento que consideraba el nivel de habilidad y la experiencia del jugador. Basamos la creación de partidas en la biblioteca de código abierto Glicko2.

Dado que se trata de un juego multijugador, queremos informar a los demás jugadores de la sala sobre eventos como "quién ingresó o se fue", "quién ganó o perdió", y si hay un desafío que aceptar. Para manejar 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 emparejados hablen entre sí y ayuden a iniciar una conexión entre pares.

Hay varias bibliotecas de terceros que puedes usar para el servicio de señalización. Esto también simplifica la configuración de WebRTC. Algunas opciones son PeerJS, SimpleWebRTC y SDK de WebRTC de PubNub. 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 pudiera manejar miles de usuarios simultáneos, algo que ya sabíamos que la API de canal puede hacer.

Una de las ventajas principales de usar Google Cloud Platform en este caso es el escalamiento. El escalamiento de los recursos necesarios para un proyecto de App Engine se controla fácilmente a través de Google Developers Console y no se necesita 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 lo sólida que es la API de Channels, pero ya la habíamos usado para el proyecto CubeSlam y había demostrado funcionar para millones de usuarios en ese proyecto, así que decidimos volver a usarla.

Como no elegimos usar una biblioteca de terceros para ayudar con WebRTC, tuvimos que compilar la nuestra. Por suerte, pudimos reutilizar gran parte del trabajo que hicimos para el proyecto CubeSlam. Cuando ambos jugadores se unen a una sesión, la sesión se establece en "activa", y ambos jugadores usarán ese ID de sesión activo para iniciar la conexión entre pares a través de la API de Channel. 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 lidiar con NAT y firewalls. Lee más información sobre la configuración de WebRTC en el artículo WebRTC en el mundo real: STUN, TURN y señalización de HTML5 Rocks.

La cantidad de servidores TURN utilizados también debe poder escalar en función del tráfico. Para manejar esto, probamos Google Deployment Manager. Nos permite implementar recursos de forma dinámica en Google Compute Engine y también instalar servidores TURN con una plantilla. Todavía está en fase alfa, pero ha funcionado sin problemas para nuestros fines. Para el servidor TURN, usamos coturn, que es una implementación muy rápida, eficiente y aparentemente confiable de STUN/TURN.

API de canal

La API de Channel se usa para enviar toda la comunicación desde y hacia la sala de juegos del cliente. Nuestra API de gestión de jugadores utiliza la API de canal para las notificaciones de eventos del juego.

Trabajar con la API de Channels tuvo algunos cambios de velocidad. Un ejemplo es que, debido a que los mensajes pueden venir desordenados, tuvimos que unir todos los mensajes en un objeto y ordenarlos. Este es un código de ejemplo que muestra 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 que las diferentes APIs del sitio fueran modulares, separadas del hosting del sitio y comenzamos con el uso de los módulos integrados en GAE. Lamentablemente, después de hacer que todo funcione en el entorno de desarrollo, nos dimos cuenta de que la API de canal no funciona con módulos en producción. En su lugar, empezamos a usar instancias de GAE separadas y encontramos problemas de CORS que nos obligaron a usar un puente postMessage de iframe.

Motor del juego

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

Por lo tanto, la lógica básica del modo multijugador es enviar la configuración del comando "action-command" al otro grupo a través de DataChannels y dejar que la simulación actúe como si fuera un juego de IA. Además, hay lógica para decidir el turno que hace, si el jugador presiona botones de pase o ataque, comandos en cola si entran y el jugador sigue mirando la animación anterior, etcétera.

Si fuera solo dos usuarios que cambian de turnos, ambos compañeros 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 las pruebas) cuando necesitábamos agregar enemigos como arañas y trolls. Para que encajaran en el flujo por turnos, debía generarse y ejecutarse exactamente en ambos lados. Esto se resolvió permitiendo que un par controle el sistema de giro y envíe el estado actual al par remoto. Luego, cuando llegue el turno de las arañas, el administrador de turnos permite que el sistema de IA cree un comando que se envía al usuario remoto. Como el motor de juego solo actúa según los comandos y el ID de entidad, el juego se simulará de la misma manera en ambos lados. Todas las unidades también pueden tener el componente de IA, que permite realizar pruebas automatizadas sencillas.

Lo óptimo era tener un procesador de lienzo más simple al comienzo del desarrollo, mientras se enfocaba 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 alcanzar un estado reproducible debido a la arquitectura.

La posición del mouse se envía con mayor frecuencia al usuario remoto y muestra indicadores sutiles con luz 3D sobre dónde se encuentra el cursor en ese momento.