L'expérience du Hobbit 2014

Ajouter le jeu WebRTC à Hobbit Experience

Daniel Isaksson
Daniel Isaksson

À l'occasion de la sortie du nouveau film sur le Hobbit, "Le Hobbit : La Bataille des Cinq Armées", nous avons travaillé à l'extension de l'expérience Chrome de l'année dernière, Un voyage dans la Terre du Milieu, avec de nouveaux contenus. Cette fois-ci, l'objectif principal a été d'élargir l'utilisation de WebGL, car un plus grand nombre de navigateurs et d'appareils peuvent afficher le contenu, et d'utiliser les fonctionnalités WebRTC dans Chrome et Firefox. Nous avions trois objectifs pour ce test :

  • Jeu en P2P à l'aide de WebRTC et WebGL sur Chrome pour Android
  • Conçois un jeu multijoueur facile à jouer et basé sur la saisie tactile
  • Héberger sur Google Cloud Platform

Définir le jeu

La logique du jeu repose sur une configuration basée sur une grille, avec des troupes qui se déplacent sur un plateau de jeu. Cela nous a permis de tester facilement le gameplay sur papier lorsque nous définissions les règles. L'utilisation d'une configuration basée sur une grille facilite également la détection des collisions dans le jeu pour maintenir de bonnes performances, car vous n'avez qu'à vérifier les collisions avec les objets situés dans la même carte ou dans les cartes voisines. Dès le départ, nous savions que nous voulions centrer le nouveau jeu sur une bataille entre les quatre principales forces du Moyen-Âge : les humains, les nains, les elfes et les orcs. Il devait également être suffisamment décontracté pour être joué dans un test Chrome et ne pas nécessiter trop d'interactions à apprendre. 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 pairs. Afficher plusieurs joueurs dans la même pièce sur un écran mobile et permettre aux utilisateurs de sélectionner 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. Nous n'utilisions la salle que pour afficher les événements et le roi actuel de la colline. Cette approche a également permis de résoudre quelques problèmes de mise en relation et de trouver les meilleurs candidats pour un combat. Dans notre précédent test Chrome Cube Slam, nous avons appris qu'il fallait beaucoup de travail pour gérer la latence dans un jeu multijoueur si le résultat du jeu en dépend. Vous devez constamment faire des suppositions sur l'état de l'adversaire, sur l'endroit où il pense que vous vous trouvez, et synchroniser cela avec les animations sur différents appareils. Cet article décrit ces problèmes plus en détail. Pour faciliter la tâche, nous avons conçu ce jeu au tour par tour.

La logique du jeu repose sur une grille, où les troupes se déplacent sur un plateau. Cela nous a permis de tester facilement le gameplay sur papier lorsque nous définissions les règles. L'utilisation d'une configuration basée sur une grille contribue également à la détection de collision dans le jeu afin de maintenir de bonnes performances, car il vous suffit de rechercher les collisions avec des objets sur les mêmes tuiles ou sur des tuiles voisines.

Composantes du jeu

Pour créer ce jeu multijoueur, nous avons dû développer plusieurs éléments clés:

  • Une API de gestion des joueurs côté serveur gère les utilisateurs, le matchmaking, les sessions et les statistiques de jeu.
  • Des serveurs pour établir la connexion entre les joueurs.
  • API permettant de gérer les signaux de l'API App Engine Channels et de communiquer avec tous les joueurs présents dans les salles de jeux.
  • 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 lecteurs

Pour accueillir un grand nombre de joueurs, nous utilisons de nombreuses salles de jeu 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 de se hisser en haut du classement dans un délai raisonnable. La limite est également liée à la taille de l'objet JSON décrivant la salle de jeu envoyée via l'API Channel, qui est limitée à 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 et l'interface de requête pour gérer les relations. NDK est une interface vers Google Cloud Datastore. L'utilisation de NDB a bien fonctionné au début, mais nous avons rapidement rencontré un problème quant à la façon dont nous devions l'utiliser. La requête a été exécutée sur la version validée de la base de données (les écritures NDK sont expliquées en détail dans cet article détaillé), ce qui peut prendre plusieurs secondes. Toutefois, les entités elles-mêmes n'ont pas connu ce délai, car elles répondent directement à partir du cache. Cela pourrait être un peu plus facile à expliquer avec 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 identifier 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 peu de bidouillage, mais cela a fonctionné. Le memcache AppEngine dispose d'un système semblable à une transaction pour les clés à l'aide de l'excellente fonctionnalité "compare and set". Les tests ont donc réussi à nouveau.

Malheureusement, Memcache n'est pas qu'un arc-en-ciel ou une licorne, mais il présente quelques limites. Les plus importantes sont la taille de valeur de 1 Mo (pas trop de pièces en lien avec un champ de bataille) et l'expiration des clés, ou comme l'explique la documentation:

Nous avons envisagé d'utiliser un autre excellent magasin de valeurs clés, Redis. Mais à l'époque, la configuration d'un cluster évolutif était un peu intimidante. Comme nous préférions nous concentrer sur la création de l'expérience plutôt que sur la maintenance des serveurs, nous n'avons pas suivi cette voie. D'autre part, Google Cloud Platform a récemment lancé une fonctionnalité simple de déploiement par clic, dont l'une des options est un cluster Redis. Cela aurait donc été une option très intéressante.

Nous avons finalement trouvé Google Cloud SQL et déplacé les relations vers MySQL. Cela a demandé beaucoup de travail, mais cela a fini par fonctionner. Les mises à jour sont maintenant totalement atomiques et les tests réussissent toujours. Cela a également rendu l'implémentation du matchmaking et du suivi des scores beaucoup plus fiable.

Au fil du temps, de plus en plus de données sont progressivement transférées de Baidu et de Memcache vers SQL, mais en général, les entités de type "player", "champ de bataille" et "salle" sont toujours stockées dans NDK, tandis que les sessions et les relations entre ces entités sont toutes stockées dans SQL.

Nous devions également suivre les matchs et associer les joueurs les uns aux autres à l'aide d'un mécanisme de mise en correspondance tenant compte de leur niveau de compétence et de leur expérience. Nous avons basé la mise en correspondance sur la bibliothèque Open Source Glicko2.

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

Configurer WebRTC

Lorsqu'un match met en relation deux joueurs, un service de signalisation permet de les mettre en relation et de démarrer une connexion entre eux.

Vous pouvez utiliser plusieurs bibliothèques tierces pour le service de signalisation, ce qui simplifie également la configuration de WebRTC. PeerJS, SimpleWebRTC et le SDK WebRTC de PubNub sont disponibles. PubNub utilise une solution de serveur hébergée. Pour ce projet, nous souhaitions 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 aurions également dû nous assurer qu'ils pouvaient gérer des milliers d'utilisateurs simultanés, ce que nous savions déjà être le cas avec l'API Channel.

Dans ce cas, l'un des principaux avantages de Google Cloud Platform est le scaling. La mise à l'échelle des ressources nécessaires à un projet AppEngine est facilement gérée via la Google Developers Console. Aucune tâche supplémentaire n'est nécessaire pour mettre à l'échelle le service de signalisation lorsque vous utilisez l'API Channels.

Nous avions des inquiétudes concernant la latence et la robustesse de l'API Channels, mais nous l'avions déjà utilisée pour le projet CubeSlam, et elle avait fonctionné 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 WebRTC, nous avons dû créer la nôtre. Heureusement, nous avons pu réutiliser une grande partie du travail réalisé pour le projet CubeSlam. Lorsque les deux joueurs ont rejoint une session, la session est définie sur "active". Les deux joueurs utilisent ensuite cet identifiant de session active pour établir la connexion peer-to-peer via l'API Channel. Ensuite, toute communication entre les deux joueurs sera gérée 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 WebRTC dans le monde réel : STUN, TURN et signalisation sur HTML5 Rocks.

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

API Channel

L'API Channel permet d'envoyer toutes les communications à destination et en provenance de la salle de jeu côté client. Notre API Player Management utilise l'API Channel pour ses notifications sur les événements de jeu.

L'utilisation de l'API Channels a connu quelques problèmes. Par exemple, étant donné que 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 illustrant 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 voulions également que les différentes API du site restent modulaires et séparées de l'hébergement du site. Nous avons donc commencé par utiliser les modules intégrés à GAE. Malheureusement, après avoir tout fait fonctionner en développement, nous avons réalisé que l'API Channel ne fonctionne pas avec les modules en production. À la place, nous avons opté pour des instances GAE distinctes et avons rencontré des problèmes CORS qui nous ont forcés à utiliser un pont de messages postMessage iFrame.

Moteur de jeu

Pour rendre le moteur de jeu aussi dynamique que possible, nous avons créé l'application frontale à l'aide de l'approche entité-composant-système (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 de la 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 gestion des collisions a été ajouté et un autre pour les joueurs contrôlés par l'IA. Au milieu du projet, nous avons pu passer à un système de rendu 3D sans modifier le reste du code. Lorsque les éléments de mise en réseau étaient opérationnels, le système d'IA pouvait être modifié pour utiliser des commandes à distance.

La logique de base du mode multijoueur consiste donc à envoyer la configuration de la commande d'action à l'autre pair via DataChannels et à laisser la simulation se comporter comme s'il s'agissait d'un joueur basé sur l'IA. De plus, une logique détermine le tour, si le joueur appuie sur les boutons de passe/d'attaque, met en file d'attente les commandes si elles arrivent alors que le joueur regarde toujours l'animation précédente, etc.

S'il n'y avait que deux utilisateurs qui se passaient le tour, les deux pairs pourraient partager la responsabilité de passer le tour à l'adversaire lorsqu'ils ont terminé, mais un troisième joueur est impliqué. Le système d'IA s'est à nouveau avéré utile (et pas seulement pour les tests) lorsque nous avons dû ajouter des ennemis tels que des araignées et des trolls. Pour les adapter au flux au tour par tour, ils devaient être créés et exécutés 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 tour-à-tour et d'envoyer l'état actuel au pair distant. Ensuite, lorsque c'est au tour des Spiders, le Turn Manager laisse le système d'IA créer une commande qui est envoyée à l'utilisateur distant. Étant donné que le moteur de jeu agit simplement sur les commandes et les ID d'entité, le jeu sera simulé exactement de la même manière des deux côtés. Toutes les unités peuvent également être équipées du composant ai, qui permet de réaliser facilement des tests automatisés.

Il était optimal d'utiliser un moteur de rendu de canevas plus simple au début du développement, tout en se concentrant sur la logique du jeu. Mais le vrai jeu a commencé lorsque la version 3D a été implémentée et que les scènes ont pris vie grâce à des environnements et des animations. Nous utilisons three.js comme moteur 3D, et il a été facile d'obtenir un état jouable grâce à l'architecture.

La position de la souris est envoyée plus fréquemment à l'utilisateur distant, et une lumière 3D subtile indique l'emplacement du curseur à l'instant.