Dra 'n multiplayer-speletjie van C++ na die web met Cheerp, WebRTC en Firebase

Inleiding

ons maatskappy Leunende tegnologieë bied oplossings vir die oordrag van tradisionele rekenaartoepassings na die web. Ons C++ samesteller vrolik genereer 'n kombinasie van WebAssembly en JavaScript, wat voorsien en maklike blaaierinteraksie, en hoë werkverrigting.

As 'n voorbeeld van die gebruik daarvan, het ons besluit om 'n multispeler-speletjie vir die web oor te dra en gekies Teeworlds. Teeworlds is 'n retro XNUMXD-multispeler-speletjie met 'n klein maar aktiewe gemeenskap van spelers (ek ingesluit!). Dit is klein in terme van beide aflaaibare hulpbronne en SVE- en GPU-vereistes - 'n ideale kandidaat.

Dra 'n multiplayer-speletjie van C++ na die web met Cheerp, WebRTC en Firebase
Werk in die Teeworlds-blaaier

Ons het besluit om hierdie projek te gebruik om mee te eksperimenteer algemene oplossings vir die oordrag van netwerkkode na die web. Dit word gewoonlik op die volgende maniere gedoen:

  • XMLHttpVersoek/haal, as die netwerkdeel slegs uit HTTP-versoeke bestaan, of
  • web voetstukke.

Beide oplossings vereis dat 'n bedienerkomponent aan die bedienerkant aangebied word, en nie een laat dit as 'n vervoerprotokol gebruik word nie. UDP. Dit is belangrik vir intydse toepassings soos videokonferensies en speletjiesagteware omdat die protokol se pakkieaflewering en bestelling waarborg TCP kan inmeng met lae latensie.

Daar is 'n derde manier - om die netwerk vanaf die blaaier te gebruik: WebRTC.

RTCDataChannel ondersteun beide betroubare en onbetroubare transmissie (in laasgenoemde geval poog dit om UDP as die vervoerprotokol te gebruik waar moontlik), en kan beide met 'n afgeleë bediener en tussen blaaiers gebruik word. Dit beteken dat ons die hele toepassing na die blaaier kan oordra, insluitend die bedienerkomponent!

Dit kom egter met 'n bykomende probleem: voordat twee WebRTC-eweknieë kan kommunikeer, moet hulle 'n relatief komplekse handdruk uitvoer om te koppel, wat verskeie derdeparty-entiteite vereis ('n seinbediener en een of meer VERSTOF/DRAAI).

Ideaal gesproke wil ons 'n netwerk-API skep wat WebRTC intern gebruik, maar so na as moontlik aan die UDP Sockets-koppelvlak is, wat nie 'n verbinding hoef te vestig nie.

Dit sal ons toelaat om voordeel te trek uit WebRTC sonder om komplekse besonderhede aan die toepassingskode bloot te stel (wat ons so min as moontlik in ons projek wou verander).

Minimum WebRTC

WebRTC is 'n stel API's beskikbaar in blaaiers vir eweknie-oordrag van oudio, video en arbitrêre data.

Die verbinding tussen eweknieë word tot stand gebring (selfs al is daar NAT aan een of albei kante) deur gebruik te maak van STUN- en/of TURN-bedieners deur 'n meganisme genaamd ICE. Eweknieë ruil ICE-inligting en kanaalparameters uit deur die aanbod en antwoord van die SDP-protokol.

Sjoe! Hoeveel afkortings gelyktydig. Kom ons verduidelik kortliks wat hierdie terme beteken:

  • Session Traversal Utilities vir NAT (VERSTOF) - 'n protokol om NAT te omseil en 'n paar (IP, poort) te verkry om direk met die gasheer te kommunikeer. As hy daarin slaag om sy taak te voltooi, dan kan die maats onafhanklik data met mekaar uitruil.
  • Traversering deur gebruik te maak van relais rondom NAT (DRAAI) word ook gebruik om NAT te omseil, maar dit doen dit deur data aan te stuur deur 'n instaanbediener wat vir beide eweknieë sigbaar is. Dit voeg latency by en is duurder om uit te voer as STUN (omdat dit regdeur die sessie toegepas word), maar soms is dit die enigste opsie.
  • Interaktiewe verbindingsvestiging (ICE) word gebruik om die beste moontlike manier te kies om twee eweknieë te verbind gebaseer op die inligting wat verkry word deur eweknieë direk te koppel, sowel as inligting wat deur enige aantal STUN- en TURN-bedieners ontvang word.
  • Protokol vir sessiebeskrywing (SDP) - dit is 'n formaat om verbindingskanaalparameters te beskryf, byvoorbeeld ICE-kandidate, multimedia-kodeks (in die geval van 'n oudio-/videokanaal), ens. Een van die eweknieë stuur 'n SDP-aanbod ("aanbod"), en die tweede reageer met 'n SDP-antwoord (“respons”) . Daarna word 'n kanaal geskep.

Om so 'n verbinding te skep, moet eweknieë die inligting wat hulle van die STUN- en TURN-bedieners ontvang, versamel en met mekaar uitruil.

Die probleem is dat hulle nog nie die vermoë het om data direk uit te ruil nie, so 'n buitebandmeganisme moet bestaan ​​om hierdie data uit te ruil: 'n seinbediener.

Die seinbediener kan baie eenvoudig wees, want sy enigste taak is om data tussen eweknieë aan te stuur tydens die "handdruk" stadium (soos getoon in die diagram hieronder).

Dra 'n multiplayer-speletjie van C++ na die web met Cheerp, WebRTC en Firebase
Vereenvoudigde WebRTC-handdrukvolgorde

Oorsig van die Teeworlds-netwerkmodel

Die netwerkargitektuur van Teeworlds is baie eenvoudig:

  • Die kliënt- en bedienerkomponente is twee verskillende programme.
  • Kliënte betree die speletjie deur aan een van verskeie bedieners te koppel, wat elkeen slegs een speletjie op 'n slag aanbied.
  • Alle data-oordrag in die speletjie word deur die bediener uitgevoer.
  • 'n Spesiale meesterbediener word gebruik om 'n lys te versamel van alle publieke bedieners wat in die speletjiekliënt vertoon word.

Danksy die gebruik van WebRTC vir data-uitruiling, kan ons die bedienerkomponent van die speletjie oordra na die blaaier waar die kliënt geleë is. Dit gee ons 'n wonderlike geleentheid...

Raak ontslae van die bedieners

Die afwesigheid van logika aan die bedienerkant het 'n goeie voordeel: ons kan die hele toepassing as statiese inhoud op Github-bladsye of op ons eie hardeware agter Cloudflare ontplooi, om sodoende vinnige aflaaie en hoë uptyd gratis te verseker. Trouens, dit sal moontlik wees om van hulle te vergeet, en as ons gelukkig is en die speletjie word gewild, dan hoef die infrastruktuur nie opgegradeer te word nie.

Vir die stelsel om te werk, moet ons egter steeds 'n eksterne argitektuur gebruik:

  • Een of meer STUN-bedieners: Ons het verskeie gratis opsies om van te kies.
  • Ten minste een TURN-bediener: hier is geen gratis opsies nie, so ons kan óf ons eie opstel óf vir die diens betaal. Gelukkig sal dit die meeste van die tyd moontlik wees om via STUN-bedieners te koppel (en ware p2p te verskaf), maar TURN is nodig as 'n terugval.
  • Seinbediener: In teenstelling met die ander twee aspekte, is sein nie gestandaardiseer nie. Waarvoor die seinbediener eintlik verantwoordelik sal wees, hang ietwat van die toepassing af. In ons geval, voordat 'n verbinding tot stand gebring word, is dit nodig om 'n klein hoeveelheid data uit te ruil.
  • Teeworlds-meesterbediener: dit word deur ander bedieners gebruik om sy bestaan ​​aan te kondig en deur kliënte om publieke bedieners te vind. Alhoewel dit nie nodig is nie (kliënte kan altyd aan 'n bediener koppel wat hulle met die hand ken), sal dit lekker wees om dit te hê sodat spelers speletjies met ewekansige mense kan speel.

Ons het besluit om Google se gratis STUN-bedieners te gebruik, en het self een TURN-bediener ontplooi.

Vir die laaste twee items het ons gebruik Firebase:

  • Die Teeworlds-meesterbediener word baie eenvoudig geïmplementeer: as 'n lys van voorwerpe wat inligting (naam, IP, kaart, modus, ...) van elke aktiewe bediener bevat. Bedieners publiseer en werk hul eie voorwerp op, en kliënte neem die hele lys en wys dit aan die speler. Ons vertoon ook die lys as HTML op die tuisblad sodat spelers eenvoudig op die bediener kan klik en reguit in die speletjie kan spring.
  • Sein is nou verwant aan ons sokkelimplementering, wat in die volgende afdeling beskryf word.

Dra 'n multiplayer-speletjie van C++ na die web met Cheerp, WebRTC en Firebase
Lys van bedieners binne die spel en op die tuisblad

Socket Implementering

Ons wil 'n API skep wat so na as moontlik aan Posix UDP Sockets is om die aantal veranderinge wat benodig word, te verminder.

Ons wil ook die nodige minimum implementeer wat benodig word vir die eenvoudigste data-uitruiling oor die netwerk.

Ons het byvoorbeeld nie werklike roetering nodig nie: alle eweknieë is op dieselfde "virtuele LAN" wat met 'n spesifieke Firebase-databasis-instansie geassosieer word.

Daarom het ons nie unieke IP-adresse nodig nie: om eweknieë uniek te identifiseer, is dit genoeg om unieke Firebase-sleutelwaardes te gebruik (soortgelyk aan domeinname), en elke eweknie ken plaaslik "vals" IP-adresse toe aan elke sleutel wat moet wees vertaal. Dit spaar ons heeltemal daarvan om IP-adresse wêreldwyd toe te ken, wat nie 'n onbenullige taak is nie.

Hier is die minimum API wat ons moet implementeer:

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

Die API is eenvoudig en soortgelyk aan die Posix Sockets API, maar met 'n paar belangrike verskille: terugbelregistrasie, plaaslike IP-toewysing en lui verbinding.

Registreer terugbel

Selfs as die oorspronklike program nie-blokkerende I/O gebruik, moet die kode herfaktor word om in 'n webblaaier te loop.

Die rede hiervoor is dat die gebeurtenislus in die blaaier vir die program versteek is (of dit nou JavaScript of WebAssembly is).

In 'n inheemse omgewing kan ons kode soos hierdie skryf

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

As die gebeurtenislus vir ons versteek is, moet ons dit in iets soos hierdie verander:

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

Plaaslike IP-toewysing

Die nodus-ID's in ons "netwerk" is nie IP-adresse nie, maar Firebase-sleutels (dit is stringe wat so lyk: -LmEC50PYZLCiCP-vqde ).

Dit is gerieflik omdat ons nie 'n meganisme nodig het om IP's toe te ken en te kyk of hulle uniek is nie (en ontslae te raak nadat 'n kliënt ontkoppel is), maar dit is dikwels nodig om eweknieë volgens numeriese waarde te identifiseer.

Dit is waarvoor die funksies is. resolve и reverseResolve: Die toepassing ontvang op een of ander manier die stringwaarde van die sleutel (via gebruikerinvoer of via die hoofbediener) en kan dit omskep na 'n IP-adres vir interne gebruik. Die res van die API ontvang ook hierdie waarde in plaas van 'n string vir eenvoud.

Dit is soortgelyk aan 'n DNS-opsoek, slegs plaaslik op die kliënt gedoen.

Dit wil sê, IP-adresse kan nie tussen verskillende kliënte gedeel word nie, en as 'n soort globale identifiseerder nodig is, sal dit op 'n ander manier gegenereer moet word.

Lui join

UDP het nie 'n verbinding nodig nie, maar soos ons gesien het, vereis WebRTC 'n lang verbindingsproses voordat dit kan begin om data tussen twee eweknieë oor te dra.

As ons dieselfde vlak van abstraksie wil verskaf, (sendto/recvfrom met arbitrêre eweknieë sonder 'n voorafverbinding), dan moet hulle 'n "lui" (vertraagde) verbinding binne die API uitvoer.

Hier is wat gebeur in die normale kommunikasie tussen die "bediener" en die "kliënt" in die geval van die gebruik van UDP, en wat ons biblioteek moet doen:

  • Bediener oproepe bind()om die bedryfstelsel te vertel dat dit pakkies op die gespesifiseerde poort wil ontvang.

In plaas daarvan sal ons 'n oop poort na Firebase publiseer onder die bedienersleutel en luister vir gebeure in sy subboom.

  • Bediener oproepe recvfrom(), wat pakkies van enige gasheer op hierdie poort aanvaar.

In ons geval moet ons die inkomende tou van pakkies wat na hierdie poort gestuur word, nagaan.

Elke poort het sy eie tou, en ons voeg bron- en bestemmingpoorte by die begin van WebRTC-datagramme sodat ons weet na watter tou om te herlei wanneer 'n nuwe pakkie aankom.

Die oproep is nie-blokkerend, so as daar geen pakkies is nie, gee ons eenvoudig -1 terug en stel errno=EWOULDBLOCK.

  • Die kliënt ontvang op een of ander eksterne manier die IP en poort van die bediener, en oproepe sendto(). Dit maak ook 'n interne oproep bind(), so die volgende recvfrom() sal die antwoord ontvang sonder om uitdruklik bind uit te voer.

In ons geval ontvang die kliënt ekstern 'n stringsleutel en gebruik die funksie resolve() om 'n IP-adres te kry.

Op hierdie stadium begin ons die WebRTC-handdruk as die twee eweknieë nie reeds aan mekaar verbind is nie. Verbindings na verskillende poorte van dieselfde eweknie gebruik dieselfde WebRTC DataChannel.

Ons doen ook indirek bind()sodat die bediener volgende weer kan koppel sendto() ingeval dit om een ​​of ander rede gesluit het.

Die bediener word in kennis gestel van die kliënt se verbinding wanneer die kliënt sy SDP-aanbod onder die bediener se poortinligting in Firebase skryf, en die bediener reageer ook daar met sy antwoord.

Die diagram hieronder toon 'n voorbeeld van boodskapvloei vir die sokskema en die eerste boodskap van die kliënt na die bediener:

Dra 'n multiplayer-speletjie van C++ na die web met Cheerp, WebRTC en Firebase
Volledige diagram van die verbindingsfase tussen kliënt en bediener

Gevolgtrekking

As jy tot hier gelees het, sal jy dalk belangstel om die teorie in aksie te sien. Die speletjie kan gespeel word teeworlds.leaningtech.com, probeer dit!


Vriendskaplike wedstryd tussen kollegas

Die netwerkbiblioteekkode is gratis beskikbaar by GitHub. Sluit aan by die gesprek op ons kanaal Gitter!

Bron: will.com

Voeg 'n opmerking