Das Hobbit-Erlebnis 2014

Hinzufügen von WebRTC-Gameplay zum Hobbit-Erlebnis

Isaksson
Daniel Isaksson

Rechtzeitig zum neuen Hobbit-Film „Der Hobbit: Die Schlacht der Fünf Heere“ haben wir das Chrome-Experiment Eine Reise durch Mittelerde aus dem letzten Jahr um einige neue Inhalte erweitert. Dieses Mal lag der Schwerpunkt darauf, die Verwendung von WebGL auszuweiten, da mehr Browser und Geräte die Inhalte sehen können und die WebRTC-Funktion in Chrome und Firefox genutzt werden kann. Mit den diesjährigen Tests hatten wir drei Ziele:

  • P2P-Gaming mit WebRTC und WebGL in Chrome für Android
  • Erstelle ein einfach zu spielendes Multiplayer-Spiel, das auf der Touch-Eingabe basiert
  • Auf der Google Cloud Platform hosten

Das Spiel definieren

Die Spiellogik basiert auf einem rasterbasierten Spielfeld, bei dem sich Truppen auf einem Spielfeld bewegen. So konnten wir das Gameplay ganz einfach auf Papier ausprobieren, während wir die Regeln festgelegt haben. Die rasterbasierte Einrichtung trägt auch zur Kollisionserkennung im Spiel bei und sorgt für eine gute Leistung, da du nur nach Kollisionen mit Objekten in denselben oder benachbarten Kacheln suchen musst. Uns war von Anfang an klar, dass wir uns im neuen Spiel auf einen Kampf zwischen den vier Hauptkräften von Mittelerde, Menschen, Zwergen, Elben und Orks konzentrieren wollten. Außerdem musste es locker genug sein, um in einem Chrome-Experiment gespielt zu werden, und nicht zu viele Interaktionen beinhalten, um es zu lernen. Zuerst haben wir fünf Schlachtfelder auf der Karte von Mittelerde definiert, die als Spieleräume dienen, in denen mehrere Spieler in Peer-to-Peer-Schlachten gegeneinander antreten können. Auf einem Smartphone-Display werden mehrere Spieler im Raum gezeigt, wobei die Möglichkeit zur Auswahl des Wettkampfs besteht. Um die Interaktion und die Szene einfacher zu gestalten, haben wir beschlossen, nur eine Schaltfläche zu verwenden, um sie anzufragen und anzunehmen. Der Raum sollte nur zur Darstellung von Veranstaltungen und zur Anzeige des aktuellen König des Hügels genutzt werden. Auf diese Weise wurden auch einige Probleme bei der Partnerzuordnung behoben und wir konnten die besten Kandidaten für einen Kampf aussuchen. Bei unserem früheren Chrome-Experiment Cube Slam haben wir herausgefunden, dass es viel Arbeit kostet, die Latenz in einem Mehrspielerspiel zu verarbeiten, wenn das Spiel darauf basiert. Du musst ständig Annahmen darüber treffen, wo der Status des Gegners sein wird, wo der Gegner glaubt, du bist, und dies mit Animationen auf verschiedenen Geräten synchronisieren. In diesem Artikel werden diese Herausforderungen ausführlicher beschrieben. Um es dir etwas einfacher zu machen, haben wir dieses Spiel rundenbasiert gestaltet.

Die Spiellogik basiert auf einem rasterbasierten Spielfeld, bei dem sich Truppen auf einem Spielfeld bewegen. So konnten wir das Gameplay ganz einfach auf Papier ausprobieren, während wir die Regeln festgelegt haben. Die rasterbasierte Einrichtung trägt auch zur Kollisionserkennung im Spiel bei und sorgt für eine gute Leistung, da du nur nach Kollisionen mit Objekten in denselben oder benachbarten Kacheln suchen musst.

Teile des Spiels

Für dieses Multiplayer-Spiel mussten wir ein paar wichtige Elemente einbauen:

  • Über eine serverseitige API zur Spielerverwaltung werden Nutzer, Spielsuche, Sitzungen und Spielstatistiken verwaltet.
  • Server zum Herstellen einer Verbindung zwischen den Spielern
  • Eine API für die Verarbeitung der Signalisierung der AppEngine Channels API, die für die Verbindung mit allen Spielern in den Spieleräumen verwendet wird.
  • Eine JavaScript-Spiele-Engine, die die Synchronisierung des Status und der RTC-Nachrichten zwischen den beiden Spielern/Peers vornimmt.
  • WebGL-Spielansicht

Spielerverwaltung

Um eine große Anzahl von Spielern zu unterstützen, verwenden wir viele parallele Spieleräume pro Schlachtfeld. Der Hauptgrund für die Begrenzung der Anzahl von Spielern pro Spielraum besteht darin, dass neue Spieler in angemessener Zeit die Spitze der Bestenliste erreichen können. Das Limit hängt auch mit der Größe des JSON-Objekts zusammen, das den Spieleraum beschreibt, der über die Channel API gesendet wurde (maximal 32 KB). Wir müssen Spieler, Räume, Punktzahlen, Sitzungen und ihre Beziehungen im Spiel speichern. Dazu haben wir zuerst NDB für Entitäten und die Abfrageschnittstelle für Beziehungen verwendet. NDB ist eine Schnittstelle zu Google Cloud Datastore. Die Nutzung von NDB funktionierte am Anfang gut, aber wir stießen bald auf ein Problem bei der Art und Weise, wie wir es nutzen mussten. Die Abfrage wurde mit der Version der Datenbank ausgeführt, für die ein Commit durchgeführt wurde (NDB-Schreibvorgänge werden in diesem ausführlichen Artikel ausführlich erläutert), die eine Verzögerung von mehreren Sekunden haben kann. Die Entitäten selbst hatten diese Verzögerung jedoch nicht, da sie direkt aus dem Cache antworteten. Es könnte etwas einfacher sein, dies mit einem Beispielcode zu erklären:

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

Nachdem wir Einheitentests hinzugefügt hatten, konnten wir das Problem klar erkennen und haben uns von den Abfragen entfernt, um die Beziehungen stattdessen in einer durch Kommas getrennten Liste in memcache zu belassen. Es fühlte sich nach einem Hack an, aber es funktionierte und der AppEngine-Memcache verfügt über ein transaktionsähnliches System für die Schlüssel, das die hervorragende "Vergleichs-und-Einstellen"-Funktion nutzt, sodass die Tests nun erneut bestanden wurden.

Leider ist Memcache nicht alles für Regenbogen und Einhörner, hat aber einige Einschränkungen. Die wichtigsten sind die Größe des Werts von 1 MB (nicht zu viele Räume im Zusammenhang mit einem Schlachtfeld) und die Ablaufzeit des Schlüssels, wie in der Dokumentation erklärt:

Wir haben die Verwendung eines weiteren großartigen Schlüssel/Wert-Speichers, Redis, in Betracht gezogen. Zu dieser Zeit war die Einrichtung eines skalierbaren Clusters jedoch ein wenig einschüchternd. Da wir uns lieber auf den Aufbau der Umgebung als auf die Wartung von Servern konzentrieren wollten, sind wir diesen Weg nicht eingeschlagen. Auf der Google Cloud Platform wurde dagegen vor Kurzem eine einfache Click-to-Deploy-Funktion veröffentlicht, wobei eine der Optionen ein Redis-Cluster war. Diese Option wäre also sehr interessant gewesen.

Schließlich fanden wir Google Cloud SQL und verschoben die Beziehungen zu MySQL. Es war viel Arbeit, aber schließlich hat es super funktioniert, die Updates sind jetzt vollständig atomar und die Tests bestehen weiterhin. Außerdem wurde die Implementierung der Zuordnung und Führung der Spielstände wesentlich zuverlässiger.

Im Laufe der Zeit wurden immer mehr Daten von NDB und Memcache zu SQL übertragen, aber im Allgemeinen werden die Spieler-, Schlacht- und Raumentitäten weiterhin in NDB gespeichert, während die Sitzungen und Beziehungen zwischen ihnen alle in SQL gespeichert werden.

Außerdem mussten wir im Auge behalten, wer mit wem spielte, und die Spieler miteinander kombinieren, indem wir das Niveau und die Erfahrung der Spieler berücksichtigten. Die Zuordnung wurde auf Grundlage der Open-Source-Bibliothek Glicko2 erstellt.

Da es sich um ein Multiplayer-Spiel handelt, möchten wir die anderen Spieler im Raum über Ereignisse informieren, z. B. „Wer hat eingetreten oder verloren“, „wer hat gewonnen oder verloren“ und ob es eine Herausforderung gibt, die sie annehmen müssen. Um dies zu umgehen, haben wir die Möglichkeit, Benachrichtigungen in die Player Management API zu empfangen, integriert.

WebRTC einrichten

Wenn sich zwei Spieler in einem Kampf miteinander kombinieren, wird ein Signalisierungsdienst genutzt, um die beiden Spieler dazu zu bringen, miteinander zu sprechen und eine Peer-Verbindung herzustellen.

Für den Signalisierungsdienst gibt es mehrere Bibliotheken von Drittanbietern, die auch die Einrichtung von WebRTC vereinfachen. Einige Optionen sind PeerJS, SimpleWebRTC und PubNub WebRTC SDK. PubNub verwendet eine gehostete Serverlösung und für dieses Projekt wollten wir es auf der Google Cloud Platform hosten. Die anderen beiden Bibliotheken verwenden node.js-Server, die wir in Google Compute Engine hätten installieren können. Wir müssten aber auch dafür sorgen, dass Tausende von Nutzern gleichzeitig ausgeführt werden können – etwas, von dem wir bereits wussten, dass es die Channel API leisten kann.

Einer der Hauptvorteile der Verwendung der Google Cloud Platform in diesem Fall ist die Skalierung. Die Skalierung der für ein AppEngine-Projekt erforderlichen Ressourcen erfolgt einfach über die Google Developers Console. Bei Verwendung der Channels API ist kein zusätzlicher Aufwand zur Skalierung des Signalisierungsdienstes erforderlich.

Es gab einige Bedenken hinsichtlich der Latenz und der Robustheit der Channels API, aber wir hatten sie zuvor für das CubeSlam-Projekt verwendet und sie hatte sich bei Millionen von Nutzern in diesem Projekt als nützlich erwiesen, also beschlossen wir, sie erneut zu verwenden.

Da wir bei WebRTC keine Drittanbieterbibliothek verwenden wollten, mussten wir eine eigene Bibliothek erstellen. Zum Glück konnten wir einen Großteil unserer Arbeit für das CubeSlam-Projekt wiederverwenden. Wenn beide Spieler einer Sitzung beigetreten sind, wird die Sitzung auf "aktiv" gesetzt und beide Spieler verwenden dann diese aktive Sitzungs-ID, um die Peer-to-Peer-Verbindung über die Channel API herzustellen. Danach wird die gesamte Kommunikation zwischen den beiden Spielern über einen RTCDataChannel abgewickelt.

Außerdem benötigen wir STUN- und Turn-Server, um die Verbindung herzustellen und mit NATs und Firewalls umzugehen. Weitere Informationen zur Einrichtung von WebRTC finden Sie im HTML5 Rocks-Artikel WebRTC in the real world: STUN, Turn, and signaling.

Die Anzahl der verwendeten Turn-Server muss ebenfalls je nach Traffic skaliert werden können. Dafür haben wir Google Deployment Manager getestet. Damit können Ressourcen dynamisch in Google Compute Engine bereitgestellt und Turn-Server mithilfe einer Vorlage installiert werden. Die Funktion befindet sich noch in der Alphaphase, aber für unsere Zwecke hat sie einwandfrei funktioniert. Für den Turn-Server verwenden wir coturn, eine sehr schnelle, effiziente und scheinbar zuverlässige Implementierung von STUN/Turn.

Die Channel API

Die Channel API wird verwendet, um die gesamte Kommunikation vom und zum Spieleraum auf der Clientseite zu senden. Unsere Spielerverwaltungs-API verwendet die Channel API für Benachrichtigungen zu Spielereignissen.

Bei der Arbeit mit der Channels API gab es ein paar Stolpersteine. Ein Beispiel ist, dass wir alle Nachrichten in einem Objekt zusammenfassen und sortieren mussten, da sie ungeordnet werden können. Hier ist ein Beispiel-Code, der die Funktionsweise veranschaulicht:

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

Außerdem wollten wir die verschiedenen APIs der Website modular halten und vom Hosting der Website getrennt halten. Zunächst haben wir die in GAE integrierten Module verwendet. Nachdem alles in der Entwicklungsphase zum Laufen gekommen ist, haben wir leider festgestellt, dass die Channel API gar nicht mit Modulen in der Produktion funktioniert. Stattdessen haben wir separate GAE-Instanzen verwendet und sind auf CORS-Probleme gestoßen, sodass wir eine postMessage-Brücke mit iFrames verwenden mussten.

Spiel-Engine

Um die Spiel-Engine so dynamisch wie möglich zu machen, haben wir die Frontend-Anwendung mit dem entity-component-system (ECS)-Ansatz erstellt. Als wir mit der Entwicklung begannen, waren die Wireframes und die Funktionsspezifikationen noch nicht festgelegt. Daher war es sehr hilfreich, Funktionen und Logik im Laufe der Entwicklung hinzufügen zu können. Im ersten Prototyp wurde beispielsweise ein einfaches Canvas-Rendering-System verwendet, um die Entitäten in einem Raster darzustellen. Ein paar Iterationen später wurde ein System für Kollisionen und eines für KI-gesteuerte Spieler hinzugefügt. In der Mitte des Projekts konnten wir zu einem 3D-Renderer-System wechseln, ohne den Rest des Codes zu ändern. Wenn die Netzwerkteile betriebsbereit waren, konnte das KI-System so geändert werden, dass es Remotebefehle verwendet.

Die grundlegende Logik des Mehrspielermodus besteht also darin, die Konfiguration des Aktionsbefehls über DataChannels an den anderen Peer zu senden, damit die Simulation wie ein KI-Spieler funktioniert. Darüber hinaus wird logisch entschieden, welcher Zug ausgeführt wird: Wenn der Spieler auf Pass-/Angriffsschaltflächen drückt, Befehle in die Warteschlange eindrückt, während er sich noch die vorherige Animation ansieht, usw.

Würden nur zwei Spieler den Zug wechseln, könnten beide Spieler die Verantwortung tragen, den Zug an den Gegner weiterzugeben, wenn sie fertig sind. Es ist jedoch ein dritter Spieler beteiligt. Das KI-System wurde nicht nur zum Testen, sondern wieder sehr praktisch, als wir Feinde wie Spinnen und Trolle hinzufügen mussten. Damit sie in den rundenbasierten Ablauf passen, mussten sie auf beiden Seiten genau gleich erzeugt und ausgeführt werden. Dies wurde dadurch gelöst, dass ein Peer das Turnsystem steuern und den aktuellen Status an den Remote-Peer senden ließ. Wenn dann die Spider an der Reihe sind, lässt der Zugmanager vom KI-System einen Befehl erstellen, der an den Remote-Nutzer gesendet wird. Da die Spiel-Engine nur auf Befehle und entity-id:s reagiert, wird das Spiel auf beiden Seiten auf dieselbe Weise simuliert. Alle Einheiten können auch die AI-Komponente haben, was einfache automatisierte Tests ermöglicht.

Es war optimal, zu Beginn der Entwicklung einen einfacheren Canvas-Renderer zu verwenden, während der Fokus auf der Spiellogik lag. Aber der wahre Spaß begann mit der Implementierung der 3D-Version und den Szenen wurden durch Umgebungen und Animationen zum Leben erweckt. Wir haben three.js als 3D-Engine verwendet und es war aufgrund der Architektur leicht, einen spielbaren Zustand zu erreichen.

Die Mausposition wird häufiger an den Remote-Benutzer gesendet und ein subtiles 3D-Licht der Person zeigt an, wo sich der Cursor gerade befindet.