Im ersten Beitrag haben wir uns die Basics von Multiuser-Anwendungen und die häufigsten Missverständnisse angesehen. In Teil 2 erklären wir die notwendige Infrastruktur und SDKs und was sich im Detail im Source-Code ändern muss, damit eine bestehende Anwendung um eine Multiuser-Funktion erweitert werden kann.
Der Hintergrund dieser Artikel-Reihe sind zwar Industrie-Anwendungsfälle wie XR-Anwendungen rund um das Thema Schulungs- und Trainings-Software, doch Antworten auf die Fragen lassen sich vor allem in der Videospiel-Entwicklung finden, welche sich schon seit Jahrzehnten mit dem Thema Multiplayer auseinandersetzt. Aus diesem Grund werden in den Beiträgen Begriffe wie Multiplayer, Game-State, Gameplay oder auch Matchmaking verwendet, um die Vorgänge zu erklären. Das soll die Anwendungsfälle nicht auf Videospiele beschränken, sondern nur vermitteln, mit welcher Terminologie sich Lösungen im Internet finden lassen.
Da wir in diesem Fall nur über Netzwerk-Multiplayer sprechen, können wir davon ausgehen, dass es immer zumindest einen Server und zwei Clients gibt, um eine Anwendung im Multiplayer erleben zu können.
Zunächst gehen wir darauf ein, aus welchen Komponenten sich eine Topologie zusammensetzen kann. Diese definiert, was ein Server ist und was ein Client. Wie wir sehen werden, gibt es auch Fälle, in denen die Aussage nicht klar getroffen werden kann.
In einem Peer-to-Peer Netzwerk gibt es keinen Server im klassischen Sinne. Stattdessen agiert einer der Clients gleichzeitig als Server – ein sogenannter Host – mit dem sich andere Clients verbinden.
In einem direkten Peer-to-Peer Netzwerk verbinden sich die Clients direkt miteinander und agieren jeweils auch als Server. Eine Player-Hosted Lösung kommt mir ihren Vor- und Nachteilen. Generell ist jedoch zu sagen, dass direktes P2P aus folgenden Gründen eher zu vernachlässigen ist:
Wenn n der Anzahl der Clients entspricht, hat eine Anwendung mit 4 Clients 6 Verbindungen, eine Anwendung mit 8 Clients schon 28 Verbindungen, bei 12 Clients sind es 66 Verbindungen.
Direct P2P wurde in diesem Beitrag nur der Vollständigkeit halber erwähnt. Aufgrund der eingangs erwähnten Probleme unterstützen etablierte Multiplayer-SDKs diese Lösung bewusst nicht.
Die wohl verbreiteste Lösung für eine robuste Multiplayer-Lösung ist der Dedicated Server. Dabei wird die Server-Runtime auf einem dedizierten Server gehostet, worauf sich einzelne Clients verbinden.
State-Management ist komplex und besonders bei Echtzeit-Anwendungen wie XR-Anwendungen und Videospielen ein wichtiges Thema. Für die vielfältigen und meist sehr einzigartigen Anforderungen bedarf es umfassende Werkzeuge, die Entwickler:innen die Arbeit an einem Multiplayer erleichtern. Diese Werkzeuge, bzw. die Code-Base für Multiplayer-Funktionalitäten, wird häufig als Netcode bezeichnet.
In diesem Abschnitt geht es um die Grundfunktionen, welche man von SDKs erwarten sollte. Werden diese nicht geboten, sollte man sich dem Aufwand bewusst werden, diese Funktionen selbst zu implementieren.
Multiplayer-SDKs bieten eine große Bandbreite an Werkzeugen für die Entwicklung und nehmen Entwickler:innen dabei in vielen Aspekten Arbeit ab – angefangen beim Transport Layer. Ein Transport Layer stellt die Verbindung zwischen den Anwendungen und der Hosts in einem Netzwerk her und sorgt für einen zuverlässigen Austausch.
Der Transport Layer eines SDKs kann folgende Funktionen enthalten:
Genutzt wird der Transport Layer von einer Vielzahl an Modulen und Funktionen, welche den Game State an andere Clients oder Server versenden. Es gibt hierbei eine Reihe an Möglichkeiten, um den State zu kommunizieren, welche sich auf die verschiedenen Arten der Synchronisierung zurückführen lassen. Diese wurden bereits in einem früheren Abschnitt behandelt.
Folgend die zwei wichtigsten Werkzeuge, welche jedes Multiplayer-SDK implementieren sollte:
Kontinuierliche Synchronisierung, wie beispielsweise der Ball bei der Simulation eines Fußballspiels, müssen so schnell und verlässlich wie möglich mit allen Clients synchronisiert werden. Darum bieten SDKs in der Regel einen Variablentyp, welcher es ermöglicht, Variablen über das Netzwerk synchronisieren zu lassen. Diese werden dann je nach Implementierung bei jeder Veränderung und / oder in einer bestimmten Frequenz synchronisiert. Hier gilt auch wieder festzulegen, wer die Autorität über die Variable hat.
In Unitys hauseigener SDK namens Netcode for Gameobjects würde solch eine Variable wie folgt verwendet werden:
Die Klasse, welche die Netzwerk-Variable verwenden möchte, muss von NetworkBehaviour erben. Die NetworkBehaviour-Klasse erbt von der allgemein bekannten MonoBehaviour-Klasse und erweitert diese um die Netzwerk-Funktionalitäten mit Methoden wie bool IsOwner() oder int GetNetworkObjectId() und auch Callback-Funktionen wie OnNetworkSpawn(), um auf Netzwerk-Events zu reagieren.
Anschließend lässt sich die Klasse NetworkVariable verwenden, um eine Netzwerk-Variable zu deklarieren.
private NetworkVariable<int> m_SomeValue = new NetworkVariable<int>();
Der Wert der Variable wird allerdings mithilfe eines eigenen Attributs verändern.
m_SomeValue.Value = k_InitialValue;
Um über Veränderungen an der Variable in Kenntnis gesetzt zu werden, nutzt man die dafür vorgesehene Callback-Funktion.
m_SomeValue.OnValueChanged += OnSomeValueChanged;
Versucht ein Client die Variable zu verändern, besitzt aber nicht Autorität darüber, schlägt dieser Funktions-Aufruf fehl. Sieh dir die Dokumentation an, um mehr Informationen über die NetworkVariable-Klasse zu erhalten.
Bei eventbasierter Synchronisierung sind RPCs außerordentlich nützlich. Diese geben die Möglichkeit bei Server und / oder Clients Funktionen auszuführen. Anhand von RPCs wird auch deutlich, warum es wichtig ist, dass Server- und Client-Runtime dieselbe Code-Basis verwenden. Denn damit ein RPC die richtige Instanz in der Anwendung findet, um dort einen Funktionsaufruf durchzuführen, muss diese Instanz denselben logischen Pfad besitzen, wie die Anwendung, welche den RPC abgeschickt hat.
Im Falle von Netcode for Gameobjects erstellt man einen RPC wie folgt:
Angenommen wir haben ein Partikel-System, welches gleichzeitig auf allen Clients gestartet werden soll. In diesem Fall existiert die ClientRpc-Annotation.
[ClientRpc]
public void PlayParticlesClientRpc() {
ParticleSystem.Play();
}
Diese Funktion könnte allerdings nur vom Server bzw. vom Host ausgeführt werden. Da Netcode for Gameobjects es nicht erlaubt, ClientRpc von Clients aufzurufen. Falls wir aber von einem Client aus, den Effekt auslösen wollen ist der Umweg über einen ServerRpc notwendig, welcher dann den ClientRpc ausführt.
[ServerRpc]
public void PlayParticlesServerRpc() {
PlayParticlesClientRpc();
}
In diesem Fall würde ein Client den ServerRpc ausführen. Der Server erhält den RPC und führt anschließend auf allen Clients aus und löst damit den ursprünglichen RPC ab. Das mag zunächst umständlich erscheinen, doch neben dem Vorteil, dass so die Autorität über den Funktion-Aufruf beim Server liegt, hat es auch zur Folge, dass die Funktion auf allen Clients gleichzeitig ausgeführt wird. Bei hoher und / oder unterschiedlicher Latenz der Clients kann es dennoch zu unterschiedlichen Erlebnissen kommen.
Neben den eben erwähnten Lösungen gibt es noch etliche für Game Engines zugeschnittene Lösungen. Nicht jede Game Engine hat ein eigenes Multiplayer-SDK, doch meist gibt es zumindest Schnittstellen.
Auch liefern SDKs unterschiedliche Lösungen für sehr spezifische Probleme, die aber nicht in jedem Projekt auftreten müssen. Ein sehr bekanntes Beispiel ist Dead Reckoning.
Dead Reckoning ist eine Technik im Multiplayer-Game-Development, die verwendet wird, um die Position und Bewegung von Objekten vorherzusagen und zu interpolieren. Außerdem wird so eine reibungslose und verzögerungsfreie Darstellung gewährleistet. Da Latenz häufig auftritt, sendet der Server nicht ständig die genaue Position aller Objekte an alle Clients. Stattdessen berechnen die Clients die Positionen basierend auf den zuletzt empfangenen Daten und den erwarteten Bewegungen. Wenn neue Daten eintreffen, passen sie die Vorhersagen an, um Diskrepanzen zu minimieren und eine möglichst flüssige Darstellung zu gewährleisten. Dies verbessert das Benutzer:innen-Erlebnis, indem es Lag reduziert und die Synchronisation zwischen den Clients verbessert.
In diesem Beitrag haben wir uns im Detail mit Werkzeugen beschäftigt, die Entwickler:innen zur Verfügung stehen, wenn sie eine Multiuser-Anwendung entwickeln möchten. Doch war das lediglich ein kleiner Einblick, um ein grundsätzliches Verständnis aufzubauen. Neben den erwähnten Möglichkeiten gibt es viele weitere, die in ihren Anwendungsgebieten so speziell sind, dass es wenig Sinn hat, sie an dieser Stelle zu erwähnen. Wichtig ist nur zu verstehen, dass diese Werkzeuge nicht alle verwendet werden müssen und sollten. Jede Anwendung ist einzigartig und sollte dementsprechend behandelt werden. Die Auswahl der richtigen Werkzeuge setzt ein tiefes Verständnis für die Struktur der Anwendung voraus und sollte immer mit Bedacht gewählt werden, um spätere Umbauten zu vermeiden.
Im letzten Teil der Reihe gehen wir näher auf die realen Optionen ein, welche Entwickler:innen haben, um mit der Entwicklung eines Multiuser-Features zu starten. Zusätzlich wird ein Fragenkatalog vorgestellt, um leichter Entscheidungen bezüglich Infrastruktur und Software treffen zu können.
Philip Ewert - ist Fullstack-Webentwickler bei iteratec und fördert zudem die Themen 3D-Entwicklung und Grafikprogrammierung im Web.
Mehr zu den Möglichkeiten von XR-Anwendungen für Ihr Unternehmen finden Sie auf unserer Webseite. Sprechen Sie uns auch gerne an.