Portujeme multiplayerovú hru z C++ na web s Cheerp, WebRTC a Firebase

Úvod

Naša spoločnosť Leaning Technologies poskytuje riešenia na portovanie tradičných desktopových aplikácií na web. Náš kompilátor C++ Cheerp generuje kombináciu WebAssembly a JavaScript, čo zabezpečuje jednoduché prepojenie s prehliadačom, a vysoký výkon.

Ako príklad jeho použitia sme sa rozhodli portovať na web multiplayerovú hru a vybrali sme pre tento projekt Teeworlds. Teeworlds je multiplayerová 2D retro hra s malou, ale aktívnou komunitou hráčov (medzi nimi aj ja!). Je malá ako z hľadiska sťahovaných zdrojov, tak aj požiadaviek na CPU a GPU — ideálny kandidát.

Portujeme multiplayerovú hru z C++ na web s Cheerp, WebRTC a Firebase
Fungujúca v prehliadači Teeworlds

Sme sa rozhodli využiť tento projekt na experimentovanie s generickými riešeniami na portovanie sieťového kódu na web. Zvyčajne sa to vykonáva nasledujúcimi spôsobmi:

  • XMLHttpRequest/fetch, ak sieťová časť pozostáva len z HTTP požiadaviek, alebo
  • WebSockets.

Obe riešenia vyžadujú hosťovanie serverového komponentu na strane servera a žiadne z nich neumožňuje používať ako prenosový protokol UDP. To je dôležité pre aplikácie v reálnom čase, ako je softvér na videokonferencie a hry, pretože záruky dodania a poradia paketov protokolu TCP môžu byť prekážkou pre nízke latencie.

Existuje aj tretia cesta — používanie siete z prehliadača: WebRTC.

RTCDataChannel podporuje ako spoľahlivý, tak aj nespoľahlivý prenos (v poslednom prípade sa snaží použiť ako prenosový protokol UDP, ak je to možné), a môže byť použitý aj s vzdialeným serverom, aj medzi prehliadačmi. To znamená, že môžeme portovať do prehliadača celú aplikáciu, vrátane serverového komponentu!

Avšak s tým sa spája dodatočná ťažkost: predtým, ako si dva WebRTC peer môžu vymieňať dáta, musia prejsť relatívne zložitým procesom „podania rúk“ (handshake) na pripojenie, na čo sú potrebné niektoré externé entity (signálny server a jeden alebo viac serverov STUN/TURN).

Ideálne by sme chceli vytvoriť sieťové API, vo vnútri používajúce WebRTC, ale čo najviac podobné rozhraniu UDP Sockets, ktoré nepotrebuje vytvárať pripojenie.

To nám umožní využiť výhody WebRTC bez potreby zverejňovať zložité detaily kódu aplikácie (ktorý sme v našom projekte chceli zmeniť čo najmenej).

Minimálne WebRTC

WebRTC je sada API dostupná v prehliadačoch, ktorá umožňuje prenos peer-to-peer zvuku, videa a rôznych dát.

Spojenie medzi peer-ami sa vytvára (aj v prípade, že sú prítomné NAT z jednej alebo oboch strán) pomocou serverov STUN a/alebo TURN prostredníctvom mechanizmu nazývaného ICE. Peer-i si vymieňajú informácie ICE a parametre kanálov prostredníctvom ponúk a odpovedí protokolu SDP.

Wow! Koľko skratiek naraz. Poďme krátko vysvetliť, čo tieto pojmy znamenajú:

  • Session Traversal Utilities for NAT (STUN) — protokol na obídenie NAT a získanie páru (IP, port) na výmenu dát priamo s hostiteľom. Ak sa mu podarí splniť svoju úlohu, peer-i si môžu samostatne vymieňať dáta.
  • Traversal Using Relays around NAT (TURN) tiež sa používa na obídenie NAT, ale realizuje to presmerovaním dát cez proxy, ktorá je viditeľná obom peer-om. Pridáva oneskorenie a je nákladnejší na vykonanie ako STUN (pretože sa používa počas celej relácie), ale niekedy je to jediná možná možnosť.
  • Interactive Connectivity Establishment (ICE) sa používa na výber najlepšieho možného spôsobu spojenia dvoch peer-ov na základe informácií získaných pri priamom spojení peer-ov, ako aj informácií získaných z akéhokoľvek množstva serverov STUN a TURN.
  • Session Description Protocol (SDP) — je to formát na opis parametrov pripojovacieho kanála, ako sú ICE kandidáti, multimediálne kodeky (v prípade audio/video kanála), atď. Jeden z peer-ov posiela SDP ponuku ("ponuku"), a druhý odpovedá SDP odpoveďou ("odpoveďou"). Potom sa vytvorí kanál.

Aby sa takéto spojenie vytvorilo, peer-om je potrebné zhromaždiť informácie, ktoré získali od serverov STUN a TURN, a vymeniť si ich navzájom.

Problém je v tom, že zatiaľ nemajú možnosť vymieňať si dáta priamo, a preto musí existovať offline mechanizmus: signálny server.

Signálny server môže byť veľmi jednoduchý, pretože jeho jedinou úlohou je presmerovanie dát medzi peer-ami v etape "podania ruky" (ako je znázornené na schéme nižšie).

Portujeme multiplayerovú hru z C++ na web s Cheerp, WebRTC a Firebase
Zjednodušená schéma postupnosti "podania ruky" WebRTC

Prehľad sieťového modelu Teeworlds

Sieťová architektúra Teeworlds je veľmi jednoduchá:

  • Komponenty klienta a servera sú dve rôzne programy.
  • Klienti sa zapájajú do hry pripojením k jednému z viacerých serverov, z ktorých každý hostí len jednu hru naraz.
  • Celá výmena dát v hre prebieha prostredníctvom servera.
  • Špeciálny master-server slúži na zhromažďovanie zoznamu všetkých verejných serverov, ktoré sa zobrazujú v hernom klienskom programe.

Vďaka využitiu WebRTC na výmenu dát môžeme presunúť serverovú komponenent do prehliadača, kde sa nachádza klient. To nám dáva skvelú príležitosť...

Zbaviť sa serverov

Absencia serverovej logiky má príjemnú výhodu: môžeme nasadiť celú aplikáciu ako statický obsah na Github Pages alebo na vlastnom hardvéri za Cloudflare, čím si zabezpečíme rýchle načítanie a vysokú dostupnosť bez nákladov. V podstate na ne môžeme zabudnúť a ak budeme mať šťastie, že hra sa stane populárnou, nebude potrebné modernizovať infraštruktúru.

Avšak, aby systém fungoval, budeme aj tak musieť použiť externú architektúru:

  • Jeden alebo viac STUN serverov: máme na výber z niekoľkých bezplatných možností.
  • Aspoň jeden TURN server: tu nie sú k dispozícii bezplatné možnosti, takže si môžeme buď nastaviť svoj, alebo platiť za službu. Našťastie väčšinu času sa pripojenie dá nadviazať cez STUN servery (a zabezpečiť pravé p2p), ale TURN je potrebný ako záložná alternatíva.
  • Signálny server: na rozdiel od dvoch ďalších aspektov nie je signalizovanie štandardizované. To, za čo vlastne bude zodpovedný signálny server, do určitej miery závisí od aplikácie. V našom prípade je potrebné vymeniť si malé množstvo dát pred nadviazaním spojenia.
  • Master-server Teeworlds: používajú ho iné servery na upozornenie na svoju existenciu a klienti na vyhľadávanie verejných serverov. Hoci nie je povinný (klienti sa vždy môžu manuálne pripojiť k známemu serveru), bolo by dobré ho mať, aby hráči mohli súťažiť v hrách s náhodnými ľuďmi.

Rozhodli sme sa použiť bezplatné STUN servery od spoločnosti Google, zatiaľ čo jeden TURN server sme si nasadili sami.

Na dva posledné body sme použili Firebase:

  • Master server Teeworlds je implementovaný veľmi jednoducho: ako zoznam objektov, ktorý obsahuje informácie (meno, IP, mapa, režim, …) každého aktívneho servera. Servery publikujú a aktualizujú svoj vlastný objekt, zatiaľ čo klienti berú celý zoznam a zobrazujú ho hráčovi. Zoznam tiež zobrazuje na domovskej stránke ako HTML, aby hráči mohli jednoducho kliknúť na server a priamo sa dostať do hry.
  • Signalizácia je úzko spojená s našou implementáciou socketov, opísanou v nasledujúcej časti.

Portujeme multiplayerovú hru z C++ na web s Cheerp, WebRTC a Firebase
Zoznam serverov v hre a na domovskej stránke

Implementácia socketov

Chceme vytvoriť API, čo najbližšie k Posix UDP Sockets, aby sme minimalizovali počet potrebných zmien.

Takisto chceme implementovať nevyhnutné minimum, potrebné na najjednoduchšiu výmenu údajov v sieti.

Napríklad, nepotrebujeme skutočné smerovanie: všetky peerové zariadenia sú v jednej „virtuálnej LAN“, spojenej s konkrétnym inštančným databázovým systémom Firebase.

Preto nepotrebujeme jedinečné IP adresy: na jedinečnú identifikáciu peerov stačí použiť jedinečné hodnoty kľúčov Firebase (podobne ako doménové mená), a každý peer lokálne priraďuje „falošné“ IP adresy každému kľúču, ktorý je potrebné previesť. To nás úplne zbavuje potreby globálneho priraďovania IP adries, čo je netriviálna úloha.

Tu je minimálne API, ktoré musíme implementovať:

// 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 je jednoduché a podobné API Posix Sockets, ale má niekoľko dôležitých rozdielov: registrácia spätných volaní, priradenie lokálnych IP a „lenivá“ konektivita.

Registrácia spätných volaní

Aj keď pôvodný program používa neblokujúci vstup/výstup, pre spustenie v webovom prehliadači je potrebné kód refaktorovať.

Dôvodom je to, že slučka udalostí v prehliadači je skrytá pred programom (či už je to JavaScript alebo WebAssembly).

V natívnom prostredí môžeme písať kód takto

while(running) {
  select(...); // čakajte na I/O udalosti
  while(true) {
    int r = readfrom(...); // pokus o čítanie
    if (r < 0 && errno == EWOULDBLOCK) // nie sú k dispozícii ďalšie údaje
      break;
    ...
  }
  ...
}

Ak je slučka udalostí pre nás skrytá, musíme ju premeniť na niečo podobné:

auto cb = []() { // toto sa zavolá, keď sú nové údaje k dispozícii
  while(true) {
    int r = readfrom(...); // pokus o čítanie
    if (r < 0 && errno == EWOULDBLOCK) // nie sú k dispozícii ďalšie údaje
      break;
    ...
  }
  ...
};
recvCallback(cb); // registrácia spätného volania

Priradenie lokálnych IP

Identifikátory uzlov v našej ‘sieti’ nie sú IP adresy, ale Firebase kľúče (to sú reťazce, ktoré vyzerajú takto: -LmEC50PYZLCiCP-vqde ).

Je to pohodlné, pretože nepotrebujeme mechanizmus na priradenie IP a kontrolu ich jedinečnosti (ako aj ich likvidáciu po odpojení klienta), ale veľmi často je potrebné identifikovať peer podľa číselnej hodnoty.

Na tento účel sa používajú funkcie resolve a reverseResolve: aplikácia získa reťazcovú hodnotu kľúča (cez vstup používateľa alebo cez majstrovský server) a môže ju previesť na IP adresu na vnútorné použitie. Zvyšok API pre jednoduchosť tiež získa túto hodnotu namiesto reťazca.

Je to podobné ako DNS vyhľadávanie, ale vykonáva sa lokálne u klienta.

To znamená, že IP adresy nemôžu byť zdieľané medzi rôznymi klientmi, a ak potrebujeme nejaký globálny identifikátor, bude ho potrebné generovať iným spôsobom.

Lenivé pripojenie

UDP nevyžaduje pripojenie, ale, ako sme videli, skôr ako začneme prenášať údaje medzi dvoma peerami, WebRTC vyžaduje dlhý proces pripojenia.

Ak chceme zabezpečiť rovnakú úroveň abstrakcie, (sendto/recvfrom s ľubovoľnými peerami bez predchádzajúceho pripojenia), musíme vykonať „lenivé“ (oneskorené) pripojenie v rámci API.

Toto je to, čo sa deje pri bežnej výmene údajov medzi „serverom“ a „klientom“ v prípade použitia UDP, a čo by mala naša knižnica vykonávať:

  • Server volá bind(), aby informoval operačný systém, že chce prijímať pakety na zadaný port.

Namiesto toho zverejníme otvorený port vo Firebase pod kľúčom servera a budeme počúvať udalosti v jeho podstromu.

  • Server volá recvfrom(), prijímajúci na tento port pakety pochádzajúce od akéhokoľvek hostiteľa.

V našom prípade je potrebné kontrolovať prichádzajúcu frontu paketov, ktoré boli odoslané na tento port.

Každý port má svoju vlastnú frontu, a pridávame na začiatok dátagramov WebRTC pôvodné a cieľové porty, aby sme vedeli, do ktorej fronty presmerovať nový paket pri jeho príchode.

Volanie je nebločka, takže ak nie sú pakety, jednoducho vrátime -1 a nastavíme errno=EWOULDBLOCK.

  • Klient získa externými prostriedkami IP a port servera a volá sendto(). Tiež pritom prebieha vnútorné volanie bind(), takže nasledujúci recvfrom() získa odpoveď bez explicitného vykonania bind.

V našom prípade klient externým spôsobom získava reťazový kľúč a používa funkciu resolve() na získanie IP adresy.

V tejto fáze začíname „podanie ruky“ WebRTC, ak sa dva peer ešte navzájom nespojili. Pripojenia na rôzne porty jedného peer používajú rovnaký DataChannel WebRTC.

Taktiež vykonávame nepriamy bind(), aby server mohol obnoviť pripojenie v nasledujúcom sendto() v prípade, že sa z nejakého dôvodu uzavrie.

Server je informovaný o pripojení klienta, keď klient zapisuje svoj SDP offer pod informáciou o porte servera vo Firebase, a server tam odpovedá svojím odpovedom.

Na nižšie uvedenom schéme je ukážka prenosu správ pre schému soketov a prenos prvého správy od klienta k serveru:

Portujeme multiplayerovú hru z C++ na web s Cheerp, WebRTC a Firebase
Úplná schéma fázy pripojenia medzi klientom a serverom

Záver

Ak ste dočítali až do konca, pravdepodobne vás zaujíma pozrieť sa na teóriu v praxi. Hru si môžete zahrať na teeworlds.leaningtech.com, vyskúšajte!


Priateľský zápas medzi kolegami

Kód sieťovej knižnice je voľne dostupný na Github. Pridajte sa k diskusii na našom kanáli v Gitter!

Zdroj: habr.com

Kúpte si spoľahlivý hosting pre webové stránky s ochranou proti DDoS, VPS VDS servery 🔥 Kúpte si spoľahlivý hosting pre webové stránky s ochranou proti DDoS, VPS VDS servery | ProHoster