Compensation de latence : pourquoi votre tir parfait n’a pas compté
Comment la compensation de latence côté serveur est réellement implémentée pour les shooters : le tampon de rembobinage, les paramètres qui le règlent, et le point où la fenêtre doit s’arrêter.
Trois tirs qui ont disparu lors du playtest
En février nous faisions un playtest interne d’un prototype de shooter à 32 joueurs. Un testeur connecté depuis Francfort avait un RTT de 78 ms, et à chaque manche il écrivait la même phrase : deux tirs à la tête, aucun compté. Les journaux du serveur le confirmaient. Au moment où il a tiré, la cible était dead centre de son écran, alors que dans le monde le serveur la voyait à 1,4 mètre à gauche.
Dans le développement de jeux multijoueurs, ce n’est pas un bug, c’est ce que fait la latence. Le joueur voit toujours le passé, le serveur ne connaît que le présent. Tout shooter qui ne comble pas cet écart pénalise systématiquement les joueurs à haut ping. La pénalité augmente avec le ping, et le joueur l’interprète comme une mauvaise visée plutôt qu’un problème réseau.
Le problème, c’est que cela ne ressemble jamais à un défaut. Personne ne dépose de rapport de crash, personne n’écrit de étapes de reproduction ; les joueurs disent simplement que le jeu « se sent mal » et partent. Tant que vous ne le mesurez pas, tout ce que vous avez, c’est le texte de la plainte.
La première tâche a donc été de transformer cette plainte en chiffre. Nous avons ajouté un petit journal côté serveur qui enregistre quels acteurs chaque rayon de tir a traversés. Pour les joueurs avec plus de 40 ms de latence, 12 % des tirs n’intersectaient rien du tout côté serveur ; pour les joueurs sur le réseau local, le même chiffre était de 2 %. Ces dix points étaient de la latence pure non compensée.
Pourquoi le serveur conserve un historique de hitbox
La solution est la compensation de latence, basée sur le rembobinage serveur. À chaque tick le serveur écrit les hitboxes de chaque personnage dans un tampon circulaire, un enregistrement que nous avons appelé FHitboxSnapshot et géré dans LagCompensationComponent.cpp. Lorsqu’un paquet de tir arrive, le serveur rembobine ces hitboxes au moment affiché sur l’écran du joueur, exécute le test de rayon à cet instant, puis restaure immédiatement le présent.
Le tampon conserve 64 ticks, ce qui, à une simulation de 60 Hz, représente 1066 ms d’historique. Un snapshot coûte 18 capsules de 32 octets, soit 576 octets par joueur, et avec 32 joueurs sur 64 ticks cela représente 1,1 Mo. Un mégaoctet par instance serveur n’est rien comparé au coût des tirs manqués.
Pendant le rembobinage nous ne déplaçons pas tout le monde, seulement les acteurs dont le volume peut intersecter le rayon. Un pré‑filtre FBoxSphereBounds balayant a réduit le nombre d’acteurs rembobinés dans une partie à 32 joueurs à 2,3 en moyenne. La première version, qui restaurait tout le monde, prenait 0,9 ms par tir ; après le filtre c’était 0,08 ms.
Le moment où vous prenez le snapshot compte autant que ce que vous stockez. Notre première version le capturait au début du tick, avant que l’animation ne soit mise à jour, de sorte que sur une cible qui sprintait les capsules du bras et de la tête étaient en retard d’une frame par rapport au maillage. Déplacer la capture dans le groupe de tick PostUpdateWork a éliminé un décalage systématique de 6 à 9 cm sur les cibles rapides.
Le calcul du RTT et du délai d’interpolation
La distance à laquelle vous rembobinez n’est pas décidée par le ping mais par la somme de deux termes : la latence aller simple et le tampon d’interpolation du client. La formule que nous utilisons est rewind = RTT / 2 + interpolationDelay. Oublier le second terme est l’erreur la plus courante que je rencontre ici.
Un client retarde délibérément les snapshots entrants afin de pouvoir les interpoler en douceur. Si vous envoyez des snapshots à 20 Hz, le tampon sûr est de deux paquets, soit 100 ms. Pour notre testeur à 78 ms, le rembobinage correct était 39 + 100 = 139 ms ; la première version ne rembobinait que 39 ms, ce qui expliquait à lui seul les tirs disparus.
Ne prenez jamais ce nombre du client. Un client modifié peut gonfler son propre interpolationDelay pour exiger qu’un tir soit résolu encore plus loin dans le passé. Nous utilisons le RTT mesuré par le serveur plus un tampon fixe dérivé du taux de snapshots auquel le client s’est abonné ; la seule chose que le client envoie est le tick du tir, et même cela est limité.
Le RTT n’est pas non plus constant, c’est une série bruitée. Au lieu d’un seul échantillon nous faisons la moyenne du 25ᵉ au 75ᵉ percentile des 20 derniers pongs ; un pic pouvait pousser le rembobinage à 300 ms et rendre les résultats aléatoires. Sur des connexions avec plus de 15 ms de jitter nous arrondissons le résultat à la baisse plutôt qu’à la hausse, car le coût d’un sur‑rembobinage est payé par le joueur qui se fait tirer.
Où la fenêtre de rembobinage doit s'arrêter
Nous l'avons limitée à 200 ms. En dessous de ce seuil, l'enregistrement des coups se comporte comme les joueurs l'attendent ; au‑dessus, la latence devient un avantage et vous commencez à tuer des cibles qui ont déjà franchi le coin et se tiennent derrière un mur.
La plainte de mourir derrière une couverture est le prix direct du gain du joueur compensé. Une compensation nulle punit le joueur à haut ping, une compensation illimitée punit le joueur à faible ping. Pour nous, 200 ms était le point où les deux courbes de plainte se croisaient : à 250 ms les plaintes de couverture doublaient, à 150 ms le taux de coups des joueurs étrangers chutait de 6 pour cent. Conservez la valeur dans la configuration, car l'échelle de la carte et la vitesse des projectiles déplacent ce point de croisement.
La fenêtre est aussi une surface d'attaque. Si le tick provient du client, un client tricheur peut envoyer un tick d'il y a trois secondes et tirer sur l'ancienne position de la cible. ClampRewindTick() vérifie à la fois la limite absolue et une moyenne mobile construite à partir des 32 derniers paquets de ce client ; si l'écart dépasse 60 ms, la requête est rejetée et l'événement est journalisé.
Ensuite il y a le tir qui atterrit après la mort. Dans le monde rembobiné la cible est vivante, dans le monde présent elle est morte depuis 40 ms. Nous acceptons cela : si le tireur était vivant lorsque le paquet a atteint le serveur, le coup tient. La règle inverse transformerait chaque balle tirée par un joueur à haut ping en un lancer de dés invisible.
Comment cela se rapporte à la prédiction et à la réconciliation
La compensation de latence ne fonctionne pas isolément ; elle doit partager une chronologie avec la prédiction client. Si le client prédit son propre mouvement pour le tick N tandis que le serveur traite le tick N‑8, le tick utilisé pour le rembobinage doit tenir compte de ce décalage. Intégrez ce décalage comme constante et la compensation dérive dès que la charge du serveur augmente, sans que personne ne puisse en expliquer la raison.
Dans notre version les deux utilisent un seul compteur. UPredictedMovementComponent appose un numéro de tick sur chaque entrée, le même numéro accompagne le paquet de tir, et pendant la réconciliation serveur le serveur l'utilise pour valider le mouvement et rembobiner les hitboxes. Dans une tentative antérieure avec deux sources temporelles séparées, le glissement d’un à deux ticks produisait environ 30 cm d’erreur de visée lors de déplacements latéraux rapides.
Ne confondez rien de cela avec le rollback netcode. Le rollback rembobine et rejoue toute la simulation, ce qui est abordable dans un jeu de combat. Dans un shooter nous ne rembobinons que les hitboxes ; la physique, les projectiles et l’état du jeu continuent d’avancer. Lorsque nous avons testé le rollback complet avec 32 joueurs, le tick serveur est passé de 4,1 ms à 19 ms, ce qui a mis fin à la discussion.
Le moment où le serveur répond fait partie de la même chaîne. Vous ne pouvez pas annuler un traceur que le client a déjà dessiné pour un tir que le serveur rejette, donc nous envoyons la confirmation du coup dans son propre petit paquet et ne dessinons le marqueur de tir que lorsque le serveur a donné son accord. Sur une connexion de 78 ms cela signifie que le marqueur arrive 40 ms en retard, ce qui génère encore beaucoup moins de plaintes qu’un marqueur mensonger.
Par où commencer et quoi mesurer
La première chose à construire est un diagnostic que vous pouvez voir de vos propres yeux. Un calque DrawDebugCapsule qui dessine à la fois les hitboxes présentes et rembobinées au moment d’un coup m’a montré plus que toutes mes hypothèses : environ 70 pour cent des coups manqués provenaient d’une ignorance du tampon d’interpolation, le reste des hitboxes se liant à la pose d’animation avec un retard d’une frame.
Ensuite, testez sous latence synthétique. Avec clumsy nous ajoutons 60, 120 et 250 ms de délai unidirectionnel et rejouons le même scénario de tir scripté 200 fois ; si le taux de coups reste dans une bande de 3 pour cent sur les trois profils, la compensation fait son travail. Insérez ce test dans la checklist pré‑release, car tout changement dans le code de mouvement peut le casser.
Enregistrez chaque requête de rembobinage rejetée avec le compte qui l’a envoyée. Ce journal fait apparaître quatre ou cinq comptes par mois pour nous, et aucun d’eux n’a été détecté par la détection côté client. Limiter la fenêtre ne maintient pas seulement l’équité du jeu, cela transforme la détection de triche en quelque chose que vous lisez dans les données plutôt qu’en conjecture.
Une dernière remarque : expliquer la fenêtre de compensation aux joueurs a mieux fonctionné que de la cacher. Depuis que nous avons ajouté une ligne unique sur l’écran de mort affichant le ping du tueur, les plaintes de couverture ne sont pas moins nombreuses, mais leur ton a changé. Les gens ont cessé de supposer qu’il s’agissait de triche.