Wir portieren ein Multiplayer-Spiel von C ++ mit Cheerp, WebRTC und Firebase ins Web

Einführung


Unser Unternehmen Leaning Technologies bietet Lösungen für die Portierung traditioneller Desktop-Anwendungen ins Web. Unser C ++ Cheerp-Compiler generiert eine Kombination aus WebAssembly und JavaScript, die sowohl eine einfache Browserinteraktion als auch eine hohe Leistung bietet.

Als Beispiel für seine Anwendung haben wir beschlossen, ein Multiplayer-Spiel für das Web zu portieren, und dafür Teeworlds ausgewählt . Teeworlds ist ein zweidimensionales Retro-Spiel für mehrere Spieler mit einer kleinen, aber aktiven Community von Spielern (einschließlich mir!). Es ist klein in Bezug auf herunterladbare Ressourcen sowie CPU- und GPU-Anforderungen - ein idealer Kandidat.


Funktioniert im Teeworlds-Browser

Wir haben uns entschlossen, dieses Projekt zu verwenden, um mit allgemeinen Lösungen für die Portierung von Netzwerkcode ins Web zu experimentieren. Dies geschieht normalerweise auf folgende Weise:

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

Bei beiden Lösungen muss die Serverkomponente auf der Serverseite gehostet werden, und bei keiner von ihnen können Sie UDP als Transportprotokoll verwenden. Dies ist wichtig für Echtzeitanwendungen wie Videokonferenzen und Spielesoftware, da Zustellgarantien und die Bestellung von TCP- Paketen die geringe Latenz beeinträchtigen können.

Es gibt einen dritten Weg: Verwenden Sie das Netzwerk über einen Browser: WebRTC .

RTCDataChannel unterstützt sowohl eine zuverlässige als auch eine unzuverlässige Übertragung (im letzteren Fall wird nach Möglichkeit versucht, UDP als Transportprotokoll zu verwenden) und kann mit einem Remote-Server und zwischen Browsern verwendet werden. Dies bedeutet, dass wir die gesamte Anwendung einschließlich der Serverkomponente auf den Browser portieren können!

Dies ist jedoch eine zusätzliche Schwierigkeit: Bevor zwei WebRTC-Peers Daten austauschen können, müssen sie ein relativ kompliziertes Handshake-Verfahren für die Verbindung ausführen, für das mehrere Entitäten von Drittanbietern (ein Signalserver und ein oder mehrere STUN / TURN- Server) erforderlich sind.

Im Idealfall möchten wir eine Netzwerk-API intern mit WebRTC erstellen, jedoch so nah wie möglich an der UDP Sockets-Schnittstelle, für die keine Verbindung hergestellt werden muss.

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

Minimum WebRTC


WebRTC ist eine in Browsern verfügbare API-Suite, die Peer-to-Peer-Audio-, Video- und willkürliche Datenübertragung bietet.

Die Verbindung zwischen den Peers wird über die STUN- und / oder TURN-Server über einen Mechanismus namens ICE hergestellt (auch wenn auf einer oder beiden Seiten NAT vorhanden ist). Peers tauschen ICE-Informationen und Kanalparameter über das SDP-Angebots- und Antwortprotokoll aus.

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

  • Session Traversal Utilities für NAT ( STUN ) - ein Protokoll zum Umgehen von NAT und zum Empfangen eines Paares (IP, Port) zum direkten Datenaustausch mit dem Host. Wenn es ihm gelingt, seine Aufgabe zu erfüllen, können Peers unabhängig voneinander Daten miteinander austauschen.
  • Das Durchlaufen von Relays um NAT ( TURN ) wird auch zum Umgehen von NAT verwendet. Dies erfolgt jedoch durch Umleiten von Daten über einen Proxy, der für beide Peers sichtbar ist. Es führt zu Verzögerungen und ist teurer in der Ausführung als STUN (da es während der gesamten Kommunikationssitzung verwendet wird), aber manchmal ist dies die einzig mögliche Option.
  • Interactive Connectivity Establishment ( ICE ) wird verwendet, um den bestmöglichen Weg zum Verbinden von zwei Peers auszuwählen, basierend auf Informationen, die durch direkte Verbindung von Peers erhalten wurden, sowie Informationen, die von einer beliebigen Anzahl von STUN- und TURN-Servern empfangen wurden.
  • Das Session Description Protocol ( SDP ) ist ein Format zur Beschreibung der Parameter des Verbindungskanals, z. B. ICE-Kandidaten, Multimedia-Codecs (im Fall eines Audio- / Videokanals) usw. Einer der Peers sendet ein SDP-Angebot ("Angebot") und der zweite antwortet mit SDP Antwort ("Antwort"). Danach wird ein Kanal erstellt.

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

Das Problem ist, dass sie noch nicht in der Lage sind, Daten direkt auszutauschen. Daher muss es einen Out-of-Band-Mechanismus für den Austausch dieser Daten geben: einen Signalserver.

Ein Signalserver kann sehr einfach sein, da seine einzige Aufgabe darin besteht, Daten zwischen Peers in der Phase „Handshake“ umzuleiten (wie in der folgenden Abbildung dargestellt).


Vereinfachte WebRTC-Handshake-Sequenz

Teeworlds Netzwerkmodellübersicht


Die Netzwerkarchitektur von Teeworlds ist sehr einfach:

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

Aufgrund der Verwendung von WebRTC für den Datenaustausch können wir die Serverkomponente des Spiels an den Browser übertragen, in dem sich der Client befindet. Es gibt uns eine großartige Gelegenheit ...

Server loswerden


Das Fehlen der Serverlogik hat einen schönen Vorteil: Wir können die gesamte Anwendung als statischen Inhalt auf Github Pages oder auf unseren eigenen Geräten hinter Cloudflare bereitstellen und so schnelle Downloads und hohe Verfügbarkeit kostenlos sicherstellen. 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 die Wahl zwischen mehreren kostenlosen Optionen.
  • Mindestens ein TURN-Server: Hier gibt es keine kostenlosen Optionen, sodass wir entweder unsere eigenen einrichten oder für den Service bezahlen können. Glücklicherweise können Sie die meiste Zeit eine Verbindung über die STUN-Server herstellen (und echtes p2p bereitstellen), aber TURN wird als Fallback benötigt.
  • Signalserver: Im Gegensatz zu den beiden anderen Aspekten ist die Signalisierung nicht standardisiert. Wofür der Signalserver tatsächlich verantwortlich ist, hängt in gewisser Weise von der Anwendung ab. In unserem Fall ist es vor dem Herstellen einer Verbindung erforderlich, eine kleine Datenmenge auszutauschen.
  • Teeworlds Master-Server: Er wird von anderen Servern verwendet, um über seine Existenz zu informieren, und von Clients, um nach öffentlichen Servern zu suchen. Obwohl dies nicht erforderlich ist (Clients können jederzeit eine Verbindung zu einem Server herstellen, den sie manuell kennen), wäre es schön, ihn zu haben, damit Spieler an Spielen mit zufälligen Personen teilnehmen können.

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

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

  • Der Teeworlds-Masterserver ist sehr einfach zu implementieren: als Liste von Objekten, die Informationen (Name, IP, Karte, Modus, ...) jedes aktiven Servers enthalten. Die Server veröffentlichen und aktualisieren ihr eigenes Objekt, und die Clients nehmen die gesamte Liste und zeigen sie dem Player an. Wir zeigen die Liste auf der Homepage auch als HTML an, sodass die Spieler einfach auf den Server klicken und direkt zum Spiel gehen können.
  • Die Signalisierung hängt eng mit unserer Socket-Implementierung zusammen, die im nächsten Abschnitt beschrieben wird.


Liste der Server im Spiel und auf der Homepage

Socket-Implementierung


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

Wir wollen auch das notwendige Minimum realisieren, das für den einfachsten Datenaustausch über das Netzwerk erforderlich ist.

Zum Beispiel benötigen wir kein echtes Routing: Alle Peers befinden sich im selben „virtuellen LAN“, das einer bestimmten Instanz der Firebase-Datenbank zugeordnet ist.

Daher benötigen wir keine eindeutigen IP-Adressen: Für die eindeutige Identifizierung von Peers ist es ausreichend, eindeutige Werte von Firebase-Schlüsseln (ähnlich wie Domänennamen) zu verwenden, und jeder Peer weist jedem Schlüssel, der konvertiert werden muss, lokal „gefälschte“ IP-Adressen zu. Dadurch entfällt die Notwendigkeit einer globalen IP-Adresszuweisung vollständig, was keine triviale Aufgabe ist.

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: Registrieren von Rückrufen, Zuweisen lokaler IPs und eine verzögerte Verbindung .

Rückrufregistrierung


Selbst wenn das Quellprogramm nicht blockierende E / A verwendet, muss der Code überarbeitet werden, damit er in einem Webbrowser ausgeführt werden kann.

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

In einer nativen Umgebung können wir auf diese Weise Code 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 die Ereignisschleife für uns verborgen ist, müssen wir daraus etwas machen:

 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 Kennungen der Knoten in unserem „Netzwerk“ sind keine IP-Adressen, sondern Firebase-Schlüssel (dies sind Zeilen, die folgendermaßen aussehen: -LmEC50PYZLCiCP-vqde ).

Dies ist praktisch, da wir keinen Mechanismus zum Zuweisen von IP und zum Überprüfen ihrer Eindeutigkeit (sowie ihrer Entsorgung nach dem Trennen des Clients) benötigen. Es ist jedoch häufig erforderlich, Peers anhand eines numerischen Werts zu identifizieren.

Hierzu werden die Funktionen resolve und reverseResolve verwendet: Die Anwendung erhält irgendwie den Zeichenfolgenwert des Schlüssels (durch Benutzereingabe oder über den Master-Server) und kann ihn für den internen Gebrauch in eine IP-Adresse konvertieren. Der Rest der API erhält der Einfachheit halber auch diesen Wert anstelle einer Zeichenfolge.

Dies ähnelt einer DNS-Suche, die nur lokal auf dem Client durchgeführt wird.

Das heißt, IP-Adressen können nicht von verschiedenen Clients gemeinsam genutzt werden. Wenn Sie eine globale Kennung benötigen, müssen Sie diese auf andere Weise generieren.

Faule Mischung


UDP benötigt keine Verbindung, aber wie wir gesehen haben, erfordert WebRTC vor dem Starten der Datenübertragung zwischen zwei Peers einen langen Verbindungsprozess.

Wenn wir dieselbe Abstraktionsebene sendto recvfrom ( sendto / recvfrom mit beliebigen Peers, ohne zuvor eine Verbindung herzustellen), müssen wir innerhalb der API eine "faule" (verzögerte) Verbindung herstellen.

Folgendes passiert während des normalen Datenaustauschs zwischen dem „Server“ und dem „Client“ bei Verwendung von UDP und was unsere Bibliothek tun sollte:

  • Der Server ruft bind() auf, um dem Betriebssystem mitzuteilen, dass es Pakete an den angegebenen Port empfangen möchte.

Stattdessen veröffentlichen wir den offenen Port in Firebase unter dem Serverschlüssel und hören Ereignisse in seinem Teilbaum ab.

  • Der Server ruft recvfrom() und akzeptiert Pakete von einem beliebigen Host an diesen Port.

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

Jeder Port hat eine eigene Warteschlange, und wir fügen die Quell- und Zielports am Anfang der WebRTC-Datagramme hinzu, um zu wissen, welche Warteschlange umgeleitet werden soll, 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 empfängt die IP und den Port des Servers auf externe Weise und ruft sendto() . Außerdem wird ein interner Aufruf von bind() ausgeführt, sodass nachfolgendes recvfrom() eine Antwort erhält, ohne bind explizit auszuführen.

In unserem Fall empfängt der Client den Zeichenfolgenschlüssel extern und verwendet die Funktion resolve() , um die IP-Adresse abzurufen.

An diesem Punkt beginnen wir mit dem „Handshake“ von WebRTC, wenn die beiden Peers noch nicht miteinander verbunden sind. Verbindungen zu verschiedenen Ports desselben Peers verwenden denselben DataRannel-WebRTC.

Wir führen auch indirekt bind() damit der Server beim nächsten sendto() falls er aus irgendeinem Grund geschlossen wird.

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



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


Vollständiges Verbindungsschrittdiagramm zwischen Client und Server

Fazit


Wenn Sie bis zum Ende gelesen haben, sind Sie wahrscheinlich daran interessiert, die Theorie in Aktion zu betrachten. Das Spiel kann auf teeworlds.leaningtech.com gespielt werden , probieren Sie es aus!


Freundschaftsspiel zwischen Kollegen

Der Netzwerkbibliothekscode ist auf Github frei verfügbar. Chatten Sie auf unserem Kanal in Gitter !

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


All Articles