Vi porterer et multiplayer-spil fra C++ til web med Cheerp, WebRTC og Firebase

Introduktion

Vores virksomhed Leaning Technologies tilbyder løsninger til portering af traditionelle desktop-applikationer til web. Vores C++-kompilator Cheerp genererer en kombination af WebAssembly og JavaScript, hvilket sikrer både nem interaktion med browseren, og høj ydeevne.

Som et eksempel på dens anvendelse har vi besluttet at portere et multiplayer-spil til web og valgte derfor Teeworlds. Teeworlds er et multiplayer 2D retro-spil med et lille, men aktivt spillerfællesskab (inklusive mig!). Det er lille både hvad angår downloadstørrelse og krav til CPU og GPU - en ideel kandidat.

Vi porterer et multiplayer-spil fra C++ til web med Cheerp, WebRTC og Firebase
Teeworlds, der kører i browseren

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

  • XMLHttpRequest/fetch, hvis den netværksbaserede del kun består af HTTP-anmodninger, eller
  • WebSockets.

Begge løsninger kræver, at serverkomponenten hostes på serversiden, og ingen af dem tillader brug af UDPsom transportprotokol. Dette er vigtigt for realtidsapplikationer, såsom videokonference-software og spil, eftersom garanti for levering og rækkefølge af pakker i protokollen TCP kan være en hindring for lav latens.

Der findes også en tredje metode - at bruge netværket fra browseren: WebRTC.

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

Men der er en ekstra udfordring: Før to WebRTC-noder kan udveksle data, skal de gennemgå en ret kompliceret 'handshake'-procedure for at oprette forbindelse, hvilket kræver flere eksterne enheder (signaleringsserver og en eller flere STUN/TURN).

Ideelt set vil vi gerne skabe et netværks-API, der internt bruger WebRTC, men så tæt på UDP-socket-grænsefladen som muligt, som ikke kræver oprettelse af forbindelse.

Det vil tillade os at udnytte fordelene ved WebRTC uden at skulle afsløre de komplekse detaljer for applikationskoden (som vi ønskede at ændre så lidt som muligt i vores projekt).

Minimal WebRTC

WebRTC er et sæt API'er til rådighed i browsere, der muliggør peer-to-peer overførsel af lyd, video og vilkårlige data.

Forbindelsen mellem brugererne etableres (selv i tilfælde af NAT på den ene eller begge sider) ved hjælp af STUN- og/eller TURN-servere gennem en mekanisme kaldet ICE. Brugerne udveksler ICE-oplysninger og kanalparametre via tilbud og svar i SDP-protokollen.

Wow! Så mange forkortelser på én gang. Lad os kort forklare, hvad disse begreber betyder:

  • Session Traversal Utilities for NAT (STUN) er en protokol til at omgå NAT og få et par (IP, port) til at udveksle data direkte med værten. Hvis den lykkes med sin opgave, kan brugerne udveksle data med hinanden selv.
  • Traversal Using Relays around NAT (TURN) bruges også til at omgå NAT, men den implementerer det ved at omdirigere data gennem en proxy, der er synlig for begge brugere. Den tilføjer forsinkelse og er dyrere at udføre end STUN (fordi den anvendes hele kommunikationssessionen), men nogle gange er det den eneste mulige løsning.
  • Interactive Connectivity Establishment (ICE) bruges til at vælge den bedste mulige måde at forbinde to brugere på baseret på oplysninger, der modtages under direkte forbindelse mellem brugerne samt oplysninger, der modtages fra det antal STUN- og TURN-servere.
  • Session Description Protocol (SDP) er et format til beskrivelse af parametre for tilslutningskanalen, såsom ICE-kandidater, mediecodec (i tilfælde af lyd-/videokanalen) osv. En af brugerne sender en SDP-tilbud ("offer"), og den anden svarer med en SDP-svar ("answer"). Herefter oprettes kanalen.

For at oprette en sådan forbindelse skal brugerne indsamle oplysningerne, de modtog fra STUN- og TURN-serverne, og udveksle dem med hinanden.

Problemet er, at de endnu ikke har mulighed for at udveksle data direkte, så der skal findes en off-band mekanisme: en signalserver.

Signalserveren kan være meget simpel, fordi dens eneste opgave er at omdirigere data mellem brugerne i "håndtryks" fasen (som vist i diagrammet nedenfor).

Vi porterer et multiplayer-spil fra C++ til web med Cheerp, WebRTC og Firebase
Forenklet diagram over WebRTC's håndtrykssekvens

Oversigt over Teeworlds netværksmodel

Teeworlds netværksarkitektur er meget enkel:

  • Klient- og serverkomponenter er to separate programmer.
  • Kunderne træder ind i spillet ved at tilslutte sig en af flere servere, hvor hver server kun hoster ét spil ad gangen.
  • Al dataoverførsel i spillet foregår gennem serveren.
  • En særskilt master-server bruges til at samle en liste over alle offentlige servere, som vises i spilklienten.

Ved at bruge WebRTC til dataudveksling kan vi flytte den serverkomponent, som spillet kræver, til browseren, hvor klienten befinder sig. Dette giver os en fremragende mulighed...

At slippe for servere

Manglen på serverlogik har den behagelige fordel, at vi kan deployere hele applikationen som statisk indhold på Github Pages eller på vores eget udstyr bag Cloudflare, hvilket dermed giver os hurtige indlæsninger og høj oppetid gratis. I praksis kan vi glemme dem, og hvis vi er heldige og spillet bliver populært, vil vi ikke skulle opgradere infrastrukturen.

Men for at systemet kan 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: her findes der ingen gratis muligheder, så vi kan enten konfigurere vores egen eller betale for en tjeneste. Heldigvis kan forbindelsen stort set altid etableres gennem STUN-servere (og sikre ægte p2p), men TURN er nødvendig som en backup.
  • Signalserver: i modsætning til de to andre aspekter er signalering ikke standardiseret. Hvad signalserveren egentlig skal håndtere, afhænger delvist af applikationen. I vores tilfælde er det nødvendigt at udveksle et lille datamængde før forbindelsen oprettes.
  • Teeworlds master-server: den anvendes af andre servere til at informere om deres eksistens og af klienter til at finde offentlige servere. Selvom den ikke er obligatorisk (klienter kan altid forbinde til en kendt server manuelt), ville det være godt at have den, så spillere kan deltage i spil med tilfældige personer.

Vi har besluttet at bruge Googles gratis STUN-servere, mens vi selv har sat en TURN-server op.

For de to sidste punkter brugte vi Firebase:

  • Teeworlds master server er implementeret på en meget simpel måde: som en liste over objekter, der indeholder information (navn, IP, kort, tilstand, …) om hver aktiv server. Serverne publicerer og opdaterer deres eget objekt, mens klienterne henter hele listen og viser den til spilleren. Vi viser også listen på startsiden som HTML, så spillere let kan klikke på serveren og komme direkte ind i spillet.
  • Signalisering er nært relateret til vores implementering af sockets, som beskrives i næste afsnit.

Vi porterer et multiplayer-spil fra C++ til web med Cheerp, WebRTC og Firebase
Serverliste indeni spillet og på startsiden

Implementering af sockets

Vi ønsker at skabe et 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 ægte routings: alle peers befinder sig i et 'virtuel LAN', der er forbundet til et specifikt Firebase-database-eksperiment.

Derfor har vi ikke brug for unikke IP-adresser: for at identificere peers unikt er det tilstrækkeligt at bruge unikke værdier af Firebase-nøgler (svarende til domænenavne), og hver peer tildeler lokalt 'falske' IP-adresser til hver nøgle, der skal omdannes. Dette fjerner fuldstændigt behovet for global tildeling af IP-adresser, hvilket er en ikke-triviel opgave.

Her er det 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'et er enkelt og ligner API'et for Posix Sockets, men har nogle vigtige forskelle: registrering af callback-funktioner, tildeling af lokale IP-adresser og 'lazy' forbindelse.

Registrering af callback-funktioner

Selv hvis den oprindelige program bruger ikke-blokerende input-output, skal koden refaktoreres for at køre i en webbrowser.

Årsagen til dette er, at begivenhedsløkken i browseren er skjult for programmet (uanset om det er JavaScript eller WebAssembly).

I et natives miljø kan vi skrive koden sådan her

while(running) {
  select(...); // vent på I/O begivenheder
  while(true) {
    int r = readfrom(...); // prøv at læse
    if (r < 0 && errno == EWOULDBLOCK) // ikke flere data tilgængelige
      break;
    ...
  }
  ...
}

Hvis begivenhedsløkken er skjult for os, skal den omdannes til noget som:

auto cb = []() { // denne vil blive kaldt, når nye data er tilgængelige
  while(true) {
    int r = readfrom(...); // prøv at læse
    if (r < 0 && errno == EWOULDBLOCK) // ikke flere data tilgængelige
      break;
    ...
  }
  ...
};
recvCallback(cb); // registrer callback'en

Tildeling af lokale IP-adresser

Noderne i vores "netværk" identificeres ikke ved IP-adresser, men ved Firebase-nøgler (de strenge, der ser sådan ud: -LmEC50PYZLCiCP-vqde ).

Det er praktisk, fordi vi ikke har brug for en mekanisme til at tildele IP og kontrollere deres unikke identitet (og også deres bortskaffelse efter frakobling af klienten), men det er ofte nødvendigt at identificere peers ved numeriske værdier.

Netop til dette bruges funktionerne resolve og reverseResolve: Applikationen får på en eller anden måde den strenge værdi af nøglen (gennem brugerinput eller gennem masterserveren), og kan konvertere den til en IP-adresse til intern brug. Resten af API'et får også for nemhed i stedet for strengen denne værdi.

Det minder om DNS-opslag, men udføres lokalt hos klienten.

Det vil sige, at IP-adresser ikke kan være fælles for forskellige klienter, og hvis der er behov for en global identifikator, skal den genereres på en anden måde.

Lazy connection

UDP kræver ikke en forbindelse, men som vi har set, kræver WebRTC en lang forbindelsesproces, før data kan overføres mellem to peers.

Hvis vi vil sikre det samme abstraheringsniveau, (sendto/recvfrom til vilkårlige peers uden forudgående forbindelse), skal vi udføre en "lazy" (forsinket) forbindelse inden for API'et.

Her er, hvad der sker ved almindelig dataudveksling mellem "serveren" og "klienten", når der bruges UDP, og hvad vores biblioteker skal udføre:

  • Serveren kalder bind(), for at informere operativsystemet om, at den vil modtage pakker på den angivne port.

I stedet offentliggør vi en åben port i Firebase under serverens nøgle og lytter efter begivenheder i dens undertræ.

  • Serveren kalder recvfrom(), modtager pakker til denne port, der kommer fra enhver vært.

I vores tilfælde skal vi kontrollere den indkommende pakkequeue, der sendes til denne port.

Hver port har sin egen kø, og vi tilføjer i starten af WebRTC-datagrammerne kilde- og destinationsportene for at vide, hvilken kø vi skal omdirigere en ny pakke til, når den ankommer.

Opkaldet er ikke-blokerende, så hvis der ikke er pakker, returnerer vi bare -1 og sætter errno=EWOULDBLOCK.

  • Klienten får serverens IP og port ved hjælp af nogle eksterne metoder, og kalder sendto(). Der udføres også et internt kald, så den efterfølgende bind()får svar uden eksplicit at udføre bind. recvfrom() получит ответ без явного выполнения bind.

I vores tilfælde får klienten en strengnøgle eksternt og bruger funktionen resolve() for at få IP-adressen.

På dette tidspunkt begynder vi "håndtrykket" WebRTC, hvis de to peers endnu ikke er forbundet med hinanden. Forbindelser til forskellige porte på én peer bruger den samme DataChannel WebRTC.

Vi udfører desuden en indirekte bind(), så serveren kan genoprette forbindelsen i den næste sendto() i tilfælde af, at den af en eller anden grund er blevet lukket.

Serveren bliver informeret om klientens forbindelse, når klienten registrerer sit SDP-offer under serverens portinformation i Firebase, og serveren svarer med sit svar der.

Diagrammet nedenfor viser et eksempel på bevægelsen af meddelelser for socket-skemaet og overførslen af den første meddelelse fra klienten til serveren:

Vi porterer et multiplayer-spil fra C++ til web med Cheerp, WebRTC og Firebase
Den komplette skematiske forbindelse mellem klienten og serveren

Konklusion

Hvis du er nået hertil, er du sikkert interesseret i at se teorien i praksis. Du kan spille spillet på teeworlds.leaningtech.com, prøv det!


En venlig match mellem kolleger

Koden til netværksbiblioteket er frit tilgængelig på Github. Deltag i samtalen på vores kanal på Gitter!

Kilde: habr.com

Køb pålidelig webhosting med DDoS-beskyttelse, VPS VDS-servere 🔥 Køb pålidelig webhosting med DDoS-beskyttelse, VPS VDS-servere | ProHoster