Client-Server-Interaktion in einem neuen mobilen PvP-Shooter und Spieleserver: Probleme und Lösungen

In früheren Artikeln der Serie (alle Links am Ende des Artikels) über die Entwicklung eines neuen schnellen Shooters haben wir die Mechanismen der Hauptarchitektur der auf ECS basierenden Spielelogik und die Merkmale der Arbeit mit einem Shooter auf dem Client untersucht, insbesondere die Implementierung eines Systems zur Vorhersage lokaler Spieleraktionen zur Steigerung der Reaktionsfähigkeit des Spiels . Dieses Mal werden wir uns eingehender mit Fragen der Client-Server-Interaktion unter Bedingungen einer schlechten Verbindung von Mobilfunknetzen und Möglichkeiten zur Verbesserung der Spielqualität für den Endbenutzer befassen. Ich werde auch kurz die Architektur des Spielservers beschreiben.




Bei der Entwicklung des neuen synchronen PvP für mobile Geräte stießen wir auf typische Probleme des Genres:

  1. Die Verbindungsqualität von mobilen Clients ist schlecht. Dies ist ein relativ hoher durchschnittlicher Ping im Bereich von 200 bis 250 ms und eine instabile Zeitverteilung des Pings unter Berücksichtigung der Änderung der Zugangspunkte (obwohl der Prozentsatz des Paketverlusts in 3G + Mobilfunknetzen entgegen der landläufigen Meinung ziemlich niedrig ist - etwa 1%).
  2. Bestehende technische Lösungen sind monströse Frameworks, die Entwickler in enge Frameworks treiben.

Wir haben den ersten Prototyp bei UNet hergestellt, obwohl er die Skalierbarkeit, die Kontrolle über die Netzwerkkomponente und die Abhängigkeit von der launischen Verbindung von Master-Clients einschränkte. Dann haben wir auf einen selbst geschriebenen Netcode über Photon Server umgestellt, aber dazu später mehr.

Betrachten Sie die Mechanismen zum Organisieren von Interaktionen zwischen Clients in synchronen PvP-Spielen. Die beliebtesten von ihnen:

  • P2P oder Peer-to-Peer . Die gesamte Logik des Spiels wird auf einem der Kunden gehostet und erfordert von uns fast keine Verkehrskosten. Aufgrund des Spielraums für Betrüger und der hohen Anforderungen an den Kunden, der das Spiel ausrichtet, sowie der Einschränkungen von NAT konnten wir diese Lösung jedoch nicht für ein Handyspiel verwenden.
  • Client-Server . Im Gegensatz dazu können Sie mit einem dedizierten Server alles, was im Spiel passiert (auf Wiedersehen, Betrüger), vollständig kontrollieren, und mit seiner Leistung können Sie einige für unser Projekt spezifische Dinge berechnen. Außerdem haben viele große Hosting-Anbieter eine eigene Subnetzstruktur, die dem Endbenutzer eine minimale Verzögerung bietet.

Es wurde beschlossen, einen autoritären Server zu schreiben.


Netzwerk mit Peer-to-Peer (links) und Client-Server (rechts)

Datenübertragung zwischen Client und Server


Wir verwenden Photon Server - dies ermöglichte es uns, die für das Projekt erforderliche Infrastruktur schnell auf der Grundlage eines Schemas bereitzustellen, das bereits im Laufe der Jahre ausgearbeitet wurde (in War Robots verwenden wir es).

Photon Server ist für uns ausschließlich eine Transportlösung ohne High-Level-Designs, die stark an eine bestimmte Spiel-Engine gebunden sind. Dies bietet einige Vorteile, da die Datenübertragungsbibliothek jederzeit ausgetauscht werden kann.

Der Spieleserver ist eine Multithread-Anwendung im Photon-Container. Für jede Übereinstimmung wird ein separater Stream erstellt, der die gesamte Arbeitslogik zusammenfasst und den Einfluss einer Übereinstimmung auf eine andere verhindert. Alle Serververbindungen werden von Photon gesteuert, und die von Clients eingehenden Daten werden der Warteschlange hinzugefügt, die dann in ECS analysiert wird.


Allgemeines Schema der Übereinstimmungsströme im Photon Server-Container

Jedes Spiel besteht aus mehreren Phasen:

  1. Der Spielclient stellt sich im sogenannten Match-Making-Service in die Warteschlange. Sobald die erforderliche Anzahl von Spielern, die bestimmte Bedingungen erfüllen, darin gesammelt ist, meldet er dies dem Spielserver unter Verwendung von gRPC. Gleichzeitig werden alle zum Erstellen des Spiels erforderlichen Daten übertragen.


    Allgemeines Schema zum Erstellen einer Übereinstimmung
  2. Auf dem Spielserver beginnt die Initialisierung des Spiels. Alle Match-Parameter werden verarbeitet und vorbereitet, einschließlich Kartendaten sowie aller Kundendaten, die vom Match-Erstellungsservice empfangen wurden. Das Verarbeiten und Vorbereiten von Daten bedeutet, dass wir alle erforderlichen Daten analysieren und in eine spezielle Teilmenge von Entitäten schreiben, die wir RuleBook nennen. Es speichert Übereinstimmungsstatistiken (die sich während des Verlaufs nicht ändern) und wird während des Verbindungs- und Autorisierungsprozesses einmal oder beim erneuten Verbinden nach Verbindungsverlust an alle Clients übertragen. Zu den statischen Übereinstimmungsdaten gehören die Kartenkonfiguration (Darstellung der Karte durch ECS-Komponenten, die sie mit der physischen Engine verbinden), Kundendaten (Spitznamen, eine Reihe von Waffen, die sie haben und die sie während des Kampfes nicht ändern usw.).
  3. Ein Match laufen lassen. ECS-Systeme , aus denen das Spiel auf dem Server besteht, beginnen zu funktionieren. Alle Systeme ticken 30 Bilder pro Sekunde.
  4. Jeder Frame liest und entpackt die Eingaben oder Kopien des Spielers, wenn die Spieler ihre Eingaben nicht innerhalb eines bestimmten Intervalls gesendet haben.
  5. Dann wird im gleichen Rahmen die Eingabe im ECS-System verarbeitet, nämlich: Änderung des Spielerstatus; die Welt, die er mit seinem Input beeinflusst; und den Status anderer Spieler.
  6. Am Ende des Frames wird der resultierende Weltzustand für den Player verpackt und über das Netzwerk gesendet.
  7. Am Ende des Spiels werden die Ergebnisse an die Kunden und an den Microservice gesendet, der die Belohnungen für den Kampf mithilfe von gRPC verarbeitet, sowie an den Analysten für das Spiel.
  8. Danach keilt sich der Match-Flow und der Flow schließt sich.


Die Reihenfolge der Aktionen auf dem Server innerhalb eines Frames

Auf der Clientseite wird die Verbindung zu einem Match wie folgt hergestellt:

  1. Zunächst wird eine Anforderung für die Warteschlange im Dienst zum Erstellen von Übereinstimmungen über den Websocket mit Serialisierung über Protobuf gestellt.
  2. Beim Erstellen eines Spiels informiert dieser Dienst den Client über die Adresse des Spielservers und überträgt die zusätzliche Nutzlast, die der Client vor dem Spiel benötigt. Jetzt ist der Client bereit, den Autorisierungsprozess auf dem Spielserver zu starten.
  3. Der Client erstellt einen UDP-Socket und sendet eine Anforderung an den Spielserver, um zusammen mit einigen Anmeldeinformationen eine Verbindung zum Spiel herzustellen. Der Server wartet bereits auf diesen Client. Wenn er verbunden ist, gibt er ihm alle notwendigen Daten, um das Spiel zu starten und die Welt zum ersten Mal anzuzeigen. Dazu gehören: RuleBook (eine Liste statischer Daten für das Spiel) sowie StringIntMap, die wir als Daten zu den im Spiel verwendeten Zeilen bezeichnen, die während des Spiels durch Ganzzahlen identifiziert werden. Dies ist notwendig, um Verkehr zu sparen, weil Durch das Übergeben von Linien in jedem Frame wird das Netzwerk erheblich belastet. Beispielsweise werden alle Spielernamen, Klassennamen, Waffenkennungen, Konten und dergleichen in StringIntMap geschrieben, wo sie mit einfachen ganzzahligen Daten codiert werden.

Wenn ein Spieler andere Benutzer direkt beeinflusst (Schaden verursacht, Effekte anwendet usw.), wird auf dem Server ein Statusverlauf durchsucht, um die Spielwelt, die der Client in einem bestimmten Simulations-Tick tatsächlich sieht, mit dem zu vergleichen, was zu diesem Zeitpunkt auf dem Server mit anderen passiert ist Spieleinheiten.

Zum Beispiel schießen Sie auf Ihren Kunden. Für Sie geschieht dies sofort, aber der Kunde ist im Vergleich zur umgebenden Welt, die er anzeigt, bereits einige Zeit „weggelaufen“. Aufgrund der lokalen Vorhersage des Verhaltens des Spielers muss der Server daher verstehen, wo und in welchem ​​Zustand sich die Gegner zum Zeitpunkt des Schusses befanden (möglicherweise waren sie bereits tot oder umgekehrt unverwundbar). Der Server überprüft alle Faktoren und urteilt über den verursachten Schaden.


Anfrage zum Erstellen eines Matches, Herstellen einer Verbindung zu einem Spieleserver und Autorisierung

Serialisierung und Deserialisierung, Packen und Entpacken der ersten Bytes der Übereinstimmung


Wir haben eine proprietäre binäre Datenserialisierung und für die Datenübertragung verwenden wir UDP.

UDP ist die naheliegendste Option zum schnellen Senden von Nachrichten zwischen Client und Server. In der Regel ist es viel wichtiger, die Daten so schnell wie möglich anzuzeigen, als sie im Prinzip anzuzeigen. Verlorene Pakete nehmen Anpassungen vor, aber die Probleme werden für jeden Fall einzeln gelöst, wie Da die Daten ständig vom Client zum Server und zurück kommen, können Sie das Konzept einer Verbindung zwischen dem Client und dem Server eingeben.

Um einen optimalen und bequemen Code basierend auf der deklarativen Beschreibung der Struktur unseres ECS zu erstellen, verwenden wir die Codegenerierung. Beim Erstellen von Komponenten werden auch Serialisierungs- und Deserialisierungsregeln für diese generiert. Die Serialisierung basiert auf einem benutzerdefinierten Binärpacker, mit dem Sie Daten auf wirtschaftlichste Weise packen können. Der während des Betriebs erhaltene Bytesatz ist nicht der optimalste, aber Sie können einen Stream erstellen, aus dem Sie einige Paketdaten lesen können, ohne dass eine vollständige Deserialisierung erforderlich ist.

Das Datenübertragungslimit von 1500 Byte (auch bekannt als MTU) ist in der Tat die maximale Paketgröße, die über Ethernet übertragen werden kann. Diese Eigenschaft kann für jeden Hop des Netzwerks und häufig sogar unter 1500 Byte konfiguriert werden. Was passiert, wenn ich ein Paket mit mehr als 1500 Byte sende? Die Paketfragmentierung beginnt. Das heißt, Jedes Paket wird zwangsweise in mehrere Fragmente aufgeteilt, die separat von einer Schnittstelle zur anderen gesendet werden. Sie können auf völlig unterschiedlichen Wegen gesendet werden, und die Zeit zum Empfangen solcher Pakete kann sich erheblich verlängern, bevor die Netzwerkschicht ein geklebtes Paket an Ihre Anwendung ausgibt.

Im Fall von Photon beginnt die Bibliothek zwangsweise, solche Pakete im zuverlässigen UDP-Modus zu senden. Das heißt, Photon wartet auf jedes Fragment des Pakets und leitet die fehlenden Fragmente weiter, wenn sie während der Weiterleitung verloren gehen. Eine solche Arbeit des Netzwerkteils ist jedoch in Spielen, in denen eine minimale Netzwerkverzögerung erforderlich ist, nicht akzeptabel. Daher wird empfohlen, die Größe der weitergeleiteten Pakete auf ein Minimum zu reduzieren und die empfohlenen 1500 Bytes nicht zu überschreiten (in unserem Spiel überschreitet die Größe eines vollständigen Zustands der Welt 1000 Bytes nicht; die Größe des Pakets mit Delta-Komprimierung beträgt 200 Bytes).

Jedes Paket vom Server hat einen kurzen Header, der mehrere Bytes enthält, die den Pakettyp beschreiben. Der Client entpackt zuerst diesen Satz von Bytes und bestimmt, um welches Paket es sich handelt. Wir verlassen uns bei der Autorisierung stark auf diese Eigenschaft unseres Deserialisierungsmechanismus: Um die empfohlene Paketgröße von 1500 Byte nicht zu überschreiten, unterteilen wir die Pakete RuleBook und StringIntMap in mehrere Stufen. und um zu verstehen, was genau wir vom Server bekommen haben - die Spielregeln oder den Status selbst - verwenden wir den Paket-Header.

Bei der Entwicklung neuer Funktionen des Projekts wächst die Paketgröße stetig. Als wir auf dieses Problem stießen, wurde beschlossen, ein eigenes Delta-Komprimierungssystem sowie ein kontextbezogenes Abschneiden von Daten zu schreiben, die der Client nicht benötigte.

Kontextsensitive Optimierung des Netzwerkverkehrs. Delta-Komprimierung


Das Beschneiden von Kontextdaten wird manuell geschrieben, basierend auf den Daten, die der Client benötigt, um die Welt korrekt anzuzeigen, und der lokalen Vorhersage seiner eigenen Daten, um korrekt zu funktionieren. Dann wird die Delta-Komprimierung auf die verbleibenden Daten angewendet.

Unser Spiel jeder Tick erzeugt einen neuen Zustand der Welt, der verpackt und an die Kunden weitergegeben werden muss. In der Regel besteht die Delta-Komprimierung darin, zuerst einen vollständigen Status mit allen erforderlichen Daten an den Client zu senden und dann nur Änderungen an diesen Daten zu senden. Dies kann wie folgt dargestellt werden:

deltaGameState = newGameState - prevGameState

Für jeden Client werden jedoch unterschiedliche Daten gesendet, und der Verlust von nur einem Paket kann dazu führen, dass Sie den gesamten Status der Welt weiterleiten müssen.

Die Weiterleitung des gesamten Zustands der Welt ist eine ziemlich teure Aufgabe für das Netzwerk. Aus diesem Grund haben wir den Ansatz geändert und die Differenz zwischen dem aktuell verarbeiteten Zustand der Welt und dem Zustand, der genau vom Kunden empfangen wird, gesendet. Zu diesem Zweck sendet der Client in seinem Paket mit der Eingabe auch eine Tick-Nummer, die eine eindeutige Kennung des Spielstatus ist, den er bereits genau erhalten hat. Jetzt weiß der Server anhand des Status, in dem die Delta-Komprimierung erstellt werden muss. Der Client hat normalerweise keine Zeit, dem Server die angekreuzte Tick-Nummer zu senden, bevor der Server den nächsten Frame mit den Daten vorbereitet. Daher gibt es auf dem Client einen Verlauf der Serverzustände der Welt, auf den der vom Server generierte deltaGameState-Patch angewendet wird.


Darstellung der Häufigkeit der Client-Server-Interaktion im Projekt

Lassen Sie uns genauer darauf eingehen, was der Kunde sendet. Bei klassischen Schützen heißt ein solches Paket ClientCmd und enthält Informationen über die gedrückten Tasten des Spielers und die Zeit, zu der das Team erstellt wurde. Innerhalb des Eingabepakets senden wir viel mehr Daten:

public sealed class InputSample { //  ,        public uint WorldTick; // ,      ,     public uint PlayerSimulationTick; //   .  (idle, , ) public MovementMagnitude MovementMagnitude; //  ,   public float MovementAngle; //    public AimMagnitude AimMagnitude; //    public float AimAngle; //   ,       public uint ShotTarget; //    ,        public float AimMagnitudeCompressed; } 


Es gibt einige interessante Punkte. Zunächst teilt der Client dem Server mit, in welchem ​​Tick er alle Objekte der ihn umgebenden Spielwelt sieht, die er nicht vorhersagen kann (WorldTick). Es scheint, dass der Kunde in der Lage ist, die Zeit für die Welt anzuhalten und alle aufgrund lokaler Vorhersagen selbst zu rennen und zu erschießen. Es ist nicht so. Wir vertrauen nur einem begrenzten Satz von Werten des Kunden und lassen ihn nicht länger als 1 Sekunde in die Vergangenheit schießen. Das WorldTick-Feld wird auch als Bestätigungspaket verwendet, auf dessen Grundlage die Delta-Komprimierung erstellt wird.

Sie können Gleitkommazahlen in einem Paket finden. Normalerweise werden solche Werte häufig verwendet, um Messwerte vom Joystick des Players zu erfassen. Sie werden jedoch nicht sehr gut über das Netzwerk übertragen, da sie einen großen „Sprung“ aufweisen und normalerweise zu genau sind. Wir quantisieren solche Zahlen und packen sie mit einem Binärpacker, damit sie einen ganzzahligen Wert nicht überschreiten, der je nach Größe in mehrere Bits passen kann. Somit ist die Verpackung der Eingabe vom Ziel-Joystick unterbrochen:

 if (Math.Abs(s.AimMagnitudeCompressed) < float.Epsilon) { packer.PackByte(0, 1); } else { packer.PackByte(1, 1); float min = 0; float max = 1; float step = 0.001f; //     1000    , //          //     packer.PackUInt32((uint)((s.AimMagnitudeCompressed - min)/step), CalcFloatRangeBits(min, max, step)); } 


Ein weiteres interessantes Merkmal beim Senden von Eingaben ist, dass einige Befehle mehrmals gesendet werden können. Sehr oft werden wir gefragt, was zu tun ist, wenn eine Person die ultimative Fähigkeit gedrückt hat und das Paket mit seiner Eingabe verloren gegangen ist. Wir senden diese Eingabe nur mehrmals. Dies sieht nach garantierter Lieferung aus, ist jedoch flexibler und schneller. Weil Die Größe des Eingangspakets ist sehr klein. Wir können mehrere benachbarte Player-Eingänge in das resultierende Paket packen. Im Moment beträgt die Fenstergröße, die ihre Anzahl bestimmt, fünf.


Eingabepakete, die in jedem Tick auf dem Client generiert und an den Server gesendet werden

Die Übertragung dieser Art von Daten ist am schnellsten und zuverlässigsten genug, um unsere Probleme zu lösen, ohne zuverlässiges UDP zu verwenden. Wir gehen von der Tatsache aus, dass die Wahrscheinlichkeit, eine solche Anzahl von Paketen hintereinander zu verlieren, sehr gering ist und ein Indikator für eine ernsthafte Verschlechterung der Qualität des gesamten Netzwerks ist. In diesem Fall kopiert der Server einfach die zuletzt vom Player empfangenen Eingaben und wendet sie an, in der Hoffnung, dass sie unverändert bleiben.

Wenn der Client feststellt, dass er sehr lange keine Pakete über das Netzwerk empfangen hat, wird der Vorgang des erneuten Verbindens mit dem Server gestartet. Der Server überwacht seinerseits, dass die Eingabewarteschlange des Players vollständig ist.

Anstelle von Schlussfolgerung und Referenz


Es gibt viele andere Systeme auf dem Spieleserver, die für das Erkennen, Debuggen und Bearbeiten von Übereinstimmungen verantwortlich sind. Spieleentwickler aktualisieren die Konfiguration, ohne den Status der Server neu zu starten, zu protokollieren und zu überwachen. Wir möchten auch ausführlicher darüber schreiben, jedoch separat.

Bei der Entwicklung eines Netzwerkspiels auf mobilen Plattformen sollten Sie zunächst auf den korrekten Betrieb Ihres Clients mit hohen Pings (ca. 200 ms), etwas häufigerem Datenverlust sowie der Größe der gesendeten Daten achten. Und Sie müssen eindeutig in das Paketlimit von 1500 Byte passen, um Fragmentierung und Verkehrsverzögerungen zu vermeiden.

Nützliche Links:


Frühere Artikel zum Projekt:

  1. "Wie wir uns auf einem mobilen Fast-Paced-Shooter bewegten: Technologie und Ansätze . "
  2. "Wie und warum haben wir unser ECS geschrieben?"
  3. "Wie wir den Netzwerkcode des mobilen PvP-Shooters geschrieben haben: Player-Synchronisation auf dem Client . "

Source: https://habr.com/ru/post/de420019/


All Articles