Portering af et multiplayer-spil fra C++ til nettet med Cheerp, WebRTC og Firebase

Indledning

vores firma Leaning teknologier leverer løsninger til portering af traditionelle desktop-applikationer til internettet. Vores C++ compiler cheerp genererer en kombination af WebAssembly og JavaScript, som giver begge dele enkel browserinteraktionog høj ydeevne.

Som et eksempel på dets anvendelse besluttede vi at overføre et multiplayer-spil til nettet og valgte Teeworlds. Teeworlds er et multiplayer XNUMXD retro-spil med et lille, men aktivt fællesskab af spillere (inklusive mig!). Den er lille både hvad angår downloadede ressourcer og CPU- og GPU-krav – en ideel kandidat.

Portering af et multiplayer-spil fra C++ til nettet med Cheerp, WebRTC og Firebase
Kører i Teeworlds browser

Vi besluttede at bruge dette projekt til at eksperimentere med generelle løsninger til portering af netværkskode til nettet. Dette gøres normalt på følgende måder:

  • XMLHttpRequest/hent, hvis netværksdelen kun består af HTTP-anmodninger, eller
  • WebSockets.

Begge løsninger kræver hosting af en serverkomponent på serversiden, og ingen af ​​dem tillader brug som transportprotokol UDP. Dette er vigtigt for realtidsapplikationer såsom videokonferencesoftware og spil, fordi det garanterer levering og rækkefølge af protokolpakker TCP kan blive en hindring for lav latenstid.

Der er en tredje måde - brug netværket fra browseren: WebRTC.

RTCDataChannel Den understøtter både pålidelig og upålidelig transmission (i sidstnævnte tilfælde forsøger den at bruge UDP som en transportprotokol, når det er muligt), og kan bruges både med en fjernserver og mellem browsere. Det betyder, at vi kan portere hele applikationen til browseren, inklusive serverkomponenten!

Dette kommer dog med en ekstra vanskelighed: før to WebRTC-peers kan kommunikere, skal de udføre et relativt komplekst håndtryk for at oprette forbindelse, hvilket kræver flere tredjepartsenheder (en signalserver og en eller flere servere) STUN/TUR).

Ideelt set vil vi gerne oprette en netværks-API, der bruger WebRTC internt, men som er så tæt som muligt på en UDP Sockets-grænseflade, der ikke behøver at etablere en forbindelse.

Dette vil give os mulighed for at drage fordel af WebRTC uden at skulle udsætte komplekse detaljer for applikationskoden (som vi ønskede at ændre så lidt som muligt i vores projekt).

Minimum WebRTC

WebRTC er et sæt API'er, der er tilgængelige i browsere, der giver peer-to-peer transmission af lyd, video og vilkårlige data.

Forbindelsen mellem peers etableres (selvom der er NAT på den ene eller begge sider) ved hjælp af STUN- og/eller TURN-servere gennem en mekanisme kaldet ICE. Peers udveksler ICE-information og kanalparametre via tilbud og svar på SDP-protokollen.

Wow! Hvor mange forkortelser på én gang? Lad os kort forklare, hvad disse udtryk betyder:

  • Session Traversal Utilities til NAT (STUN) — en protokol til at omgå NAT og opnå et par (IP, port) til at udveksle data direkte med værten. Hvis han formår at fuldføre sin opgave, så kan peers uafhængigt udveksle data med hinanden.
  • Traversering ved hjælp af relæer omkring NAT (TUR) bruges også til NAT-traversal, men det implementerer dette ved at videresende data gennem en proxy, der er synlig for begge peers. Det tilføjer latency og er dyrere at implementere end STUN (fordi det anvendes gennem hele kommunikationssessionen), men nogle gange er det den eneste mulighed.
  • Etablering af interaktiv forbindelse (ICE) bruges til at vælge den bedst mulige metode til at forbinde to peers baseret på information opnået fra at forbinde peers direkte, samt information modtaget af et vilkårligt antal STUN- og TURN-servere.
  • Sessionsbeskrivelsesprotokol (SDP) er et format til at beskrive forbindelseskanalparametre, for eksempel ICE-kandidater, multimedie-codecs (i tilfælde af en audio/video-kanal) osv... En af peerne sender et SDP-tilbud, og den anden svarer med et SDP-svar ... Herefter oprettes en kanal.

For at skabe en sådan forbindelse skal peers indsamle de oplysninger, de modtager fra STUN- og TURN-serverne, og udveksle dem med hinanden.

Problemet er, at de endnu ikke har mulighed for at kommunikere direkte, så der skal eksistere en out-of-band-mekanisme for at udveksle disse data: en signalserver.

En signalserver kan være meget enkel, fordi dens eneste opgave er at videresende data mellem peers i håndtrykfasen (som vist i diagrammet nedenfor).

Portering af et multiplayer-spil fra C++ til nettet med Cheerp, WebRTC og Firebase
Forenklet WebRTC-håndtryksekvensdiagram

Oversigt over Teeworlds netværksmodel

Teeworlds netværksarkitektur er meget enkel:

  • Klient- og serverkomponenterne er to forskellige programmer.
  • Klienter går ind i spillet ved at oprette forbindelse til en af ​​flere servere, som hver kun er vært for ét spil ad gangen.
  • Al dataoverførsel i spillet foregår via serveren.
  • En speciel masterserver bruges til at samle en liste over alle offentlige servere, der vises i spilklienten.

Takket være brugen af ​​WebRTC til dataudveksling kan vi overføre spillets serverkomponent til den browser, hvor klienten er placeret. Dette giver os en fantastisk mulighed...

Slip af med servere

Manglen på serverlogik har en fin fordel: Vi kan implementere hele applikationen som statisk indhold på Github Pages eller på vores egen hardware bag Cloudflare og dermed sikre hurtige downloads og høj oppetid gratis. Faktisk kan vi glemme dem, og hvis vi er heldige, og spillet bliver populært, så skal infrastrukturen ikke moderniseres.

Men for at systemet skal fungere, skal vi stadig bruge en ekstern arkitektur:

  • En eller flere STUN-servere: Vi har flere gratis muligheder at vælge imellem.
  • Mindst én TURN-server: der er ingen gratis muligheder her, så vi kan enten oprette vores egen eller betale for tjenesten. Heldigvis kan forbindelsen det meste af tiden etableres gennem STUN-servere (og give ægte p2p), men TURN er nødvendig som en reservemulighed.
  • Signalserver: I modsætning til de to andre aspekter er signalering ikke standardiseret. Hvad signalserveren faktisk vil være ansvarlig for afhænger lidt af applikationen. I vores tilfælde, før du etablerer en forbindelse, er det nødvendigt at udveksle en lille mængde data.
  • Teeworlds Master Server: Den bruges af andre servere til at reklamere for deres eksistens og af klienter til at finde offentlige servere. Selvom det ikke er påkrævet (klienter kan altid oprette forbindelse til en server, de kender til manuelt), ville det være rart at have, så spillere kan deltage i spil med tilfældige personer.

Vi besluttede at bruge Googles gratis STUN-servere og implementerede selv en TURN-server.

Til de sidste to punkter brugte vi Firebase:

  • Teeworlds masterserver er implementeret meget enkelt: som en liste over objekter, der indeholder information (navn, IP, kort, tilstand, ...) for hver aktiv server. Servere udgiver og opdaterer deres eget objekt, og klienter tager hele listen og viser den til afspilleren. Vi viser også listen på hjemmesiden som HTML, så spillere blot kan klikke på serveren og blive taget direkte til spillet.
  • Signalering er tæt forbundet med vores sockets-implementering, beskrevet i næste afsnit.

Portering af et multiplayer-spil fra C++ til nettet med Cheerp, WebRTC og Firebase
Liste over servere inde i spillet og på hjemmesiden

Implementering af stikkontakter

Vi ønsker at skabe en API, der er så tæt på Posix UDP Sockets som muligt for at minimere antallet af nødvendige ændringer.

Vi ønsker også at implementere det nødvendige minimum for den enkleste dataudveksling over netværket.

For eksempel har vi ikke brug for rigtig routing: alle peers er på det samme "virtuelle LAN", der er forbundet med en specifik Firebase-databaseinstans.

Derfor har vi ikke brug for unikke IP-adresser: unikke Firebase-nøgleværdier (svarende til domænenavne) er tilstrækkelige til entydigt at identificere peers, og hver peer tildeler lokalt "falske" IP-adresser til hver nøgle, der skal oversættes. Dette eliminerer fuldstændig behovet for global IP-adressetildeling, hvilket er en ikke-triviel opgave.

Her er den minimale API, vi skal implementere:

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

API'en er enkel og ligner Posix Sockets API, men har et par vigtige forskelle: logning af tilbagekald, tildeling af lokale IP'er og dovne forbindelser.

Registrering af tilbagekald

Selvom det originale program bruger ikke-blokerende I/O, skal koden omfaktoriseres for at køre i en webbrowser.

Grunden til dette er, at hændelsesløkken i browseren er skjult for programmet (det være sig JavaScript eller WebAssembly).

I det oprindelige miljø kan vi skrive kode som denne

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

Hvis begivenhedsløkken er skjult for os, skal vi gøre den til noget som dette:

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

Lokal IP-tildeling

Node-id'erne i vores "netværk" er ikke IP-adresser, men Firebase-nøgler (de er strenge, der ser sådan ud: -LmEC50PYZLCiCP-vqde ).

Dette er praktisk, fordi vi ikke har brug for en mekanisme til at tildele IP'er og kontrollere deres unikke karakter (samt bortskaffelse af dem, efter at klienten afbrydes), men det er ofte nødvendigt at identificere peers med en numerisk værdi.

Det er præcis, hvad funktionerne bruges til. resolve и reverseResolve: Applikationen modtager på en eller anden måde nøglens strengværdi (via brugerinput eller via masterserveren), og kan konvertere den til en IP-adresse til intern brug. Resten af ​​API'en modtager også denne værdi i stedet for en streng for nemheds skyld.

Dette ligner DNS-opslag, men udføres lokalt på klienten.

Det vil sige, at IP-adresser ikke kan deles mellem forskellige klienter, og hvis der er behov for en form for global identifikator, skal den genereres på en anden måde.

Doven forbindelse

UDP behøver ikke en forbindelse, men som vi har set, kræver WebRTC en langvarig forbindelsesproces, før den kan begynde at overføre data mellem to peers.

Hvis vi ønsker at give det samme abstraktionsniveau, (sendto/recvfrom med vilkårlige peers uden forudgående forbindelse), så skal de udføre en "doven" (forsinket) forbindelse inde i API'en.

Dette er, hvad der sker under normal kommunikation mellem "serveren" og "klienten", når du bruger UDP, og hvad vores bibliotek skal gøre:

  • Server opkald bind()at fortælle operativsystemet, at det ønsker at modtage pakker på den angivne port.

I stedet vil vi udgive en åben port til Firebase under servernøglen og lytte efter begivenheder i dens undertræ.

  • Server opkald recvfrom(), der accepterer pakker, der kommer fra enhver vært på denne port.

I vores tilfælde skal vi kontrollere den indgående kø af pakker sendt til denne port.

Hver port har sin egen kø, og vi tilføjer kilde- og destinationsportene til begyndelsen af ​​WebRTC-datagrammerne, så vi ved, hvilken kø vi skal videresende til, når en ny pakke ankommer.

Opkaldet er ikke-blokerende, så hvis der ikke er pakker, returnerer vi blot -1 og indstiller errno=EWOULDBLOCK.

  • Klienten modtager IP'en og serverens port på en ekstern måde og kalder sendto(). Dette foretager også et internt opkald. bind(), derfor efterfølgende recvfrom() vil modtage svaret uden eksplicit at udføre bind.

I vores tilfælde modtager klienten eksternt strengnøglen og bruger funktionen resolve() for at få en IP-adresse.

På dette tidspunkt starter vi et WebRTC-håndtryk, hvis de to peers endnu ikke er forbundet med hinanden. Forbindelser til forskellige porte af samme peer bruger den samme WebRTC DataChannel.

Vi udfører også indirekte bind()så serveren kan oprette forbindelse igen i den næste sendto() i tilfælde af at den lukkede af en eller anden grund.

Serveren får besked om klientens forbindelse, når klienten skriver sit SDP-tilbud under serverportinformationen i Firebase, og serveren svarer med sit svar der.

Diagrammet nedenfor viser et eksempel på beskedflow for et socket-skema og transmissionen af ​​den første besked fra klienten til serveren:

Portering af et multiplayer-spil fra C++ til nettet med Cheerp, WebRTC og Firebase
Komplet diagram over forbindelsesfasen mellem klient og server

Konklusion

Hvis du har læst så langt, er du sikkert interesseret i at se teorien i aktion. Spillet kan spilles på teeworlds.leaningtech.com, Prøv det!


Venskabskamp mellem kolleger

Netværksbibliotekskoden er gratis tilgængelig på Github. Deltag i samtalen på vores kanal kl grid!

Kilde: www.habr.com

Tilføj en kommentar