L'expérience du Hobbit 2014

Ajouter le jeu WebRTC à Hobbit Experience

Daniel Isaksson
Daniel Isaksson

À temps pour le nouveau film du Hobbit "Le Hobbit: La Bataille des cinq armées", nous avons ajouté de nouveaux contenus à l'expérience Chrome Experiment de l'année dernière, Un périple à travers la Terre du Milieu. Cette fois-ci, l'objectif principal était d'élargir l'utilisation de WebGL afin que davantage de navigateurs et d'appareils puissent afficher le contenu, et d'exploiter les fonctionnalités WebRTC dans Chrome et Firefox. Cette année, nous avions trois objectifs:

  • Gameplay en P2P avec WebRTC et WebGL sur Chrome pour Android
  • Créer un jeu multijoueur facile à jouer basé sur la saisie tactile
  • Héberger sur Google Cloud Platform

Définir le jeu

La logique du jeu repose sur une grille de troupes qui se déplacent sur un plateau de jeu. Tout au long de la définition des règles, nous avons pu facilement tester le gameplay sur papier. L'utilisation d'une configuration basée sur une grille contribue également à la détection des collisions dans le jeu afin de maintenir de bonnes performances, car il vous suffit de rechercher les collisions avec des objets situés sur la même tuile ou des tuiles voisines. Nous savions dès le départ que nous voulions concentrer le nouveau jeu sur une bataille entre les quatre forces principales : la Terre du Milieu, les Humains, les Nains, les Elfes et les Orques. Il devait également être assez informel pour être joué lors d'une expérience Chrome Experiment, sans avoir à apprendre trop d'interactions. Nous avons commencé par définir cinq champs de bataille sur la carte de la Terre du Milieu. Ils servent de salles de jeux où plusieurs joueurs peuvent s'affronter lors d'une bataille entre collègues. Présenter plusieurs joueurs dans la salle sur un écran mobile et permettre aux utilisateurs de choisir qui défier était un défi en soi. Pour faciliter l'interaction et la scène, nous avons décidé de n'avoir qu'un seul bouton pour défier et accepter, et n'utiliser la pièce que pour montrer les événements et qui est le roi actuel de la colline. Cette approche a également résolu quelques problèmes de mise en correspondance et nous a permis de trouver les meilleurs candidats au combat. Lors de notre précédente expérience Chrome, Cube Slam, nous avons appris que la gestion de la latence dans un jeu multijoueurs demande beaucoup de travail si le résultat du jeu s'appuie dessus. Vous devez constamment estimer où se trouvera l'état de l'adversaire, c'est-à-dire là où l'adversaire pense que vous êtes, et synchroniser cela avec des animations sur différents appareils. Cet article explique ces problèmes plus en détail. Pour vous faciliter la tâche, nous avons créé ce jeu au tour par tour.

La logique du jeu repose sur une grille de troupes qui se déplacent sur un plateau de jeu. Tout au long de la définition des règles, nous avons pu facilement tester le gameplay sur papier. L'utilisation d'une configuration basée sur une grille aide également à la détection des collisions dans le jeu afin de maintenir de bonnes performances, car il vous suffit de vérifier les collisions avec des objets de la même carte ou des tuiles voisines.

Parties du jeu

Pour créer ce jeu multijoueur, nous avons dû construire un certain nombre d'éléments clés:

  • Une API de gestion des joueurs côté serveur gère les utilisateurs, la mise en correspondance, les sessions et les statistiques de jeu.
  • Des serveurs permettant d'établir la connexion entre les joueurs.
  • API destinée à gérer les signaux de l'API AppEngine Channels afin de permettre la connexion et la communication avec tous les joueurs dans les salles de jeux.
  • Un moteur de jeu JavaScript qui gère la synchronisation de l'état et la messagerie RTC entre les deux joueurs/pairs
  • Vue du jeu WebGL.

Gestion des joueurs

Pour prendre en charge un grand nombre de joueurs, nous utilisons de nombreuses salles de jeux parallèles par champ de bataille. La principale raison de limiter le nombre de joueurs par salle de jeu est de permettre aux nouveaux joueurs d'atteindre le sommet du classement dans un délai raisonnable. La limite est également liée à la taille de l'objet JSON décrivant la salle de jeux envoyée via l'API Channel, dont la limite est de 32 Ko. Nous devons stocker les joueurs, les salles, les scores, les sessions et leurs relations dans le jeu. Pour ce faire, nous avons d'abord utilisé NDB pour les entités, puis l'interface de requête pour gérer les relations. NDB est une interface pour Google Cloud Datastore. Au début, l'utilisation de NDB fonctionnait bien, mais nous avons rapidement rencontré un problème. La requête a été exécutée sur la version "validée" de la base de données. Les écritures NDB sont expliquées en détail dans cet article détaillé, ce qui peut avoir un retard de plusieurs secondes. Toutefois, les entités elles-mêmes n'ont pas ce délai, car elles répondent directement à partir du cache. Elle sera peut-être un peu plus facile à expliquer à l'aide d'un exemple de code:

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

Après avoir ajouté des tests unitaires, nous avons pu voir clairement le problème. Nous avons donc abandonné les requêtes pour conserver les relations dans une liste séparée par des virgules dans memcache. Cela ressemblait à un piratage, mais cela a fonctionné et le Memcache d'App Engine a un système de type transaction pour les clés utilisant l'excellente fonctionnalité « compare and set » (comparer et définir). Les tests ont donc à nouveau réussi.

Malheureusement, Memcache n'est pas seulement un arc-en-ciel et une licorne, mais comporte quelques limites. Les plus notables sont la taille de la valeur de 1 Mo (il n'y a pas trop de salles pour un champ de bataille) et l'expiration des clés, comme l'explique la documentation:

Nous avons envisagé d'utiliser un autre excellent magasin de paires clé-valeur, Redis. Mais à l'époque, la configuration d'un cluster évolutif était un peu intimidante et, comme nous préférons nous concentrer sur le développement de l'expérience plutôt que sur la maintenance des serveurs, nous n'avons pas choisi cette voie. D'autre part, Google Cloud Platform a récemment lancé une fonctionnalité simple de déploiement par clic, avec l'une des options disponibles, un cluster Redis. Cette option aurait donc été très intéressante.

Enfin, nous avons découvert Google Cloud SQL et déplacé les relations vers MySQL. C'était beaucoup de travail, mais ça a bien fonctionné, les mises à jour sont désormais entièrement atomiques et les tests réussissent toujours. Cela a également rendu la mise en œuvre de la mise en correspondance et le suivi des scores beaucoup plus fiables.

Au fil du temps, de plus en plus de données sont progressivement transférées de NDB et de Memcache à SQL, mais en général, les entités du joueur, du champ de bataille et de la salle sont toujours stockées dans la bibliothèque NDB, tandis que les sessions et les relations entre elles sont toutes stockées en SQL.

Nous devions aussi savoir qui jouait et associer les uns aux autres à l'aide d'un mécanisme d'association tenant compte du niveau de compétence et de l'expérience des joueurs. La mise en correspondance a été basée sur la bibliothèque Open Source Glicko2.

Étant donné qu'il s'agit d'un jeu multijoueur, nous souhaitons informer les autres joueurs présents dans la salle d'événements tels que "qui est entré ou parti", "qui a gagné ou perdu" et s'il y a un défi à accepter. Pour gérer cela, nous avons intégré la possibilité de recevoir des notifications dans l'API Player Management.

Configurer WebRTC

Lorsque deux joueurs sont impliqués dans une bataille, un service de signalement est utilisé pour permettre aux deux pairs de communiquer entre eux et d'établir un lien entre eux.

Il existe plusieurs bibliothèques tierces que vous pouvez utiliser pour le service de signalement, ce qui simplifie également la configuration de WebRTC. Certaines options sont PeerJS, SimpleWebRTC et le SDK PubNub WebRTC. PubNub utilise une solution de serveur hébergé. Pour ce projet, nous voulions l'héberger sur Google Cloud Platform. Les deux autres bibliothèques utilisent des serveurs Node.js que nous aurions pu installer sur Google Compute Engine, mais nous devrions également nous assurer qu'ils pouvaient gérer des milliers d'utilisateurs simultanés, ce que nous savions déjà que l'API Channel peut faire.

Dans ce cas, l'un des principaux avantages de Google Cloud Platform est son scaling. Google Developers Console permet de gérer facilement la mise à l'échelle des ressources nécessaires pour un projet AppEngine. Aucune action supplémentaire n'est requise pour faire évoluer le service de signalement lorsque vous utilisez l'API Channels.

Nous avions des inquiétudes concernant la latence et la fiabilité de l'API Channels, mais nous l'avions déjà utilisée pour le projet CubeSlam, et elle s'est avérée efficace pour des millions d'utilisateurs dans ce projet. Nous avons donc décidé de l'utiliser à nouveau.

Comme nous n'avons pas choisi d'utiliser une bibliothèque tierce pour nous aider avec WebRTC, nous avons dû créer la nôtre. Heureusement, nous avons pu réutiliser une grande partie du travail que nous avons effectué pour le projet CubeSlam. Lorsque les deux joueurs ont rejoint une session, la session est définie sur "active". Ils utilisent alors cet ID de session active pour initier la connexion peer-to-peer via l'API Channel. Toutes les communications entre les deux joueurs se feront ensuite via un RTCDataChannel.

Nous avons également besoin de serveurs STUN et TURN pour établir la connexion et gérer les NAT et les pare-feu. Pour en savoir plus sur la configuration de WebRTC, consultez l'article de HTML5 Rocks WebRTC in the real world: STUN, TURN, and signaling.

Le nombre de serveurs TURN utilisés doit également pouvoir évoluer en fonction du trafic. Pour cela, nous avons testé Google Deployment Manager. Il permet de déployer des ressources de manière dynamique sur Google Compute Engine et d'installer des serveurs TURN à l'aide d'un modèle. Il est encore en version alpha, mais pour nos besoins, il a fonctionné parfaitement. Pour le serveur TURN, nous utilisons coturn, une implémentation très rapide, efficace et en apparence fiable de STUN/TURN.

API Channel

L'API Channel permet d'envoyer toutes les communications vers et depuis la salle de jeu côté client. Notre API Player Management utilise l'API Channel pour ses notifications concernant les événements de jeu.

L'utilisation de l'API Channels a rencontré quelques problèmes. Par exemple, comme les messages peuvent ne pas être ordonnés, nous avons dû encapsuler tous les messages dans un objet et les trier. Voici un exemple de code expliquant son fonctionnement:

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

Nous souhaitions également que les différentes API du site restent modulaires et séparées de l'hébergement du site. Nous avons commencé par utiliser les modules intégrés à GAE. Malheureusement, après avoir tout mis en œuvre dans le développement, nous nous sommes rendu compte que l'API Channel ne fonctionnait pas du tout avec les modules en production. Au lieu de cela, nous avons utilisé des instances GAE distinctes et avons rencontré des problèmes CORS qui nous ont obligés à utiliser un iFrame postMessage Bridge.

Moteur de jeu

Pour rendre le moteur de jeu aussi dynamique que possible, nous avons créé l'application frontend à l'aide de l'approche entity-component-system (ECS). Lorsque nous avons commencé le développement, les maquettes fonctionnelles et les spécifications fonctionnelles n'étaient pas définies. Il a donc été très utile de pouvoir ajouter des fonctionnalités et une logique au fur et à mesure du développement. Par exemple, le premier prototype utilisait un système de rendu de canevas simple pour afficher les entités dans une grille. Quelques itérations plus tard, un système de collision a été ajouté et un autre pour les joueurs contrôlés par IA. Au milieu du projet, nous avons pu passer à un système de rendu 3D sans modifier le reste du code. Lorsque les parties réseau étaient opérationnelles, le système d'IA a pu être modifié pour utiliser des commandes à distance.

La logique de base du mode multijoueur consiste à envoyer la configuration de la commande action à l'autre pair via DataChannels et à laisser la simulation agir comme si c'était un joueur d'IA. De plus, une logique permet de déterminer le tour : si le joueur appuie sur les boutons de passe ou d'attaque, les commandes de file d'attente s'ils entrent dans la file d'attente alors que le joueur continue de regarder l'animation précédente, etc.

S'il ne s'agissait que de deux joueurs qui changent de tour, les deux pairs pourraient partager la responsabilité de faire le tour à l'adversaire une fois qu'ils ont terminé, mais un troisième joueur sera impliqué. Le système d'IA est redevenue pratique (pas seulement pour les tests), lorsque nous avons dû ajouter des ennemis tels que des araignées et des trolls. Pour qu'elles s'intègrent dans le flux au tour par tour, il devait être généré et exécuté exactement de la même manière des deux côtés. Le problème a été résolu en permettant à un pair de contrôler le système de rotation et d'envoyer l'état actuel au pair distant. Ensuite, lorsque les araignées tournent, le gestionnaire de rotation laisse le système d'IA créer une commande qui est envoyée à l'utilisateur distant. Étant donné que le moteur de jeu n'agit que sur les commandes et l'ID d'entité, le jeu sera simulé de la même manière des deux côtés. Toutes les unités peuvent également comporter un composant d'IA, ce qui facilite les tests automatisés.

Au début du développement, il était préférable d'avoir un moteur de rendu de canevas plus simple, tout en se concentrant sur la logique du jeu. Mais le vrai plaisir a commencé lorsque la version 3D a été implémentée, et que les scènes ont pris vie avec des environnements et des animations. Nous utilisons three.js pour le moteur 3D, et il a été facile d'obtenir un état jouable en raison de l'architecture.

La position de la souris est envoyée plus fréquemment à l'utilisateur distant, ainsi que de subtiles indications 3D sur la position actuelle du curseur.