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

Einführung

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

Als Beispiel für seine Anwendung haben wir uns entschieden, ein Multiplayer-Spiel für das Web zu portieren und haben uns für Teeworldsentschieden. Teeworlds ist ein Mehrspieler-2D-Retro-Spiel mit einer kleinen, aber aktiven Spielergemeinschaft (zu der auch ich gehöre!). Es ist sowohl in Bezug auf herunterladbare Ressourcen als auch auf die Anforderungen an CPU und GPU gering — der ideale Kandidat.

Wir portieren ein Multiplayer-Spiel von C++ auf das Web mit Cheerp, WebRTC und Firebase.
Teeworlds läuft im Browser

Wir haben uns entschieden, dieses Projekt zu nutzen, um mit allgemeinen Lösungen zur Portierung von Netzwerkcode ins Web zu experimentieren.Normalerweise wird dies auf folgende Weise ausgeführt:

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

Beide Lösungen erfordern das Hosten einer Serverkomponente auf der Serverseite, und keine von ihnen ermöglicht die Verwendung eines Transportprotokolls. UDP nachzudenken. Dies ist wichtig für Echtzeitanwendungen wie Videokonferenzsoftware und Spiele, da die Garantie für die Zustellung und die Reihenfolge von Paketen im Protokoll. TCP kann eine hohe Latenz stören.

Es gibt auch einen dritten Weg – das Browsersystem zu nutzen: WebRTC.

RTCDataChannel es unterstützt sowohl zuverlässige als auch unzuverlässige Übertragung (im letzten Fall versucht es in der Regel, 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, einschließlich der Serverkomponente, in den Browser portieren können!

Allerdings gibt es hierbei eine zusätzliche Schwierigkeit: bevor zwei WebRTC-Peers Daten austauschen können, müssen sie einen relativ komplexen „Handshake“-Prozess durchlaufen, um eine Verbindung herzustellen, der mehrere externe Entitäten erfordert (Signalisierungsserver und einen oder mehrere STUN/TURN).

Idealerweise möchten wir eine Netzwerk-API erstellen, die intern WebRTC nutzt, aber so nah wie möglich an der Schnittstelle von UDP Sockets ist, ohne eine Verbindung herstellen zu müssen.

Damit können wir die Vorteile von WebRTC nutzen, ohne die komplexen Details des Anwendungscodes preisgeben zu müssen (den den wir in unserem Projekt so wenig wie möglich ändern wollten).

Minimal WebRTC

WebRTC ist eine in Browsern verfügbare API-Sammlung, die die peer-to-peer Übertragung von Audio, Video und beliebigen Daten ermöglicht.

Die Verbindung zwischen den Peers wird (auch bei NAT auf einer oder beiden Seiten) über STUN- und/oder TURN-Server durch einen Mechanismus namens ICE hergestellt. Peers tauschen ICE-Informationen und Kanalparameter über das offer und answer Protokoll von SDP aus.

Wow! So viele Abkürzungen auf einmal. Lassen Sie uns kurz erklären, was diese Begriffe bedeuten:

  • Session Traversal Utilities for NAT (STUN) – ein Protokoll, das NAT umgeht und ein (IP, Port) Paar erhält, um Daten direkt mit dem Host auszutauschen. Wenn es erfolgreich ist, können die Peers Daten direkt untereinander austauschen.
  • Traversal Using Relays around NAT (TURN) wird auch verwendet, um NAT zu umgehen, jedoch geschieht dies durch die Umleitung von Daten über einen Proxy, der für beide Peers sichtbar ist. Dies verursacht eine Verzögerung und ist im Vergleich zu STUN (da es während der gesamten Kommunikationssitzung angewendet wird) kostenintensiver, aber manchmal ist es die einzige Option.
  • Interaktive Verbindungsherstellung (ICE) wird verwendet, um die bestmögliche Methode zur Verbindung zweier Peers basierend auf Informationen auszuwählen, die beim direkten Verbinden der Peers sowie von beliebig vielen STUN- und TURN-Servern erhalten wurden.
  • Session Description Protocol (SDP) ist ein Format zur Beschreibung von Verbindungsparameter, wie z. B. ICE-Kandidaten, Multimedia-Codecs (im Fall von Audio-/Video-Kanälen) usw. Einer der Peers sendet ein SDP-Angebot, während der andere mit einer SDP-Antwort antwortet. Danach wird der Kanal erstellt.

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

Das Problem ist, dass sie derzeit nicht in der Lage sind, Daten direkt auszutauschen, daher muss ein Off-Channel-Mechanismus existieren: ein Signalisierungsserver.

Ein Signalserver kann sehr einfach sein, da seine einzige Aufgabe darin besteht, Daten zwischen den Peers während des "Handshake"-Prozesses weiterzuleiten (wie im nachstehenden Diagramm dargestellt).

Wir portieren ein Multiplayer-Spiel von C++ auf das Web mit Cheerp, WebRTC und Firebase.
Vereinfachtes Diagramm des WebRTC-Handshake-Prozesses

Überblick über das Netzwerkmodell von Teeworlds

Die Netzwerkarchitektur von Teeworlds ist sehr einfach:

  • Die Komponenten von Client und Server sind zwei verschiedene Programme.
  • Clients treten dem Spiel bei, indem sie sich mit einem der mehreren Server verbinden, von denen jeder zu einem bestimmten Zeitpunkt nur ein Spiel hostet.
  • Der gesamte Datenaustausch 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.

Durch die Nutzung von WebRTC für den Datenaustausch können wir die Serverkomponente des Spiels in den Browser, in dem sich der Client befindet, übertragen. Dies bietet uns die großartige Möglichkeit...

Server überflüssig zu machen.

Das Fehlen von Serverlogik hat einen netten Vorteil: Wir können die gesamte Anwendung als statischen Inhalt auf Github Pages oder auf unserer eigenen Hardware hinter Cloudflare bereitstellen, wodurch wir uns schnelle Ladezeiten und hohe Verfügbarkeit kostenlos sichern. Im Grunde genommen können wir sie vergessen, und sollten wir Glück haben und das Spiel wird populär, ist eine Infrastrukturmodernisierung nicht erforderlich.

Um jedoch das System am Laufen zu halten, müssen wir trotzdem externe Architektur verwenden:

  • Einen oder mehrere STUN-Server: Hier haben wir die Auswahl aus mehreren kostenlosen Optionen.
  • Mindestens einen TURN-Server: Hier gibt es keine kostenlosen Optionen, daher können wir entweder unseren eigenen einrichten oder für einen Dienst bezahlen. Glücklicherweise können bei den meisten Verbindungen die STUN-Server verwendet werden (und somit echtes P2P ermöglichen), aber TURN ist als Backup erforderlich.
  • Signalisierungsserver: Anders als in den beiden anderen Aspekten ist die Signalgebung nicht standardisiert. Was der Signalisierungsserver tatsächlich übernehmen wird, hängt teilweise von der Anwendung ab. In unserem Fall ist es notwendig, vor der Verbindungsherstellung eine kleine Datenmenge auszutauschen.
  • Teeworlds Master-Server: Er wird von anderen Servern verwendet, um auf sich aufmerksam zu machen, und von Clients, um öffentliche Server zu finden. Obwohl er nicht zwingend erforderlich ist (Clients können sich immer manuell mit einem bekannten Server verbinden), wäre es vorteilhaft, ihn zu haben, damit Spieler an Spielen mit zufälligen Personen teilnehmen können.

Wir haben uns entschieden, die kostenlosen STUN-Server von Google zu nutzen, und einen TURN-Server haben wir selbst bereitgestellt.

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

  • Der Master-Server von Teeworlds ist sehr einfach umgesetzt: als eine Liste von Objekten, die Informationen (Name, IP, Karte, Modus, …) zu jedem aktiven Server enthalten. Die Server veröffentlichen und aktualisieren ihr eigenes Objekt, während die Clients die gesamte Liste abrufen und sie dem Spieler anzeigen. Zudem stellen wir die Liste auf der Startseite als HTML dar, sodass Spieler einfach auf den Server klicken können, um direkt ins Spiel zu gelangen.
  • Die Signalgebung steht in engem Zusammenhang mit unserer Socket-Implementierung, die im nächsten Abschnitt beschrieben wird.

Wir portieren ein Multiplayer-Spiel von C++ auf das Web mit Cheerp, WebRTC und Firebase.
Die Serverliste innerhalb des Spiels und auf der Startseite

Implementierung von Sockets

Wir möchten eine API erstellen, die so nah wie möglich an Posix UDP Sockets ist, um die erforderlichen Änderungen auf ein Minimum zu reduzieren.

Wir möchten auch das notwendige Minimum umsetzen, das für einen einfachen Datenaustausch über das Netzwerk erforderlich ist.

Zum Beispiel benötigen wir keine echte Routing-Funktion: Alle Peers befinden sich in einem "virtuellen LAN", das mit einer bestimmten Instanz der Firebase-Datenbank verbunden ist.

Daher benötigen wir keine einzigartigen IP-Adressen: Für die eindeutige Identifikation von Peers genügt es, einzigartige Firebase-Schlüsselwerte zu verwenden (ähnlich wie bei Domainnamen). Jeder Peer weist lokal "falsche" IP-Adressen jedem Schlüssel zu, die dann umgewandelt werden müssen. Dies befreit uns vollständig von der Notwendigkeit, IP-Adressen global zuzuweisen, was eine nicht triviale Aufgabe darstellt.

Hier ist die minimale 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 API der Posix-Sockets, hat jedoch einige wichtige Unterschiede: Registrierung von Rückrufen, Zuweisung lokaler IPs und "lazy" Verbindungen.

Registrierung von Rückrufen

Selbst wenn das ursprüngliche Programm nicht blockierenden I/O verwendet, muss der Code für die Ausführung im Webbrowser refaktoriert werden.

Der Grund dafür ist, dass die Ereignisschleife im Browser der Anwendung verborgen ist (ob es sich nun um JavaScript oder WebAssembly handelt).

In einer nativen Umgebung können wir Code so schreiben

while(running) {
  select(...); // warte auf I/O-Events
  while(true) {
    int r = readfrom(...); // versuche zu lesen
    if (r < 0 && errno == EWOULDBLOCK) // keine weiteren Daten verfügbar
      break;
    ...
  }
  ...
}

Wenn uns die Ereignisschleife verborgen bleibt, müssen wir sie in etwas Ähnliches umwandeln:

auto cb = []() { // dies wird aufgerufen, wenn neue Daten verfügbar sind
  while(true) {
    int r = readfrom(...); // versuche zu lesen
    if (r < 0 && errno == EWOULDBLOCK) // keine weiteren Daten verfügbar
      break;
    ...
  }
  ...
};
recvCallback(cb); // Callback registrieren

Zuweisung lokaler IPs

Die Identifikatoren der Knoten in unserem "Netzwerk" sind keine IP-Adressen, sondern Firebase-Schlüssel (das sind Strings, die so aussehen: -LmEC50PYZLCiCP-vqde ).

Das ist praktisch, da wir keinen Mechanismus zur Zuweisung von IPs und zur Überprüfung ihrer Eindeutigkeit benötigen (sowie deren Nutzung nach der Trennung des Clients), aber oft ist es notwendig, Peers durch einen numerischen Wert zu identifizieren.

Genau dafür werden die Funktionen verwendet resolve und reverseResolve: die Anwendung erhält auf irgendeine Weise den Stringwert des Schlüssels (durch Benutzereingabe oder über den Master-Server) und kann ihn in eine IP-Adresse für interne Zwecke umwandeln. Der Rest der API erhält ebenfalls der Einfachheit halber anstelle des Strings diesen Wert.

Das ist ähnlich wie eine DNS-Anfrage, die jedoch lokal beim Client ausgeführt wird.

Das heißt, IP-Adressen können nicht für verschiedene Clients gemeinsam sein, und wenn eine globale Kennung erforderlich ist, muss diese auf andere Weise generiert werden.

Faules Verbindung

UDP benötigt keine Verbindung, aber wie wir gesehen haben, erfordert WebRTC einen umfangreichen Verbindungsprozess, bevor Daten zwischen zwei Peers übertragen werden können.

Wenn wir dasselbe Abstraktionsniveau gewährleisten wollen, (sendto/recvfrom mit beliebigen Peers ohne vorherige Verbindung), müssen wir eine „lazy“ (verzögerte) Verbindung innerhalb der API ausführen.

Das passiert beim normalen Datenaustausch zwischen „Server“ und „Client“, wenn UDP verwendet wird, und was unsere Bibliothek tun sollte:

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

Stattdessen veröffentlichen wir einen offenen Port in Firebase unter dem Schlüssel des Servers und lauschen Ereignissen in seinem untergeordneten Baum.

  • Der Server ruft recvfrom(), um Pakete zu empfangen, die von jedem Host an diesen Port gesendet werden.

In unserem Fall müssen wir die Eingangsqueue der Pakete überprüfen, die an diesen Port gesendet wurden.

Jeder Port hat seine eigene Warteschlange, und wir fügen den WebRTC-Datagrammen die Quell- und Zielports am Anfang hinzu, um zu wissen, in welche Warteschlange ein neues Paket umgeleitet werden soll.

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

  • Der Client erhält über externe Mittel die IP-Adresse und den Port des Servers und ruft auf sendto(). Gleichzeitig erfolgt ein interner Aufruf bind(), daher erhält der folgende recvfrom() eine Antwort, ohne dass ein bind ausgeführt werden muss.

In unserem Fall erhält der Client extern einen String-Schlüssel und verwendet die Funktion resolve() zur Ermittlung der IP-Adresse.

An dieser Stelle beginnen wir das „Handschlag“-Verfahren von WebRTC, wenn die beiden Peers noch nicht miteinander verbunden sind. Verbindungen zu unterschiedlichen Ports eines Peers verwenden denselben WebRTC DataChannel.

Außerdem führen wir einen indirekten bind(), damit der Server die Verbindung im nächsten sendto() wiederherstellen kann, falls diese aus irgendeinem Grund geschlossen wurde.

Der Server wird über die Verbindung des Clients informiert, wenn der Client sein SDP-Angebot mit den Informationen zum Serverport in Firebase aufzeichnet und der Server darauf mit seiner Antwort reagiert.

In der unten gezeigten Abbildung ist ein Beispiel für den Nachrichtenaustausch im Socket-Schema und die Übertragung der ersten Nachricht vom Client zum Server dargestellt:

Wir portieren ein Multiplayer-Spiel von C++ auf das Web mit Cheerp, WebRTC und Firebase.
Das vollständige Schema des Verbindungsaufbaus zwischen Client und Server.

Fazit

Wenn Sie bis hierher gelesen haben, sind Sie wahrscheinlich interessiert, die Theorie in der Praxis zu sehen. Sie können auf teeworlds.leaningtech.com, versuchen Sie es!


Freundschaftsspiel unter Kollegen

Der Code der Netzwerkbibliothek ist öffentlich zugänglich unter Github. Treten Sie unserem Austausch auf unserem Kanal bei Gitter!

Quelle: habr.com

Kaufen Sie zuverlässiges Hosting für Websites mit DDoS-Schutz, VPS VDS-Server 🔥 Kaufen Sie zuverlässiges Hosting für Websites mit DDoS-Schutz, VPS VDS-Server | ProHoster