Multiplayer-Arena: Dynamische Game-Sessions mit Docker & FastAPI

Emre Kalkan

Game: https://46.101.127.20.sslip.io/ (online bis 31. März 2026)

Git Repo: https://github.com/ek101-collab/ArenaGame-with-ContainerSessions

Controls:

  • Movement: WASD
  • Dash: Shift
  • Attack: Space

Einleitung und Motivation

Im Rahmen der Vorlesung “System Engineering und Management” bestand die Kernaufgabe darin, ein Projekt zu konzipieren und umzusetzen, das moderne Web- und Cloud-Technologien nutzt. Zu Beginn der Projektphase lag mein Fokus auf einer klassischen Wetter-App. Das Ziel war es, den grundlegenden Umgang mit Serverarchitekturen, REST-APIs und Daten-Parsing zu verstehen. Doch während der ersten Prototypen wurde mir klar, dass ich diese Gelegenheit nutzen wollte, um tiefer in ein Feld einzutauchen, das meine wahre Leidenschaft und mein Karriereziel darstellt: die Spieleentwicklung.

Besonders die Architektur von Multiplayerspielen hat mich schon immer fasziniert. Die Herausforderung, hunderte von Datenpaketen pro Sekunde konsistent über ein Netzwerk zu verteilen, ist eine der komplexesten Aufgaben im System Engineering. So entstand die Idee, eine vereinfachte Version eines „Arena Fighters“ (ähnlich wie Super Smash Bros.) zu entwickeln.

Bei der Umsetzung von “ArenaGame” lag die Priorität nicht auf einem massiven Umfang an Inhalten. Stattdessen konzentrierte ich mich auf die präzise Synchronisation fundamentaler Mechaniken: Laufen, Schlagen und Dashen. Diese Aktionen müssen über das Netzwerk so abgeglichen werden, dass sich das Spiel für alle Kontrahenten fair und flüssig anfühlt. Da ich zuvor fast ausschließlich an lokalen Anwendungen gearbeitet hatte und Netzwerk-Kommunikation für mich Neuland war, war mein primäres Ziel die Implementierung einer soliden, entkoppelten und skalierbaren Serverarchitektur.

In den folgenden Kapiteln stelle ich die einzelnen Komponenten des Projekts im Detail vor. Ich erläutere die gewählten technischen Ansätze und beschreibe, wie ich die Architektur aufgebaut habe, um die Anforderungen an eine stabile Spielsession zu erfüllen.

Spielprinzip

Zu Beginn werden alle teilnehmenden Spieler zufällig in der Arena positioniert. Ab diesem Moment können sich die Spieler frei bewegen und andere Spieler angreifen. Zusätzlich steht ihnen eine Dash-Mechanik zur Verfügung, mit der sie Angriffen ausweichen oder vor Gegnern flüchten können.

Ziel des Spiels ist es, alle gegnerischen Spieler gegen die Wände der Arena zu schlagen. Um zu verhindern, dass sich alle Spieler dauerhaft in der Mitte der Arena aufhalten und sich durch Treffer nur minimal bewegen, habe ich eine Knockback-Mechanik integriert, die für mehr Dynamik im Spielverlauf sorgt.

Diese Mechanik funktioniert so, dass ein Spieler bei jedem Treffer einen bestimmten Prozentsatz an Knockback aufbaut. Je höher dieser Prozentsatz ist, desto stärker fällt der Rückstoß aus, der bei einem weiteren Treffer ausgelöst wird. Dadurch wird nicht nur das Spielgeschehen beschleunigt, sondern auch verhindert, dass eine Runde zu lange dauert oder im Extremfall gar kein Ende findet.

Jeder erfolgreiche Treffer erhöht den Knockback-Wert um 20 %, welcher für alle Spieler sichtbar über dem jeweiligen Charakter angezeigt wird. Der maximal erreichbare Wert liegt bei 110 %. Diese Grenze wurde bewusst gewählt, da das Spiel den Namen „110 %“ tragen sollte. Erreicht ein Spieler diesen Wert, wird bei einem Treffer ein extrem starker Rückstoß ausgelöst, wodurch es deutlich schwieriger wird, sich innerhalb der Arena zu halten.

Sobald alle Spieler bis auf einen die Arena-Wand oder -Grenze berührt haben und dadurch ausgeschieden sind, gewinnt der verbleibende Spieler. Der Gewinner wird anschließend auf dem Bildschirm angezeigt, bevor alle Spieler wieder ins Hauptmenü zurückgeführt werden.

Architektur

Wie ich bereits zu Beginn erwähnt habe, war es mein Ziel, eine Serverarchitektur zu entwickeln, die verschiedene Aufgaben klar voneinander trennt. Aus diesem Grund habe ich eine mikroserviceähnliche Struktur umgesetzt, bei der einzelne Komponenten jeweils spezifische Aufgaben übernehmen:

  • Spiel (Frontend – Darstellung)
  • Matchmaker (Backend – Orchestrierung)
  • Game Server (Backend – Datensynchronisierung)

Bild 1 – Architekturdiagramm

Ruft ein Spieler das Frontend über die entsprechende URL auf, hat er die Möglichkeit, entweder selbst ein Spiel zu hosten oder einem bestehenden Spiel beizutreten. Zur Vereinfachung ist im oberen Diagramm ausschließlich die Host-Variante dargestellt, zudem erfolgt die Darstellung nur in eine Richtung.

Möchte ein Spieler ein Spiel hosten, sendet das Frontend eine Anfrage an den Matchmaker. Dieser startet daraufhin einen neuen Game Server und übermittelt dessen URL an das Frontend. Anschließend verbindet sich das Frontend direkt mit dem zugewiesenen Game Server.

Im Diagramm sind mehrere Spiel- und Game-Server-Blöcke dargestellt. Diese visualisieren, dass bei jedem Klick auf „Host“ ein neuer Game Server vom Matchmaker erstellt und genau einem Spieler zugewiesen wird. Während dieser Erstellungsphase erhält das Frontend zusätzlich einen generierten Code, der für das Beitreten weiterer Spieler verwendet wird.

Mithilfe dieses Codes können andere Spieler einem bereits gestarteten Game Server beitreten und am Spiel des Hosts teilnehmen.

Mit diesem Ansatz verfolgte ich drei zentrale Ziele. Erstens sollte die Skalierbarkeit der einzelnen Komponenten gewährleistet werden, sodass sie unabhängig voneinander erweitert oder vervielfältigt werden können. Zweitens war mir die Unabhängigkeit der Komponenten besonders wichtig. Fällt beispielsweise der Matchmaker aus, soll sich dies weder auf das Frontend noch auf die Game-Server auswirken.

Daraus ergibt sich auch das dritte Ziel, ein vereinfachtes Debugging. Durch die klare Entkopplung der einzelnen Dienste konnte ich während der Entwicklung gezielt überprüfen, ob die Kommunikation zwischen den jeweiligen Komponenten korrekt funktioniert. So ließ sich beispielsweise analysieren, ob das Frontend erfolgreich eine Verbindung zum Matchmaker aufbaut oder ob der Matchmaker ordnungsgemäß mit den Game-Servern kommuniziert.

Dies stellt jedoch lediglich die grundlegende Idee der Architektur dar und dient dazu, ein grobes Gesamtbild zu vermitteln. Um ein tiefergehendes Verständnis dieser Architektur zu erlangen, ist es notwendig, näher auf die technische Umsetzung einzugehen, welche in den folgenden Kapiteln erläutert wird.

Frontend (Spiel)

Bevor ich mit der Implementierung der Serverarchitektur begann, beschäftigte ich mich zunächst mit der grundlegenden Konzeption des Spiels. Anfangs hatte ich die Idee, das Spiel mithilfe eines Frameworks wie Node.js oder React Native umzusetzen. Allerdings wurde schnell deutlich, dass dieser Ansatz mit einem erheblichen Mehraufwand verbunden gewesen wäre.

Dieser zusätzliche Aufwand hätte insbesondere daraus resultiert, dass zentrale Systeme wie die Darstellung der Arena und der Spieler, ein Animationssystem, ein Game-Loop-Tick sowie eine Physik-Engine vollständig selbst hätten implementiert werden müssen. Da ich diesen Aufwand als unverhältnismäßig einschätzte, entschied ich mich für den Einsatz einer Game-Engine, die diese grundlegenden Systeme bereits von Haus aus mitbringt.

Als Game-Engine wählte ich Godot, da ich mit dieser bereits aus früheren Projekten vertraut war und sie zudem einen Web-Export unterstützt. In Godot begann ich zunächst mit der Implementierung der Spielfunktionen des Spielers und testete diese lokal. Dabei wurde unter anderem die Knockback-Mechanik überprüft, indem zwei Spieler instanziiert wurden, von denen einer manuell gesteuert wurde.

Da Godot über eine eigene Skriptsprache namens GDScript verfügt, welche in ihrer Syntax stark an Python angelehnt ist, konnte die Implementierung mithilfe geeigneter Quellen vergleichsweise zügig umgesetzt werden.

Bild 2 – Charakter

Bild 3 – Arena

Nachdem das grundlegende Gerüst der Spiellogik in der Godot-Engine stand, begann ich mit der Umsetzung der Netzwerk-Anbindung. Dafür habe ich einen zentralen Network-Manager erstellt, der als globales Singleton (Autoload in Godot) eingebunden ist. Diese Klasse ist von überall im Spiel erreichbar, egal ob man sich gerade im Hauptmenü oder bereits in der Arena befindet.

Da die Server- und Netzwerkstruktur recht komplex ist, war es mir wichtig, die gesamte Netzwerk-Kommunikation an einer einzigen Stelle zu bündeln. Auf diese Weise bleibt die eigentliche Spiellogik übersichtlich und muss sich nicht mit Netzwerkdetails beschäftigen. Der Network-Manager übernimmt diese Aufgaben vollständig. Da diese Klasse sehr umfangreich ist, beschränke ich mich im Folgenden auf die wichtigsten Schritte im Verbindungsablauf.

Godot stellt intern eine eigene Klasse namens WebSocketPeer zur Verfügung, die für die Nutzung von WebSockets gedacht ist. WebSockets sind allgemein eine Verbindungsart zwischen einem Client und einem Server, die dauerhaft bestehen bleibt und nicht wie klassische HTTP-Anfragen nach dem Prinzip „Request und Response“ funktioniert. Dadurch entsteht eine deutlich geringere Latenz, was für mein Spiel besonders wichtig ist, da die Eingaben der Spieler in jedem Frame aktualisiert werden müssen. Aus diesem Grund war mir von Anfang an klar, dass ich diese Klasse verwenden werde.

Beim Start des Spiels existiert zunächst noch keine aktive WebSocket-Verbindung, auch wenn diese bereits im Hintergrund in der _process()-Funktion überprüft wird. Die _process()-Funktion stellt in Godot den sogenannten Tick-Loop der Engine dar und wird beispielsweise etwa 60 Mal pro Sekunde aufgerufen.

func _process(_delta):
	ws.poll()
	var state = ws.get_ready_state()

	if state == WebSocketPeer.STATE_OPEN:
		if not connected:
			connected = true
			print("WebSocket geöffnet und bereit!")
			connected_to_server.emit() 
		
		while ws.get_available_packet_count() > 0:
			var packet = ws.get_packet()
			var data = JSON.parse_string(packet.get_string_from_utf8())
			if data:
				_handle_internal_data(data)
				message_received.emit(data)

	elif state == WebSocketPeer.STATE_CLOSED or state == WebSocketPeer.STATE_CLOSING:
		if connected:
			connected = false
			print("WebSocket geschlossen/getrennt.")

Um eine Verbindung zum WebSocket herzustellen, stehen zwei Optionen zur Verfügung. Entweder erstellt der Spieler ein neues Spiel oder tritt einem bereits bestehenden Spiel bei. Für diese beiden Funktionen habe ich eine entsprechende Benutzeroberfläche umgesetzt, die wie folgt aussieht:

Bild 4 – Main Menu

Wenn der Spieler auf Host klickt, sendet der Network Manager eine normale HTTP-Anfrage an den Matchmaker, die über Godots eigene HTTPRequest-Klasse realisiert wird. Wird die Anfrage erfolgreich beantwortet und liefert der Matchmaker eine gültige Antwort, ruft Godot die Funktion connect_ws() auf, die eine WebSocket-Verbindung direkt zwischen dem Spieler (Host) und dem Game Server herstellt. Ab diesem Zeitpunkt überprüft der WebSocketPeer in der _process()-Funktion kontinuierlich über seine poll()-Methode, ob neue Datenpakete eingetroffen sind, und verarbeitet diese entsprechend.

func request_new_session():
	var http = HTTPRequest.new()
	add_child(http)
	http.request_completed.connect(func(_result, response_code, _headers, body):
		if response_code != 200:
			print("Matchmaker Server Fehler: ", response_code)
			return
			
		var response_text = body.get_string_from_utf8()
		print("MATCHMAKER ANTWORT: ", response_text)
		var json = JSON.parse_string(response_text)
		
		if json and json.has("ip") and json.has("port"):
			_connect_ws(json.ip, int(json.port))
		else:
			print("Matchmaker Fehler: Ungültiges JSON erhalten")
		http.queue_free()
	)
	http.request(MATCHMAKER_URL + "/create_session", [], HTTPClient.METHOD_POST)

Die Join-Funktion funktioniert ähnlich wie die request_new_session()-Methode, mit dem Unterschied, dass bei der HTTP-Anfrage zusätzlich ein Beitrittscode mitgesendet wird.

Darauf folgt dann der Aufbau der Websocketverbindung:

func _connect_ws(ip: String, port: int):
	ws = WebSocketPeer.new()
	connected = false 
	
	var domain = "46.101.127.20.sslip.io"
	var url = ""
	
	if OS.has_feature("web"):
		url = "wss://" + domain + "/game/" + str(port) + "/ws"
	else:
		url = "ws://" + ip + ":" + str(port) + "/ws"
			
	print("Verbinde sicher zu: ", url)
	var err = ws.connect_to_url(url)
	if err != OK:
		print("Fehler beim Verbindungsaufbau: ", err)

Durch diesen Ansatz wird eine aktive WebSocket-Verbindung aufgebaut, über die anschließend wichtige Daten zwischen Client und Server ausgetauscht werden können. Der Network Manager übernimmt dabei jedoch noch eine weitere Aufgabe, nämlich die Bereitstellung der send_json-Funktion. Auf den genauen Code dieser Funktion gehe ich hier nicht näher ein, sondern erläutere lediglich ihren Zweck.

Wie bereits zuvor erwähnt, handelt es sich beim Network Manager um eine globale Klasse, auf die alle anderen Klassen in Godot zugreifen können. Genau dafür ist diese Methode gedacht. Dadurch können beispielsweise Spielerklassen kontinuierlich ihren aktuellen Zustand, wie Position, Aktionen oder Treffer, an den Game Server senden (als Dictionary), woraufhin die anderen Spieler entsprechend reagieren.

Neben dem Spieler greifen aber auch weitere Klassen, wie etwa der GameHandler oder die Lobby, auf Serverereignisse zu. Diese sind für das grundlegende Verständnis jedoch weniger relevant. Wichtig ist an dieser Stelle vor allem zu zeigen, dass nach dem Aufbau der WebSocket-Verbindung der Austausch von Informationen zwischen Godot und dem Game Server vergleichsweise einfach umgesetzt werden kann.

Jetzt haben wir uns angesehen, wie Godot eine Verbindung zum Matchmaker und darüber anschließend zum Game Server aufbaut, was vergleichsweise einfach umzusetzen ist. Diese Einfachheit entsteht jedoch nicht von selbst, sondern setzt eine saubere Umsetzung im Hintergrund voraus. Damit kommen wir nun zum Kern der gesamten Serverarchitektur, dem Backend.

Backend (Game Server)

Bevor wir uns den Matchmaker ansehen, der die Verbindungen zwischen Server und Client verwaltet, betrachten wir zunächst die Architektur des Game Servers selbst. Diese Reihenfolge halte ich für sinnvoller, da wir bisher die Kommunikation nur in eine Richtung gesehen haben, nämlich von Godot zum Game Server, jedoch noch nicht, woher die Antworten kommen und wie die Zustände der Spieler gesetzt werden. Außerdem ist der Matchmaker in gewisser Form vom Game Server abhängig, da dieser das grundlegende Fundament darstellt, das benötigt wird, um überhaupt einen Game Server starten zu können.

Der Game-Server hat zunächst nur eine einzige Aufgabe, die spielrelevanten Informationen zwischen den Spielern zu synchronisieren und zu aktualisieren. Im Grunde ist der Server der Vermittler des eigentlichen Spiels, der ständig Nachrichten empfängt, sie kurz checkt und dann an die richtigen Leute verteilt.

Für die Umsetzung des Game Servers habe ich FastAPI verwendet. Dieses Web-Framework für Python diente mir als Grundlage für die gesamte Serverlogik. Dort habe ich festgelegt, wie eingehende Daten, zum Beispiel Spielerpositionen oder Zustandsänderungen, verarbeitet werden. Dabei ist wichtig zu verstehen, dass FastAPI selbst nicht der eigentliche Server ist, der von außen angesprochen wird, sondern lediglich die Logik bereitstellt. Für mich war es eine gute Basis, auf der ich mein Spiel Schritt für Schritt aufbauen konnte.

Damit diese Logik auch tatsächlich als Webservice im Netzwerk erreichbar ist, brauchte ich zusätzlich ein Server-Interface. Dafür habe ich Uvicorn eingesetzt. Uvicorn fungiert als asynchroner ASGI-Server (Asynchronous Server Gateway Interface). Seine Hauptaufgabe besteht darin, die eingehenden Verbindungen zu verwalten und die Datenpakete an die FastAPI-Anwendung weiterzureichen. Man kann sich Uvicorn dabei als die Schicht vorstellen, die zwischen dem Netzwerk und meiner Anwendung vermittelt.

Zum Starten des Servers habe ich dann folgende Zeile verwendet:
uvicorn.run(app, host=”0.0.0.0″, port=8000)

Der erste Parameter ist die Anwendung selbst, die IP-Adresse sorgt dafür, dass der Server von überall erreichbar ist, und der Port legt fest, über welches „Tor“ kommuniziert wird. Da die Kommunikation im Spiel über WebSockets läuft, war die asynchrone Arbeitsweise von Uvicorn für mich besonders wichtig. So konnte der Server mehrere dauerhafte Verbindungen der Spieler gleichzeitig handhaben, ohne dass alles blockiert wird.

So viel zum Grundgerüst, nun geht es etwas mehr in die eigentliche Logik des Servers. Die FastAPI-Anwendung verwaltet zunächst ein zentrales Objekt namens game_state. Darin wird gespeichert, welche Spieler aktuell verbunden sind, wer der Host ist und ob das Match überhaupt schon läuft.

Das Herzstück der Kommunikation ist der WebSocket-Endpoint, der über @app.websocket(“/ws”) definiert ist. Sobald ein Client eine Verbindung aufbaut, startet eine asynchrone while True-Schleife. Diese hält die Verbindung dauerhaft offen und wartet kontinuierlich auf eingehende JSON-Pakete.

Die komplette Steuerung des Servers findet innerhalb dieses Endpoints statt. Mithilfe einer Reihe von if-Abfragen wird der type der eingehenden Nachricht ausgewertet und je nach Inhalt der passende Logikblock ausgeführt:

  • Host und Join: Bei der Verbindung werden die Spieler registriert. Jeder Spieler erhält eine eindeutige player_id, die mit Hilfe der Bibliothek UUID generiert wird, und der Name wird gespeichert. Der erste Spieler wird automatisch als Host festgelegt.
  • start_game: Dieser Block kann nur vom Host ausgelöst werden. Der Server setzt das Spiel auf „aktiv“, generiert zufällige Startpositionen für alle Spieler und signalisiert allen Clients den Spielbeginn.
  • player_state: Dieser Block wird am häufigsten genutzt, um Positionen und Bewegungen der Spieler zu synchronisieren. Um Bandbreite zu sparen und Ruckler beim Absender zu vermeiden, habe ich eine broadcast_except_self-Funktion eingesetzt, die die Daten an alle Spieler außer dem Sender verteilt.
  • hit: Meldet ein Client einen Treffer, berechnet der Server den neuen „Knockback“-Wert und informiert alle Teilnehmer über die Aktualisierung. Die Berechnung erfolgt bewusst auf dem Server, da beim ersten Prototypen, bei dem der Client die Berechnung übernommen hat, extreme Bugs und Fehlerwerte auftraten. Durch die Server-Berechnung ist die Konsistenz gewährleistet, und professionell betrachtet, sogar ein Schutz gegen Cheating.
  • player_died: Hier prüft der Server, ob nach dem Ausscheiden eines Spielers noch genügend Teilnehmer aktiv sind. Ist nur noch ein Spieler übrig, wird das Spiel beendet und der Sieger bekannt gegeben.

Neben diesen Aufgaben übernimmt der Server noch eine weitere wichtige Funktion, das Ressourcenmanagement. Die Details dazu werden zwar später beim Deployment noch einmal relevant, aber es lohnt sich, die grundlegende Logik schon hier zu erklären.

Da jeder Server nur für ein einzelnes Spiel zuständig ist, ist es wichtig, dass er nach Spielende wieder heruntergefahren oder abgeschaltet wird. Dafür habe ich mir die folgenden Sicherheitsmechanismen überlegt:

  • shutdown_sequence: Nach Spielende oder beim Verbindungsabbruch des Hosts wird der Matchmaker über den Endpunkt /session_done benachrichtigt. Nach einer kurzen Verzögerung beendet sich der Prozess über os._exit(0).
  • idle_timeout_check: Um ungenutzte Sessions zu vermeiden, die erstellt, aber nie gestartet wurden, gibt es einen 5-Minuten-Timer. Erfolgt innerhalb dieser Zeit kein Spielstart, fährt der Server selbstständig herunter.

Ich habe mir früh Gedanken über diese Art von Sicherheitsmechanismen gemacht, da in der realen Welt Effizienz und Kosteneinsparung eine große Rolle spielen. Wenn Server weiterlaufen, obwohl sie nicht mehr benötigt werden, können schnell hohe Kosten entstehen, und der Speicher der Systeme würde sich unnötig füllen, was im schlimmsten Fall zu Abstürzen führen kann. Aus diesem Grund habe ich diese Mechanismen direkt in den Game Server integriert.

Eine weitere Sache, die hier wichtig ist, ist CORS (Cross-Origin Resource Sharing). In der Web-Entwicklung sorgt CORS quasi dafür, dass ein Server Anfragen von anderen Webseiten oder Domains akzeptieren darf, man kann es sich als eine Art „Sicherheitsfreigabe“ vorstellen.

Ich erwähne das hier, weil ich selbst Probleme hatte, als ich meine FastAPI-Implementierung getestet habe. Beim Versuch, mich vom Frontend mit dem Game Server zu verbinden, bekam ich eine Fehlermeldung, dass die Verbindung nicht aufgebaut werden darf. Nach einigem Nachforschen und Ausprobieren stellte sich heraus, dass ich CORS in meiner Anwendung aktivieren musste. Ohne diese Einstellung verweigert der Browser aus Sicherheitsgründen die Verbindung zum Server.

Um CORS in FastAPI zu aktivieren, habe ich folgendes Middleware-Setup hinzugefügt:

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

Mit allow_origins=[“*”] erlaubt man dem Browser, die Daten vom Game Server abzufragen, egal von welcher Domain die Anfrage kommt.

Backend (Matchmaker)

Wie zuvor erwähnt, fungiert der Matchmaker als Vermittler und Orchestrierer zwischen Client und Game-Server. Seine Aufgabe ist es, auf Anfrage eines Spielers eine neue Game-Server-Instanz zu starten oder bestehende Sessions zu vermitteln. Der Matchmaker wurde, genau wie der Game-Server, mit FastAPI umgesetzt. Auch hier kommt eine CORS-Middleware zum Einsatz, allerdings mit dem Unterschied, dass der Zugriff explizit auf die offizielle Domain des Frontends beschränkt wird.

Ein wichtiger Punkt in der Architektur ist das Port-Management. Jedem neuen Game-Server wird ein eigener Port zugewiesen, damit die Datenpakete der Spieler beim Versenden eindeutig zugeordnet werden können. Da eine IP-Adresse jeden Port zur selben Zeit nur einmal vergeben kann, dient der Port als eindeutige Kennung für die jeweilige Spiel-Session. Würden zwei Spiele gleichzeitig denselben Port nutzen, gäbe es Adresskonflikte und der zweite Server könnte gar nicht erst starten.

Um dies abzusichern, habe ich die Funktion get_next_free_port eingebaut, die dafür sorgt, dass immer nur freie Ports aus einem festgelegten Bereich vergeben werden. Zusätzlich prüft eine weitere Funktion, is_port_open, mittels einer Socket-Verbindung, ob ein neu gestarteter Server tatsächlich bereit ist, bevor die Verbindungsdaten an den Client weitergegeben werden. Dabei ist zu beachten, dass sich diese Socket-Verbindung von der WebSocket-Verbindung unterscheidet. Sie dient lediglich als eine Art „Anklopfen“. Sobald der Game Server antwortet, wird die Verbindung direkt wieder geschlossen. Der Grund, warum ich is_port_open implementiert habe, wird im späteren Kapitel Probleme & Herausforderungen noch wichtig.

Um die Systemressourcen zu schonen, ist der Portbereich auf 30.000 bis 30.100 begrenzt, was maximal 100 gleichzeitig laufende Game Server erlaubt.

Neben dem Starten von Servern fungiert der Matchmaker auch als zentrales Verzeichnis. Er speichert jeden aktiven Game-Code zusammen mit der IP-Adresse und dem Port in einer Liste (active_sessions). Wenn ein weiterer Spieler einer Runde beitreten möchte, muss er lediglich den Spiel-Code angeben. Der Matchmaker gleicht diesen Code mit der Liste ab und liefert die passenden Verbindungsdaten zurück.

Im Gegensatz zum Game Server läuft die Kommunikation beim Matchmaker aber nicht über WebSockets, sondern über HTTP-Anfragen. Der Lebenszyklus einer Spiel-Session wird dabei über drei zentrale Endpunkte gesteuert:

  • Erstellung (/create_session): Wenn ein Spieler ein Spiel hostet, generiert der Matchmaker einen eindeutigen vierstelligen Game-Code und sucht einen freien Port. Anschließend startet er eine neue Instanz des Game-Servers.
  • Beitritt (/join_session): Spieler können über den Game-Code die zugehörigen Verbindungsdaten (IP und Port) aus dem internen Speicher des Matchmakers abrufen.
  • Abschluss (/session_done): Sobald eine Session beendet ist, sendet der Game-Server ein Signal an diesen Endpunkt. Der Matchmaker entfernt die Session aus seiner Liste, wodurch der Port sofort für neue Instanzen freigegeben wird.

Im Grunde ist das die Architektur des Matchmakers, relativ simpel gehalten. Ich möchte außerdem erwähnen, dass ich bei beiden Backends (Game Server und Matchmaker) keinen ausführlichen Code eingefügt habe, da dies den Rahmen dieses Blogs sprengen würde. Mir geht es hier vor allem darum, die Funktionsweise meines Systems zu erklären. Deshalb findet man in diesen beiden Kapiteln auch keine Codesnippets, abgesehen von der CORS-Implementierung.

Deployment (IaaS)

Als ich fertig mit der grundlegenden Codebasis war und das Spiel lokal auf meinem Rechner, von der Anfrage zum Matchmaker bis hin zur eigentlichen Verbindung mit dem Game-Server, stabil lief, war meine nächste Aufgabe, diese Architektur in einer Cloud zu hosten. Lokal wäre das Spiel zwar im eigenen Heimnetzwerk nutzbar gewesen, aber das entsprach zum einen nicht meiner Vorstellung eines echten Multiplayers. Zum anderen wollte ich die Technologien und Ansätze, die in der Vorlesung angesprochen wurden, selbst ausprobieren und das Projekt in eine realitätsnahe Umgebung einbetten.

Viele moderne Systeme und Services basieren heute auf Cloud-Lösungen, zum Beispiel bei Amazon oder Netflix. Der Hauptgrund dafür ist zum einen die globale Erreichbarkeit und zum anderen die flexible Skalierbarkeit. In meinem Fall bedeutet das, dass die Infrastruktur theoretisch mitwachsen kann, wenn die Last steigt, etwa dann, wenn viele Spieler gleichzeitig neue Sessions erstellen.

Für das Deployment habe ich mich für ein Droplet beim Cloud-Anbieter DigitalOcean entschieden. Dabei handelt es sich um ein klassisches Infrastructure-as-a-Service-Modell (IaaS). Im Gegensatz zu fertigen Software-Lösungen bekommt man hier die volle Kontrolle über das Betriebssystem. Zum einen habe ich mich für diesen Ansatz entschieden, weil ich lernen wollte, wie man einen „echten“ Server konfiguriert und ein eigenes Projekt darauf zum Laufen bringt. Zum anderen war dieses Modell auch notwendig, da ich die Docker-Engine selbst verwalten muss, um Game-Server-Instanzen dynamisch zu starten und die Ports im gewünschten Bereich gezielt freizugeben.

Damit kommen wir auch direkt zum nächsten wichtigen Baustein für das Deployment, Docker. Docker ist ein Werkzeug, mit dem sich Anwendungen in isolierten Umgebungen, sogenannten Containern, verpacken und ausführen lassen. Der Grund für die Nutzung von Docker war ähnlich wie beim Droplet. Einerseits wollte ich den Umgang mit Containern besser lernen, da ich zuvor nur wenig Erfahrung damit hatte. Andererseits war Docker für den Cloud-Betrieb sehr sinnvoll.

Ich hätte das gesamte Spiel theoretisch auch direkt auf dem Betriebssystem des Droplets installieren können. Allerdings stellt sich dann die Frage, was passiert, wenn ich den Cloud-Anbieter wechseln möchte. Das Droplet bei DigitalOcean nutzt Linux als Betriebssystem, würde ich später auf ein Windows-System wechseln, müssten eventuell Teile der Anwendung angepasst werden, da jedes Betriebssystem eigene technische Unterschiede hat. Durch das Verpacken von Frontend und den Backends in Container habe ich dieses Problem umgangen und die Anwendung deutlich unabhängiger vom darunterliegenden System gemacht.

Dadurch ergeben sich mehrere Vorteile:

  • Isolation und Konsistenz: Zum Beispiel ein Game-Server-Container enthält alles, was er zum Laufen braucht (Python-Laufzeitumgebung, Bibliotheken, Code). Dadurch ist garantiert, dass das Spiel in der Cloud exakt so funktioniert wie auf meinem lokalen Rechner, unabhängig von der Konfiguration des Droplets.
  • Dynamische Instanziierung: Da der Game-Server als Docker-Image vorliegt, kann der Matchmaker bei Bedarf sekundenschnell eine neue, saubere Instanz starten. Sobald eine Runde vorbei ist, wird der Container einfach gelöscht. Das hält das System sauber und gibt Ressourcen sowie Ports sofort wieder frei.
  • Sicherheit und Port-Mapping: Durch Docker kann ich gezielt steuern, welche Ports nach außen hin geöffnet werden. Während der Matchmaker fest auf einem Port kommuniziert, weise ich den Game-Server-Containern beim Start dynamisch Ports aus meinem Bereich (30.000 bis 30.100) zu. Dies geschieht über das sogenannte Port-Mapping, bei dem ein Port des Containers auf einen Port des Droplets gespiegelt wird.

Durch die Kombination aus einem IaaS-Droplet und der Docker-Engine habe ich mir im Grunde eine eigene kleine „Game-Server-Cloud“ aufgebaut, die flexibel auf Spieleranfragen reagieren kann, ohne dass ich für jede neue Runde manuell eingreifen muss. Man könnte sagen, dass ich damit eine Art reduziertes, selbstgebautes Kubernetes erstellt habe, einen eigenen Orchestrator, der speziell auf meine Anforderungen zugeschnitten ist.

Kubernetes ist ein extrem mächtiges, aber auch hochkomplexes Werkzeug mit einer, meiner Meinung nach, sehr steilen Lernkurve. Für mein Projekt wäre der Einsatz von Kubernetes überdimensioniert gewesen („Overkill“). Mit meinem Ansatz habe ich daher zwei Fliegen mit einer Klappe geschlagen. Ich konnte die grundlegende Funktionsweise von Kubernetes durch den Eigenbau verstehen und gleichzeitig eine leichtgewichtige Lösung schaffen, die exakt zu den Bedürfnissen meines Spiels passt.

Bevor wir auf die technischen Details der Container eingehen und darauf, wie sie aufgebaut sind, möchte ich noch einmal auf einen Punkt zurückkommen, den ich bereits im Kapitel Backend (Game Server) angesprochen habe, das Ressourcenmanagement. Dort habe ich die Sicherheitsmechanismen beschrieben, mit denen fertige oder ungenutzte Spiel-Sessions sauber beendet werden. In diesem Zusammenhang hatte ich auch den Aufruf os.exit(0) erwähnt.

Das Besondere bei Containern ist, dass mit diesem Befehl nicht nur der Prozess endet, sondern gleich der gesamte Container beendet und entfernt wird. Das bedeutet, dass nicht der Matchmaker die Game-Server-Container aktiv herunterfährt, sondern dass sich die Container selbst beenden, sobald eine Spiel-Session nicht mehr gebraucht wird. Dadurch hat der Matchmaker nur die Aufgabe, neue Game Server zu starten, muss sich aber nicht darum kümmern, wie lange eine Session läuft oder wann sie beendet wird. Das reduziert die Komplexität und die Last, da keine zusätzlichen Überprüfungen notwendig sind.

Genau dieser Ansatz hat mich während der Entwicklung besonders fasziniert. Jeder gestartete Game-Server-Container ist komplett unabhängig von allen anderen. Es wird keine zentrale Liste oder ein Array geführt, das den Zustand aller Game Server überwacht, sondern jeder Container entscheidet selbst, wann er sich beendet. Bevor er herunterfährt, informiert er lediglich den Matchmaker eigenständig darüber, dass die Session beendet wurde, sodass die zuvor belegten Ports wieder freigegeben werden können.

Doch kommen wir nun zu der technischen Umsetzung und Zusammensetzung der unterschiedlichen Container. Grundsätzlich legt man die Regeln zum Erstellen eines Containers in einer sogenannten Dockerfile fest. In so einer Dockerfile steht zum Beispiel, welches Basis-Image verwendet werden soll (etwa ein schlankes Python-System), welche Dateien kopiert werden müssen und welche Befehle beim Start des Containers ausgeführt werden. Für mein Projekt habe ich drei spezifische Dockerfiles entwickelt, die jeweils eine eigene Säule meiner Architektur bilden.

Das Frontend basiert auf einem Caddy-Image. Caddy ist ein moderner, sehr leichtgewichtiger Webserver, der sich perfekt dazu eignet, die exportierten Spieldateien auszuliefern. Ein wichtiger Punkt in diesem Dockerfile ist die Konfiguration der HTTP-Header:

  • Cross-Origin-Opener-Policy: same-origin
  • Cross-Origin-Embedder-Policy: require-corp

Diese Sicherheits-Header musste ich selbst mitliefern, da der Browser die Ausführung des Spiels ansonsten aus Sicherheitsgründen blockiert hätte. Durch meine Recherche bin ich darauf gestoßen, dass dies damit zusammenhängt, dass Web-Games, abhängig von der verwendeten Engine, häufig auf SharedArrayBuffer und Multithreading setzen.

Beim Matchmaker habe ich ein Python 3.10-slim Image verwendet. Das Besondere dabei, damit der Matchmaker selbst Container starten kann, installiere ich im Dockerfile zusätzlich die docker.io Bibliothek. Sie ist hauptsächlich dafür da, dass der Container für den Matchmaker überhaupt die notwendigen Werkzeuge besitzt, um mit der Docker-Engine des Droplets zu kommunizieren. Diese Engine hatte ich ja schon zu Beginn direkt auf dem Droplet installiert, um dort überhaupt Docker nutzen zu können.

Doch mit dem Dockerfile allein ist es noch nicht getan. Da das Frontend und der Matchmaker immer zusammen laufen müssen, habe ich eine Docker Compose Datei erstellt. Man kann sich diese Datei wie eine Art Gruppenanweisung vorstellen. Statt jeden Container mühsam einzeln mit langen Befehlen zu starten, schreibt man alles einmal in diese Datei und startet das gesamte System gleichzeitig.

In dieser Docker Compose Datei konnte ich dann auch sogenannte Volumes festlegen. Normalerweise nutzt man Volumes bei Docker, um Daten (z.B Bilder oder Datenbanken) dauerhaft zu speichern, damit sie nicht gelöscht werden, wenn man den Container stoppt.

In meinem Fall habe ich das Volume aber für einen technischen Zweck genutzt. Ich habe den Docker-Socket des Droplets (/var/run/docker.sock) direkt in den Matchmaker-Container gemountet. Man kann sich das wie ein Verbindungskabel vorstellen.

Ohne dieses Volume wäre der Matchmaker in seinem Container komplett isoliert. Er wüsste gar nicht, dass er auf einem Server mit einer Docker-Engine läuft. Durch das Volume “spiegle” ich den Zugriff auf die Docker-Steuerung des Droplets direkt in den Container hinein. Zusammen mit der installierten docker.io Bibliothek kann mein Matchmaker-Code jetzt so tun, als wäre er direkt auf dem Droplet installiert. Er kann also einfach “nach draußen” greifen und der Docker-Engine sagen, dass sie einen neuen Game-Server-Container starten soll.

Ein weiterer Vorteil der Compose-Datei war die Übersicht beim Networking. Ich konnte genau festlegen, dass das Frontend auf Port 80 für alle Spieler erreichbar ist, während der Matchmaker auf Port 8001 auf Anfragen wartet. Die eigentlichen Game-Server stehen nicht in der Compose-Datei, weil sie ja erst erstellt werden, wenn wirklich jemand spielen möchte.

Dass der Container nach dem Spiel sofort verschwindet (nach os.exit(0)), liegt an der aktivierten Funktion auto_remove=True. Beim Programmieren des Matchmakers habe ich festgelegt, dass jeder neue Game Server mit diesem Flag gestartet wird.

Normalerweise bleiben Container nach dem Beenden im Status „Exited“ auf dem System liegen und verbrauchen weiterhin Speicherplatz, bis man sie manuell löscht. Durch auto_remove=True erkennt die Docker-Engine auf dem Droplet aber sofort, wenn der Game-Server-Prozess fertig ist, und räumt den kompletten Container automatisch weg. Das war für mich die einfachste Lösung, um sicherzustellen, dass das Droplet nicht irgendwann voll läuft:

container = client.containers.run(
           "mein-game-server",
           detach=True,
           ports={'8000/tcp': ('0.0.0.0', assigned_port)},
           environment={"GAME_SESSION_CODE": code},
           auto_remove=True
       )

Zuletzt haben wir das Dockerfile für den Game-Server. Man kann sich dieses Dockerfile als die „Blaupause“ vorstellen, die der Matchmaker jedes Mal nutzt, wenn er einen neuen Container startet. Da diese Container erst in dem Moment entstehen, in dem ein Spieler ein Spiel anfragt, war es mir wichtig, dass das Image möglichst klein ist und schnell startet. Ich habe mich hier ebenfalls für ein Python-Slim-Image entschieden. In diesem Dockerfile werden lediglich die nötigen Bibliotheken für die Spiellogik (wie FastAPI und Uvicorn für die WebSockets) installiert und anschließend der eigentliche Programmcode des Spiels hineinkopiert.

Besonders wichtig war für mich hier das Port-Konzept innerhalb des Dockerfiles. Im Dockerfile ist fest hinterlegt, dass der Server intern immer auf Port 8000 lauscht. Das macht die Programmierung einfach, da ich im Spielcode keine variablen Ports verwalten muss. Da die internen Container-Ports sowieso isoliert voneinander sind und keinen Einfluss auf andere Container haben, konnte ich das so beibehalten. Die eigentliche Portzuweisung wird, wie bereits behandelt, durch den Matchmaker vorgenommen. Dieser mappt einen freien externen Port (aus meinem Bereich von 30.000 bis 30.100) immer auf dieselbe interne Portnummer des Containers (8000).

HTTP vs HTTPS

Am Anfang war mein System nur über die reine IP-Adresse meines Droplets erreichbar (46.101.127.20). Das funktionierte zwar, lief aber ausschließlich über HTTP. Sobald ich jedoch versuchte, die Browser-Funktionen für das Spiel zu nutzen, stieß ich auf zwei große Probleme:

  • Zertifikats-Problem: Für eine reine IP-Adresse bekommt man kein kostenloses SSL-Zertifikat von Anbietern wie Let’s Encrypt. Ohne Zertifikat gibt es kein HTTPS.
  • Sicherheits-Blockade: Moderne Browser blockieren heute viele Funktionen, wie zum Beispiel den SharedArrayBuffer, der für die Spiel-Engine wichtig ist, oder zeigen deutliche Warnmeldungen an, wenn eine Seite nicht über HTTPS ausgeliefert wird. In meinem Fall kam es sogar vor, dass der Browser das Frontend komplett blockierte, da es nur über HTTP erreichbar war.

Um zunächst ein SSL-Zertifikat zu bekommen, bräuchte man eigentlich eine eigene Domain. Da Domains aber meistens Geld kosten und ich das vermeiden wollte, habe ich einen Dienst namens sslip.io genutzt. Dieser Dienst macht etwas Einfaches, aber Geniales. Er wandelt meine IP-Adresse in einen Hostnamen um. Aus 46.101.127.20 wurde so 46.101.127.20.sslip.io.

Der Vorteil ist, dass dies jetzt wie eine „echte“ Domain aussieht und mein Caddy-Webserver endlich ein offizielles SSL-Zertifikat anfordern konnte. Das Coole an Caddy ist, dass es diesen Prozess komplett automatisch erledigt. Es stellt selbstständig eine Verbindung zu einer Zertifizierungsstelle wie Let’s Encrypt oder ZeroSSL her und besorgt sich ein legitimes SSL-Zertifikat. Somit war das erste Problem gelöst und mein Frontend lief endlich über HTTPS.

Nachdem mein Frontend über HTTPS erreichbar war, tauchte das nächste Problem auf. Das Frontend versuchte nun, eine Verbindung zum Matchmaker und zu den Game Servern aufzubauen. Diese liefen allerdings noch auf „unsicheren“ Ports wie zum Beispiel 8001 oder 30005 über HTTP.

Der Browser blockierte dies sofort wegen sogenanntem Mixed Content. Wenn eine Hauptseite über HTTPS geladen wird, dürfen darüber keine unsicheren HTTP- oder WS-Verbindungen aufgebaut werden. Dadurch konnte mein Spiel keine Verbindung zum Backend herstellen. Wie bereits beschrieben, läuft die Kommunikation zwischen Client und Game Server über WebSockets. Auch hier gibt es eine sichere und eine unsichere Variante, nämlich ws (Websocket) und wss (Websocket Secure). Sobald das Frontend über HTTPS läuft, müssen alle WebSocket-Verbindungen ebenfalls über wss erfolgen. Versuchte mein Spiel also, sich über ws mit einem Game Server zu verbinden, blockierte der Browser diese Verbindung sofort und verlangte zwingend eine sichere wss-Verbindung.

Das eigentliche Problem war jedoch, dass meine Game Server selbst nichts von SSL-Zertifikaten wussten. Sie kommunizieren intern ausschließlich über normales ws. Ich musste also einen Weg finden, die Verschlüsselung nach außen hin bereitzustellen, ohne jeden einzelnen Game Server umbauen zu müssen.

Um diesen Konflikt zu lösen, habe ich den gesamten Datenverkehr über Caddy geleitet. Anstatt dass der Spiel-Client im Browser direkt eine Verbindung zum Port des Game Servers aufbaut, schickt er seine Anfrage an die sichere Adresse meines Reverse Proxys, also an Caddy. Erst relativ spät habe ich festgestellt, dass sich Caddy nicht nur als Webserver, sondern auch sehr gut als Reverse Proxy nutzen lässt.

Während ein normaler Proxy meist dafür gedacht ist, aus einem internen Netzwerk nach außen zu kommunizieren, funktioniert ein Reverse Proxy genau umgekehrt. Er nimmt Anfragen aus dem öffentlichen Internet entgegen und leitet sie sicher an interne Dienste weiter. In meinem Fall sind das die Game Server Container.

Konkret sendet das Frontend eine Anfrage in der Form wss://46.101.127.20.sslip.io/game/30005 an Caddy. Damit signalisiert der Client, dass er sich mit dem Game Server auf Port 30005 verbinden möchte. Caddy übernimmt hier die Rolle eines Übersetzers. Er nimmt die verschlüsselte wss-Anfrage entgegen, prüft sie mit dem vorhandenen SSL-Zertifikat und entschlüsselt die Datenpakete.

Anschließend leitet Caddy diese Daten intern als normales ws-Protokoll an den entsprechenden Game Server weiter. Der Rückweg funktioniert nach dem gleichen Prinzip. Sendet der Game Server Daten zurück, gehen diese zunächst unverschlüsselt an Caddy, werden dort sofort wieder verschlüsselt und anschließend sicher als wss-Paket an den Browser zurückgesendet.

Diese Umstellung war für die Funktion des Projekts absolut entscheidend, da moderne Browser ohne wss-Verbindungen das Spiel schlichtweg nicht starten würden. Zwar gäbe es theoretisch die Möglichkeit, im Browser unsichere Inhalte manuell zu erlauben, für einen realistischen Einsatz wäre das jedoch völlig ungeeignet. Durch den Einsatz von Caddy konnte ich die Sicherheitsanforderungen der Browser erfüllen, die Kommunikation verschlüsseln und gleichzeitig den Code der Game Server sehr einfach halten. Da Caddy die komplette SSL-Verarbeitung übernimmt, muss sich der Game Server selbst nicht um Verschlüsselung kümmern, was die Komplexität und Fehleranfälligkeit deutlich reduziert.

CI/CD-Pipeline & Tests

Eine weitere Sache, die ich kurz ansprechen möchte, ist meine kleine CI/CD-Pipeline, die ich für dieses Projekt eingerichtet habe. Sie sorgt dafür, dass Änderungen am Code nach einem Push automatisch auf meinem Droplet bereitgestellt werden. Dabei verbindet sich GitHub während der Pipeline per SSH mit dem Droplet und führt dort die notwendigen Befehle aus, um die Anwendung zu aktualisieren und die benötigten Docker-Container zu bauen oder neu zu starten.

Damit dieser Zugriff sicher und zuverlässig funktioniert, habe ich ein SSH-Schlüsselpaar erstellt, bestehend aus einem privaten und einem öffentlichen Schlüssel. Der öffentliche Schlüssel wurde auf dem Droplet hinterlegt, während ich den privaten Schlüssel in den GitHub Secrets gespeichert habe. Dadurch ist sichergestellt, dass sich ausschließlich meine Pipeline mit dem Server verbinden kann und es nicht zu Zugriffsverweigerungen oder interaktiven Passwortabfragen kommt.

Bevor das eigentliche Deployment ausgeführt wird, war es mir wichtig, dass bestimmte Tests automatisch laufen. Dadurch wollte ich sicherstellen, dass keine fehlerhaften Änderungen auf dem Droplet landen und dort Probleme verursachen. Für den Matchmaker und den Game Server habe ich deshalb einfache, aber zentrale Tests geschrieben. Dabei überprüfe ich unter anderem die korrekte Vergabe der Ports, den erfolgreichen Aufbau einer WebSocket-Verbindung sowie das Verhalten des Matchmakers, wenn keine aktive Spiel-Session existiert.

Für die Umsetzung der Pipeline habe ich GitHub Actions verwendet. Dazu habe ich eine deploy.yml-Datei erstellt, in der die einzelnen Schritte und Regeln der Pipeline definiert sind. Diese habe ich anschließend direkt in GitHub eingebunden, sodass die Pipeline automatisch bei bestimmten Events, wie einem Push auf den Main-Branch, ausgeführt wird.

Die Implementierung der CI/CD-Pipeline erfolgte relativ spät, kurz vor der Abgabe des Projekts. Der Grund dafür war, dass ich zunächst wichtigere Prioritäten hatte, wie eine stabile Spiellogik, eine funktionierende Serverkommunikation und ein sauber konfiguriertes Droplet. Trotzdem habe ich mich bewusst dazu entschieden, die Pipeline noch zu integrieren.

Zum einen plane ich, das Projekt in Zukunft weiterzuführen, und wollte vermeiden, bei jeder Änderung manuell das Repository auf dem Droplet aktualisieren oder die Docker-Container neu bauen und starten zu müssen. Zum anderen war es für mich auch eine gute Übung, da ich diesen Schritt bei früheren privaten Projekten oft ausgelassen habe. Durch diese Pipeline ist der Entwicklungsprozess nun deutlich strukturierter, sicherer und langfristig wartbarer.

Probleme & Herausforderungen

Während der Entwicklung meines Projekts bin ich auf zahlreiche Probleme gestoßen, die ich hier unmöglich alle im Detail beschreiben kann. Dennoch möchte ich auf einige zentrale Herausforderungen eingehen, die mich besonders viel Zeit und Nerven gekostet haben. Zunächst beginne ich mit den technischen Problemen, da diese den größten Einfluss auf den Entwicklungsprozess hatten.

Im Kapitel Backend (Matchmaker) hatte ich bereits die Funktion is_port_free erwähnt. Diese Funktion entstand nicht zufällig, sondern aus einem sehr konkreten Problem heraus. Nachdem mein erster Prototyp lokal auf meinem eigenen Rechner stabil lief, war ich überzeugt, dass das System bereit für eine Präsentation während der Vorlesung sei. Am Tag der Präsentation wollte ich das Spiel unterwegs im Zug noch einmal testen, diesmal in einem anderen Netzwerk. Genau dort trat plötzlich ein unerwartetes Verhalten auf. Jedes Mal, wenn ich ein Spiel hosten wollte, erhielt ich im Browser eine Fehlermeldung. In seltenen Fällen funktionierte der Verbindungsaufbau, in den meisten Fällen jedoch nicht. Das war besonders verwirrend, da das System zu Hause zuverlässig lief.

An der HdM schilderte ich dieses Problem dem Dozenten, der mir zwei entscheidende Fragen stellte. Erstens, ob der Game Server überhaupt gestartet werde, und zweitens, ob die Verbindungsanfrage des Clients den Game Server tatsächlich erreiche. Der ersten Frage konnte ich relativ schnell nachgehen, da ich im Droplet sehen konnte, dass der Matchmaker den Game-Server-Container trotz Fehlermeldung im Browser korrekt startete. Damit rückte die zweite Frage in den Fokus.

Beim genaueren Debugging schaute ich mir schließlich die Logdateien des Game Servers an und stellte fest, dass der Client sich nie mit diesem verbunden hatte. Nach längerer Analyse wurde mir klar, dass zwar der Container selbst gestartet wurde, der darin laufende Uvicorn-Webserver jedoch zu diesem Zeitpunkt noch nicht bereit war. Der Matchmaker gab dem Client also bereits die Verbindungsdaten zurück, obwohl der eigentliche Server, der die FastAPI-Anwendung bereitstellt, noch nicht auf Anfragen reagieren konnte. Genau aus diesem Grund implementierte ich die Funktion is_port_free. Sie führt dieses bereits beschriebene „Anklopfen“ durch und stellt sicher, dass der Matchmaker erst dann antwortet, wenn der Uvicorn-Server tatsächlich erreichbar ist.

Ein weiteres großes Problem war die Kommunikation über HTTP und HTTPS. Mir war es sehr wichtig, eine saubere und sichere Verbindung zu haben, ohne dass Spieler in den Browsereinstellungen unsichere Inhalte aktivieren müssen. Solche Warnungen könnten abschreckend wirken oder sogar den Eindruck einer unseriösen oder potenziell gefährlichen Webseite vermitteln. Ich probierte zahlreiche Ansätze aus, auch jenseits von Caddy, stieß jedoch immer wieder auf dieselben Blockaden seitens des Browsers. Erst relativ spät entschied ich mich, zusätzlich eine KI zu Hilfe zu nehmen, die mir schließlich verständlich erklärte, wie ich meine bestehende Architektur mithilfe von Caddy als Reverse Proxy korrekt absichern konnte. Erst dadurch gelang es mir, die WebSocket-Kommunikation zuverlässig über wss bereitzustellen.

Ein weiteres technisches Problem betraf die Wahl der Programmiersprache. Python eignet sich nur bedingt für Game Server, da Spiele kontinuierlich Daten senden. In meinem Fall geschah dies etwa 60 Mal pro Sekunde pro Client. Mit wenigen Spielern funktionierte das noch problemlos, doch als ich an der HdM mit mehr als fünf Spielern gleichzeitig testete, traten deutliche Probleme auf. Die Spielfiguren begannen zu springen oder sich scheinbar zu teleportieren. Der Grund dafür liegt in der Natur von Python als interpretierter Sprache. Für performante Echtzeit-Server wären kompilierten Sprachen wie C++ oder Go deutlich besser geeignet. Ich hatte mich dennoch für Python entschieden, da ich darin bereits Erfahrung hatte und der Fokus des Projekts nicht primär auf maximaler Performance lag.

Neben diesen Punkten gab es weitere technische Schwierigkeiten. So füllte sich der Speicher des Droplets relativ schnell, sobald viele Sessions gleichzeitig liefen, was letztlich sogar zu Abstürzen führte. Bei der Wahl des Cloud-Anbieters war es mir wichtig, die Kosten so gering wie möglich zu halten, da ich das Spiel nur für einen begrenzten Zeitraum hosten wollte. Da ich Probleme hatte, das GitHub Student Pack zu beanspruchen, konnte ich keine kostenlosen Cloud-Guthaben nutzen, die bei manchen Anbietern bis zu 200 Euro betragen. Schließlich entschied ich mich für DigitalOcean und wählte bewusst das kleinste verfügbare Droplet, um die laufenden Kosten möglichst niedrig zu halten.

Neben den technischen Herausforderungen gab es auch persönliche Schwierigkeiten. Da ich zuvor kaum Erfahrung mit komplexen Netzwerkarchitekturen hatte, bestand ein großer Teil der Entwicklungszeit aus Ausprobieren, Verwerfen und Neuaufbauen. Das führte häufig zu Frustration, insbesondere wenn Dinge trotz großer Mühe nicht funktionierten. Hinzu kam ein erheblicher Zeitdruck, da ich parallel mehrere anspruchsvolle Fächer belegte. Da ich zu diesem Zeitpunkt bereits relativ weit mit dem Projekt fortgeschritten war, kam es für mich nicht mehr infrage, es einfach zu verwerfen und mit einem neuen, leichteren Projekt von vorne zu beginnen.

Was habe ich gelernt?

Während der Entwicklung hatte ich eine schwierige und teilweise sehr anstrengende Zeit, dennoch hat sich dieser Aufwand für mich absolut gelohnt. Ich konnte zahlreiche neue Konzepte kennenlernen, die ich zuvor weder verwendet noch wirklich gekannt hatte. Viele Inhalte aus der Vorlesung empfand ich als ideale Grundlage, um sie direkt in mein Projekt zu integrieren und praktisch anzuwenden.

Dabei habe ich nicht nur eine cloudbasierte Anwendung entwickelt, sondern auch ein völlig neues Verständnis für den Aufbau von Netzwerkarchitekturen gewonnen. Konzepte wie Microservices, deren eigenständiger Aufbau sowie deren Bereitstellung waren für mich extrem lehrreich und zugleich motivierend. Besonders spannend war es, diese nicht nur theoretisch zu verstehen, sondern selbst umzusetzen.

Zum ersten Mal hatte ich außerdem intensiven Kontakt mit echtem Deployment auf einem Server. Ich habe gelernt, worauf man dabei achten muss, wie ein solches System verwaltet wird und wie Fehler in einer produktionsnahen Umgebung analysiert und behoben werden können. Auch mit Docker hatte ich erstmals sehr intensiv zu tun und verstehe nun deutlich besser, welchen Zweck Container erfüllen und welchen Mehrwert sie in modernen Architekturen bieten.

Ein weiterer wichtiger Lernaspekt war das Thema Ressourcenmanagement. Ich habe gelernt, wie entscheidend es ist, ungenutzte Ressourcen konsequent zu beenden, da sonst unnötige Kosten entstehen können, etwa wenn mehrere ungenutzte Game-Server parallel laufen. Da ich den Server selbst finanziert habe, wurde mir die Bedeutung dieses Konzepts besonders deutlich. Diese Erfahrung hätte ich rein theoretisch so nicht machen können.

Durch das Projekt habe ich außerdem gelernt, Technologien bewusst auszuwählen, sinnvoll voneinander zu trennen und kritisch zu hinterfragen, ob sie dem Projekt wirklich nutzen oder eher zusätzliche Komplexität erzeugen. Auch wenn meine Netzwerkarchitektur sicher nicht perfekt ist und an einigen Stellen Optimierungspotenzial besitzt, habe ich eigenständig eine funktionierende Architektur entworfen, die für mein zukünftiges Verständnis, insbesondere im Bereich der Spieleentwicklung, sehr wertvoll sein wird.

Ich habe nun ein deutlich besseres Verständnis dafür, wie zum Beispiel Multiplayer-Spiele auf einer grundlegenden Ebene funktionieren. Mir ist bewusst geworden, dass in professionellen Systemen weitere Microservices wie Authentifizierung oder Datenbanken integriert werden, die für mein Projekt zwar nicht notwendig waren, deren Funktionsweise ich nun jedoch besser nachvollziehen kann.

Durch diese Vorlesung und die Umsetzung des Projekts fühle ich mich insgesamt deutlich sicherer im Bereich Softwareentwicklung, und insbesondere im Hinblick auf Deployment und Bereitstellung von Anwendungen.


Posted

in

by

Emre Kalkan

Tags:

Comments

Leave a Reply