Portere et flerspillerspill fra C++ til nettet med Cheerp, WebRTC og Firebase

Innledning

vårt selskap Leaning teknologier tilbyr løsninger for portering av tradisjonelle skrivebordsapplikasjoner til nettet. Vår C++ kompilator cheerp genererer en kombinasjon av WebAssembly og JavaScript, som gir begge deler enkel nettleserinteraksjon, og høy ytelse.

Som et eksempel på applikasjonen bestemte vi oss for å overføre et flerspillerspill til nettet og valgte Teeworlds. Teeworlds er et flerspillers XNUMXD-retrospill med et lite, men aktivt fellesskap av spillere (inkludert meg!). Den er liten både når det gjelder nedlastede ressurser og CPU- og GPU-krav – en ideell kandidat.

Portere et flerspillerspill fra C++ til nettet med Cheerp, WebRTC og Firebase
Kjører i Teeworlds-nettleseren

Vi bestemte oss for å bruke dette prosjektet til å eksperimentere med generelle løsninger for portering av nettverkskode til nettet. Dette gjøres vanligvis på følgende måter:

  • XMLHttpRequest/fetch, hvis nettverksdelen kun består av HTTP-forespørsler, eller
  • WebSockets.

Begge løsningene krever hosting av en serverkomponent på serversiden, og ingen av dem tillater bruk som transportprotokoll UDP. Dette er viktig for sanntidsapplikasjoner som videokonferanseprogramvare og spill, fordi det garanterer levering og rekkefølge av protokollpakker TCP kan bli en hindring for lav latenstid.

Det er en tredje måte - bruk nettverket fra nettleseren: WebRTC.

RTCDataChannel Den støtter både pålitelig og upålitelig overføring (i sistnevnte tilfelle prøver den å bruke UDP som transportprotokoll når det er mulig), og kan brukes både med en ekstern server og mellom nettlesere. Dette betyr at vi kan portere hele applikasjonen til nettleseren, inkludert serverkomponenten!

Dette kommer imidlertid med en ekstra vanskelighet: før to WebRTC-kolleger kan kommunisere, må de utføre et relativt komplekst håndtrykk for å koble til, noe som krever flere tredjepartsenheter (en signalserver og en eller flere servere) STUN/SVING).

Ideelt sett vil vi lage et nettverks-API som bruker WebRTC internt, men som er så nært som mulig til et UDP Sockets-grensesnitt som ikke trenger å etablere en tilkobling.

Dette vil tillate oss å dra nytte av WebRTC uten å måtte eksponere komplekse detaljer for applikasjonskoden (som vi ønsket å endre så lite som mulig i prosjektet vårt).

Minimum WebRTC

WebRTC er et sett med API-er tilgjengelig i nettlesere som gir peer-to-peer-overføring av lyd, video og vilkårlige data.

Forbindelsen mellom peers etableres (selv om det er NAT på en eller begge sider) ved hjelp av STUN- og/eller TURN-servere gjennom en mekanisme kalt ICE. Peers utveksler ICE-informasjon og kanalparametere via tilbud og svar på SDP-protokollen.

Wow! Hvor mange forkortelser på en gang? La oss kort forklare hva disse begrepene betyr:

  • Session Traversal Utilities for NAT (STUN) — en protokoll for å omgå NAT og skaffe et par (IP, port) for å utveksle data direkte med verten. Hvis han klarer å fullføre oppgaven sin, kan jevnaldrende utveksle data med hverandre uavhengig.
  • Traversering ved hjelp av releer rundt NAT (SVING) brukes også for NAT-traversering, men den implementerer dette ved å videresende data gjennom en proxy som er synlig for begge peers. Det legger til latency og er dyrere å implementere enn STUN (fordi det brukes gjennom hele kommunikasjonsøkten), men noen ganger er det det eneste alternativet.
  • Etablering av interaktiv tilkobling (ICE) brukes til å velge den best mulige metoden for å koble sammen to peers basert på informasjon hentet fra å koble sammen peers direkte, samt informasjon mottatt av et hvilket som helst antall STUN- og TURN-servere.
  • Sesjonsbeskrivelsesprotokoll (SDP) er et format for å beskrive parametere for tilkoblingskanal, for eksempel ICE-kandidater, multimediekodeker (i tilfelle av en lyd-/videokanal), etc... En av kollegaene sender et SDP-tilbud, og den andre svarer med et SDP-svar ... Etter dette opprettes en kanal.

For å opprette en slik forbindelse, må jevnaldrende samle inn informasjonen de mottar fra STUN- og TURN-serverne og utveksle den med hverandre.

Problemet er at de ennå ikke har muligheten til å kommunisere direkte, så det må eksistere en mekanisme utenfor båndet for å utveksle disse dataene: en signalserver.

En signalserver kan være veldig enkel fordi dens eneste jobb er å videresende data mellom likemenn i håndtrykkfasen (som vist i diagrammet nedenfor).

Portere et flerspillerspill fra C++ til nettet med Cheerp, WebRTC og Firebase
Forenklet WebRTC-håndtrykksekvensdiagram

Teeworlds nettverksmodelloversikt

Teeworlds nettverksarkitektur er veldig enkel:

  • Klient- og serverkomponentene er to forskjellige programmer.
  • Klienter går inn i spillet ved å koble til en av flere servere, som hver er vert for bare ett spill om gangen.
  • All dataoverføring i spillet utføres gjennom serveren.
  • En spesiell hovedserver brukes til å samle en liste over alle offentlige servere som vises i spillklienten.

Takket være bruken av WebRTC for datautveksling kan vi overføre serverkomponenten til spillet til nettleseren der klienten befinner seg. Dette gir oss en flott mulighet...

Bli kvitt servere

Mangelen på serverlogikk har en fin fordel: vi kan distribuere hele applikasjonen som statisk innhold på Github-sider eller på vår egen maskinvare bak Cloudflare, og dermed sikre raske nedlastinger og høy oppetid gratis. Faktisk kan vi glemme dem, og hvis vi er heldige og spillet blir populært, trenger ikke infrastrukturen å moderniseres.

Men for at systemet skal fungere, må vi fortsatt bruke en ekstern arkitektur:

  • En eller flere STUN-servere: Vi har flere gratis alternativer å velge mellom.
  • Minst én TURN-server: det er ingen gratis alternativer her, så vi kan enten sette opp vår egen eller betale for tjenesten. Heldigvis kan det meste av tiden tilkoblingen etableres gjennom STUN-servere (og gi ekte p2p), men TURN er nødvendig som et reservealternativ.
  • Signalserver: I motsetning til de to andre aspektene, er ikke signalering standardisert. Hva signalserveren faktisk vil være ansvarlig for avhenger litt av applikasjonen. I vårt tilfelle, før du oppretter en forbindelse, er det nødvendig å utveksle en liten mengde data.
  • Teeworlds Master Server: Den brukes av andre servere for å annonsere deres eksistens og av klienter for å finne offentlige servere. Selv om det ikke er nødvendig (klienter kan alltid koble til en server de kjenner til manuelt), ville det være fint å ha slik at spillere kan delta i spill med tilfeldige personer.

Vi bestemte oss for å bruke Googles gratis STUN-servere, og implementerte en TURN-server selv.

For de to siste punktene vi brukte Fire:

  • Teeworlds hovedserver er implementert veldig enkelt: som en liste over objekter som inneholder informasjon (navn, IP, kart, modus, ...) for hver aktive server. Servere publiserer og oppdaterer sitt eget objekt, og klienter tar hele listen og viser den til spilleren. Vi viser også listen på hjemmesiden som HTML, slik at spillere ganske enkelt kan klikke på serveren og bli tatt rett til spillet.
  • Signalering er nært knyttet til vår sockets-implementering, beskrevet i neste avsnitt.

Portere et flerspillerspill fra C++ til nettet med Cheerp, WebRTC og Firebase
Liste over servere inne i spillet og på hjemmesiden

Implementering av stikkontakter

Vi ønsker å lage en API som er så nær Posix UDP Sockets som mulig for å minimere antall endringer som trengs.

Vi ønsker også å implementere det nødvendige minimum som kreves for den enkleste datautvekslingen over nettverket.

For eksempel trenger vi ikke reell ruting: alle peers er på samme "virtuelle LAN" knyttet til en spesifikk Firebase-databaseforekomst.

Derfor trenger vi ikke unike IP-adresser: unike Firebase-nøkkelverdier (ligner på domenenavn) er tilstrekkelige til å identifisere peers unikt, og hver peer tildeler lokalt "falske" IP-adresser til hver nøkkel som må oversettes. Dette eliminerer fullstendig behovet for global IP-adressetilordning, som er en ikke-triviell oppgave.

Her er minimum API vi trenger for å 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 lik Posix Sockets API, men har noen viktige forskjeller: logging av tilbakeringinger, tildeling av lokale IP-er og late tilkoblinger.

Registrere tilbakeringinger

Selv om det originale programmet bruker ikke-blokkerende I/O, må koden refaktoreres for å kjøre i en nettleser.

Grunnen til dette er at hendelsesløkken i nettleseren er skjult for programmet (det være seg JavaScript eller WebAssembly).

I det opprinnelige miljøet kan vi skrive kode som dette

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 hendelsessløyfen er skjult for oss, må vi gjøre den om til noe slikt:

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

Node-ID-ene i "nettverket" vårt er ikke IP-adresser, men Firebase-nøkler (de er strenger som ser slik ut: -LmEC50PYZLCiCP-vqde ).

Dette er praktisk fordi vi ikke trenger en mekanisme for å tildele IP-er og sjekke deres unike egenskaper (så vel som å avhende dem etter at klienten kobler fra), men det er ofte nødvendig å identifisere peers med en numerisk verdi.

Det er nettopp dette funksjonene brukes til. resolve и reverseResolve: Applikasjonen mottar på en eller annen måte strengverdien til nøkkelen (via brukerinndata eller via masterserveren), og kan konvertere den til en IP-adresse for intern bruk. Resten av API-en mottar også denne verdien i stedet for en streng for enkelhets skyld.

Dette ligner på DNS-oppslag, men utføres lokalt på klienten.

Det vil si at IP-adresser ikke kan deles mellom ulike klienter, og hvis en slags global identifikator er nødvendig, må den genereres på en annen måte.

Lat forbindelse

UDP trenger ikke en tilkobling, men som vi har sett, krever WebRTC en langvarig tilkoblingsprosess før den kan begynne å overføre data mellom to likemenn.

Hvis vi ønsker å gi samme abstraksjonsnivå, (sendto/recvfrom med vilkårlige jevnaldrende uten forutgående tilkobling), må de utføre en "lat" (forsinket) tilkobling inne i APIen.

Dette er hva som skjer under normal kommunikasjon mellom "serveren" og "klienten" når du bruker UDP, og hva biblioteket vårt bør gjøre:

  • Serveranrop bind()for å fortelle operativsystemet at det ønsker å motta pakker på den angitte porten.

I stedet vil vi publisere en åpen port til Firebase under servernøkkelen og lytte etter hendelser i undertreet.

  • Serveranrop recvfrom(), aksepterer pakker som kommer fra hvilken som helst vert på denne porten.

I vårt tilfelle må vi sjekke den innkommende køen av pakker sendt til denne porten.

Hver port har sin egen kø, og vi legger til kilde- og destinasjonsportene i begynnelsen av WebRTC-datagrammene slik at vi vet hvilken kø vi skal videresende til når en ny pakke kommer.

Samtalen er ikke-blokkerende, så hvis det ikke er noen pakker, returnerer vi ganske enkelt -1 og setter errno=EWOULDBLOCK.

  • Klienten mottar IP-en og porten til serveren på noen eksterne måter, og anroper sendto(). Dette foretar også en intern samtale. bind(), derfor etterfølgende recvfrom() vil motta svaret uten å eksplisitt utføre bind.

I vårt tilfelle mottar klienten strengnøkkelen eksternt og bruker funksjonen resolve() for å få en IP-adresse.

På dette tidspunktet starter vi et WebRTC-håndtrykk hvis de to jevnaldrende ennå ikke er koblet til hverandre. Tilkoblinger til forskjellige porter av samme peer bruker samme WebRTC DataChannel.

Vi utfører også indirekte bind()slik at serveren kan koble til på nytt i neste sendto() i tilfelle den stengte av en eller annen grunn.

Serveren varsles om klientens tilkobling når klienten skriver sitt SDP-tilbud under serverportinformasjonen i Firebase, og serveren svarer med sitt svar der.

Diagrammet nedenfor viser et eksempel på meldingsflyt for et socketskjema og overføringen av den første meldingen fra klienten til serveren:

Portere et flerspillerspill fra C++ til nettet med Cheerp, WebRTC og Firebase
Komplett diagram over koblingsfasen mellom klient og server

Konklusjon

Hvis du har lest så langt, er du sannsynligvis interessert i å se teorien i aksjon. Spillet kan spilles på teeworlds.leaningtech.com, Prøv det!


Vennskapskamp mellom kolleger

Nettverksbibliotekskoden er fritt tilgjengelig på Github. Bli med i samtalen på kanalen vår kl gitter!

Kilde: www.habr.com

Legg til en kommentar