Nous portons un jeu multijoueur de C ++ sur le Web avec Cheerp, WebRTC et Firebase

Présentation


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

À titre d'exemple de son application, nous avons décidé de porter un jeu multijoueur pour le Web et avons choisi Teeworlds pour cela. Teeworlds est un jeu rétro bidimensionnel multi-joueurs avec une petite mais active communauté de joueurs (dont moi!). Il est petit en termes de ressources téléchargeables et d'exigences CPU et GPU - un candidat idéal.


Fonctionne dans le navigateur Teeworlds

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

  • XMLHttpRequest / fetch si la partie réseau se compose uniquement de requêtes HTTP, ou
  • WebSockets

Les deux solutions nécessitent d'héberger le composant serveur côté serveur, et aucune d'entre elles ne vous permet d'utiliser UDP comme protocole de transport. Ceci est important pour les applications en temps réel telles que les logiciels de vidéoconférence et de jeux, car les garanties de livraison et la commande de paquets TCP peuvent interférer avec de faibles latences.

Il existe une troisième méthode - utilisez le réseau à partir d'un navigateur: WebRTC .

RTCDataChannel prend en charge à la fois la transmission fiable et non fiable (dans ce dernier cas, si possible, il essaie d'utiliser UDP comme protocole de transport), et peut être utilisé avec un serveur distant et entre les navigateurs. Cela signifie que nous pouvons porter l'application entière vers le navigateur, y compris le composant serveur!

Cependant, c'est une difficulté supplémentaire: avant que deux homologues WebRTC puissent échanger des données, ils doivent effectuer une procédure d'établissement de liaison relativement compliquée pour la connexion, qui nécessite plusieurs entités tierces (un serveur de signaux et un ou plusieurs serveurs STUN / TURN ).

Idéalement, nous aimerions créer une API réseau en interne à l'aide de WebRTC, mais aussi près que possible de l'interface UDP Sockets, qui n'a pas besoin d'établir une connexion.

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

WebRTC minimum


WebRTC est une suite d'API disponible dans les navigateurs qui permet le transfert audio, vidéo et arbitraire de données d'égal à égal.

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

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

  • Session Traversal Utilities for NAT ( STUN ) - un protocole pour contourner NAT et recevoir une paire (IP, port) pour échanger des données directement avec l'hôte. S'il parvient à terminer sa tâche, les pairs peuvent échanger des données indépendamment les uns avec les autres.
  • La traversée à l'aide de relais autour de NAT ( TURN ) est également utilisée pour contourner NAT, mais elle le fait en redirigeant les données via un proxy visible par les deux pairs. Il ajoute du retard et est plus coûteux à exécuter que STUN (car il est utilisé tout au long de la session de communication), mais parfois c'est la seule option possible.
  • L'établissement de connectivité interactive ( ICE ) est utilisé pour sélectionner la meilleure façon possible de connecter deux homologues en fonction des informations obtenues en connectant directement les homologues, ainsi que des informations reçues par un nombre illimité de serveurs STUN et TURN.
  • Le SDP ( Session Description Protocol ) est un format permettant de décrire les paramètres du canal de connexion, par exemple, les candidats ICE, les codecs multimédias (dans le cas d'un canal audio / vidéo), etc ... L'un des pairs envoie une offre SDP ("offre"), et le second répond avec SDP Réponse ("réponse"). Après cela, un canal est créé.

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

Le problème est qu'ils n'ont pas encore la possibilité d'échanger directement des données, il doit donc y avoir un mécanisme hors bande pour échanger ces données: un serveur de signaux.

Un serveur de signaux peut être très simple, car sa seule tâche est de rediriger les données entre pairs au stade de la «prise de contact» (comme illustré dans le diagramme ci-dessous).


Séquence de prise de contact simplifiée WebRTC

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


L'architecture réseau de 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, dont chacun héberge un seul jeu à la fois.
  • Tous les transferts de données dans le jeu se font 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 de jeu.

En raison de 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 bel avantage: nous pouvons déployer l'intégralité de l'application en tant que contenu statique sur les pages Github ou sur notre propre équipement derrière Cloudflare, garantissant ainsi des téléchargements rapides et une disponibilité élevée gratuite. En fait, nous pouvons les oublier, et si nous avons de la chance et que le jeu devient populaire, l'infrastructure ne devra pas être modernisée.

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

  • Un ou plusieurs serveurs STUN: nous avons le choix entre plusieurs options gratuites.
  • Au moins un serveur TURN: il n'y a pas d'options gratuites ici, nous pouvons donc configurer le vôtre ou payer le service. Heureusement, la plupart du temps, vous pouvez vous connecter via les serveurs STUN (et fournir un vrai p2p), mais TURN est nécessaire comme solution de rechange.
  • Serveur de signaux: contrairement aux deux autres aspects, la signalisation n'est pas standardisée. Les responsabilités du serveur de signal dépendent en quelque sorte de l'application. Dans notre cas, avant d'établir une connexion, il est nécessaire d'échanger une petite quantité de données.
  • Serveur maître Teeworlds: il est utilisé par d'autres serveurs pour notifier son existence et des clients pour rechercher des serveurs publics. Bien que cela ne soit pas obligatoire (les clients peuvent toujours se connecter à un serveur qu'ils connaissent manuellement), 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: comme une liste d'objets contenant des informations (nom, IP, carte, mode, ...) de chaque serveur actif. Les serveurs publient et mettent à jour leur propre objet, et les clients prennent la liste entière et l'affiche 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 socket, décrite dans la section suivante.


Liste des serveurs à l'intérieur du jeu et sur la page d'accueil

Implémentation de socket


Nous voulons créer une API aussi proche que possible des sockets UDP Posix pour minimiser le nombre de changements nécessaires.

Nous voulons également réaliser le minimum nécessaire requis 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 homologues sont dans le même «LAN virtuel» associé à une instance spécifique de la base de données Firebase.

Par conséquent, nous n'avons pas besoin d'adresses IP uniques: pour une identification unique des homologues, il suffit d'utiliser des valeurs uniques de clés Firebase (similaires aux noms de domaine), et chaque homologue attribue localement de «fausses» adresses IP à chaque clé qui doit être convertie. Cela élimine complètement le besoin d'une attribution globale d'adresse IP, ce qui est une tâche non triviale.

Voici l'API minimale que nous devons mettre en œuvre:

// 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 elle présente plusieurs différences importantes: l' enregistrement de rappels, l'attribution d'adresses IP locales et une connexion paresseuse .

Enregistrement de rappel


Même si le programme source 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 du navigateur est masquée du programme (que ce soit JavaScript ou WebAssembly).

Dans un environnement natif, nous pouvons écrire du code de cette façon

 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 est cachée pour nous, 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 IP locale


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

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

Pour cela, les fonctions de resolve et de resolve reverseResolve sont utilisées: l'application obtient en quelque sorte la valeur de chaîne de la clé (via une entrée utilisateur ou via le serveur maître), et peut la convertir en une adresse IP pour un usage interne. Le reste de l'API obtient également cette valeur au lieu d'une chaîne pour plus de simplicité.

Ceci est similaire à une recherche DNS, effectuée uniquement localement sur le client.

Autrement dit, les adresses IP ne peuvent pas être partagées entre différents clients, et si vous avez besoin d'une sorte d'identifiant global, vous devrez le générer d'une manière différente.

Mélange paresseux


UDP n'a pas besoin d'une connexion, mais, comme nous l'avons vu, avant de commencer le transfert de données entre deux pairs, WebRTC nécessite un long processus de connexion.

Si nous voulons fournir le même niveau d'abstraction ( sendto / recvfrom avec des pairs arbitraires sans première connexion), alors nous devons établir une connexion «paresseuse» (retardée) à l'intérieur de l'API.

Voici ce qui se passe lors de l'échange normal de données entre le «serveur» et le «client» en cas d'utilisation d'UDP, et ce que notre bibliothèque doit faire:

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

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

  • Le serveur appelle recvfrom() , acceptant les paquets de n'importe quel hôte vers 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 pour savoir quelle file d'attente à rediriger lorsqu'un nouveau paquet arrive.

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

  • Le client reçoit, par un moyen externe, l'adresse IP et le port du serveur, et appelle sendto() . De plus, un appel interne à bind() est effectué, donc recvfrom() recevra une 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 l'adresse IP.

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

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

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



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


Diagramme complet des étapes de connexion entre le client et le serveur

Conclusion


Si vous avez lu jusqu'à la fin, vous êtes probablement intéressé à regarder 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 le chat sur notre chaîne dans Gitter !

Source: https://habr.com/ru/post/fr468031/


All Articles