Tester un code réseau robuste avec Godot | Blog | Sales Gosses !

Tester un code réseau robuste avec Godot | Blog | Sales Gosses ! Tester un code réseau robuste avec Godot | Blog | Sales Gosses !
  • Steam
  • Itch.io
  • Google Play
Les sales gosses et la maîtresse

Blog

Tester un code réseau robuste avec Godot

2024-10-23

Le plus gros défi que m'a posé le développement de Sales Gosses ! a été la partie multijoueur en ligne : en effet, synchroniser des ordinateurs avec une latence parfois conséquente tout en conservant l'aspect « jeu d'action nerveux » est loin d'être simple. Je vous en parle !

Compensation de latence, prédiction/réconciliation, etc.

Je ne vais pas faire un tutoriel détaillé sur ces points car il en existe déjà des tonnes, mais pour vous donner une idée du principe : lorsqu'un ordinateur client réalise une action (dans mon cas, par exemple, appuyer sur le bouton pour donner une baffe à un autre gosse), le serveur va recevoir cette action, calculer ce qui se passe, et envoyer le résultat au client…

Le problème, c'est que même avec une latence légère entre les deux ordinateurs, disons 10 ms, on se retrouve avec 20 ms entre l'appui sur une touche et la réception du résultat. Ça n'a pas l'air énorme, mais si on met un délai de 20 ms entre chacune des pressions sur vos touches de clavier et la réalisation de l'action qui en découle, vous allez vite péter un plomb.

En principe, on compense donc cela par plusieurs techniques : dans mon cas, le client « valide » par défaut l'action réalisée et l'applique dans sa scène locale. C'est une prédiction. Lorsque le serveur reçoit l'action, il rembobine le jeu de la durée de la latence (donc par exemple, 10 ms en arrière), applique l'action, et refait tourner l'univers jeu l'équivalent 10 ms, tout cela en arrière plan, sans que ça ne se voit sur le jeu du serveur. Puis il envoie l'état final au client, qui lorsqu'il le reçoit, va donc soit valider son propre état si sa prédiction était correcte, soit le corriger si le serveur a renvoyé un résultat différent (on appelle ça la « réconciliation »).

Je ne vais pas vous mentir : c'est très TRÈS compliqué à mettre tout cela en œuvre de manière fiable et « invisible ». C'est-à-dire que les personnes qui jouent doivent s'en rendre compte le moins possible, il ne faut pas qu'il y ait de « sauts » de caméra, d'inconsistances, de trucs bizarres… En pratique, ça arrivera toujours un peu bien sûr : si votre client avait calculé que vous aviez réussi à taper un gosse mais que, sur le serveur, ce gosse avait finalement bougé, eh bien vous verrez votre action « annulée », et le gosse finalement repartir comme si rien ne s'était passé.

C'est à cause d'un retard sur cet aspect réseau que j'ai été obligé de repousser la sortie de 2 semaines, parce que mon code était justement beaucoup trop instable, et que dans certains cas, le jeu se trouvait cassé ou dans des états bugués, étranges. Bref.

L'épineuse question du test

Évidemment, pour tester un jeu multi, le mieux est encore d'être à plusieurs. Mais en attendant, lorsque vous êtes en plein développement et que vous incrémentez petit à petit, c'est tout de même nécessaire de tester « seul » derrière son PC.

On peut bien sûr avoir plusieurs machines pour tester, mais ça reste relativement laborieux, surtout s'il faut remettre à jour le jeu sur toutes les machines à chaque fois qu'on modifie une ligne de script.

Bref, le plus simple est encore de lancer deux instances ou plus sur son propre PC et de créer une partie sur localhost. Godot permet très facilement de lancer plusieurs instances tout en gardant le débugueur ouvert sur chacune d'entre elle, ce qui est hyper pratique. Tout cela permet déjà de tester la bonne communication entre plusieurs instances, et ça n'est pas rien. Sauf qu'évidemment, la latence entre deux instances sur la même machine est très très faible, et la communication entre les deux instances est fiable à 100 % (on ne va jamais « perdre » de paquet au sein d'un même ordinateur) : on n'est pas du tout dans des conditions réseau réelles.

C'est là qu'arrive une commande (sur GNU/Linux) fort pratique : tc, pour traffic control settings. En l'occurrence, voyons cette commande :


# tc qdisc add dev lo root netem delay 50ms loss 1%

Cette commande, lancée en root, permet d'ajouter « artificiellement » 50 ms de latence localement, ainsi que de perdre artificiellement 1 % des paquets qui transitent localement. Ainsi, on peut simuler des conditions de réseau plus ou moins dégradées tout en restant sur une seule machine, et donc tester la robustesse de notre code réseau. Pratique, non ?

Pour retirer cette dégradation artificielle, on peut simplement faire :


# tc qdisc del dev lo root

Godot, reliable/unreliable

Godot fournie une API réseau haut niveau qui permet de s'abstraire des protocoles réseaux bas niveau comme UDP ou TCP. En l'occurrence, Sales Gosses ! utilise la classe ENetMultiplayerPeer qui utilise la bibliothèque ENet, elle-même basée sur UDP.

Pour expliquer la différence entre UDP et TCP, on peut regarder l'un des nombreux mèmes sur le sujet :

TCP/UDP

En gros, TCP transmet les paquets de manière fiable mais lente, et UDP de manière non-fiable (des paquets peuvent être perdus) mais rapide. Avec son API haut-niveau, Godot permet toutefois de choisir le mode de fiabilité :

  • reliable : mode fiable, qui garantit l'arrivée de tous les paquets dans l'ordre d'envoi. On a donc une surcouche à UDP où il y a, en interne, un mécanisme qui vérifie la réception des paquets et réalise un nouvel envoi si la réception a échoué. Ça peut évidemment être plus lent, surtout sur un réseau dégradé avec de nombreuses pertes. Ça nous donne une sorte d'équivalent de TCP mais sur une base UDP.
  • unreliable: mode non-fiable, des paquets peuvent être perdus, sorte de mode UDP « brut »
  • unreliable_ordered: mode non-fiable mais où on garantit au moins l'ordre d'arrivée des paquets (mode que je n'ai jamais utilisé pour ma part)

En pratique, comment ça se manifeste quand le jeu tourne ? Eh bien je me suis dit que j'allais mettre en place des petits tests pour mesurer tout ça.

J'ai mis en place un programme simple qui va simplement appeler une fonction distante (via un appel rpc dans Godot) toutes les 4 frames, 50 fois de suite. Côté client, on note, à chaque frame, combien de fois on a reçu cet appel. Les diagrammes suivants montrent le nombre de paquet reçu à un moment donné (et, pour correspondance, les paquets envoyés par le serveur en dessous).

(Si cela vous intéresse, vous pouvez télécharger le projet Godot pour lancer les tests vous-même.)

Évidemment, si on a un réseau parfait (aucune latence et aucune perte de paquet), on se retrouve avec les envois du serveur presque parfaitement synchronisés avec les réceptions du client, que ce soit en mode reliable ou unreliable :

0ms

Si on ajoute un petit peu de latence (50 ms), on note effectivement le décalage entre les deux « peignes ». En revanche, on conserve une réception plus ou moins régulière, encore une fois, pas de différence entre reliable et unreliable :

50ms

Là où, évidemment, les comportements divergent, c'est si on ajoute des pertes de paquets. Voici par exemple l'effet que donne un taux de perte de 1 % si on utilise le mode unreliable :

1pct loss, unreliable

Et si on pousse à 5 % :

5pct loss, unreliable

On voit que certains appels sont perdus. Ce sont bien plus que 1 % ou 5 % d'appels perdus car un appel est constitué de plusieurs paquets (et il suffit d'un paquet perdu pour provoquer la perte complète de l'appel de fonction). En revanche, pour les paquets effectivement reçus, on garde une bonne régularité.

Ce qui est intéressant, c'est ce qui se passe en mode reliable avec 1 % de pertes :

1pct loss, reliable

Et avec 5 % :

5pct loss, reliable

Vous comprenez ce qui se passe ? Lorsqu'un appel se perd, Godot va essayer de le renvoyer jusqu'à ce qu'il passe… je ne connais pas les détails d'implémentation, mais j'imagine qu'il y a une sorte d'indexation des paquets et que côté client, Godot attend d'avoir bien reçu tous les paquets dans l'ordre avant de réaliser l'appel des fonctions.

Ce qui fait que lorsqu'on a eu une perte de paquet, au moment où cette perte est « corrigée », on reçoit tous les appels en retard d'un coup ! Cette méthode est donc bien efficace pour s'assurer de ne perdre aucun appel de fonction… en revanche, cela a un coût : certains paquets peuvent arriver avec beaucoup de retard et retarder les paquets suivants !

En pratique, j'utilise mode unreliable lorsque le serveur envoi l'état du jeu aux clients : en effet, dans ce cas, si un état se perd, ce n'est pas dramatique, mais il est plus intéressant pour le client d'avoir le prochain état « bien synchronisé » que de recevoir plusieurs états d'un coup.

J'utilise le mode reliable pour l'envoi des input clients au serveur : le serveur a besoin de pouvoir recalculer l'état du jeu de manière fiable, et il n'est pas acceptable que certains inputs clients « se perdent ». Cela peut occasionner un peu de latence, et un peu plus de travail pour le serveur qui va devoir « rembobiner » le jeu un peu plus loin si un input arrive avec beaucoup de retard, mais c'est le prix à payer pour avoir un jeu stable.

Et évidemment, cela va sans dire, mais on utilise le mode reliable pour tout ce qui nécessite une garantie de réception : l'ouverture de la communication entre serveurs et client, l'envoi de signaux comme le signal de lancement du jeu, d'arrêt, d'envoi des scores, etc.

Encore plus loin ?

On a déjà une bonne base de test avec tc, mais on peut faire mieux (ou pire, selon le point de vue) : en effet, en pratique, la qualité d'une connexion entre deux ordinateurs peut varier dans le temps (un réseau qui devient encombré, une personne qui joue sur téléphone dans les transports, etc.). Que se passe-t-il si, d'un coup, la latence d'un des ordinateurs passe de 15 ms à 50 ms ? Si on se met à perdre 2 % des paquets au lieu de 0 % ?

Pour tester ça, j'ai mis en place ce petit script, à lancer en root pendant que les instances du jeu tournent.


#!/bin/bash

while true; do
  delay=$((RANDOM % 91 + 10))
  loss=$((RANDOM % 4))
  interval=$(awk -v min=2 -v max=5 'BEGIN{srand(); print min+rand()*(max-min)}')
  echo "Real network simulated with: delay=${delay}ms loss=${loss}%"
  tc qdisc add dev lo root netem delay ${delay}ms loss ${loss}%
  sleep $interval
  tc qdisc del dev lo root
done

Ce script va modifier la qualité du réseau régulièrement (entre 2 et 5 secondes, aléatoirement), en ajoutant une latence comprise entre 10 et 100 ms, et un taux de perte compris entre 0 % et 3 %. Alors oui, ça simule un réseau vraiment très variable et pourri, mais après tout, si le jeu fonctionne dans des conditions pourries, il devrait rouler tout seul dans des conditions correctes !

Je ne vous affiche pas de graphiques pour cette variante car il faut laisser tourner longtemps pour voir quelque chose, et le « peigne » devient trop dense, mais je trouve que c'est un petit morceau de script intéressant.

Conclusion

Évidemment, tout cela ne remplace pas de véritables tests en multi, avec des gens connectés dans des villes plus ou moins éloignées. Ne serait-ce que pour l'aspect « interactions », si ce n'est pour l'aspect réseau.

Mais avec quelques commandes tc, on peut déjà simuler un vrai réseau imparfait, et donc débuguer et corriger beaucoup de chose sans quitter le confort de son poste de travail unique (bon, à condition d'avoir assez de RAM pour faire tourner plusieurs instances du jeu).

Et pour les gens qui cherchaient à comprendre un peu mieux les comportements concrets des modes reliable et unreliable de Godot, j'espère que ces explications et ces graphes vous ont aidé.

À bientôt pour de nouvelles aventure :)

Jeu utilisant le moteur libre Godot.

CC-By-Sa 2021-2024 Simon « Gee » Giraudot
Site gracieusement hébergé par Framasoft.

Logo
i