Portera ett multiplayer-spel från C++ till webben med Cheerp, WebRTC och Firebase

Inledning

vårt bolag Lutande teknologier tillhandahåller lösningar för att porta traditionella skrivbordsprogram till webben. Vår C++ kompilator cheerp genererar en kombination av WebAssembly och JavaScript, vilket ger och enkel webbläsarinteraktionoch hög prestanda.

Som ett exempel på dess tillämpning bestämde vi oss för att porta ett multiplayer-spel till webben och valde Teeworlds. Teeworlds är ett multiplayer XNUMXD-retrospel med en liten men aktiv grupp av spelare (inklusive mig!). Den är liten både vad gäller nedladdade resurser och krav på CPU och GPU - en idealisk kandidat.

Portera ett multiplayer-spel från C++ till webben med Cheerp, WebRTC och Firebase
Körs i Teeworlds webbläsare

Vi bestämde oss för att använda det här projektet för att experimentera med allmänna lösningar för portering av nätverkskod till webben. Detta görs vanligtvis på följande sätt:

  • XMLHttpRequest/fetch, om nätverksdelen endast består av HTTP-förfrågningar, eller
  • WebSockets.

Båda lösningarna kräver värd för en serverkomponent på serversidan, och ingen av dem tillåter användning som transportprotokoll UDP. Detta är viktigt för realtidsapplikationer som videokonferensprogram och spel, eftersom det garanterar leverans och ordning av protokollpaket TCP kan bli ett hinder för låg latens.

Det finns ett tredje sätt - använd nätverket från webbläsaren: WebRTC.

RTCDataChannel Den stöder både tillförlitlig och opålitlig överföring (i det senare fallet försöker den använda UDP som ett transportprotokoll när det är möjligt), och kan användas både med en fjärrserver och mellan webbläsare. Det betyder att vi kan porta hela applikationen till webbläsaren, inklusive serverkomponenten!

Detta kommer dock med ytterligare en svårighet: innan två WebRTC-kamrater kan kommunicera måste de utföra en relativt komplex handskakning för att ansluta, vilket kräver flera tredjepartsenheter (en signalserver och en eller flera servrar BEDÖVA/SVÄNG).

Helst skulle vi vilja skapa ett nätverks-API som använder WebRTC internt, men som är så nära som möjligt ett UDP Sockets-gränssnitt som inte behöver upprätta en anslutning.

Detta gör att vi kan dra fördel av WebRTC utan att behöva exponera komplexa detaljer för applikationskoden (som vi ville ändra så lite som möjligt i vårt projekt).

Minsta WebRTC

WebRTC är en uppsättning API:er tillgängliga i webbläsare som tillhandahåller peer-to-peer-överföring av ljud, video och godtyckliga data.

Kopplingen mellan peers upprättas (även om det finns NAT på ena eller båda sidorna) med hjälp av STUN- och/eller TURN-servrar genom en mekanism som kallas ICE. Peers utbyter ICE-information och kanalparametrar via erbjudande och svar av SDP-protokollet.

Wow! Hur många förkortningar på en gång? Låt oss kort förklara vad dessa termer betyder:

  • Session Traversal Utilities för NAT (BEDÖVA) — ett protokoll för att kringgå NAT och erhålla ett par (IP, port) för utbyte av data direkt med värden. Om han lyckas slutföra sin uppgift kan kamrater oberoende utbyta data med varandra.
  • Traversering med hjälp av reläer runt NAT (SVÄNG) används också för NAT-traversering, men den implementerar detta genom att vidarebefordra data via en proxy som är synlig för båda peers. Det lägger till latens och är dyrare att implementera än STUN (eftersom det tillämpas under hela kommunikationssessionen), men ibland är det det enda alternativet.
  • Interactive Connectivity Etablering (IS) används för att välja den bästa möjliga metoden för att koppla ihop två peers baserat på information som erhålls från att koppla peers direkt, samt information som tas emot av valfritt antal STUN- och TURN-servrar.
  • Sessionsbeskrivningsprotokoll (SDP) är ett format för att beskriva anslutningskanalparametrar, t.ex. ICE-kandidater, multimedia-codecs (i fallet med en ljud-/videokanal), etc... En av peers skickar ett SDP-erbjudande och den andra svarar med ett SDP-svar ... Efter detta skapas en kanal.

För att skapa en sådan anslutning måste kamrater samla in informationen de får från STUN- och TURN-servrarna och utbyta den med varandra.

Problemet är att de ännu inte har förmågan att kommunicera direkt, så det måste finnas en out-of-band-mekanism för att utbyta dessa data: en signalserver.

En signalserver kan vara väldigt enkel eftersom dess enda uppgift är att vidarebefordra data mellan peers i handskakningsfasen (som visas i diagrammet nedan).

Portera ett multiplayer-spel från C++ till webben med Cheerp, WebRTC och Firebase
Förenklat WebRTC-handskakningssekvensdiagram

Teeworlds nätverksmodellöversikt

Teeworlds nätverksarkitektur är väldigt enkel:

  • Klient- och serverkomponenterna är två olika program.
  • Klienter går in i spelet genom att ansluta till en av flera servrar, som var och en är värd för endast ett spel åt gången.
  • All dataöverföring i spelet sker via servern.
  • En speciell masterserver används för att samla in en lista över alla publika servrar som visas i spelklienten.

Tack vare användningen av WebRTC för datautbyte kan vi överföra serverkomponenten i spelet till webbläsaren där klienten finns. Detta ger oss en fantastisk möjlighet...

Bli av med servrar

Avsaknaden av serverlogik har en trevlig fördel: vi kan distribuera hela applikationen som statiskt innehåll på Github-sidor eller på vår egen hårdvara bakom Cloudflare, vilket säkerställer snabba nedladdningar och hög drifttid gratis. Faktum är att vi kan glömma dem, och om vi har tur och spelet blir populärt, så behöver inte infrastrukturen moderniseras.

Men för att systemet ska fungera måste vi fortfarande använda en extern arkitektur:

  • En eller flera STUN-servrar: Vi har flera gratisalternativ att välja mellan.
  • Minst en TURN-server: det finns inga gratisalternativ här, så vi kan antingen sätta upp vår egen eller betala för tjänsten. Lyckligtvis kan anslutningen oftast upprättas via STUN-servrar (och ge sann p2p), men TURN behövs som ett reservalternativ.
  • Signaleringsserver: Till skillnad från de andra två aspekterna är signalering inte standardiserad. Vad signalservern faktiskt kommer att ansvara för beror lite på applikationen. I vårt fall, innan du upprättar en anslutning, är det nödvändigt att utbyta en liten mängd data.
  • Teeworlds Master Server: Den används av andra servrar för att annonsera om deras existens och av klienter för att hitta offentliga servrar. Även om det inte krävs (klienter kan alltid ansluta till en server som de känner till manuellt), skulle det vara trevligt att ha så att spelare kan delta i spel med slumpmässiga personer.

Vi bestämde oss för att använda Googles gratis STUN-servrar och implementerade en TURN-server själva.

För de två sista punkterna använde vi Firebase:

  • Teeworlds masterserver implementeras mycket enkelt: som en lista över objekt som innehåller information (namn, IP, karta, läge, ...) för varje aktiv server. Servrar publicerar och uppdaterar sitt eget objekt, och klienter tar hela listan och visar den för spelaren. Vi visar också listan på hemsidan som HTML så att spelare helt enkelt kan klicka på servern och föras direkt till spelet.
  • Signalering är nära relaterad till vår sockets-implementering, som beskrivs i nästa avsnitt.

Portera ett multiplayer-spel från C++ till webben med Cheerp, WebRTC och Firebase
Lista över servrar inne i spelet och på hemsidan

Implementering av uttag

Vi vill skapa ett API som är så nära Posix UDP Sockets som möjligt för att minimera antalet ändringar som behövs.

Vi vill också implementera det nödvändiga minimum som krävs för det enklaste datautbytet över nätverket.

Till exempel behöver vi ingen riktig routing: alla peers finns på samma "virtuella LAN" som är kopplat till en specifik Firebase-databasinstans.

Därför behöver vi inte unika IP-adresser: unika Firebase-nyckelvärden (liknande domännamn) är tillräckliga för att unikt identifiera peers, och varje peer tilldelar lokalt "falska" IP-adresser till varje nyckel som behöver översättas. Detta eliminerar helt behovet av global IP-adresstilldelning, vilket är en icke-trivial uppgift.

Här är det minsta API som vi behöver implementera:

// 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 är enkelt och liknar Posix Sockets API, men har några viktiga skillnader: logga återuppringningar, tilldela lokala IP-adresser och lata anslutningar.

Registrera återuppringningar

Även om det ursprungliga programmet använder icke-blockerande I/O, måste koden refaktoreras för att köras i en webbläsare.

Anledningen till detta är att händelseslingan i webbläsaren är dold från programmet (vare sig det är JavaScript eller WebAssembly).

I den ursprungliga miljön kan vi skriva kod så här

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

Om händelseslingan är dold för oss, måste vi förvandla den till något så här:

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

Nod-ID:n i vårt "nätverk" är inte IP-adresser, utan Firebase-nycklar (de är strängar som ser ut så här: -LmEC50PYZLCiCP-vqde ).

Detta är praktiskt eftersom vi inte behöver en mekanism för att tilldela IP-adresser och kontrollera deras unika (liksom att kassera dem efter att klienten kopplat från), men det är ofta nödvändigt att identifiera peers med ett numeriskt värde.

Det är precis vad funktionerna används till. resolve и reverseResolve: Applikationen tar på något sätt emot nyckelns strängvärde (via användarinmatning eller via huvudservern), och kan konvertera den till en IP-adress för intern användning. Resten av API:et får också detta värde istället för en sträng för enkelhets skull.

Detta liknar DNS-sökning, men utförs lokalt på klienten.

Det vill säga att IP-adresser inte kan delas mellan olika klienter, och om någon form av global identifierare behövs måste den genereras på ett annat sätt.

Lat anslutning

UDP behöver ingen anslutning, men som vi har sett kräver WebRTC en lång anslutningsprocess innan den kan börja överföra data mellan två peers.

Om vi ​​vill ge samma abstraktionsnivå, (sendto/recvfrom med godtyckliga peers utan föregående anslutning), måste de utföra en "lat" (fördröjd) anslutning inuti API:et.

Detta är vad som händer under normal kommunikation mellan "servern" och "klienten" när du använder UDP, och vad vårt bibliotek ska göra:

  • Serveranrop bind()för att tala om för operativsystemet att det vill ta emot paket på den angivna porten.

Istället kommer vi att publicera en öppen port till Firebase under servernyckeln och lyssna efter händelser i dess underträd.

  • Serveranrop recvfrom(), accepterar paket som kommer från vilken värd som helst på denna port.

I vårt fall måste vi kontrollera den inkommande kön av paket som skickas till denna port.

Varje port har sin egen kö, och vi lägger till käll- och destinationsportarna i början av WebRTC-datagrammen så att vi vet vilken kö vi ska vidarebefordra till när ett nytt paket kommer.

Samtalet är icke-blockerande, så om det inte finns några paket returnerar vi helt enkelt -1 och ställer in errno=EWOULDBLOCK.

  • Klienten tar emot serverns IP och port på något externt sätt och anropar sendto(). Detta gör också ett internt samtal. bind(), alltså efterföljande recvfrom() kommer att få svaret utan att uttryckligen utföra bind.

I vårt fall tar klienten emot strängnyckeln externt och använder funktionen resolve() för att få en IP-adress.

Vid denna tidpunkt initierar vi en WebRTC-handskakning om de två peers ännu inte är anslutna till varandra. Anslutningar till olika portar av samma peer använder samma WebRTC DataChannel.

Vi utför även indirekt bind()så att servern kan återansluta i nästa sendto() ifall den stängdes av någon anledning.

Servern meddelas om klientens anslutning när klienten skriver sitt SDP-erbjudande under serverportinformationen i Firebase och servern svarar med sitt svar där.

Diagrammet nedan visar ett exempel på meddelandeflöde för ett socketschema och överföringen av det första meddelandet från klienten till servern:

Portera ett multiplayer-spel från C++ till webben med Cheerp, WebRTC och Firebase
Komplett diagram över anslutningsfasen mellan klient och server

Slutsats

Om du har läst så här långt är du förmodligen intresserad av att se teorin i praktiken. Spelet kan spelas på teeworlds.leaningtech.com, försök!


Vänskapsmatch mellan kollegor

Nätverksbibliotekskoden är fritt tillgänglig på Github. Gå med i samtalet på vår kanal kl Gitter!

Källa: will.com

Lägg en kommentar