Trasferimento di un gioco multiplayer da C++ al Web con Cheerp, WebRTC e Firebase

Introduzione

La nostra azienda Tecnologie appoggiate fornisce soluzioni per il porting delle applicazioni desktop tradizionali sul web. Il nostro compilatore C++ evviva genera una combinazione di WebAssembly e JavaScript, che fornisce entrambi semplice interazione con il browsere prestazioni elevate.

Come esempio della sua applicazione, abbiamo deciso di portare un gioco multiplayer sul web e abbiamo scelto Teeworlds. Teeworlds è un gioco retrò XNUMXD multiplayer con una piccola ma attiva comunità di giocatori (incluso me!). È piccolo sia in termini di risorse scaricate che di requisiti di CPU e GPU: un candidato ideale.

Trasferimento di un gioco multiplayer da C++ al Web con Cheerp, WebRTC e Firebase
In esecuzione nel browser Teeworlds

Abbiamo deciso di utilizzare questo progetto per sperimentare soluzioni generali per il porting del codice di rete sul web. Questo di solito viene fatto nei seguenti modi:

  • XMLHttpRequest/recupero, se la parte di rete è costituita solo da richieste HTTP, oppure
  • WebSockets.

Entrambe le soluzioni richiedono l'hosting di un componente server sul lato server e nessuna delle due consente l'utilizzo come protocollo di trasporto UDP. Questo è importante per le applicazioni in tempo reale come software e giochi per videoconferenze, perché garantisce la consegna e l'ordine dei pacchetti di protocollo TCP potrebbe diventare un ostacolo alla bassa latenza.

Esiste un terzo modo: utilizzare la rete dal browser: WebRTC.

RTCDataChannel Supporta sia trasmissioni affidabili che inaffidabili (in quest'ultimo caso tenta di utilizzare UDP come protocollo di trasporto quando possibile) e può essere utilizzato sia con un server remoto che tra browser. Ciò significa che possiamo trasferire l'intera applicazione sul browser, inclusa la componente server!

Tuttavia, ciò comporta un’ulteriore difficoltà: prima che due peer WebRTC possano comunicare, devono eseguire un handshake relativamente complesso per connettersi, che richiede diverse entità di terze parti (un server di segnalazione e uno o più server STORDIRE/TURNO).

Idealmente, vorremmo creare un'API di rete che utilizzi WebRTC internamente, ma che sia il più vicino possibile a un'interfaccia Socket UDP che non necessita di stabilire una connessione.

Questo ci permetterà di sfruttare WebRTC senza dover esporre dettagli complessi al codice dell'applicazione (che abbiamo voluto modificare il meno possibile nel nostro progetto).

WebRTC minimo

WebRTC è un insieme di API disponibili nei browser che fornisce la trasmissione peer-to-peer di audio, video e dati arbitrari.

La connessione tra peer viene stabilita (anche se è presente NAT su uno o entrambi i lati) utilizzando server STUN e/o TURN attraverso un meccanismo chiamato ICE. I peer scambiano informazioni ICE e parametri del canale tramite offerta e risposta del protocollo SDP.

Oh! Quante abbreviazioni contemporaneamente? Spieghiamo brevemente cosa significano questi termini:

  • Utilità di attraversamento della sessione per NAT (STORDIRE) — un protocollo per bypassare il NAT e ottenere una coppia (IP, porta) per lo scambio di dati direttamente con l'host. Se riesce a completare il suo compito, i peer possono scambiarsi dati in modo indipendente tra loro.
  • Attraversamento tramite relè attorno al NAT (TURNO) viene utilizzato anche per l'attraversamento NAT, ma lo implementa inoltrando i dati tramite un proxy visibile a entrambi i peer. Aggiunge latenza ed è più costoso da implementare rispetto allo STUN (perché viene applicato durante l'intera sessione di comunicazione), ma a volte è l'unica opzione.
  • Creazione di connettività interattiva (ICE) utilizzato per selezionare il miglior metodo possibile per connettere due peer in base alle informazioni ottenute direttamente dai peer di connessione, nonché alle informazioni ricevute da un numero qualsiasi di server STUN e TURN.
  • Protocollo di descrizione della sessione (SDP) è un formato per descrivere i parametri del canale di connessione, ad esempio candidati ICE, codec multimediali (nel caso di un canale audio/video), ecc... Uno dei peer invia un'offerta SDP e il secondo risponde con una risposta SDP . . Successivamente, viene creato un canale.

Per creare tale connessione, i peer devono raccogliere le informazioni che ricevono dai server STUN e TURN e scambiarle tra loro.

Il problema è che non hanno ancora la capacità di comunicare direttamente, quindi deve esistere un meccanismo fuori banda per scambiare questi dati: un server di segnalazione.

Un server di segnalazione può essere molto semplice perché il suo unico compito è inoltrare dati tra peer nella fase di handshake (come mostrato nello schema seguente).

Trasferimento di un gioco multiplayer da C++ al Web con Cheerp, WebRTC e Firebase
Diagramma semplificato della sequenza di handshake WebRTC

Panoramica del modello di rete Teeworlds

L'architettura di rete di Teeworlds è molto semplice:

  • I componenti client e server sono due programmi diversi.
  • I client entrano nel gioco connettendosi a uno dei numerosi server, ognuno dei quali ospita solo un gioco alla volta.
  • Tutti i trasferimenti di dati nel gioco vengono effettuati tramite il server.
  • Uno speciale server principale viene utilizzato per raccogliere un elenco di tutti i server pubblici visualizzati nel client di gioco.

Grazie all'utilizzo di WebRTC per lo scambio dati, possiamo trasferire la componente server del gioco al browser su cui si trova il client. Questo ci offre una grande opportunità...

Sbarazzarsi dei server

La mancanza di logica del server ha un bel vantaggio: possiamo distribuire l'intera applicazione come contenuto statico su Github Pages o sul nostro hardware dietro Cloudflare, garantendo così download rapidi e tempi di attività elevati gratuitamente. In effetti, possiamo dimenticarcene e, se siamo fortunati e il gioco diventa popolare, non sarà necessario modernizzare l'infrastruttura.

Tuttavia, affinché il sistema funzioni, dobbiamo comunque utilizzare un'architettura esterna:

  • Uno o più server STUN: abbiamo diverse opzioni gratuite tra cui scegliere.
  • Almeno un server TURN: qui non ci sono opzioni gratuite, quindi possiamo crearne uno nostro o pagare il servizio. Fortunatamente, la maggior parte delle volte la connessione può essere stabilita tramite server STUN (e fornire un vero p2p), ma TURN è necessario come opzione di fallback.
  • Server di segnalazione: a differenza degli altri due aspetti, la segnalazione non è standardizzata. Ciò di cui sarà effettivamente responsabile il server di segnalazione dipende in qualche modo dall'applicazione. Nel nostro caso, prima di stabilire una connessione, è necessario scambiare una piccola quantità di dati.
  • Teeworlds Master Server: viene utilizzato da altri server per pubblicizzare la propria esistenza e dai client per trovare server pubblici. Sebbene non sia obbligatorio (i clienti possono sempre connettersi manualmente a un server di cui sono a conoscenza), sarebbe bello averlo in modo che i giocatori possano partecipare a giochi con persone a caso.

Abbiamo deciso di utilizzare i server STUN gratuiti di Google e abbiamo implementato noi stessi un server TURN.

Per gli ultimi due punti abbiamo utilizzato Firebase:

  • Il server master di Teeworlds è implementato in modo molto semplice: come un elenco di oggetti contenenti informazioni (nome, IP, mappa, modalità, ...) di ciascun server attivo. I server pubblicano e aggiornano il proprio oggetto e i client prendono l'intero elenco e lo mostrano al giocatore. Mostriamo anche l'elenco sulla home page in formato HTML in modo che i giocatori possano semplicemente fare clic sul server ed essere portati direttamente al gioco.
  • La segnalazione è strettamente correlata all'implementazione dei nostri socket, descritta nella sezione successiva.

Trasferimento di un gioco multiplayer da C++ al Web con Cheerp, WebRTC e Firebase
Elenco dei server all'interno del gioco e nella home page

Implementazione delle prese

Vogliamo creare un'API il più vicino possibile ai socket UDP Posix per ridurre al minimo il numero di modifiche necessarie.

Vogliamo anche implementare il minimo necessario richiesto per il più semplice scambio di dati sulla rete.

Ad esempio, non abbiamo bisogno di un routing reale: tutti i peer si trovano sulla stessa "LAN virtuale" associata a una specifica istanza del database Firebase.

Pertanto, non abbiamo bisogno di indirizzi IP univoci: valori di chiave Firebase univoci (simili ai nomi di dominio) sono sufficienti per identificare in modo univoco i peer, e ogni peer assegna localmente indirizzi IP "falsi" a ciascuna chiave che deve essere tradotta. Ciò elimina completamente la necessità di assegnare un indirizzo IP globale, che è un compito non banale.

Ecco l'API minima che dobbiamo implementare:

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

L'API è semplice e simile all'API Posix Sockets, ma presenta alcune importanti differenze: registrazione di richiamate, assegnazione di IP locali e connessioni lente.

Registrazione delle richiamate

Anche se il programma originale utilizza I/O non bloccanti, il codice deve essere sottoposto a refactoring per essere eseguito in un browser web.

La ragione di ciò è che il ciclo di eventi nel browser è nascosto al programma (sia esso JavaScript o WebAssembly).

Nell'ambiente nativo possiamo scrivere codice come questo

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

Se il ciclo degli eventi ci è nascosto, dobbiamo trasformarlo in qualcosa del genere:

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

Assegnazione IP locale

Gli ID dei nodi nella nostra "rete" non sono indirizzi IP, ma chiavi Firebase (sono stringhe che assomigliano a questa: -LmEC50PYZLCiCP-vqde ).

Ciò è comodo perché non abbiamo bisogno di un meccanismo per assegnare gli IP e verificarne l'unicità (così come per eliminarli dopo la disconnessione del client), ma è spesso necessario identificare i peer tramite un valore numerico.

Questo è esattamente lo scopo per cui vengono utilizzate le funzioni. resolve и reverseResolve: L'applicazione riceve in qualche modo il valore stringa della chiave (tramite input dell'utente o tramite il server master) e può convertirlo in un indirizzo IP per uso interno. Anche il resto dell'API riceve questo valore anziché una stringa per semplicità.

Questa operazione è simile alla ricerca DNS, ma eseguita localmente sul client.

Cioè, gli indirizzi IP non possono essere condivisi tra client diversi e, se è necessario un qualche tipo di identificatore globale, dovrà essere generato in un modo diverso.

Connessione pigra

UDP non necessita di connessione ma, come abbiamo visto, WebRTC richiede un lungo processo di connessione prima di poter iniziare a trasferire dati tra due peer.

Se vogliamo fornire lo stesso livello di astrazione, (sendto/recvfrom con peer arbitrari senza connessione preventiva), allora devono eseguire una connessione “lazy” (ritardata) all’interno dell’API.

Questo è ciò che accade durante la normale comunicazione tra il “server” e il “client” quando si utilizza UDP e cosa dovrebbe fare la nostra libreria:

  • Chiamate al server bind()per dire al sistema operativo che desidera ricevere pacchetti sulla porta specificata.

Pubblicheremo invece una porta aperta su Firebase sotto la chiave del server e ascolteremo gli eventi nel suo sottoalbero.

  • Chiamate al server recvfrom(), accettando pacchetti provenienti da qualsiasi host su questa porta.

Nel nostro caso, dobbiamo controllare la coda in entrata dei pacchetti inviati a questa porta.

Ogni porta ha la propria coda e aggiungiamo le porte di origine e di destinazione all'inizio dei datagrammi WebRTC in modo da sapere a quale coda inoltrare quando arriva un nuovo pacchetto.

La chiamata non è bloccante, quindi se non ci sono pacchetti, restituiamo semplicemente -1 e impostiamo errno=EWOULDBLOCK.

  • Il client riceve l'IP e la porta del server con mezzi esterni e chiama sendto(). Ciò effettua anche una chiamata interna. bind(), quindi successivo recvfrom() riceverà la risposta senza eseguire esplicitamente il bind.

Nel nostro caso il client riceve esternamente la chiave stringa e utilizza la funzione resolve() per ottenere un indirizzo IP.

A questo punto avviamo un handshake WebRTC se i due peer non sono ancora connessi tra loro. Le connessioni a porte diverse dello stesso peer utilizzano lo stesso WebRTC DataChannel.

Eseguiamo anche indiretti bind()in modo che il server possa riconnettersi in seguito sendto() nel caso in cui chiudesse per qualche motivo.

Il server riceve una notifica della connessione del client quando il client scrive la sua offerta SDP sotto le informazioni sulla porta del server in Firebase e il server risponde con la sua risposta lì.

Il diagramma seguente mostra un esempio di flusso di messaggi per uno schema socket e la trasmissione del primo messaggio dal client al server:

Trasferimento di un gioco multiplayer da C++ al Web con Cheerp, WebRTC e Firebase
Schema completo della fase di connessione tra client e server

conclusione

Se hai letto fin qui, probabilmente sei interessato a vedere la teoria in azione. È possibile continuare a giocare teeworlds.leaningtech.com, Provalo!


Amichevole tra colleghi

Il codice della libreria di rete è liberamente disponibile su Github. Partecipa alla conversazione sul nostro canale all'indirizzo griglia!

Fonte: habr.com

Aggiungi un commento