Multiplayer Game with AWS | StadtLandFluss

Dieser Blogbeitrag soll einen Einblick in die Entwicklung unserer Webanwendung mit den unten definierten Funktionen geben sowie unsere Lösungsansätze, Herausforderungen und Probleme aufzeigen. 

Cloud Computing Vorlesung

Ziel der Vorlesung “Software Development for Cloud Computing” ist es, aktuelle Cloud Technologien kennen zu lernen und diese im Rahmen von Übungen und kleinen Projekten anzuwenden. Unser Team hat sich im Rahmen dieser als Prüfungsleistung zu erbringenden Projektarbeit dazu entschieden, das bekannte Spiel „Stadt, Land, Fluss“ als Multiplayer-Online Game umzusetzen. 

Projekt Idee & Inspiration

Zu Beginn der Vorlesung war sich unsere Projektgruppe noch sehr unsicher, was wir als Projekt mit Cloudkomponenten umsetzen wollten, da wir noch keine bis sehr geringe Vorerfahrung in der Cloud-Entwicklung hatten. Erste Brainstormings hatten ergeben, dass wir gerne eine Webanwendung entwerfen wollten. Jedoch war es gar nicht so leicht Zugriff zu interessanten Daten zu bekommen. 

Letztendlich hat sich unsere Gruppe dazu entschieden, sich nicht von Daten abhängig zu machen, sondern etwas Eigenes zu kreieren. 

Die Inspiration für unsere finale Idee (Stadt-Land-Fluss) war das Online-Spiel Skribbl IO, ein kostenloses Multiplayer-Zeichen- und Ratespiel. Dabei wird in jeder Runde ein Spieler ausgewählt, der etwas zeichnet, das die anderen erraten sollen. Skribbl ermöglicht es dem Spieler auch, einen eigenen Raum zu erstellen und Freunde einzuladen, die einen Link zu diesem Raum teilen.

Im Rahmen unseres Projektes hat uns die Idee gefallen etwas zu entwickeln, was man danach mit Freunden zusammen nutzen kann. Den Multiplayer Ansatz fanden wir spannend, da wir so etwas noch nie umgesetzt haben. Da wir alle Stadt-Land-Fluss Fans sind, fiel unsere Wahl auf dieses Spiel. 

Ziel

Primäres Ziel des Projektes war es für uns, erste Erfahrungen in Cloud-Computing zu sammeln und gleichzeitig unsere Fähigkeiten im Software-Engineering auszubauen. 

Konkret war es die Idee ein Stadt-Land-Fluss Spiel mit den folgenden Funktionalitäten zu entwickeln: 

  • Schritt 1: Raumerstellung
    • Spieler kann einen neuen Raum erstellen, oder über eine Raum-Id einem Raum beitreten
  • Schritt 2: Spieldaten bestimmen
    • Der Spieler, welcher einen Raum erstellt, soll die Kategorien selber bestimmen können, sowie die Zeit, welche man für das Ausfüllen einer Spielrunde hat, ebenfalls sollen Mitspieler- und Rundenanzahl bestimmt werden können
  • Schritt 3: Waiting Room 
    • Nach Erstellen oder Beitreten eines Raumes, kommt der Spieler in einem Warteraum, wo er die festgelegten Parameter der Spielrunden sieht und informiert wird, welche Spieler der Runde schon beigetreten sind 
  • Schritt 4: Letter Generator 
    • Der Buchstabe für eine Runde soll zufällig generiert werden, sich aber nicht wiederholen innerhalb eines Spiels
  • Schritt 5: Spielrunde
    • Auf der Seite der Texteingaben, soll ein Spieler die Runde stoppen können, sobald er alles ausgefüllt hat, dies triggert den Stopp bei allen Mitspielern
  • Schritt 6: Kontrollieren der Eingaben 
    • Alle Spieler sehen nach einer Runde ihre eigenen, aber auch alle anderen Eingaben der Mitspieler sowie die Punkte, die dabei erreicht wurden
    • Dabei werden die Punkte nach folgendem Schema berechnet:
      • Hat ein Spieler als Einziger in dieser Kategorie eine Eingabe und ist diese auch gültig (beginnt mit dem generierten Buchstaben), dann erhält er für dieses Feld 20 Punkte
      • Haben andere in diesem Feld auch Eingaben, erhält der Spieler für eine gültige Eingabe 10 Punkte
      • Hat ein anderer Spieler in der gleichen Kategorie die gleiche Angabe, erhält der Spieler 5 Punkte
      • Ist die Eingabe leer oder beginnt sie nicht mit dem generierten Buchstaben, werden keine Punkte vergeben
  • Schritt 7 : Hall of Fame  
    • Darstellung der Spieler-Ränge und ihrer Punkte nach Abschließen aller Runden

Das erste Mockup der zu erstellenden Webanwendung entsprach folgendem Design und war unser Leitfaden für die Entwicklungsphase: 

Skizze der groben Web Anwendung zu Beginn

Einblick in das Spiel – Demo 

Frameworks – Cloud Services – Infrastructure

Frontend

Aufgrund von vorhandenen Vorerfahrungen wurde die zweite Entscheidung getroffen, das Frontend mit Hilfe des Angular Frameworks umzusetzen. Angular ist ein TypeScript-basiertes Front-End-Webapplikationsframework. Das Backend wurde mit Python als Programmiersprache umgesetzt. Zum einen war hier mehr Vorerfahrung vorhanden bei einigen Teammitgliedern und zum anderen haben wir mehr Beispiele zur Anwendung von Websockets und AWS im Zusammenhang mit Angular gefunden, was uns sehr geholfen hat. 

Backend

Wie zu Beginn schon erwähnt, hat uns die parallel zum Projekt laufende Vorlesung gleich zu Beginn den großen Funktionsumfang von AWS aufgezeigt.  Besonders interessant fanden wir die Einsatzmöglichkeiten von Lambda Funktionen. Im Zusammenhang damit hat uns die Funktion gefallen ein API Gateway aufzubauen zu können. Da man bei der Programmiersprache völlig frei wählen kann, haben wir uns für Python entschieden. In der Python Programmierung hatten wir als Team zwar wenig Erfahrung, haben aber in dem Projekt eine Chance gesehen, uns in dieses Thema weiter einarbeiten zu können und unsere Fähigkeiten zu verbessern. 

Architektur

Architektur

Cloud Komponenten 

Vor dem Projektstart hatten wir zu Beginn die Schwierigkeit zu entscheiden, welchen Cloud-Anbieter wir für die Entwicklung nutzen wollen. Voraussetzungen für die Entscheidungen waren, dass es eine ausführliche Dokumentation der Möglichkeiten und Funktionen gibt (aufgrund der mangelnden Vorerfahrung), ebenfalls wollten wir nicht eine Kreditkarte als Zahlungsoption hinterlegen müssen und auch keine bis sehr wenig Kosten verursachen. 

Zu Beginn der Vorlesung hieß es noch, dass wir eventuell ein Konto bei der IBM-Cloud oder über AWS von der Hochschule bekommen würden. Allerdings war dies leider doch nicht der Fall, weswegen wir nach erstem Warten selbst eine Entscheidung treffen mussten. Wir haben uns schlussendlich für AWS (Amazon Web Services) entschieden, da es einer der führenden Anbieter im Cloud Computing ist. Hierbei hat uns gefallen, dass es sehr viele Tutorials und gute Dokumentation zu den einzelnen AWS Services gab. Ein Nachteil war, dass man beim Anlegen eines Kontos eine Kreditkarte hinterlegen musste. Vorteil war andererseits, dass man mit einem Gratis-Kontingent (Free Tier) an Funktionsaufrufen, Rollen, und DB Kapazitäten etc. startet, weswegen im Rahmen des Projektes dahingehend keine Kosten entstehen sollten. Im späteren Verlauf haben wir herausgefunden, dass man allerdings für die Funktionalität von AmazonCloudWatch, welches ein Service zur Einsicht der Logs ist, zahlen muss. Die Kosten waren nicht hoch, weswegen es kein Problem darstellte, allerdings sollte man sich eindeutig über die Kosten, welche bei der Entwicklung entstehen können, im Klaren sein, um nicht böse überrascht zu werden. 

Amazon Free Tier

Übersicht Free Tier : https://aws.amazon.com/de/free/?all-free-tier.sort-by=item.additionalFields.SortRank&all-free-tier.sort-order=asc&awsf.Free%20Tier%20Types=tier%23always-free&awsf.Free%20Tier%20Categories=*all

AWS Services 

Serverless 

Für das Anlegen unseres Projektes und auch der späteren Möglichkeit einer übersichtlichen Vorlage des Codes zur Bewertung, haben wir nach einer Möglichkeit gesucht, alle für AWS benötigten Konfigurationen sowie das Anlegen der verschiedenen Lambda-Funktionen in unserem GitLab Reporsitory hinterlegen zu können und direkt im Code Editor bearbeiten und ändern zu können. 

Nach einiger Recherche sind wir dabei auf das Serverless Framework gestoßen. Vorteile des Serverless Frameworks gegenüber der AWS Grafikoberfläche sind, dass alle man alle Elemente wie Datenbanken, Buckets, API-Routen und Aufrufe sowie Lambda Functions über eine serverless.yaml Datei verwalten kann. Zudem ist es nicht nötig, AWS-Kontoschlüsseln oder anderen Kontoanmeldeinformationen in Skripte oder Umgebungsvariablen zu kopieren oder einzufügen. Über die serverless.yaml können alle Ressourcen und Funktionen übersichtlich und schnell angelegt und bearbeitet werden. 

Functions

In der serverless.yaml angelegte Lambda Funktionen entsprechen dem folgenden Schema : 

Funktion Definition in serverless.yml 

Die in der Datei definierten Funktionen werden jeweils über einen eigenen „handler“ referenziert. Das bedeutet, dass sie über ein „Event“ aufgerufen werden können. Ein Event entspricht dabei beispielsweise einem API-Aufruf. 

broadcast_to_room Lambda-Funktion

Resources 

Neben unseren Funktionen sind in der serverless.yml ebenfalls alle Tabellen sowie Buckets definiert. Dies erleichtert die Einrichtung neuer Ressourcen, welche benötigt werden und gibt eine gute Übersicht. 

iamRoleStatements

Zudem können IAM-Rollen und Berechtigungen, die auf Lambda-Funktionen angewendet werden, konfiguriert werden. 

Allgemeine Konfiguration 

Es können ganz einfach allgemeine Settings angegeben werden, wie zum Beispiel: 

Übersicht der Möglichkeiten: https://www.serverless.com/framework/docs/providers/aws/guide/serverless.yml/

Dynamo DB 

Um unser Spiel umsetzen zu können, waren wir darauf angewiesen, bestimmte Daten in einer Datenbank zu speichern, dies waren beispielsweise Elemente wir eine Raum-ID, die Spielernamen, die gewählten Spielparameter, sowie die benutzen Buschstaben und eingetragene Ergebnisse der Spieler. Da die Einträge in der Datenbank immer variieren und keinem starren Muster folgen, beispielsweise ist es möglich eine unterschiedliche Zahl an Kategorien pro Spielraum zu haben, haben wir nach einer NoSQL Lösung gesucht. Wir wollten eine Datenbank mit nicht-relationalen Ansatz, um nicht von einem festen Tabellenschemata abhängig zu sein. Unsere Wahl fiel dabei auf Amazon DynamoDB, welches eine schnelle NOSQL-Schlüssel-Wert-Datenbank für beliebig große Datenmengen ist. 

Es gibt eine Tabelle „game_data“, welche alle relevanten Informationen enthält. 

Datenstruktur

Ein Beispiel unserer Datenstruktur ist in der folgenden Abbildung zu erkennen:

Diese JSON-Datei zeigt ein laufendes Spiel mit insgesamt zwei Spielern. Ganz oben ist die Room-Id gespeichert, welche einen einzigartigen Wert aufweist. Diese ist für die Spielrunde besonders wichtig, da so mehrere Räume generiert und gleichzeitig laufen können. Der darunter gespeicherte Array „categories“ beinhaltet alle Kategorien als String-Elemente, die der Spieler, der den Raum erstellt hat, ausgewählt hat. Das Dictionary „game_players“ enthält die Informationen zu allen Spielern. Dazu gehört bspw. der Username, der als Key im Dictionary agiert, die jeweilige Raum-Id, die Punkteanzahl sowie die Eingaben, die der Spieler für eine Runde eingegeben hat. Daneben werden zwei weitere Werte gespeichert, „next_round“ und „status“, welche dazu dienen, zu erkennen, dass die nächste Runde beginnen kann, also alle Spieler bereit sind, oder ob der Spieler noch aktiv ist, also das Spiel nicht bereits verlassen hat.

Des Weiteren werden die Rundenanzahl, die Spiellänge für eine Runde, sowie in einem Array die generierten Buchstaben der Runden gespeichert. Dieser wird pro Runde immer um den nächsten zufällig generierten Buchstaben erweitert.

Datenbank-Interaktionen

Während eines Spiels müssen immer wieder Spielerdaten abgefragt, verändert, hinzugefügt oder gelöscht werden. Dazu gehören bspw. das Anlegen eines neuen Spiels, das Hinzufügen neuer Spieler zu der jeweiligen Raum-Id, das Aktualisieren der erreichten Punkte etc. Diese Interaktionen haben wir in den Lambda-Funktionen, die später noch einmal genauer erklärt werden, implementiert und sie werden aufgerufen, sobald diese benötigt werden. Da diese in Python implementiert wurden, haben wir die AWS SDK für Python, Boto3 verwendet, die eine Integration von Python-Anwendungen und Bibliotheken in AWS-Services, darunter DynamoDB, ermöglicht.

Quelle: https://aws.amazon.com/de/sdk-for-python/

Abfragen von Daten

Auch wenn Boto3 als AWS-SDK für Python nützliche Methoden bezüglich DynamoDB bereitstellt, war es nicht immer einfach, diese zu verwenden. Da unsere Datenstruktur durch die Verschachtelungen recht kompliziert war, war es aufwendig, die benötigten Informationen abzufragen. 

Die obige Abbildung zeigt einen Ausschnitt aus einer Lambda-Funktion und stellt die Abfrage von Spieldaten dar. Zunächst wird die Verbindung zu der gewünschten Datenbank (game_data) hergestellt, indem Endpoint-Url sowie die Region angegeben werden muss. Mit dieser Datenbank-Instanz kann nun mit Methoden wie „.get()“ auf die gewünschte Information in der Datenbank zugegriffen werden. Das Verwenden dieser Funktionen war demnach einfach, jedoch wurden die Daten in der DynamoDB sehr oft in Dictionaries gespeichert, welches an vielen Stellen gar nicht nötig war, was in der Abbildung durch das sich wiederholende „.get(‚L‘)“, „.get(‚M‘)“ etc. zu erkennen ist.

In Wirklichkeit waren die Daten nämlich so aufgebaut:

Man erkennt, dass die Daten durch DynamoDB zusätzlich in Dictionaries und Arrays geschachtelt werden und es war schwierig zu erkennen, um was es sich nun handelt. Das Abfragen wurde dadurch viel komplizierter gestaltet, als es eigentlich ist und war auch sehr fehleranfällig.

Anlegen von Daten

Das Anlegen neuer Daten in die Datenbank erfolgt mithilfe der Methode „put_item“, welcher ein JSON-Objekt mitgegeben werden muss. Die Abbildung zeigt das Anlegen eines neuen Raumes.

Löschen von Daten

Das Löschen von Daten erfolgt ähnlich wie das Updaten von Daten, da der jeweiligen Methode „delete_item“ mehrere Parameter mitgegeben werden können, die den Befehl spezifizieren. In unserem Projekt haben wir komplizierte Aufrufe jedoch nicht gebraucht, weshalb das Löschen im Vergleich zum Updaten von Daten einfach war.

Update von Daten

Das Updaten von Daten erfolgt mithilfe der Methode „update_item“, dessen übergebene Parameter komplexer sind als die anderen und auch abhängig davon sind, welche Typen (Array, Dictionaries, ein einfacher Wert) aktualisiert werden sollen. Um zu bestimmen, welches Objekt in der Datenbank bearbeitet werden soll, kann ein sogenannter „Key“, welcher in der obigen Abbildung die Raum-Id ist, mitgegeben werden. Zusätzlich können die übergebenen Parameter überprüft (ConditionExpression) und schließlich bestimmt werden, wie das Objekt aktualisiert werden soll. In diesem Beispiel wird dem Array, der die generierten Buchstaben eines Spiels speichert, um einen Wert erweitert, weshalb als Wert für den Parameter „UpdateExpression“ ein „list_append“ verwendet wird.

S3

AWS S3 (Simple Storage Service) ist ein Service für das Speichern von Objekten. In unserem Projekt haben wir S3 benutzt, um unsere Anwendung zu hosten. Hierfür haben wir einen S3-Bucket erstellt und dort unter „Static website hosting“ die Einstiegs- und Fehlerseite unserer Anwendung angegeben. Damit Benutzer über die dabei erzeugte URL auf unsere Seite zugreifen können, haben wir diese anschließend noch öffentlich zugreifbar gemacht und eine Policy hinzugefügt, um im öffentlichen Modus den Inhalt des Buckets lesen zu können.

Über die AWS CLI können danach immer die aktuellen Files der gebauten Anwendung in S3 hochgeladen werden, wenn die Anwendung deployt werden soll (siehe auch CI/CD Kapitel):

Ausschnitt aus der CI/CD Pipeline zum Hochladen der Dateien in S3

Lambda

„AWS Lambda ist ein Serverless-, ereignisgesteuerter Computing-Service, mit dem Sie Code für praktisch jede Art von Anwendung oder Backend-Service ausführen können, ohne Server bereitzustellen oder zu verwalten. Sie können Lambda in über 200 AWS-Services und Software-as-a-Service (SaaS)-Anwendungen auslösen und Sie zahlen nur für das, was Sie nutzen.“

https://aws.amazon.com/de/lambda/?c=ser&sec=srv

Amazon API- Gateway 

Für die definierten Lambda Funktionen hat man bei AWS die Möglichkeit eine eigene Web-API mit einem http-Endpunkt zu erstellen. Dafür kann man das Amazon API Gateway verwenden. Die Funktion des API-Gateway ist es Tools für die Erstellung und Dokumentation von Web-APIs, die HTTP-Anforderungen an Lambda-Funktionen weiterleiten, zu erstellen. 

Websocket API Routen 

Api RouteBeschreibung
broadcast_to_roomSendet eine Nachricht an alle Spieler in einem Raum anhand der Raum-Id und den in der Dynamo DB gespeicherten Connection-Ids der Spieler.
check_roundsetzt den Status der nächsten Runde des jeweiligen Benutzers und sendet die Nachricht für die nächste Runde an die Spieler im Raum, wenn alle Benutzer die nächste Runde angeklickt haben.
create_room Erstellt einen neuen Raum in der Datenbank mit den angegebenen Werten oder Standardwerten, wenn keine Werte angegeben werden.
enter_roomFügt einen Benutzer zu einem Raum hinzu, indem sein Username sowie die entsprechende Connection-Id in der Dynamo DB unter dem Schlüssel der angegeben Raum-Id gespeichert werden. 
get_current_playersPrüft, ob der Spieler den Raum betreten darf und sendet alle Spielernamen des Raumes an den neuen Spieler.
get_results_for_roomSendet Raumdaten und Spielerdaten an alle Spieler des Raums mit der angegebenen Raum-Id.
load_user_inputslädt alle Benutzereingaben aus der Datenbank und sendet alle Werte (z.B. [[“Stuttgart”, “Rhein”, “Deutschland”]]) an die Spieler des Raums mit der angegebenen Raum-Id.
navigate_players_to_next_roomNavigiert alle Spieler in einem Raum zum Spielraum und wird aufgerufen, wenn der Hauptakteur die Taste “Spiel starten” drückt.
play_roundFragt Timer, Kategorien und Rundeneinstellungen für den Spielraum über die Room-Id aus der Datenbank ab. 
remove_player_from_roomSetzt den Spielerstatus auf inaktiv in der Dynamo DB und löscht die Raumdaten aus der Datenbank, wenn alle Spieler dieses Raums inaktiv sind.
save_roundSpeichert Benutzereingaben aus Kategoriefeldern in der Datenbank.
start_roundBeginnt die nächste Runde, indem er einen neuen Buchstaben erzeugt und prüft, ob dieser Buchstabe bereits ausgewählt wurde und speichert den erzeugten Buchstaben in der Datenbank.
stop_roundStoppt eine Spielrunde, wenn jemand die Stopptaste gedrückt hat. 

Amazon CloudWatch 

Amazon CloudWatch entspricht einem Überwachungs- und Beobachtungsservice für Entwickler.  Wir wollten über CloudWatch die Verwendung unserer API protokollieren. Dabei gibt es die Möglichkeit der Ausführungsprotokollierung. Hierbei verwaltet das API-Gateway die CloudWatch-Protokolle. Es können verschiedene Protokollgruppen erstellt werden, welche dann die verschiedenen Aufrufe, Abfragen und Antworten an den Protokollstrom melden. Zu den protokollierten Daten gehören beispielsweise Fehler. 

Protokollgruppen

Logs der Methode load_user_inputs

Testing 

Beim Testing haben wir uns hauptsächlich auf das Testen der Lambda-Funktionen mit Unittests fokussiert. Dafür haben wir die Bibliothek Moto benutzt, mit der man AWS Services mocken kann. Dadurch konnten wir in den Tests unsere Datenbank mocken und beispielsweise auch testen, ob beim Aufruf der Lambdas Datenbankeinträge richtig angelegt oder Daten richtig aktualisiert werden. Allgemein muss für das Mocken der Datenbank nur die Annotation @mock_dynamodb2 über der Testklasse eingefügt werden und anschließend kann in der setUp-Methode die Datenbank definiert werden, die für die Tests benutzt werden soll. Dadurch können auch Testdaten in die Datenbank eingefügt werden, um bestimmte Testfälle zu testen.

Neben Moto haben wir die Bibliothek unittest.mock benutzt, mit der zum Beispiel das Senden einer Nachricht über die Websocket-Verbindung gemockt werden kann, oder auch der Aufruf einer Lambda-Funktion. Zudem kann man mit Methoden wie assert_called() oder assert_called_with(…) überprüfen, ob und mit welchen Argumenten die gemockte Methode aufgerufen wurde. Allgemein war dies bei unserem Projekt sehr hilfreich, da wir in fast jeder Lambda-Funktion Nachrichten über die Websocket-Verbindung schicken und somit auch testen konnten, ob die richtigen Nachrichten geschickt werden.

Für manuelle Tests oder zum kurzen Testen von bestimmten Eingabewerten war auch die Seite https://www.piesocket.com/websocket-tester sehr hilfreich, da man dort die verschiedenen Lambda-Funktionen über eine Websocket-Verbindung aufrufen kann.

CI/CD Pipeline 

CI/CD Pipeline in GitLab auf dem master-Branch

Um unsere Tests automatisiert ablaufen zu lassen und auch andere Schritte wie das Deployen der Lambda-Funktionen nicht immer manuell ausführen zu müssen, haben wir in GitLab eine CI/CD Pipeline erstellt. Da wir alle vor diesem Projekt noch nie eine CI/CD Pipeline angelegt hatten, konnten wir somit durch das Projekt auch Erfahrungen in diesem Bereich sammeln. Allgemein ist unsere Pipeline unterteilt in verschiedene Stages: In der Testing-Stage werden die Unittests für unsere Lambda-Funktionen ausgeführt. In der Build-Stage werden die aktuellen Versionen der Lambda-Funktionen über das serverless-Framework deployt und anschließend wird unsere Angular-Anwendung gebaut. Am Ende wird unsere Anwendung in der Deploy-Stage deployt, sodass sie danach über eine öffentliche Url verfügbar ist. Je nachdem, auf welchen Branch ein Entwickler in unserem Repository pusht, werden unterschiedliche Jobs ausgeführt. So werden zum Beispiel die Unittests auf jedem Branch ausgeführt, das Deployen der Lambda-Funktionen jedoch nur beim Pushen auf den develop- oder master-Branch und das Deployen der Anwendung nur beim Pushen auf den master-Branch.

Insgesamt gibt es in GitLab unter Settings -> CI/CD die Möglichkeit, Variablen anzulegen, die in der CI/CD Pipeline benutzt werden. Wir haben daher in IAM (Identity and Access Management) bei AWS einen Benutzer für die Pipeline angelegt, der nur die Rechte hat, die in der Pipeline benötigt werden. Die Keys dieses Benutzers haben wir anschließend als Variablen in GitLab angelegt, sodass zum Beispiel das Deployen der Lambda-Funktionen in der Pipeline mit diesem Benutzer durchgeführt werden kann.

Schwierigkeiten 

Während unserer Arbeit am Projekt sind uns einige Schwierigkeiten begegnet, die im Folgenden näher beschrieben werden.

Datenstruktur

Wie bereits beschrieben hatte sich das Abfragen von Daten in der DynamoDB als sehr aufwendig und komplex dargestellt, auch wenn der Methodenaufruf an sich leicht zu verstehen und einfach ist. Durch die weiteren Verschachtelungen, die AWS zusätzlich zu unserer bereits komplexen Datenstruktur hinzugefügt hat, wurde das Abfragen von Daten schwieriger dargestellt, als es eigentlich ist und auch der Code, den wir dafür geschrieben haben, wurde durch dieses sehr unleserlich. Dadurch kam es in unserer Gruppe oft zu Fehlern bei den Abfragen, da der Fehler nicht direkt erkannt wurde und die endgültige Datenstruktur in der Datenbank für unsere Gruppe unklar wurde. 

Wir haben diesbezüglich, auch nachdem wir die Struktur festgelegt hatten, mehrmals gelesen, dass DynamoDB für komplexe Datenstrukturen und Interaktionen (auch das Updaten von Daten) schlichtweg nicht geeignet ist. Dennoch haben wir es dabei belassen, da wir bereits im Projekt weit fortgeschritten waren und keine Zeit mehr hatten, eine Alternative zu finden.

Deployen von Lambdas

Damit die Lambdas, die wir in der serverless.yaml definiert und in den jeweiligen Handler-Dateien implementiert hatten, in unserer Anwendung aufgerufen werden konnten, war es nötig, den Befehl „serverless deploy“ aufzurufen, der alle definierten Lambdas in den Handler-Dateien deployed. Das Problem war, dass dadurch bestehende Funktionen, an denen andere Gruppenmitglieder arbeiteten, anschließend überschrieben wurden, was das Arbeiten sehr behinderte, wenn man an dem Projekt gleichzeitig arbeitete.

Um diese Situation weitestgehend zu verhindern, haben wir beschlossen, die Methoden auf dem Development Branch allesamt mit dem Befehl „serverless deploy“ zu deployen. Anschließend wird auf unterschiedlichen Branches gearbeitet und anschließend nur die Funktion, an der man gerade arbeitet, mit dem Befehl „serverless deploy function –function [Name der Funktion]“ deployed. Dieses hat nur teilweise funktioniert, da das Deployen einer einzigen Funktion nur möglich war, wenn diese bereits existiert, also durch „Serverless deploy“ deployed wurde.

Fazit

Alles in allem haben wir durch das Projekt einen Einblick in die Möglichkeiten von Cloud Computing bekommen und konnten verschiedene Dinge in diesem Bereich ausprobieren. Wir konnten vor allem Erfahrungen mit AWS Lambda, API Gateway und dem serverless Framework sammeln, da dies der Schwerpunkt unseres Projektes war. Zudem haben wir einige grundlegende Dinge gelernt, zum Beispiel dass es sinnvoll ist, schon früh im Projekt eine CI/CD Pipeline aufzubauen oder auch CloudWatch zu aktivieren, um Fehler in den Lambda Funktionen schneller zu erkennen und beheben zu können.

Allgemein haben wir durch das Projekt auch gelernt, dass es sehr wichtig ist, sich am Anfang Gedanken zu Themen wie Security oder auch dem generellen Aufbau des Projektes zu machen. Aus Zeitgründen verfolgt man sonst oft die am schnellsten funktionierende Lösung und kann dann später nur mit viel Aufwand grundlegende Dinge ändern. Für das nächste Projekt würden wir daher mehr Zeit für die Einarbeitung und Recherche einplanen, um dies zu vermeiden. Bei unserem Projekt wäre es vor allem sinnvoll gewesen, schon von Beginn an die verschiedenen Umgebungen einzurichten, sodass alle Entwickler lokal unabhängig voneinander entwickeln und testen können und man auf der Entwicklungsumgebung Änderungen durchführen kann, ohne die Produktivumgebung zu beeinflussen.

Peer2Peer Multiplayer Real-time Strategy Game “Admiral: WW2”

Admiral: WW2

1 Intro

Gaming is fun. Strategy games are fun. Multiplayer is fun. That’s the idea behind this project.

In the past I developed some games with the Unity engine – mainly 2D strategy games – and so I thought it is now time for an awesome 3D multiplayer game; or more like a prototype.

The focus of this blog post is not on the game development though, but rather on the multiplayer part with the help of Cloud Computing.

However where to start? There are many ways one can implement a multiplayer game. I chose a quite simple, yet most of the time very effective and cheap approach: Peer-to-Peer (P2P).

But first, let us dive in the gameplay of Admiral: WW2 (working title).

2 Game Demo

2.1 Gameplay

Admiral: WW2 is basically like the classic board game “Battleships”. You’ve got a fleet and the enemy player has got a fleet. Destroy the enemy’s fleet before your own fleet is sunk. The big difference is that Admiral: WW2 is a real-time strategy game. So the gameplay is more like a real-life simulation where you as the admiral can command your ships via direct orders:

  • Set speed of a ship (stop, slow ahead, full ahead, …)
  • Set course of a ship
  • Set the target of the ship (select a ship in the enemy fleet)

Currently there is only one ship class (the German cruiser Admiral Hipper), so the tactical options are limited. Other classes like battleships, destroyers or even aircraft carriers would greatly improve replayability; on the other hand they would need many other game mechanics to be implemented first.

Ships have multiple damage zones:

  • Hull (decreases the ship’s hitpoints or triggers a water ingress [water level of the ship increases and reduces the hitpoints based on the amount of water in the hull])
  • Turrets (disables the gun turrets)
  • Rudder (rudder cannot change direction anymore)
  • Engine/Propeller (ship cannot accelerate anymore)

If a ship loses all hitpoints the ship will sink and is not controllable.

2.2 The Lobby Menu

Before entering the gameplay action the player needs to connect to another player to play against. This is done via the lobby menu.

Here is the place where games are hosted and all available matches are listed.

On the right hand side is the host panel. To create a game the host must enter a unique name and a port. If the IP & Port combination of the host already exists, hosting is blocked.

After entering valid infos the public IP of the host is obtained via an external service (e.g. icanhazip.com). Then the match is registered on a server and the host waits for incoming connections from other players.

On the left hand side there is the join panel. The player must enter a port before viewing the match list. After clicking “Join”, a Peer-to-Peer connection to the host is established. Currently the game only supports two players, so after both peers (host and player) are connected the game will launch.

More on the connection process later.

3 Multiplayer Communication with Peer2Peer

3.1 Peer-to-Peer

P2P allows a direct connection between the peers with UDP packets – in this case the game host and player.

So in between no dedicated server handling all the game traffic data is needed, thus reducing hosting costs immensely.

Because most peers are behind a NAT and therefore connection requests between peers are blocked, one can make use of the NAT-Traversal method Hole-Punching.

3.1.1 P2P Connection with Hole-Punching

Given peer A and peer B. A direct connection between A and B is possible if:

  • A knows the public IP of B
  • A knows the UDP port B will open
  • B knows the public IP of A
  • B knows the UDP port A will open
  • A and B initiate the connection simultaneously

This works without port-forwarding, because each peer keeps the port open as if they would contact a simple web server and wait for the response.

To exchange the public IPs and ports of each peer a Rendezvous-Server behind no NAT is required.

3.1.2 Rendezvous-Server

The Rendezvous-Server needs to be hosted in the public web, so behind no NAT. Both peers now can send simple web requests as if the users would browse the internet.

If peer A tells the server he wants to host a game, the server saves the public IP and port of A.

If B now decides to join A’s game the server informs B of the IP and port of A.

A is informed of B’s public IP and port as well.

After this process A and B can now hole-punch through their NATs and establish a P2P connection to each other.

A Rendezvous-Server can be very cheap, because the workload is quite small.

But there are some cases where Hole-Punching does not succeed (“…we find that about 82% of the NATs tested support hole punching for UDP…”, https://bford.info/pub/net/p2pnat/).

In those cases a Relay-Server is needed.

3.1.3 Relay-Server

The Relay-Server is only used as a backup in case P2P fails. It has to be hosted in the public internet, so behind no NAT.

Its only task is the transfer of all game data from one origin peer to all other peers. So the game data just takes a little detour to the Relay-Server before continuing it’s usual way to the peers.

This comes at a price though. Since all of the game traffic is now travelling through this server the workload can be quite tough depending on the amount of information the game needs to exchange. Naturally the ping or RTT (Round Trip Time: the time it takes to send a packet from peer to peer) of a packet is increased resulting in lags. And finally multiple Relay-Servers would be required in each region (Europe, America, Asia, …). Otherwise players far away from the Relay-Server suffer heavy lags. All of these lead to high demands on the server hardware. To be clear: a proper Relay-Server architecture can be expensive in time and money.

Because of that in this project I ignored the worst-case and focused on the default Peer-to-Peer mechanism.

3.1.4 Peer2Peer Conclusion

The big advantage of this method: it’s mainly serverless, so the operation costs of the multiplayer is very low. Because of that, P2P is a very viable multiplayer solution for small projects and indie games. The only thing that is needed is a cheap Rendezvous-Server (of course only if no Relay-Server is used). P2P also does not require to port-forward, which can be a difficult and/or time consuming task depending on the player’s knowledge.

But there are disadvantages:

  • A home network bandwidth may not be enough to host larger games with much traffic; a server hosted at a server farm has much more bandwidth
  • The game stops if a P2P host leaves the game
  • No server authority
    • every player has a slightly different game state that needs to be synchronized often; a dedicated server has only one state and distributes it to the players; players only send inputs to the server
    • anti-cheat has to be performed by every peer and not just the server
    • random is handled better if only the server generates random values, otherwise seeds have to be used
    • game states may need to be interpolated between peers, which is not the case if only the server owns the game state

A dedicated server would solve these disadvantages but in return the hardware requirements are much higher making this approach more expensive. Also multiple servers would be needed in all regions of the world to reduce ping/RTT.

3.2 Game Connection Process

After starting the game the player sees the multiplayer games lobby. As described previously the player can host or join a game from the list.

3.2.1 Hosting a game

The host needs to input a unique game name and the port he will open for the connection. When the host button is clicked the following procedure is triggered:

  1. Obtain public IP address
    • Originally this should be handled by the Rendezvous-Server, because it is hosted behind no NAT and can see the public IP of requests, but limitations of the chosen hosting service prevented this approach (more on that later)
    • Instead I used a web request to free services like icanhazip.com or bot.whatismyipaddress.com as a second backup in case the first service is down; these websites respond with a plain text containing the ipv6 or ipv4 of client/request
  2. The Rendezvous-Server is notified of the new multiplayer game entry and saves the game with public IP and port, both sent to the server by the host
    • Host sends GET-Request to the server (web server) containing all the information needed /registermpgame?name=GameOne&hostIP=1.1.1.1&hostPort=4141
    • On success the game is registered and a token is returned to the host; the token is needed for further actions affecting the created multiplayer game
  3. The host now waits for incoming connections from other players/peers
    • The host sends another GET-Request to the Rendezvous-Server /listenforjoin?token=XYZ123
      • This is a long-polling request (websocket alternative): the connection is held open by the server until a player joined the multiplayer game
      • If that is the case the GET-Request is resolved with the public IP and port of the joined player, so that hole-punching is possible
      • If no player joins until the timeout is reached (I’ve set the timeout to 15 seconds), the request is resolved with http status code 204 No content and no body
      • In that case the GET-Request has to be sent again and again until a player joins
  4. On player join both peers init a connection and punch through NAT
  5. If successful the game starts
  6. (Otherwise a Relay-Server is needed; explained previously)
  7. The host closes the game with another GET /startorremovempgame?token=XYZ123

3.2.2 Joining a game

The player first needs to input a valid port. After that he is presented with a list of multiplayer games by retrieving the information from the Rendezvous-Server with a GET-Request to the endpoint /mpgameslist. This returns a JSON list with game data objects containing the following infos:

  • name: multiplayer game name
  • hostIP: public IP of the host
  • hostPort: port the host will open for the connection

If the player clicks “Join” on a specific game list item the following process handles the connection with the host:

  1. Obtain public IP address
    • Originally this should be handled by the Rendezvous-Server, because it is hosted behind no NAT and can see the public IP of requests, but limitations of the chosen hosting service prevented this approach (more on that later)
    • Instead I used a web request to free services like icanhazip.com or bot.whatismyipaddress.com as a second backup in case the first service is down; these websites respond with a plain text containing the ipv6 or ipv4 of the client/request
  2. Inform the Rendezvous-Server of the join
    • Send a GET-Request with all the information needed /joinmpgame?name=GameOne&ownIP=2.2.2.2&hostPort=2222
    • Now the host is informed by the server if the host was listening
    • The server resolves the request with the public IP and port of the host
    • Now the player and the host try to establish a P2P connection with hole-punching
    • If successful the game starts
    • (Otherwise a Relay-Server is needed; explained previously)

3.3 Game Synchronization

Real-time synchronization of game states is a big challenge. Unlike turn-based games the game does not wait until all infos are received from the other players. The game always goes on with a desirably minimal amount of lag.

Of course the whole game state could be serialized and sent to all players, but this would have to happen very frequently and the package size would be very large. Thus resulting in very high bandwidth demand.

Another approach is to only send user inputs/orders, which yields far less network traffic. I used this lightweight idea, so when the player issues an order the order is immediately transmitted to the other player. There the order is executed as well.

The following game events are synchronized:

  • GameStart: After the game scene is loaded the game is paused and the peer sends this message to the other player periodically until he receives the same message from the other peer; then the game is started
  • RandomSeed: Per game a “random seed master” (the host) periodically generates a random seed and distributes that seed to the other player; this seed is then used for all random calculations
  • All 3 ship orders:
    • ShipCourse
    • ShipSpeed
    • ShipTarget
  • GameSync: All of the previous messages still led to diverging game states, so a complete game serialization and synchronization is scheduled to happen every 30 seconds
    • Projectile positions, rotations, velocities are synched
    • The whole ship state is synched
    • Both game states (the received one and the own one) are interpolated, because I don’t use an authoritative server model and so both game states are “valid”

The following game events should have a positive impact on game sync, but are not implemented yet:

  • ProjectileFire: Syncs projectiles being fired
  • Waves: Because the waves have a small impact on the position where projectiles are fired and hit the ship the waves should be in-sync as well

3.3.1 IDs

In game development you mostly work with references. So for example a ship has a reference to another ship as the firing target. In code this has the benefit of easy access to the target ship’s properties, fields and methods.

The problem is with networking these references do not work. Every machine has different references although it may represent the same ship. So if we want to transfer the order “Ship1 course 180” we cannot use the local reference value to Ship1.

Ship1 needs an unique ID that is exactly the same on all machines. Now we can send “ShipWithID1234 course 180” and every machine knows which ship to address.

In code this is a bit more tedious, because the received ID has to be resolved to the appropriate ship reference.

The most difficult part is finding unique IDs for all gameobjects.

Ships can obtain an ID easily on game start by the host. Projectiles are a bit more tricky, because they are spawned later on. I solved this by counting the shots fired by a gun turret and combining the gun turret’s ID with the shot number to generate a guaranteed unique ID, provided the gun turret ID is unique. Gun turret IDs are combined as well: Ship ID + gun turret location (sternA, sternB, bowA, bowB, …).

Of course with an authoritative server this gets easier as only the server generates IDs and distributes them to all clients.

3.3.2 Lockstep

Additionally there is an interesting and promising approach to discretize the continuous game time called Lockstep. It is used in prominent real-time strategy games like Age of Empires (https://www.gamasutra.com/view/feature/131503/1500_archers_on_a_288_network_.php). The basic idea is to split up the time in small time chunks, for example 200ms intervals. In this time frame every player can do exactly one action that gets transferred to all the other players. Of course this action can also be “no action”. The action is then executed in the next interval almost simultaneously for all players. This way the real-time game is transformed into a turn-based game. It is important to adjust the interval based on the connection speeds between the players, so that no player lags behind. For the players the small order input delay is usually unnoticed, if the interval is small enough.

An important requirement is that the game is deterministic and orders issued by players have the same outcome on all machines. Sure there are ways to handle random game actions, but because AdmiralWW:2 uses random for many important calculations and my development time frame was limited I unfortunately did not implement this technique.

4 Rendezvous-Server Hosting

There are almost unlimited hosting options on the internet. Usually the selection shrinks after a specific programming language is picked. But because I used NodeJS with Typescript, which transpiles the code to default Javascript, there were still plenty of hosting options. If I decided to write the server in C# and therefore run a .NET Core application like the game is written with (Unity uses C# or some exotic programmers use Javascript) many hosting providers drop out.

4.1 Alternatives

Of course there is the option of renting an own dedicated server: very expensive for a simple Rendezvous-Server and maintenance heavy, but powerful and flexible (.NET ok).

There’s the option of a managed server: little maintenance but very, very expensive.

We have VPS (Virtual Private Servers): dedicated servers that are used by many customers and the hardware is distributed among them, cheaper.

Then there are the big players like AWS, Google Cloud Platform, IBM Cloud and Microsoft Azure: they can get very expensive, but in return they offer vast opportunities and flexibility; it is easy to scale and monitor your whole infrastructure and a load-balancer can increase availability and efficiency of your server(s); on the other hand the learning-curve is steeper and setting up a project needs more time.

4.2 Heroku

Heroku is a cloud based Platform-as-a-service (PaaS) offering hosting of many common programming languages like Javascript/NodeJS (which I used), Python and Ruby. It does not offer as many possibilities as AWS and co, but it is way simpler to learn and set up.

Also it does have a completely free plan, which grants over 500 hours uptime per month. This is not enough to run the whole month with 30 * 24 = 720 hours, but the application sleeps after 1 hour with no actions and automatically wakes up again if needed. This is perfectly fine for a Rendezvous-Server, because it is not used all the time. The wake up time is not that bad as well (around 4-8 seconds).

Of course Heroku offers scaling so that the performance is massively increased and the app will never sleep, but this comes with a price tag.

In a paid plan Heroku also has a solid monitoring page with events, up- and downtimes, traffic and so on.

Server logs are easily accessible as well.

For setup you just need to create a “Procfile” in your project folder that defines what to execute after the build is completed: web: npm run start will run the npm script called start as a web service. The application is then publicly reachable on your-app-name.herokuapp.com. The NodeJS web server can then listen on the port that is provided by Heroku in the environment variable process.env.PORT.

Deployment is automated: just push to your github master branch (or the branch you specified in Heroku); after that a github webhook triggers the build of your app in Heroku.

But during development I discovered a big disadvantage: Heroku does not support ipv6.

This is a problem, because I wanted to use the Rendezvous-Server as a STUN-Server as well, which can determine and save the public IPs of client requests. But if a client like me only has Dual-Stack lite (unique ipv6 but the ipv4 address is shared among multiple customers) Peer2Peer is not possible with the shared ipv4.

As a workaround the clients obtain their public ipv4 or ipv6 via GET-Request from icanhazip.com or as a backup from bot.whatismyipaddress.com. These websites return a plain text body containing the public IP. After that the peers send their public IP to the Rendezvous-Server as explained previously.

5 Architecture Overview

Typescript usually is a very good choice for larger projects, simply because of the type-safety and development-time error checking. This guarantees no more searching for bugs like typos as it is the case in plain Javascript.

To realize the web server I used the very popular ExpressJS, which does not need any introduction and should be well-known by this time.

6 Conclusion

Real-time multiplayer games are tricky. The game states quickly diverge and much effort has to be done to counteract this. Game time differences and lag drastically compound this. But methods such as Lockstep can help to synchronize the time across multiple players.

While developing, try to keep the game as deterministic as possible, so that player actions yield the same result on every machine. Random is usually problematic, but can be handled via a dedicated game server or seeds.

Peer-to-Peer is a simple and great solution for smaller indie multiplayer games, but comes with some disadvantages. For larger projects dedicated/authoritative servers are favourable.

Heroku offers a fast and simple setup for hosting cloud applications and the free plan is great for smaller projects. If demand increases scaling is no problem and the deployment is automated. But be aware of the missing ipv6 support of Heroku.

All in all: Gaming is fun. Strategy games are fun. Multiplayer is fun – for the player and an exciting challenge for developers.