Portierung eines Multiplayer-Spiels von C++ ins Web mit Cheerp, WebRTC und Firebase

Einführung

unsere Firma Lehnende Technologien bietet Lösungen für die Portierung herkömmlicher Desktop-Anwendungen ins Web. Unser C++-Compiler jubeln generiert eine Kombination aus WebAssembly und JavaScript, die beides bietet einfache Browser-Interaktionund hohe Leistung.

Als Anwendungsbeispiel haben wir beschlossen, ein Multiplayer-Spiel ins Internet zu portieren, und haben uns entschieden Teeworlds. Teeworlds ist ein Multiplayer-XNUMXD-Retro-Spiel mit einer kleinen, aber aktiven Spielergemeinschaft (einschließlich mir!). Es ist sowohl hinsichtlich der heruntergeladenen Ressourcen als auch hinsichtlich der CPU- und GPU-Anforderungen gering – ein idealer Kandidat.

Portierung eines Multiplayer-Spiels von C++ ins Web mit Cheerp, WebRTC und Firebase
Läuft im Teeworlds-Browser

Wir haben beschlossen, dieses Projekt zum Experimentieren zu nutzen Allgemeine Lösungen für die Portierung von Netzwerkcode ins Web. Dies geschieht in der Regel auf folgende Weise:

  • XMLHttpRequest/fetch, wenn der Netzwerkteil nur aus HTTP-Anfragen besteht, oder
  • WebSockets.

Beide Lösungen erfordern das Hosten einer Serverkomponente auf der Serverseite und ermöglichen keine Verwendung als Transportprotokoll UDP. Dies ist wichtig für Echtzeitanwendungen wie Videokonferenzsoftware und Spiele, da es die Zustellung und Reihenfolge der Protokollpakete garantiert TCP kann zu einem Hindernis für niedrige Latenzzeiten werden.

Es gibt einen dritten Weg – nutzen Sie das Netzwerk über den Browser: WebRTC.

RTCDataChannel Es unterstützt sowohl zuverlässige als auch unzuverlässige Übertragungen (im letzteren Fall versucht es, wann immer möglich, UDP als Transportprotokoll zu verwenden) und kann sowohl mit einem Remote-Server als auch zwischen Browsern verwendet werden. Das bedeutet, dass wir die gesamte Anwendung auf den Browser portieren können, einschließlich der Serverkomponente!

Dies bringt jedoch eine zusätzliche Schwierigkeit mit sich: Bevor zwei WebRTC-Peers kommunizieren können, müssen sie einen relativ komplexen Handshake durchführen, um eine Verbindung herzustellen, wofür mehrere Drittparteien (ein Signalisierungsserver und ein oder mehrere Server) erforderlich sind BETÄUBEN/WENDE).

Idealerweise möchten wir eine Netzwerk-API erstellen, die WebRTC intern verwendet, aber möglichst nah an einer UDP-Sockets-Schnittstelle ist, die keine Verbindung herstellen muss.

Dadurch können wir die Vorteile von WebRTC nutzen, ohne komplexe Details im Anwendungscode offenlegen zu müssen (den wir in unserem Projekt so wenig wie möglich ändern wollten).

Mindestens WebRTC

WebRTC ist eine Reihe von APIs, die in Browsern verfügbar sind und eine Peer-to-Peer-Übertragung von Audio-, Video- und beliebigen Daten ermöglichen.

Die Verbindung zwischen Peers wird (auch wenn auf einer oder beiden Seiten NAT vorhanden ist) mithilfe von STUN- und/oder TURN-Servern über einen Mechanismus namens ICE hergestellt. Peers tauschen ICE-Informationen und Kanalparameter über Angebot und Antwort des SDP-Protokolls aus.

Wow! Wie viele Abkürzungen gleichzeitig? Lassen Sie uns kurz erklären, was diese Begriffe bedeuten:

  • Session-Traversal-Dienstprogramme für NAT (BETÄUBEN) — ein Protokoll zur Umgehung von NAT und zum Erhalten eines Paares (IP, Port) zum direkten Datenaustausch mit dem Host. Gelingt es ihm, seine Aufgabe zu erfüllen, können die Peers selbstständig Daten untereinander austauschen.
  • Traversal mit Relays um NAT herum (WENDE) wird auch für NAT-Traversal verwendet, implementiert dies jedoch durch die Weiterleitung von Daten über einen Proxy, der für beide Peers sichtbar ist. Es erhöht die Latenz und ist teurer in der Implementierung als STUN (da es während der gesamten Kommunikationssitzung angewendet wird), aber manchmal ist es die einzige Option.
  • Einrichtung einer interaktiven Konnektivität (EIS) Wird verwendet, um die bestmögliche Methode zum Verbinden zweier Peers auszuwählen, basierend auf Informationen, die von direkt verbundenen Peers sowie Informationen erhalten werden, die von einer beliebigen Anzahl von STUN- und TURN-Servern empfangen werden.
  • Sitzungsbeschreibungsprotokoll (SDP) ist ein Format zur Beschreibung von Verbindungskanalparametern, zum Beispiel ICE-Kandidaten, Multimedia-Codecs (im Fall eines Audio-/Videokanals) usw. Einer der Peers sendet ein SDP-Angebot und der zweite antwortet mit einer SDP-Antwort . . Anschließend wird ein Kanal erstellt.

Um eine solche Verbindung herzustellen, müssen Peers die Informationen, die sie von den STUN- und TURN-Servern erhalten, sammeln und untereinander austauschen.

Das Problem besteht darin, dass sie noch nicht in der Lage sind, direkt zu kommunizieren. Daher muss ein Out-of-Band-Mechanismus zum Austausch dieser Daten vorhanden sein: ein Signalisierungsserver.

Ein Signalisierungsserver kann sehr einfach sein, da seine einzige Aufgabe darin besteht, Daten zwischen Peers in der Handshake-Phase weiterzuleiten (wie im Diagramm unten dargestellt).

Portierung eines Multiplayer-Spiels von C++ ins Web mit Cheerp, WebRTC und Firebase
Vereinfachtes WebRTC-Handshake-Sequenzdiagramm

Übersicht über das Teeworlds-Netzwerkmodell

Die Netzwerkarchitektur von Teeworlds ist sehr einfach:

  • Die Client- und Serverkomponenten sind zwei verschiedene Programme.
  • Clients betreten das Spiel, indem sie sich mit einem von mehreren Servern verbinden, von denen jeder jeweils nur ein Spiel hostet.
  • Die gesamte Datenübertragung im Spiel erfolgt über den Server.
  • Ein spezieller Master-Server wird verwendet, um eine Liste aller öffentlichen Server zu sammeln, die im Spielclient angezeigt werden.

Dank der Verwendung von WebRTC zum Datenaustausch können wir die Serverkomponente des Spiels an den Browser übertragen, in dem sich der Client befindet. Das gibt uns eine große Chance...

Befreien Sie sich von Servern

Das Fehlen einer Serverlogik hat einen schönen Vorteil: Wir können die gesamte Anwendung als statischen Inhalt auf Github-Seiten oder auf unserer eigenen Hardware hinter Cloudflare bereitstellen und sorgen so kostenlos für schnelle Downloads und eine hohe Verfügbarkeit. Tatsächlich können wir sie vergessen, und wenn wir Glück haben und das Spiel populär wird, muss die Infrastruktur nicht modernisiert werden.

Damit das System funktioniert, müssen wir jedoch noch eine externe Architektur verwenden:

  • Ein oder mehrere STUN-Server: Wir haben mehrere kostenlose Optionen zur Auswahl.
  • Mindestens ein TURN-Server: Hier gibt es keine kostenlosen Optionen, wir können also entweder einen eigenen einrichten oder für den Service bezahlen. Glücklicherweise kann die Verbindung in den meisten Fällen über STUN-Server hergestellt werden (und stellt echtes P2P bereit), aber TURN wird als Ausweichoption benötigt.
  • Signalisierungsserver: Im Gegensatz zu den beiden anderen Aspekten ist die Signalisierung nicht standardisiert. Wofür der Signalisierungsserver tatsächlich verantwortlich ist, hängt etwas von der Anwendung ab. In unserem Fall ist es vor dem Verbindungsaufbau notwendig, eine kleine Datenmenge auszutauschen.
  • Teeworlds-Masterserver: Er wird von anderen Servern verwendet, um ihre Existenz bekannt zu geben, und von Clients, um öffentliche Server zu finden. Dies ist zwar nicht erforderlich (Clients können jederzeit manuell eine Verbindung zu einem Server herstellen, den sie kennen), aber es wäre schön, wenn Spieler mit zufällig ausgewählten Personen an Spielen teilnehmen können.

Wir haben uns für die Nutzung der kostenlosen STUN-Server von Google entschieden und selbst einen TURN-Server bereitgestellt.

Für die letzten beiden Punkte haben wir verwendet Firebase:

  • Der Teeworlds-Masterserver ist sehr einfach implementiert: als Liste von Objekten, die Informationen (Name, IP, Karte, Modus, ...) jedes aktiven Servers enthalten. Server veröffentlichen und aktualisieren ihr eigenes Objekt, und Clients übernehmen die gesamte Liste und zeigen sie dem Player an. Wir zeigen die Liste auch als HTML auf der Startseite an, sodass Spieler einfach auf den Server klicken und direkt zum Spiel weitergeleitet werden können.
  • Die Signalisierung steht in engem Zusammenhang mit unserer Socket-Implementierung, die im nächsten Abschnitt beschrieben wird.

Portierung eines Multiplayer-Spiels von C++ ins Web mit Cheerp, WebRTC und Firebase
Liste der Server im Spiel und auf der Homepage

Implementierung von Sockets

Wir möchten eine API erstellen, die Posix UDP Sockets so nahe wie möglich kommt, um die Anzahl der erforderlichen Änderungen zu minimieren.

Außerdem wollen wir das nötige Minimum für den einfachsten Datenaustausch über das Netzwerk umsetzen.

Beispielsweise benötigen wir kein echtes Routing: Alle Peers befinden sich im selben „virtuellen LAN“, das einer bestimmten Firebase-Datenbankinstanz zugeordnet ist.

Daher benötigen wir keine eindeutigen IP-Adressen: Einzigartige Firebase-Schlüsselwerte (ähnlich wie Domänennamen) reichen aus, um Peers eindeutig zu identifizieren, und jeder Peer weist jedem zu übersetzenden Schlüssel lokal „gefälschte“ IP-Adressen zu. Dadurch entfällt die nicht triviale Aufgabe der globalen IP-Adresszuweisung vollständig.

Hier ist die Mindest-API, die wir implementieren müssen:

// Create and destroy a socket
int socket();
int close(int fd);
// Bind a socket to a port, and publish it on Firebase
int bind(int fd, AddrInfo* addr);
// Send a packet. This lazily create a WebRTC connection to the 
// peer when necessary
int sendto(int fd, uint8_t* buf, int len, const AddrInfo* addr);
// Receive the packets destined to this socket
int recvfrom(int fd, uint8_t* buf, int len, AddrInfo* addr);
// Be notified when new packets arrived
int recvCallback(Callback cb);
// Obtain a local ip address for this peer key
uint32_t resolve(client::String* key);
// Get the peer key for this ip
String* reverseResolve(uint32_t addr);
// Get the local peer key
String* local_key();
// Initialize the library with the given Firebase database and 
// WebRTc connection options
void init(client::FirebaseConfig* fb, client::RTCConfiguration* ice);

Die API ist einfach und ähnelt der Posix Sockets API, weist jedoch einige wichtige Unterschiede auf: Protokollierung von Rückrufen, Zuweisung lokaler IPs und verzögerte Verbindungen.

Rückrufe registrieren

Selbst wenn das ursprüngliche Programm nicht blockierende E/A verwendet, muss der Code umgestaltet werden, um in einem Webbrowser ausgeführt zu werden.

Der Grund dafür ist, dass die Ereignisschleife im Browser vor dem Programm (sei es JavaScript oder WebAssembly) verborgen ist.

In der nativen Umgebung können wir Code wie diesen schreiben

while(running) {
  select(...); // wait for I/O events
  while(true) {
    int r = readfrom(...); // try to read
    if (r < 0 && errno == EWOULDBLOCK) // no more data available
      break;
    ...
  }
  ...
}

Wenn uns die Ereignisschleife verborgen bleibt, müssen wir sie in etwa so umwandeln:

auto cb = []() { // this will be called when new data is available
  while(true) {
    int r = readfrom(...); // try to read
    if (r < 0 && errno == EWOULDBLOCK) // no more data available
      break;
    ...
  }
  ...
};
recvCallback(cb); // register the callback

Lokale IP-Zuweisung

Die Knoten-IDs in unserem „Netzwerk“ sind keine IP-Adressen, sondern Firebase-Schlüssel (es sind Zeichenfolgen, die so aussehen: -LmEC50PYZLCiCP-vqde ).

Dies ist praktisch, da wir keinen Mechanismus benötigen, um IPs zuzuweisen und ihre Eindeutigkeit zu überprüfen (und sie auch nicht zu entfernen, nachdem der Client die Verbindung getrennt hat), aber es ist oft notwendig, Peers anhand eines numerischen Werts zu identifizieren.

Genau hierfür dienen die Funktionen. resolve и reverseResolve: Die Anwendung empfängt auf irgendeine Weise den Zeichenfolgenwert des Schlüssels (per Benutzereingabe oder über den Master-Server) und kann ihn zur internen Verwendung in eine IP-Adresse umwandeln. Auch der Rest der API erhält der Einfachheit halber diesen Wert anstelle einer Zeichenfolge.

Dies ähnelt der DNS-Suche, wird jedoch lokal auf dem Client durchgeführt.

Das heißt, IP-Adressen können nicht von verschiedenen Clients gemeinsam genutzt werden, und wenn eine Art globaler Identifikator benötigt wird, muss dieser auf andere Weise generiert werden.

Faule Verbindung

UDP benötigt keine Verbindung, aber wie wir gesehen haben, erfordert WebRTC einen langwierigen Verbindungsprozess, bevor es mit der Datenübertragung zwischen zwei Peers beginnen kann.

Wenn wir die gleiche Abstraktionsebene bereitstellen möchten, (sendto/recvfrom mit beliebigen Peers ohne vorherige Verbindung), dann müssen sie eine „verzögerte“ (verzögerte) Verbindung innerhalb der API herstellen.

Dies geschieht bei der normalen Kommunikation zwischen dem „Server“ und dem „Client“ bei Verwendung von UDP und was unsere Bibliothek tun sollte:

  • Serveraufrufe bind()um dem Betriebssystem mitzuteilen, dass es Pakete auf dem angegebenen Port empfangen möchte.

Stattdessen veröffentlichen wir einen offenen Port in Firebase unter dem Serverschlüssel und warten auf Ereignisse in seinem Unterbaum.

  • Serveraufrufe recvfrom(), der Pakete akzeptiert, die von jedem Host an diesem Port kommen.

In unserem Fall müssen wir die eingehende Warteschlange der an diesen Port gesendeten Pakete überprüfen.

Jeder Port hat seine eigene Warteschlange, und wir fügen die Quell- und Zielports am Anfang der WebRTC-Datagramme hinzu, damit wir wissen, an welche Warteschlange wir weiterleiten müssen, wenn ein neues Paket eintrifft.

Der Aufruf ist nicht blockierend. Wenn also keine Pakete vorhanden sind, geben wir einfach -1 zurück und setzen errno=EWOULDBLOCK.

  • Der Client erhält die IP und den Port des Servers über externe Mittel und ruft auf sendto(). Dadurch wird auch ein interner Anruf getätigt. bind(), also nachträglich recvfrom() erhält die Antwort, ohne explizit bind auszuführen.

In unserem Fall erhält der Client extern den String-Schlüssel und nutzt die Funktion resolve() um eine IP-Adresse zu erhalten.

An dieser Stelle initiieren wir einen WebRTC-Handshake, wenn die beiden Peers noch nicht miteinander verbunden sind. Verbindungen zu verschiedenen Ports desselben Peers verwenden denselben WebRTC DataChannel.

Wir führen auch indirekt durch bind()damit sich der Server beim nächsten Mal wieder verbinden kann sendto() für den Fall, dass es aus irgendeinem Grund geschlossen wurde.

Der Server wird über die Verbindung des Clients benachrichtigt, wenn der Client sein SDP-Angebot unter den Server-Port-Informationen in Firebase schreibt, und der Server antwortet dort mit seiner Antwort.

Das folgende Diagramm zeigt ein Beispiel für den Nachrichtenfluss für ein Socket-Schema und die Übertragung der ersten Nachricht vom Client zum Server:

Portierung eines Multiplayer-Spiels von C++ ins Web mit Cheerp, WebRTC und Firebase
Vollständiges Diagramm der Verbindungsphase zwischen Client und Server

Abschluss

Wenn Sie bis hierher gelesen haben, sind Sie wahrscheinlich daran interessiert, die Theorie in der Praxis zu sehen. Das Spiel kann weiter gespielt werden teeworlds.leaningtech.com, Versuch es!


Freundschaftsspiel zwischen Kollegen

Der Code der Netzwerkbibliothek ist unter frei verfügbar Github. Beteiligen Sie sich an der Unterhaltung auf unserem Kanal unter Gitter!

Source: habr.com

Kommentar hinzufügen