Dans les articles précédents de la série (tous les liens à la fin de l'article) sur le développement d'un nouveau jeu de tir rapide, nous avons examiné les mécanismes de l'architecture de base de la logique de jeu basée sur ECS, et les caractéristiques de travailler avec un tireur sur le client, en particulier, la mise en œuvre d'un
système de prédiction des actions des joueurs locaux pour augmenter la réactivité du jeu . Cette fois, nous nous attarderons plus en détail sur les problèmes d'interaction client-serveur dans des conditions de mauvaise connexion des réseaux mobiles et sur les moyens d'améliorer la qualité du jeu pour l'utilisateur final. Je décrirai également brièvement l'architecture du serveur de jeu.

Lors du développement du nouveau PvP synchrone pour les appareils mobiles, nous avons rencontré des problèmes typiques du genre:
- La qualité de connexion des clients mobiles est médiocre. Il s'agit d'un ping moyen relativement élevé dans la région de 200-250 ms et d'une distribution temporelle instable du ping tenant compte du changement de points d'accès (bien que, contrairement à la croyance populaire, le pourcentage de perte de paquets dans les réseaux mobiles 3G + soit assez faible - environ 1%).
- Les solutions techniques existantes sont des cadres monstrueux qui poussent les développeurs dans des cadres étroits.
Nous avons réalisé le premier prototype chez UNet, même s'il imposait des restrictions d'évolutivité, un contrôle sur le composant réseau et une dépendance accrue à la connexion capricieuse des clients maîtres. Ensuite, nous sommes passés à un netcode auto-écrit au-dessus de
Photon Server , mais plus à ce sujet plus tard.
Considérez les mécanismes d'organisation des interactions entre les clients dans les jeux PvP synchrones. Le plus populaire d'entre eux:
- P2P ou peer-to-peer . Toute la logique du match est hébergée sur l'un des clients et ne nécessite presque aucun frais de trafic de notre part. Mais la portée des tricheurs et les exigences élevées pour le client hébergeant le match, ainsi que les limites du NAT ne nous ont pas permis de prendre cette solution pour un jeu mobile.
- Client-serveur . Un serveur dédié, au contraire, vous permet de contrôler entièrement tout ce qui se passe dans le match (au revoir, tricheurs), et ses performances vous permettent de calculer certaines choses spécifiques à notre projet. En outre, de nombreux grands hébergeurs ont leur propre structure de sous-réseau, ce qui offre un délai minimal pour l'utilisateur final.
Il a été décidé d'écrire un serveur autoritaire.
Mise en réseau avec peer-to-peer (à gauche) et client-serveur (à droite)Transfert de données entre le client et le serveur
Nous utilisons
Photon Server - cela nous a permis de déployer rapidement l'infrastructure nécessaire au projet sur la base d'un schéma déjà élaboré au fil des ans (dans War Robots, nous l'utilisons).
Photon Server est exclusivement une solution de transport pour nous, sans conceptions de haut niveau qui sont fortement liées à un moteur de jeu spécifique. Ce qui donne un certain avantage, car la bibliothèque de transfert de données peut être remplacée à tout moment.
Le serveur de jeu est une application multi-thread dans le conteneur Photon. Un flux distinct est créé pour chaque correspondance, qui encapsule toute la logique du travail et empêche l'influence d'une correspondance sur une autre. Toutes les connexions au serveur sont contrôlées par Photon et les données qui lui sont parvenues des clients sont ajoutées à la file d'attente, qui est ensuite analysée dans ECS.
Schéma général des flux de correspondance dans le conteneur Photon ServerChaque match se compose de plusieurs étapes:
- Le client du jeu fait la queue dans le soi-disant service de mise en correspondance. Dès que le nombre requis de joueurs remplissant certaines conditions est réuni, il le signale au serveur de jeu à l'aide de gRPC. Dans le même temps, toutes les données nécessaires à la création du jeu sont transmises.

Schéma général de création d'une correspondance - Sur le serveur de jeu, l'initialisation du match commence. Tous les paramètres de correspondance sont traités et préparés, y compris les données cartographiques, ainsi que toutes les données client reçues du service de création de correspondance. Le traitement et la préparation des données impliquent que nous analysions toutes les données nécessaires et les écrivions dans un sous-ensemble spécial d'entités que nous appelons RuleBook. Il stocke les statistiques de correspondance (qui ne changent pas au cours de son déroulement) et sera transmis à tous les clients pendant le processus de connexion et d'autorisation sur le serveur de jeu une fois ou lors de la reconnexion après avoir perdu la connexion. Les données de correspondance statiques incluent la configuration de la carte (présentation de la carte par les composants ECS qui les connectent au moteur physique), les données client (surnoms, un ensemble d'armes qu'ils ont et ne changent pas pendant la bataille, etc.).
- Exécution d'un match. Les systèmes ECS qui composent le jeu sur le serveur commencent à fonctionner. Tous les systèmes tournent à 30 images par seconde.
- Chaque image lit et décompresse les entrées ou les copies du lecteur si les joueurs n’ont pas envoyé leur entrée dans un certain intervalle.
- Ensuite, dans la même trame, l'entrée est traitée dans le système ECS, à savoir: changement d'état du joueur; le monde qu'il influence avec sa contribution; et le statut des autres joueurs.
- À la fin de la trame, l'état mondial résultant est empaqueté pour le lecteur et envoyé sur le réseau.
- À la fin du match, les résultats sont envoyés aux clients et au microservice, qui traite les récompenses pour la bataille à l'aide de gRPC, ainsi qu'à l'analyste du match.
- Après cela, le flux de match se coince et le flux se ferme.
La séquence d'actions sur le serveur dans une trameCôté client, le processus de connexion à une correspondance est le suivant:
- Tout d'abord, une demande est faite pour la mise en file d'attente dans le service pour la création de correspondances via websocket avec la sérialisation via protobuf.
- Lors de la création d'une correspondance, ce service informe le client de l'adresse du serveur de jeu et transfère la charge utile supplémentaire requise par le client avant la correspondance. Le client est maintenant prêt à démarrer le processus d'autorisation sur le serveur de jeu.
- Le client crée un socket UDP et commence à envoyer une demande au serveur de jeu pour se connecter à la correspondance avec certaines informations d'identification. Le serveur attend déjà ce client. Une fois connecté, il lui donne toutes les données nécessaires pour démarrer le jeu et afficher le monde pour la première fois. Ceux-ci incluent: RuleBook (une liste de données statiques pour le match), ainsi que StringIntMap, que nous appelons des données sur les lignes utilisées dans le gameplay qui seront identifiées par des entiers pendant le match). Cela est nécessaire pour économiser du trafic, car les lignes passant chaque trame crée une charge importante sur le réseau. Par exemple, tous les noms de joueurs, noms de classe, identifiants d'armes, comptes et similaires, toutes les informations sont écrites dans StringIntMap, où elles sont encodées à l'aide de données entières simples.
Lorsqu'un joueur affecte directement d'autres utilisateurs (cause des dommages, impose des effets, etc.), un historique d'état est recherché sur le serveur pour comparer le monde du jeu que le client voit réellement dans une simulation spécifique avec ce qui se passait sur le serveur avec d'autres à ce moment entités de jeu.
Par exemple, vous tirez sur votre client. Pour vous, cela se produit instantanément, mais le client s'est déjà «enfui» depuis un certain temps par rapport au monde environnant, qu'il affiche. Par conséquent, en raison de la prédiction locale du comportement du joueur, le serveur doit comprendre où et dans quel état se trouvaient les adversaires au moment du tir (peut-être qu'ils étaient déjà morts ou, inversement, invulnérables). Le serveur vérifie tous les facteurs et rend son verdict sur les dommages causés.
Demande de création d'une correspondance, connexion à un serveur de jeu et autorisationSérialisation et désérialisation, empaquetage et déballage des premiers octets du match
Nous avons une sérialisation de données binaires propriétaire, et pour le transfert de données, nous utilisons UDP.
UDP est l'option la plus évidente pour envoyer rapidement des messages entre le client et le serveur, où il est généralement beaucoup plus important d'afficher les données dès que possible que de les afficher en principe. Les paquets perdus font des ajustements, mais les problèmes sont résolus pour chaque cas individuellement, comme Étant donné que les données proviennent constamment du client vers le serveur et vice-versa, vous pouvez saisir le concept d'une connexion entre le client et le serveur.
Pour créer un code optimal et pratique basé sur la description déclarative de la structure de notre ECS, nous utilisons la génération de code. Lors de la création de composants, des règles de sérialisation et de désérialisation sont également générées pour eux. La sérialisation est basée sur un packer binaire personnalisé qui vous permet de compresser les données de la manière la plus économique. L'ensemble d'octets obtenu pendant son fonctionnement n'est pas le plus optimal, mais il vous permet de créer un flux à partir duquel vous pouvez lire certaines données de paquet sans avoir besoin de sa désérialisation complète.
La limite de transfert de données de 1500 octets (alias MTU) est, en fait, la taille de paquet maximale qui peut être transférée via Ethernet. Cette propriété peut être configurée sur chaque tronçon du réseau et souvent même en dessous de 1500 octets. Que se passe-t-il si j'envoie un paquet de plus de 1 500 octets? La fragmentation des paquets commence. C'est-à-dire chaque paquet sera divisé de force en plusieurs fragments, qui seront envoyés séparément d'une interface à l'autre. Ils peuvent être envoyés par des itinéraires complètement différents, et le temps de réception de tels paquets peut augmenter considérablement avant que la couche réseau n'émette un paquet collé à votre application.
Dans le cas de Photon, la bibliothèque commence à envoyer de tels paquets de force en mode UDP fiable. C'est-à-dire Photon attendra chaque fragment du paquet et transmettra les fragments manquants s'ils sont perdus lors de la transmission. Mais un tel travail de la partie réseau est inacceptable dans les jeux où un délai réseau minimum est requis. Par conséquent, il est recommandé de réduire la taille des paquets transférés au minimum et de ne pas dépasser les 1500 octets recommandés (dans notre jeu, la taille d'un état complet du monde ne dépasse pas 1000 octets; la taille du paquet avec compression delta est de 200 octets).
Chaque paquet du serveur a un en-tête court qui contient plusieurs octets décrivant le type de paquet. Le client déballe d'abord cet ensemble d'octets et détermine le paquet avec lequel nous avons affaire. Nous comptons beaucoup sur cette propriété de notre mécanisme de désérialisation lors de l'autorisation: afin de ne pas dépasser la taille de paquet recommandée de 1500 octets, nous divisons les packages RuleBook et StringIntMap en plusieurs étapes; et afin de comprendre exactement ce que nous avons obtenu du serveur - les règles du jeu ou l'état lui-même - nous utilisons l'en-tête du package.
Lors du développement de nouvelles fonctionnalités du projet, la taille du package augmente régulièrement. Lorsque nous avons rencontré ce problème, il a été décidé d'écrire notre propre système de compression delta, ainsi qu'un découpage contextuel des données dont le client n'avait pas besoin.
Optimisation du trafic réseau contextuelle. Compression delta
L'écrêtage des données contextuelles est écrit manuellement en fonction des données dont le client a besoin pour afficher correctement le monde et la prédiction locale de ses propres données pour fonctionner correctement. Ensuite, la compression delta est appliquée aux données restantes.
Notre jeu chaque tick produit un nouvel état du monde, qui doit être emballé et transmis aux clients. En règle générale, la compression delta consiste à envoyer d'abord un état complet avec toutes les données nécessaires au client, puis à n'envoyer que les modifications apportées à ces données. Cela peut être représenté comme suit:
deltaGameState = newGameState - prevGameStateMais pour chaque client, des données différentes sont envoyées et la perte d'un seul paquet peut entraîner le fait que vous devez transmettre l'état complet du monde.
La transmission de l'état complet du monde est une tâche assez coûteuse pour le réseau. Par conséquent, nous avons modifié l'approche et envoyé la différence entre l'état de traitement actuel du monde et celui qui est exactement reçu par le client. Pour ce faire, le client dans son paquet avec l'entrée envoie également un numéro de tick, qui est un identifiant unique de l'état du jeu qu'il a déjà reçu exactement. Le serveur sait maintenant sur la base de quel état il est nécessaire de construire la compression delta. Le client n'a généralement pas le temps d'envoyer au serveur le numéro de tick qu'il a avant que le serveur ne prépare la trame suivante avec les données. Par conséquent, sur le client, il existe un historique des états du serveur dans le monde, auquel le correctif deltaGameState généré par le serveur est appliqué.
Illustration de la fréquence des interactions client-serveur dans le projetArrêtons-nous plus en détail sur ce que le client envoie. Dans les tireurs classiques, un tel package est appelé ClientCmd et contient des informations sur les touches enfoncées du joueur et l'heure de création de l'équipe. À l'intérieur du paquet d'entrée, nous envoyons beaucoup plus de données:
public sealed class InputSample {
Il y a quelques points intéressants. Tout d'abord, le client indique au serveur dans quelle case il voit tous les objets du monde du jeu qui l'entourent qu'il n'est pas en mesure de prédire (WorldTick). Il peut sembler que le client est capable «d'arrêter» le temps pour le monde, et de courir et de tirer sur tout le monde lui-même en raison des prévisions locales. Ce n'est pas le cas. Nous ne faisons confiance qu'à un ensemble limité de valeurs du client et ne le laissons pas plonger dans le passé pendant plus d'une seconde. Le champ WorldTick est également utilisé comme package d'accusé de réception, sur la base duquel la compression delta est créée.
Vous pouvez trouver des nombres à virgule flottante dans un paquet. En règle générale, ces valeurs sont souvent utilisées pour prendre des mesures à partir du joystick du lecteur, mais elles ne sont pas très bien transmises sur le réseau, car elles ont un «rebond» important et sont généralement trop précises. Nous quantifions ces nombres et les emballons à l'aide d'un packer binaire afin qu'ils ne dépassent pas une valeur entière pouvant tenir sur plusieurs bits, en fonction de sa taille. Ainsi, l'emballage de l'entrée du joystick de visée est rompu:
if (Math.Abs(s.AimMagnitudeCompressed) < float.Epsilon) { packer.PackByte(0, 1); } else { packer.PackByte(1, 1); float min = 0; float max = 1; float step = 0.001f;
Une autre caractéristique intéressante lors de l'envoi d'une entrée est que certaines commandes peuvent être envoyées plusieurs fois. Très souvent, on nous demande quoi faire si une personne a appuyé sur la capacité ultime et que le paquet avec son entrée a été perdu? Nous envoyons simplement cette entrée plusieurs fois. Cela ressemble à une livraison garantie, mais plus flexible et plus rapide. Parce que la taille du paquet d'entrée est très petite, nous pouvons emballer plusieurs entrées de joueur adjacentes dans le paquet résultant. Pour le moment, la taille de la fenêtre qui détermine leur nombre est de cinq.
Paquets d'entrée générés sur le client à chaque tick et envoyés au serveurLa transmission de ce type de données est la plus rapide et la plus fiable pour résoudre nos problèmes sans utiliser d'UDP fiable. Nous partons du fait que la probabilité de perdre un tel nombre de paquets d'affilée est très faible et est un indicateur d'une grave dégradation de la qualité du réseau dans son ensemble. Si cela se produit, le serveur copie simplement la dernière entrée reçue du lecteur et l'applique, en espérant qu'elle reste inchangée.
Si le client se rend compte qu'il n'a pas reçu de paquets sur le réseau pendant très longtemps, le processus de reconnexion au serveur démarre. Le serveur, quant à lui, vérifie que la file d'attente d'entrée du lecteur est terminée.
Au lieu de conclusion et de référence
Il existe de nombreux autres systèmes sur le serveur de jeu qui sont responsables de la détection, du débogage et de l'édition des correspondances «par le gain», les concepteurs de jeux mettant à jour la configuration sans redémarrer, consigner et surveiller l'état des serveurs. Nous voulons également écrire à ce sujet plus en détail, mais séparément.
Tout d'abord, lors du développement d'un jeu en réseau sur des plates-formes mobiles, vous devez faire attention au bon fonctionnement de votre client avec des pings élevés (environ 200 ms), une perte de données légèrement plus fréquente, ainsi que la taille des données envoyées. Et vous devez vous adapter clairement à la limite de paquets de 1500 octets pour éviter la fragmentation et les retards de trafic.
Liens utiles:
Articles précédents sur le projet:
- "Comment nous avons évolué sur un jeu de tir mobile rapide: technologie et approches . "
- "Comment et pourquoi nous avons écrit notre ECS . "
- "Comme nous l'avons écrit le code réseau du tireur PvP mobile: synchronisation du joueur sur le client . "