Portage d'un jeu multijoueur du C++ vers le web avec Cheerp, WebRTC et Firebase

introduction

Notre entreprise Technologies d’apprentissage fournit des solutions pour le portage d'applications de bureau traditionnelles sur le Web. Notre compilateur C++ Bravo génère une combinaison de WebAssembly et de JavaScript, qui fournit à la fois interaction simple avec le navigateuret hautes performances.

Comme exemple d'application, nous avons décidé de porter un jeu multijoueur sur le Web et avons choisi Teeworlds. Teeworlds est un jeu rétro multijoueur en XNUMXD avec une communauté de joueurs petite mais active (dont moi !). Il est petit à la fois en termes de ressources téléchargées et d'exigences en matière de CPU et de GPU - un candidat idéal.

Portage d'un jeu multijoueur du C++ vers le web avec Cheerp, WebRTC et Firebase
Exécution dans le navigateur Teeworlds

Nous avons décidé d'utiliser ce projet pour expérimenter solutions générales pour le portage du code réseau sur le Web. Cela se fait généralement des manières suivantes :

  • XMLHttpRequête/récupération, si la partie réseau est constituée uniquement de requêtes HTTP, ou
  • WebSockets.

Les deux solutions nécessitent l'hébergement d'un composant serveur côté serveur et aucune ne permet une utilisation comme protocole de transport. UDP. Ceci est important pour les applications en temps réel telles que les logiciels de vidéoconférence et les jeux, car cela garantit la livraison et l'ordre des paquets de protocole. TCP peut devenir un frein à une faible latence.

Il existe une troisième façon : utiliser le réseau depuis le navigateur : WebRTC.

Canal de données RTC Il prend en charge les transmissions fiables et peu fiables (dans ce dernier cas, il essaie d'utiliser UDP comme protocole de transport autant que possible) et peut être utilisé à la fois avec un serveur distant et entre navigateurs. Cela signifie que nous pouvons porter l'intégralité de l'application sur le navigateur, y compris le composant serveur !

Cependant, cela s'accompagne d'une difficulté supplémentaire : avant que deux homologues WebRTC puissent communiquer, ils doivent effectuer une poignée de main relativement complexe pour se connecter, qui nécessite plusieurs entités tierces (un serveur de signalisation et un ou plusieurs serveurs). ÉTOURDIR/TOUR).

Idéalement, nous aimerions créer une API réseau qui utilise WebRTC en interne, mais qui soit aussi proche que possible d'une interface UDP Sockets qui n'a pas besoin d'établir une connexion.

Cela nous permettra de profiter de WebRTC sans avoir à exposer des détails complexes au code de l'application (que nous voulions modifier le moins possible dans notre projet).

WebRTC minimal

WebRTC est un ensemble d'API disponibles dans les navigateurs qui permettent la transmission peer-to-peer de données audio, vidéo et arbitraires.

La connexion entre pairs est établie (même s'il y a un NAT d'un côté ou des deux côtés) à l'aide des serveurs STUN et/ou TURN via un mécanisme appelé ICE. Les pairs échangent des informations ICE et des paramètres de canal via l'offre et la réponse du protocole SDP.

Ouah! Combien d'abréviations à la fois ? Expliquons brièvement ce que signifient ces termes :

  • Utilitaires de traversée de session pour NAT (ÉTOURDIR) — un protocole pour contourner le NAT et obtenir une paire (IP, port) pour échanger des données directement avec l'hôte. S'il parvient à accomplir sa tâche, ses pairs peuvent alors échanger des données de manière indépendante.
  • Traversée utilisant des relais autour de NAT (TOUR) est également utilisé pour la traversée NAT, mais il l'implémente en transférant les données via un proxy visible par les deux pairs. Il ajoute de la latence et est plus coûteux à mettre en œuvre que STUN (car il est appliqué tout au long de la session de communication), mais c'est parfois la seule option.
  • Établissement de connectivité interactive (VÉLO) utilisé pour sélectionner la meilleure méthode possible de connexion de deux pairs sur la base des informations obtenues à partir des pairs se connectant directement, ainsi que des informations reçues par un nombre quelconque de serveurs STUN et TURN.
  • Protocole de description de session (SDP) est un format permettant de décrire les paramètres du canal de connexion, par exemple, les candidats ICE, les codecs multimédia (dans le cas d'un canal audio/vidéo), etc... L'un des pairs envoie une Offre SDP, et le second répond par une Réponse SDP . . Après cela, un canal est créé.

Pour créer une telle connexion, les pairs doivent collecter les informations qu'ils reçoivent des serveurs STUN et TURN et les échanger entre eux.

Le problème est qu'ils n'ont pas encore la capacité de communiquer directement, il faut donc qu'un mécanisme hors bande existe pour échanger ces données : un serveur de signalisation.

Un serveur de signalisation peut être très simple car son seul travail consiste à transmettre des données entre pairs lors de la phase de prise de contact (comme le montre le diagramme ci-dessous).

Portage d'un jeu multijoueur du C++ vers le web avec Cheerp, WebRTC et Firebase
Diagramme de séquence de prise de contact WebRTC simplifié

Présentation du modèle de réseau Teeworlds

L'architecture du réseau Teeworlds est très simple :

  • Les composants client et serveur sont deux programmes différents.
  • Les clients entrent dans le jeu en se connectant à l'un des nombreux serveurs, chacun n'hébergeant qu'un seul jeu à la fois.
  • Tous les transferts de données dans le jeu s'effectuent via le serveur.
  • Un serveur maître spécial est utilisé pour collecter une liste de tous les serveurs publics affichés dans le client du jeu.

Grâce à l'utilisation de WebRTC pour l'échange de données, nous pouvons transférer le composant serveur du jeu vers le navigateur où se trouve le client. Cela nous donne une belle opportunité...

Débarrassez-vous des serveurs

L'absence de logique de serveur présente un avantage appréciable : nous pouvons déployer l'intégralité de l'application sous forme de contenu statique sur les pages Github ou sur notre propre matériel derrière Cloudflare, garantissant ainsi des téléchargements rapides et une disponibilité élevée gratuitement. En fait, nous pouvons les oublier, et si nous avons de la chance et que le jeu devient populaire, alors l'infrastructure n'aura pas besoin d'être modernisée.

Cependant, pour que le système fonctionne, nous devons encore utiliser une architecture externe :

  • Un ou plusieurs serveurs STUN : Nous proposons plusieurs options gratuites.
  • Au moins un serveur TURN : il n'y a pas d'options gratuites ici, nous pouvons donc soit créer le nôtre, soit payer pour le service. Heureusement, la plupart du temps, la connexion peut être établie via des serveurs STUN (et fournir un véritable p2p), mais TURN est nécessaire comme option de secours.
  • Serveur de signalisation : contrairement aux deux autres aspects, la signalisation n'est pas standardisée. La responsabilité réelle du serveur de signalisation dépend quelque peu de l'application. Dans notre cas, avant d'établir une connexion, il est nécessaire d'échanger une petite quantité de données.
  • Teeworlds Master Server : Il est utilisé par d'autres serveurs pour annoncer leur existence et par les clients pour trouver des serveurs publics. Bien que ce ne soit pas obligatoire (les clients peuvent toujours se connecter manuellement à un serveur qu'ils connaissent), ce serait bien de l'avoir pour que les joueurs puissent participer à des jeux avec des personnes aléatoires.

Nous avons décidé d'utiliser les serveurs STUN gratuits de Google et avons déployé nous-mêmes un serveur TURN.

Pour les deux derniers points, nous avons utilisé Firebase:

  • Le serveur maître Teeworlds est implémenté très simplement : sous forme d'une liste d'objets contenant les informations (nom, IP, carte, mode, ...) de chaque serveur actif. Les serveurs publient et mettent à jour leur propre objet, et les clients prennent la liste complète et l'affichent au joueur. Nous affichons également la liste sur la page d'accueil au format HTML afin que les joueurs puissent simplement cliquer sur le serveur et accéder directement au jeu.
  • La signalisation est étroitement liée à notre implémentation de sockets, décrite dans la section suivante.

Portage d'un jeu multijoueur du C++ vers le web avec Cheerp, WebRTC et Firebase
Liste des serveurs dans le jeu et sur la page d'accueil

Implémentation de sockets

Nous souhaitons créer une API aussi proche que possible des sockets Posix UDP afin de minimiser le nombre de modifications nécessaires.

Nous souhaitons également mettre en œuvre le minimum nécessaire pour l'échange de données le plus simple sur le réseau.

Par exemple, nous n'avons pas besoin d'un véritable routage : tous les pairs sont sur le même "LAN virtuel" associé à une instance de base de données Firebase spécifique.

Par conséquent, nous n'avons pas besoin d'adresses IP uniques : des valeurs de clé Firebase uniques (similaires aux noms de domaine) suffisent pour identifier de manière unique les pairs, et chaque pair attribue localement de « fausses » adresses IP à chaque clé qui doit être traduite. Cela élimine complètement le besoin d’attribution d’adresses IP globales, qui est une tâche non triviale.

Voici l'API minimale que nous devons implémenter :

// 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 est simple et similaire à l'API Posix Sockets, mais présente quelques différences importantes : journalisation des rappels, attribution d'adresses IP locales et connexions paresseuses.

Enregistrement des rappels

Même si le programme d'origine utilise des E/S non bloquantes, le code doit être refactorisé pour s'exécuter dans un navigateur Web.

La raison en est que la boucle d'événements dans le navigateur est masquée du programme (que ce soit JavaScript ou WebAssembly).

Dans l'environnement natif, nous pouvons écrire du code comme celui-ci

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

Si la boucle d'événements nous est cachée, alors nous devons la transformer en quelque chose comme ceci :

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

Attribution d'adresse IP locale

Les identifiants de nœuds de notre « réseau » ne sont pas des adresses IP, mais des clés Firebase (ce sont des chaînes qui ressemblent à ceci : -LmEC50PYZLCiCP-vqde ).

C'est pratique car nous n'avons pas besoin d'un mécanisme pour attribuer des adresses IP et vérifier leur unicité (ainsi que pour les éliminer après la déconnexion du client), mais il est souvent nécessaire d'identifier les pairs par une valeur numérique.

C'est exactement à cela que servent les fonctions. resolve и reverseResolve: L'application reçoit d'une manière ou d'une autre la valeur de chaîne de la clé (via la saisie de l'utilisateur ou via le serveur maître) et peut la convertir en adresse IP pour un usage interne. Le reste de l'API reçoit également cette valeur au lieu d'une chaîne par souci de simplicité.

Ceci est similaire à la recherche DNS, mais effectuée localement sur le client.

Autrement dit, les adresses IP ne peuvent pas être partagées entre différents clients et si un identifiant global est nécessaire, il devra être généré d'une manière différente.

Connexion paresseuse

UDP n'a pas besoin de connexion, mais comme nous l'avons vu, WebRTC nécessite un long processus de connexion avant de pouvoir commencer à transférer des données entre deux homologues.

Si nous voulons fournir le même niveau d'abstraction, (sendto/recvfrom avec des pairs arbitraires sans connexion préalable), ils doivent alors effectuer une connexion « paresseuse » (retardée) à l’intérieur de l’API.

C'est ce qui se passe lors d'une communication normale entre le « serveur » et le « client » lors de l'utilisation d'UDP, et ce que notre bibliothèque doit faire :

  • Appels au serveur bind()pour indiquer au système d'exploitation qu'il souhaite recevoir des paquets sur le port spécifié.

Au lieu de cela, nous publierons un port ouvert sur Firebase sous la clé du serveur et écouterons les événements dans sa sous-arborescence.

  • Appels au serveur recvfrom(), acceptant les paquets provenant de n'importe quel hôte sur ce port.

Dans notre cas, nous devons vérifier la file d'attente entrante des paquets envoyés à ce port.

Chaque port a sa propre file d'attente, et nous ajoutons les ports source et de destination au début des datagrammes WebRTC afin de savoir vers quelle file d'attente transférer lorsqu'un nouveau paquet arrive.

L'appel n'est pas bloquant, donc s'il n'y a pas de paquets, nous renvoyons simplement -1 et définissons errno=EWOULDBLOCK.

  • Le client reçoit l'adresse IP et le port du serveur par des moyens externes et appelle sendto(). Cela permet également d'effectuer un appel interne. bind(), donc ultérieur recvfrom() recevra la réponse sans exécuter explicitement bind.

Dans notre cas, le client reçoit en externe la clé de chaîne et utilise la fonction resolve() pour obtenir une adresse IP.

À ce stade, nous lançons une poignée de main WebRTC si les deux pairs ne sont pas encore connectés l'un à l'autre. Les connexions à différents ports du même homologue utilisent le même WebRTC DataChannel.

Nous effectuons également des bind()afin que le serveur puisse se reconnecter dans le prochain sendto() au cas où il serait fermé pour une raison quelconque.

Le serveur est informé de la connexion du client lorsque celui-ci écrit son offre SDP sous les informations de port du serveur dans Firebase, et le serveur y répond avec sa réponse.

Le diagramme ci-dessous montre un exemple de flux de messages pour un schéma de socket et la transmission du premier message du client au serveur :

Portage d'un jeu multijoueur du C++ vers le web avec Cheerp, WebRTC et Firebase
Schéma complet de la phase de connexion entre client et serveur

Conclusion

Si vous avez lu jusqu'ici, vous êtes probablement intéressé à voir la théorie en action. Le jeu peut être joué sur teeworlds.leaningtech.com, essayez-le !


Match amical entre collègues

Le code de la bibliothèque réseau est disponible gratuitement sur Github. Rejoignez la conversation sur notre chaîne à Grille!

Source: habr.com

Ajouter un commentaire