WebRTC-Gameplay zum Hobbit-Erlebnis hinzufügen
Pünktlich zum neuen Hobbit-Film „Der Hobbit: Die Schlacht der Fünf Heere“ haben wir den Chrome-Test Eine Reise durch Mittelerde aus dem letzten Jahr um einige neue Inhalte erweitert. Der Schwerpunkt lag diesmal darauf, die Verwendung von WebGL zu erweitern, damit die Inhalte in mehr Browsern und auf mehr Geräten angezeigt werden können, und die WebRTC-Funktionen in Chrome und Firefox zu nutzen. Wir hatten drei Ziele mit dem Test in diesem Jahr:
- P2P-Gaming mit WebRTC und WebGL in Chrome für Android
- Ein einfaches, auf Touchbedienung basierendes Multiplayer-Spiel entwickeln
- Auf der Google Cloud Platform hosten
Spiel definieren
Die Spiellogik basiert auf einem Raster, auf dem sich Truppen auf einem Spielbrett bewegen. So konnten wir das Gameplay auf Papier testen, während wir die Regeln definierten. Eine rasterbasierte Einrichtung hilft auch bei der Kollisionserkennung im Spiel, um eine gute Leistung zu erzielen, da Sie nur nach Kollisionen mit Objekten in denselben oder benachbarten Kacheln suchen müssen. Wir wussten von Anfang an, dass wir das neue Spiel auf eine Schlacht zwischen den vier Hauptkräften Mittelerdes – Menschen, Zwergen, Elfen und Orks – konzentrieren wollten. Außerdem musste es ungezwungen genug sein, um in einem Chrome-Test gespielt zu werden, und nicht zu viele Interaktionen erfordern. Wir haben zuerst fünf Schlachtfelder auf der Karte von Mittelerde definiert, die als Spielräume dienen, in denen mehrere Spieler in einem Peer-to-Peer-Kampf gegeneinander antreten können. Es war schon eine Herausforderung, mehrere Spieler im Raum auf einem Mobilgerät anzuzeigen und Nutzern zu ermöglichen, auszuwählen, wen sie herausfordern möchten. Um die Interaktion und die Szene zu vereinfachen, haben wir uns entschieden, nur eine Schaltfläche für Herausforderungen und deren Annahme zu verwenden. Der Raum dient nur dazu, Ereignisse anzuzeigen und zu zeigen, wer der aktuelle König des Hügels ist. Dadurch konnten wir auch einige Probleme beim Matchmaking beheben und die besten Kandidaten für ein Duell finden. Bei unserem vorherigen Chrome-Test Cube Slam haben wir gelernt, dass es viel Arbeit erfordert, die Latenz in einem Mehrspielerspiel zu handhaben, wenn das Ergebnis des Spiels davon abhängt. Sie müssen ständig davon ausgehen, wo sich der Gegner befindet, wo er denkt, dass Sie sich befinden, und das mit den Animationen auf verschiedenen Geräten synchronisieren. In diesem Artikel werden diese Herausforderungen ausführlicher erläutert. Um es etwas einfacher zu machen, haben wir dieses Spiel rundenbasiert gestaltet.
Die Spiellogik basiert auf einem Raster, auf dem sich Truppen auf einem Spielbrett bewegen. So konnten wir das Gameplay auf Papier testen, während wir die Regeln definierten. Eine rasterbasierte Einrichtung hilft auch bei der Kollisionserkennung im Spiel, um eine gute Leistung zu erzielen, da Sie nur nach Kollisionen mit Objekten in denselben oder benachbarten Kacheln suchen müssen.
Teile des Spiels
Für dieses Multiplayer-Spiel mussten wir einige wichtige Teile entwickeln:
- Eine serverseitige API zur Spielerverwaltung verarbeitet Nutzer, Matchmaking, Sitzungen und Spielstatistiken.
- Server, die die Verbindung zwischen den Spielern herstellen.
- Eine API für die Verarbeitung der AppEngine Channels API-Signalisierung, die zum Verbinden und Kommunizieren mit allen Spielern in den Spielräumen verwendet wird.
- Eine JavaScript-Game-Engine, die die Synchronisierung des Status und der RTC-Nachrichten zwischen den beiden Spielern/Peers übernimmt.
- Die WebGL-Spielansicht.
Spielerverwaltung
Um eine große Anzahl von Spielern zu unterstützen, verwenden wir viele parallele Spielräume pro Battleground. Der Hauptgrund für die Begrenzung der Anzahl der Spieler 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 Gameroom beschreibt und über die Channel API gesendet wird. Dieses Objekt darf maximal 32 KB groß sein. Wir müssen Spieler, Räume, Punkte, Sitzungen und ihre Beziehungen im Spiel speichern. Dazu haben wir zuerst NDB für Entitäten und die Abfrageoberfläche für Beziehungen verwendet. NDB ist eine Schnittstelle zu Google Cloud Datastore. Die Verwendung von NDB funktionierte anfangs hervorragend, aber wir stießen bald auf ein Problem mit der Art und Weise, wie wir es verwenden mussten. Die Abfrage wurde auf der „committierten“ Version der Datenbank ausgeführt (NDB-Schreibevorgänge werden in diesem ausführlichen Artikel ausführlich erläutert). Dies kann zu einer Verzögerung von mehreren Sekunden führen. Die Entitäten selbst hatten diese Verzögerung jedoch nicht, da sie direkt aus dem Cache antworten. Mit Beispielcode lässt sich das vielleicht etwas einfacher 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 Unit-Tests hinzugefügt hatten, konnten wir das Problem klar erkennen und haben die Abfragen aufgegeben, um die Beziehungen stattdessen in einer durch Kommas getrennten Liste in Memcache zu speichern. Das war ein bisschen ein Hack, aber es hat funktioniert. Der AppEngine Memcache hat ein transaktionsähnliches System für die Schlüssel mit der hervorragenden Funktion „Compare and Set“. Die Tests waren also wieder erfolgreich.
Leider hat Memcache ein paar Einschränkungen. Die wichtigsten sind die maximale Wertgröße von 1 MB (es können nicht zu viele Räume mit einem Schlachtfeld verknüpft sein) und das Ablaufdatum des Schlüssels. Wie in den Docs beschrieben:
Wir haben auch die Verwendung eines anderen hervorragenden Schlüssel-Werte-Speichers, Redis, in Betracht gezogen. Zu dieser Zeit war die Einrichtung eines skalierbaren Clusters jedoch etwas entmutigend. Da wir uns lieber auf die Entwicklung der Website konzentrieren wollten als auf die Wartung von Servern, haben wir diesen Weg nicht eingeschlagen. Auf der anderen Seite hat die Google Cloud Platform vor Kurzem eine einfache Click-to-Deploy-Funktion veröffentlicht, zu deren Optionen ein Redis-Cluster gehört. Das wäre eine sehr interessante Option gewesen.
Schließlich haben wir Google Cloud SQL gefunden und die Beziehungen in MySQL verschoben. Es war viel Arbeit, aber letztendlich hat es gut funktioniert. Die Updates sind jetzt vollständig atomar und die Tests werden weiterhin bestanden. Außerdem wurde die Implementierung des Matchmakings und der Punkteerfassung viel zuverlässiger.
Im Laufe der Zeit wurden immer mehr Daten von NDB und Memcache nach SQL verschoben. Im Allgemeinen werden die Entitäten „Spieler“, „Gefechtsfeld“ und „Raum“ jedoch weiterhin in NDB gespeichert, während die Sitzungen und Beziehungen zwischen ihnen in SQL gespeichert werden.
Außerdem mussten wir im Auge behalten, wer gegen wen spielte, und Spieler mithilfe eines Abgleichmechanismus, der das Können und die Erfahrung der Spieler berücksichtigte, gegeneinander antreten lassen. Für die Spielersuche haben wir die Open-Source-Bibliothek Glicko2 verwendet.
Da es sich um ein Multiplayer-Spiel handelt, möchten wir die anderen Spieler im Raum über Ereignisse wie „Wer ist beigetreten oder gegangen?“, „Wer hat gewonnen oder verloren?“ und ob es eine Herausforderung gibt, die angenommen werden kann, informieren. Aus diesem Grund haben wir die Möglichkeit zum Empfangen von Benachrichtigungen in die Player Management API integriert.
WebRTC einrichten
Wenn zwei Spieler für ein Duell gematcht werden, wird ein Signalisierungsdienst verwendet, um die beiden übereinstimmenden Peers miteinander zu verbinden und eine Peer-Verbindung herzustellen.
Es gibt mehrere Drittanbieterbibliotheken, die Sie für den Signalisierungsdienst verwenden können. Das vereinfacht auch die Einrichtung von WebRTC. Beispiele sind PeerJS, SimpleWebRTC und PubNub WebRTC SDK. PubNub verwendet eine gehostete Serverlösung und für dieses Projekt wollten wir die Google Cloud Platform nutzen. Die anderen beiden Bibliotheken verwenden Node.js-Server, die wir in der Google Compute Engine hätten installieren können. Wir mussten jedoch auch dafür sorgen, dass sie Tausende von gleichzeitigen Nutzern bewältigen können, was wir bereits von der Channel API wussten.
Einer der Hauptvorteile der Verwendung der Google Cloud Platform in diesem Fall ist die Skalierung. Die Skalierung der für ein App Engine-Projekt erforderlichen Ressourcen kann ganz einfach über die Google Developers Console erfolgen. Bei Verwendung der Channels API ist keine zusätzliche Arbeit erforderlich, um den Signalisierungsdienst zu skalieren.
Es gab einige Bedenken hinsichtlich der Latenz und der Robustheit der Channels API. Wir hatten sie jedoch bereits für das CubeSlam-Projekt verwendet und sie hatte sich bei Millionen von Nutzern in diesem Projekt bewährt. Deshalb haben wir uns entschieden, sie noch einmal zu verwenden.
Da wir uns nicht für die Verwendung einer Drittanbieterbibliothek für WebRTC entschieden haben, mussten wir unsere eigene entwickeln. Glücklicherweise konnten wir einen Großteil der Arbeit, die wir für das CubeSlam-Projekt geleistet hatten, wiederverwenden. Wenn beide Spieler einer Sitzung beigetreten sind, wird die Sitzung auf „aktiv“ gesetzt. Beide Spieler verwenden dann diese aktive Sitzungs-ID, um die Peer-to-Peer-Verbindung über die Channel API zu initiieren. 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 NATs und Firewalls zu verarbeiten. Weitere Informationen zum Einrichten von WebRTC finden Sie im HTML5 Rocks-Artikel WebRTC in der Praxis: STUN, TURN und Signalisierung.
Die Anzahl der verwendeten TURN-Server muss sich außerdem je nach Traffic skalieren lassen. Dazu haben wir den Google Deployment Manager getestet. So können wir Ressourcen dynamisch in der Google Compute Engine bereitstellen und TURN-Server mithilfe einer Vorlage installieren. Es befindet sich noch in der Alphaphase, funktioniert aber für unsere Zwecke einwandfrei. Als TURN-Server verwenden wir coturn, eine sehr schnelle, effiziente und scheinbar zuverlässige Implementierung von STUN/TURN.
Channel API
Die Channel API wird verwendet, um alle Kommunikationen zwischen dem Gameroom und dem Client zu senden. Die Player Management API verwendet die Channel API für Benachrichtigungen zu Spielereignissen.
Die Arbeit mit der Channels API war nicht immer ganz einfach. Da die Nachrichten unsortiert eintreffen können, mussten wir sie in ein Objekt einschließen und sortieren. Hier ein Beispielcode:
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 und getrennt vom Hosting der Website halten. Wir haben mit der Verwendung der in GAE integrierten Module begonnen. Nachdem wir alles in der Entwicklungsversion zum Laufen gebracht hatten, haben wir leider festgestellt, dass die Channel API in der Produktionsversion überhaupt nicht mit Modulen funktioniert. Stattdessen haben wir separate GAE-Instanzen verwendet und sind auf CORS-Probleme gestoßen, die uns dazu zwangen, eine PostMessage-Bridge für den Iframe zu verwenden.
Spiel-Engine
Um die Game-Engine so dynamisch wie möglich zu gestalten, haben wir die Front-End-Anwendung mit dem Entity-Component-System (ECS) erstellt. Als wir mit der Entwicklung begannen, waren die Wireframes und die funktionale Spezifikation noch nicht festgelegt. Daher war es sehr hilfreich, im Laufe der Entwicklung Funktionen und Logik hinzufügen zu können. Im ersten Prototyp wurde beispielsweise ein einfaches Canvas-Rendering-System verwendet, um die Entitäten in einem Raster anzuzeigen. Ein paar Iterationen später wurden 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. Sobald die Netzwerkkomponenten einsatzbereit waren, konnte das KI-System so geändert werden, dass es Remote-Befehle verwendet.
Die grundlegende Logik des Multiplayer-Modus besteht also darin, die Konfiguration des Aktionsbefehls über DataChannels an den anderen Peer zu senden und die Simulation so zu steuern, als wäre es ein KI-Spieler. Außerdem gibt es eine Logik, die festlegt, welche Runde es ist, ob der Spieler die Pass-/Angriffsschaltflächen drückt, Befehle in die Warteschlange stellt, wenn sie eingehen, während der Spieler noch die vorherige Animation sieht usw.
Wenn es nur zwei Nutzer wären, die sich abwechseln, könnten beide die Verantwortung teilen, den Zug an den Gegner weiterzugeben, wenn sie fertig sind. Es ist jedoch ein dritter Spieler beteiligt. Das KI-System kam uns nicht nur beim Testen, sondern auch beim Hinzufügen von Gegnern wie Spinnen und Trollen wieder zugute. Damit sie in den rundenbasierten Ablauf passen, mussten sie auf beiden Seiten genau gleich erstellt und ausgeführt werden. Das Problem wurde gelöst, indem ein Peer das Abbiegesystem steuerte und den aktuellen Status an den Remote-Peer sendet. Wenn es dann an die Spinnen kommt, lässt der Turn-Manager das KI-System einen Befehl erstellen, der an den Remote-Nutzer gesendet wird. Da die Game-Engine nur auf Befehle und Entitäts-IDs reagiert, wird das Spiel auf beiden Seiten gleich simuliert. Alle Einheiten können auch die KI-Komponente haben, die einfache automatisierte Tests ermöglicht.
Am Anfang der Entwicklung war es optimal, einen einfacheren Canvas-Renderer zu haben, während ich mich auf die Spiellogik konzentrierte. Aber der Spaß begann erst richtig, als die 3D-Version implementiert wurde und die Szenen mit Umgebungen und Animationen zum Leben erwachten. Wir verwenden three.js als 3D-Engine. Aufgrund der Architektur war es einfach, einen spielbaren Zustand zu erreichen.
Die Mausposition wird häufiger an den Remote-Nutzer gesendet und ein 3D-Licht gibt subtile Hinweise darauf, wo sich der Cursor gerade befindet.