Een multiplayergame overzetten van C++ naar internet met Cheerp, WebRTC en Firebase

Introductie

onze onderneming Leunende technologieën biedt oplossingen voor het porten van traditionele desktopapplicaties naar internet. Onze C++-compiler opvrolijken genereert een combinatie van WebAssembly en JavaScript, die beide biedt eenvoudige browserinteractieen hoge prestaties.

Als voorbeeld van de toepassing ervan hebben we besloten een multiplayergame naar het web te porten en hebben we gekozen Teeworlds. Teeworlds is een XNUMXD-retrogame voor meerdere spelers met een kleine maar actieve spelersgemeenschap (waaronder ikzelf!). Het is klein, zowel wat betreft gedownloade bronnen als CPU- en GPU-vereisten - een ideale kandidaat.

Een multiplayergame overzetten van C++ naar internet met Cheerp, WebRTC en Firebase
Wordt uitgevoerd in de Teeworlds-browser

We besloten dit project te gebruiken om mee te experimenteren algemene oplossingen voor het porten van netwerkcode naar internet. Dit gebeurt meestal op de volgende manieren:

  • XMLHttpRequest/fetch, als het netwerkgedeelte alleen uit HTTP-verzoeken bestaat, of
  • WebSockets.

Beide oplossingen vereisen het hosten van een servercomponent aan de serverzijde, en geen van beide maakt gebruik als transportprotocol mogelijk UDP. Dit is belangrijk voor real-time toepassingen zoals videoconferentiesoftware en games, omdat het de levering en volgorde van protocolpakketten garandeert TCP kan een belemmering vormen voor een lage latentie.

Er is een derde manier: gebruik het netwerk vanuit de browser: WebRTC.

RTCDataChannel Het ondersteunt zowel betrouwbare als onbetrouwbare transmissie (in het laatste geval probeert het waar mogelijk UDP als transportprotocol te gebruiken) en kan zowel met een externe server als tussen browsers worden gebruikt. Dit betekent dat we de gehele applicatie naar de browser kunnen porten, inclusief het servergedeelte!

Dit brengt echter een extra moeilijkheid met zich mee: voordat twee WebRTC-peers kunnen communiceren, moeten ze een relatief complexe handshake uitvoeren om verbinding te maken, waarvoor verschillende externe entiteiten nodig zijn (een signaleringsserver en een of meer servers STUNNEN/BEURT).

Idealiter zouden we een netwerk-API willen maken die WebRTC intern gebruikt, maar die zo dicht mogelijk bij een UDP Sockets-interface ligt die geen verbinding tot stand hoeft te brengen.

Hierdoor kunnen we profiteren van WebRTC zonder dat we complexe details aan de applicatiecode hoeven bloot te leggen (die we in ons project zo min mogelijk wilden veranderen).

Minimale WebRTC

WebRTC is een set API's die beschikbaar zijn in browsers en die peer-to-peer-overdracht van audio, video en willekeurige gegevens mogelijk maken.

De verbinding tussen peers wordt tot stand gebracht (zelfs als er NAT aan één of beide kanten is) met behulp van STUN- en/of TURN-servers via een mechanisme dat ICE wordt genoemd. Peers wisselen ICE-informatie en kanaalparameters uit via aanbod en antwoord van het SDP-protocol.

Wauw! Hoeveel afkortingen tegelijk? Laten we kort uitleggen wat deze termen betekenen:

  • Session Traversal-hulpprogramma's voor NAT (STUNNEN) — een protocol voor het omzeilen van NAT en het verkrijgen van een paar (IP, poort) voor het rechtstreeks uitwisselen van gegevens met de host. Als hij zijn taak weet te volbrengen, kunnen peers zelfstandig gegevens met elkaar uitwisselen.
  • Traversal met behulp van relais rond NAT (BEURT) wordt ook gebruikt voor NAT-traversal, maar implementeert dit door gegevens door te sturen via een proxy die zichtbaar is voor beide peers. Het voegt latentie toe en is duurder om te implementeren dan STUN (omdat het gedurende de hele communicatiesessie wordt toegepast), maar soms is het de enige optie.
  • Interactieve connectiviteitsvestiging (ICE) wordt gebruikt om de best mogelijke methode te selecteren om twee peers met elkaar te verbinden, op basis van informatie die is verkregen door rechtstreeks verbinding te maken met peers, evenals informatie ontvangen door een willekeurig aantal STUN- en TURN-servers.
  • Sessie Beschrijving Protocol (SDP) is een formaat voor het beschrijven van verbindingskanaalparameters, bijvoorbeeld ICE-kandidaten, multimediacodecs (in het geval van een audio-/videokanaal), enz... Een van de peers verzendt een SDP-aanbieding en de tweede reageert met een SDP-antwoord . Hierna wordt een kanaal aangemaakt.

Om een ​​dergelijke verbinding tot stand te brengen, moeten peers de informatie die zij ontvangen van de STUN- en TURN-servers verzamelen en met elkaar uitwisselen.

Het probleem is dat ze nog niet de mogelijkheid hebben om rechtstreeks te communiceren, dus er moet een out-of-band mechanisme bestaan ​​om deze gegevens uit te wisselen: een signaleringsserver.

Een signaleringsserver kan heel eenvoudig zijn, omdat zijn enige taak het doorsturen van gegevens tussen peers in de handshake-fase is (zoals weergegeven in het onderstaande diagram).

Een multiplayergame overzetten van C++ naar internet met Cheerp, WebRTC en Firebase
Vereenvoudigd WebRTC-handshake-sequentiediagram

Overzicht van Teeworlds netwerkmodellen

De netwerkarchitectuur van Teeworld is heel eenvoudig:

  • De client- en servercomponenten zijn twee verschillende programma's.
  • Klanten komen aan het spel door verbinding te maken met een van de verschillende servers, die elk slechts één spel tegelijk hosten.
  • Alle gegevensoverdracht in het spel wordt uitgevoerd via de server.
  • Er wordt een speciale masterserver gebruikt om een ​​lijst te verzamelen van alle openbare servers die in de gameclient worden weergegeven.

Dankzij het gebruik van WebRTC voor gegevensuitwisseling kunnen we de servercomponent van het spel overbrengen naar de browser waarin de client zich bevindt. Dit geeft ons een geweldige kans...

Weg met servers

Het ontbreken van serverlogica heeft een mooi voordeel: we kunnen de hele applicatie als statische content op Github Pages of op onze eigen hardware achter Cloudflare inzetten, waardoor we gratis snelle downloads en hoge uptime garanderen. In feite kunnen we ze vergeten, en als we geluk hebben en het spel populair wordt, hoeft de infrastructuur niet gemoderniseerd te worden.

Om het systeem te laten werken, moeten we echter nog steeds een externe architectuur gebruiken:

  • Eén of meerdere STUN-servers: we hebben verschillende gratis opties waaruit u kunt kiezen.
  • Ten minste één TURN-server: er zijn hier geen gratis opties, dus we kunnen onze eigen server opzetten of voor de service betalen. Gelukkig kan de verbinding meestal tot stand worden gebracht via STUN-servers (en echte p2p bieden), maar TURN is nodig als terugvaloptie.
  • Signaleringsserver: In tegenstelling tot de andere twee aspecten is signalering niet gestandaardiseerd. Waar de signaleringsserver feitelijk verantwoordelijk voor zal zijn, hangt enigszins af van de toepassing. In ons geval is het, voordat een verbinding tot stand wordt gebracht, noodzakelijk om een ​​kleine hoeveelheid gegevens uit te wisselen.
  • Teeworlds Master Server: Het wordt gebruikt door andere servers om hun bestaan ​​te adverteren en door klanten om openbare servers te vinden. Hoewel het niet vereist is (klanten kunnen altijd handmatig verbinding maken met een server die ze kennen), zou het leuk zijn als spelers met willekeurige mensen aan games kunnen deelnemen.

We besloten gebruik te maken van de gratis STUN-servers van Google en hebben zelf één TURN-server geïmplementeerd.

Voor de laatste twee punten hebben we gebruik gemaakt Firebase:

  • De masterserver van Teeworlds is heel eenvoudig geïmplementeerd: als een lijst met objecten die informatie (naam, IP, kaart, modus, ...) van elke actieve server bevatten. Servers publiceren en updaten hun eigen object, en clients nemen de hele lijst en geven deze aan de speler weer. We geven de lijst ook als HTML op de startpagina weer, zodat spelers eenvoudig op de server kunnen klikken en rechtstreeks naar het spel kunnen worden geleid.
  • Signalering hangt nauw samen met onze sockets-implementatie, die in de volgende sectie wordt beschreven.

Een multiplayergame overzetten van C++ naar internet met Cheerp, WebRTC en Firebase
Lijst met servers in het spel en op de startpagina

Implementatie van stopcontacten

We willen een API creëren die zo dicht mogelijk bij Posix UDP Sockets ligt om het aantal benodigde wijzigingen te minimaliseren.

We willen ook het noodzakelijke minimum implementeren dat nodig is voor de eenvoudigste gegevensuitwisseling via het netwerk.

We hebben bijvoorbeeld geen echte routering nodig: alle peers bevinden zich op hetzelfde "virtuele LAN" dat is gekoppeld aan een specifiek Firebase-database-exemplaar.

Daarom hebben we geen unieke IP-adressen nodig: unieke Firebase-sleutelwaarden (vergelijkbaar met domeinnamen) zijn voldoende om peers uniek te identificeren, en elke peer wijst lokaal "nep" IP-adressen toe aan elke sleutel die moet worden vertaald. Dit elimineert volledig de noodzaak voor het toewijzen van globale IP-adressen, wat een niet-triviale taak is.

Hier is de minimale API die we moeten implementeren:

// 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);

De API is eenvoudig en vergelijkbaar met de Posix Sockets API, maar kent een paar belangrijke verschillen: het registreren van callbacks, het toewijzen van lokale IP's en luie verbindingen.

Terugbelverzoeken registreren

Zelfs als het oorspronkelijke programma niet-blokkerende I/O gebruikt, moet de code opnieuw worden aangepast om in een webbrowser te kunnen worden uitgevoerd.

De reden hiervoor is dat de gebeurtenislus in de browser verborgen is voor het programma (of het nu JavaScript of WebAssembly is).

In de native omgeving kunnen we dergelijke code schrijven

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;
    ...
  }
  ...
}

Als de gebeurtenislus voor ons verborgen is, moeten we er zoiets als dit van maken:

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-toewijzing

De knooppunt-ID's in ons "netwerk" zijn geen IP-adressen, maar Firebase-sleutels (het zijn tekenreeksen die er als volgt uitzien: -LmEC50PYZLCiCP-vqde ).

Dit is handig omdat we geen mechanisme nodig hebben voor het toewijzen van IP's en het controleren van hun uniciteit (en het verwijderen ervan nadat de client de verbinding heeft verbroken), maar het is vaak nodig om peers te identificeren aan de hand van een numerieke waarde.

Dit is precies waar de functies voor worden gebruikt. resolve и reverseResolve: De applicatie ontvangt op een of andere manier de stringwaarde van de sleutel (via gebruikersinvoer of via de masterserver) en kan deze omzetten naar een IP-adres voor intern gebruik. Voor de eenvoud ontvangt de rest van de API deze waarde ook in plaats van een tekenreeks.

Dit is vergelijkbaar met DNS-lookup, maar wordt lokaal op de client uitgevoerd.

Dat wil zeggen dat IP-adressen niet tussen verschillende clients kunnen worden gedeeld, en als er een soort globale identificatie nodig is, zal deze op een andere manier moeten worden gegenereerd.

Luie verbinding

UDP heeft geen verbinding nodig, maar zoals we hebben gezien vereist WebRTC een langdurig verbindingsproces voordat het kan beginnen met het overbrengen van gegevens tussen twee peers.

Als we hetzelfde abstractieniveau willen bieden, (sendto/recvfrom met willekeurige peers zonder voorafgaande verbinding), dan moeten ze een “luie” (vertraagde) verbinding uitvoeren binnen de API.

Dit is wat er gebeurt tijdens normale communicatie tussen de “server” en de “client” bij gebruik van UDP, en wat onze bibliotheek zou moeten doen:

  • Serveroproepen bind()om het besturingssysteem te vertellen dat het pakketten op de opgegeven poort wil ontvangen.

In plaats daarvan publiceren we een open poort naar Firebase onder de serversleutel en luisteren we naar gebeurtenissen in de substructuur ervan.

  • Serveroproepen recvfrom(), waarbij pakketten worden geaccepteerd die afkomstig zijn van elke host op deze poort.

In ons geval moeten we de inkomende wachtrij van pakketten controleren die naar deze poort worden verzonden.

Elke poort heeft zijn eigen wachtrij, en we voegen de bron- en bestemmingspoorten toe aan het begin van de WebRTC-datagrammen, zodat we weten naar welke wachtrij we moeten doorsturen als er een nieuw pakket arriveert.

De oproep is niet-blokkerend, dus als er geen pakketten zijn, retourneren we eenvoudigweg -1 en stellen we in errno=EWOULDBLOCK.

  • De client ontvangt op een externe manier het IP-adres en de poort van de server en belt sendto(). Hierdoor wordt er ook intern gebeld. bind(), dus volgend recvfrom() ontvangt het antwoord zonder de binding expliciet uit te voeren.

In ons geval ontvangt de client extern de stringsleutel en gebruikt hij de functie resolve() om een ​​IP-adres te verkrijgen.

Op dit punt starten we een WebRTC-handshake als de twee peers nog niet met elkaar zijn verbonden. Verbindingen met verschillende poorten van dezelfde peer gebruiken hetzelfde WebRTC DataChannel.

Ook indirect treden wij op bind()zodat de server de volgende keer opnieuw verbinding kan maken sendto() voor het geval het om een ​​of andere reden gesloten is.

De server wordt op de hoogte gesteld van de verbinding van de client wanneer de client zijn SDP-aanbod schrijft onder de serverpoortinformatie in Firebase, en de server reageert daar met zijn antwoord.

Het onderstaande diagram toont een voorbeeld van de berichtenstroom voor een socketschema en de verzending van het eerste bericht van de client naar de server:

Een multiplayergame overzetten van C++ naar internet met Cheerp, WebRTC en Firebase
Compleet diagram van de verbindingsfase tussen client en server

Conclusie

Als je dit tot nu toe hebt gelezen, ben je waarschijnlijk geïnteresseerd om de theorie in actie te zien. Het spel kan verder gespeeld worden teeworlds.leaningtech.com, probeer het!


Vriendschappelijke wedstrijd tussen collega's

De netwerkbibliotheekcode is gratis beschikbaar op GitHub. Neem deel aan het gesprek op ons kanaal op rooster!

Bron: www.habr.com

Voeg een reactie