L'expérience du Hobbit 2014

Ajouter un gameplay WebRTC à l'expérience Hobbit

Daniel Isaksson
Daniel Isaksson

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

  • Jeux en P2P avec WebRTC et WebGL sur Chrome pour Android
  • Créer 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 qui servent de salles de jeu où plusieurs joueurs peuvent s'affronter dans des batailles en mode peer-to-peer. 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 simplifier l'interaction et la scène, nous avons décidé de n'utiliser qu'un seul bouton pour défier et accepter, et d'utiliser la salle uniquement pour afficher les événements et le roi de la colline actuel. 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 explique ces défis 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 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'à rechercher les collisions avec des objets dans la même carte ou les cartes voisines.

Composantes du jeu

Pour créer ce jeu multijoueur, nous avons dû créer 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 la signalisation de l'API AppEngine Channels utilisée pour se connecter et communiquer avec tous les joueurs des salles de jeu.
  • 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. NDB est une interface de Google Cloud Datastore. L'utilisation de NDB a très bien fonctionné au début, mais nous avons rapidement rencontré un problème concernant la façon dont nous devions l'utiliser. La requête a été exécutée sur la version "committée" de la base de données (les écritures NDB sont expliquées en détail dans cet article détaillé), qui peut avoir un délai de plusieurs secondes. Toutefois, les entités elles-mêmes n'ont pas connu ce délai, car elles répondent directement à partir du cache. Il peut être un peu plus facile de l'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 tout rose. Il présente quelques limites, dont les plus notables sont la taille de valeur de 1 Mo (vous ne pouvez pas avoir trop de salles associées à un champ de bataille) et l'expiration des clés, ou comme l'expliquent les documentations:

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. 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 en un 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 parfaitement. Les mises à jour sont désormais entièrement atomiques et les tests sont toujours réussis. Cela a également rendu l'implémentation du matchmaking et du suivi des scores beaucoup plus fiable.

Au fil du temps, une partie croissante des données a été transférée de NDB et de memcache vers SQL, mais en général, les entités joueur, champ de bataille et salle sont toujours stockées dans NDB, tandis que les sessions et les relations entre elles sont 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. Parmi les options disponibles, citons PeerJS, SimpleWebRTC et le SDK WebRTC PubNub. 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.

L'un des principaux avantages de l'utilisation de Google Cloud Platform dans ce cas est la scalabilité. 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 que nous avons effectué pour le projet CubeSlam. Lorsque les deux joueurs ont rejoint une session, celle-ci est définie sur "active". Les deux joueurs utilisent ensuite cet ID de session actif pour établir la connexion point à point 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. Il est encore en version alpha, mais pour nos besoins, il a fonctionné parfaitement. 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 rencontré quelques obstacles. Par exemple, comme les messages peuvent être envoyés sans ordre, nous avons dû les encapsuler 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. Nous avons donc utilisé des instances GAE distinctes et avons rencontré des problèmes CORS qui nous ont obligés à utiliser un pont postMessage dans une 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 collision et un système pour les joueurs contrôlés par IA ont été ajoutés. 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 multijoueur consiste donc à envoyer la configuration de la commande d'action à l'autre pair via DataChannels et à laisser la simulation agir comme s'il s'agissait d'un joueur 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. Pour résoudre ce problème, un pair a été autorisé à contrôler le système de virage et à envoyer l'état actuel au pair distant. Lorsque c'est au tour des araignées, le gestionnaire de tours permet au système d'IA de 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 les ID d'entité, le jeu sera simulé 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 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 comme moteur 3D, et l'architecture nous a permis d'atteindre facilement un état jouable.

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.