Comment j'ai construit le rollback netcode d'un platformer à 4 joueurs sous Unity - Hugo Leblanc
← devlog

Comment j'ai construit le rollback netcode d'un platformer à 4 joueurs sous Unity

Serveur qui fait foi, prédiction, réconciliation, interpolation, extrapolation, puis délai d'entrée et rollback. Comment marche chaque étape, avec des démos à manipuler et les chiffres mesurés sur Runner Party.

Un adversaire qui se téléporte, un tir que tu as vu toucher et qui ne compte pas, un joueur que ton écran montre derrière toi alors qu’il est déjà devant : tous ceux qui ont joué en ligne connaissent ces désynchronisations. Elles ont une seule cause, le temps que met une information pour traverser le réseau, et chaque technique de netcode est une façon de le cacher. Certaines le cachent en ajoutant du délai, et ton personnage saute un peu après ton appui. D’autres devinent ce que font les autres joueurs, puis reviennent en arrière quand elles se sont trompées : c’est le rollback. Cet article les prend une par une, dans l’ordre où elles s’empilent, jusqu’au rollback des jeux de combat, celui qui fait tourner Runner Party, un jeu de plateforme et de course à quatre joueurs.

Chaque étape a sa démo. Elles simulent deux joueurs et un serveur directement dans la page, avec une latence que tu règles. Le code des démos est du JavaScript volontairement simple ; quand il y a du C#, c’est le vrai code du jeu.

Le temps dans un jeu en réseau

Un jeu avance par ticks. À chaque tick, il lit les entrées des joueurs et fait avancer la simulation d’un pas fixe. À 60 ticks par seconde, un tick dure 16,7 ms. Tant que tout se passe sur une seule machine, l’entrée lue au tick 100 est jouée au tick 100.

Voici le jeu des démos, réduit au minimum : une piste, des joueurs qui courent et qui sautent.

const DT = 1 / 60;

// Un tick du jeu : le même code partout, donc le même résultat partout pour les mêmes entrées.
function step(s, input) {
  let x = s.x + input.dir * RUN_SPEED * DT;
  x = Math.min(WORLD_WIDTH - 0.5, Math.max(0.5, x));
  let vy = s.vy;
  if (input.jump && s.grounded) vy = JUMP_SPEED;
  vy -= GRAVITY * DT;
  let y = s.y + vy * DT;
  let grounded = false;
  if (y <= 0) { y = 0; vy = 0; grounded = true; }
  return { x, y, vy, grounded };
}

En réseau, deux durées comptent :

  • l’aller simple, le temps qu’un message met pour aller d’une machine à une autre ;
  • l’aller-retour, ou ping, le temps d’une question et de sa réponse. À peu près deux allers simples.

Prenons un ping de 100 ms, donc 50 ms d’aller simple. À 60 ticks par seconde, une entrée met 3 ticks pour arriver chez l’autre, et la réponse 3 de plus. Trois ticks, c’est peu. C’est aussi le temps qu’il faut à un personnage qui court à 8 unités par seconde pour faire 0,4 unité, ou à un personnage qui saute pour être déjà à un demi-corps du sol.

Toute la suite répond à une seule question : pendant que les entrées sont en route, qu’est-ce que chaque écran montre ?

Deux PC de joueurs ne peuvent pas toujours se parler directement : la plupart sont derrière une box internet qui refuse les connexions qu’elle n’a pas demandées. On passe donc par un relais, un serveur sur internet que tout le monde peut joindre. Chaque machine lui envoie ses paquets, et il les fait suivre aux autres. Ça marche partout, mais c’est un détour : le ping dépend du chemin jusqu’au relais, et deux relais peuvent donner des pings très différents pour les mêmes joueurs.

Le serveur qui fait foi

Quand quatre machines simulent la même partie, elles finissent par ne plus être d’accord : sur qui a touché qui, sur qui a ramassé la boîte. Il faut un arbitre. La solution classique est de désigner une machine dont la version fait foi : le serveur. Il reçoit les entrées de tout le monde, simule, et renvoie le résultat. Dans un jeu entre amis, c’est souvent l’un des joueurs, l’hôte.

Ce modèle protège aussi contre une partie de la triche. Un joueur n’envoie que ses entrées, « je vais à droite », « je saute », jamais le résultat. Il ne peut donc pas annoncer qu’il a touché quelqu’un, ni se déplacer plus vite que le jeu ne le permet : c’est le serveur qui joue ses entrées avec les vraies règles, et qui décide de ce qui s’est passé.

Le client le plus simple ne simule rien. Il envoie ses entrées, et il affiche ce que le serveur lui renvoie.

Avec 100 ms de ping :

  1. à t = 0, le joueur appuie sur droite. Le client envoie l’entrée ;
  2. à t = 50 ms, le serveur la reçoit et la joue : le personnage avance ;
  3. à t = 100 ms, le client reçoit le nouvel état et l’affiche.

Le joueur a appuyé à 0 et voit son personnage bouger à 100 ms. Le délai ressenti est un aller-retour complet.

Client naïf
Écran du joueur A
Serveur
Écran du joueur B
80 ms
0 ms
1×
  • A
  • B
  • position réelle
  • latence : aller simple
Le client naïf. A n'avance qu'une fois que le serveur a joué son entrée et renvoyé l'état. Le délai ressenti vaut un aller-retour : monte la latence, il suit.

Deux défauts se voient tout de suite. Le délai d’abord : dans un jeu de plateforme, 160 ms entre l’appui et le saut suffisent à rater chaque trou. Ensuite l’image saccade, parce que le serveur n’envoie pas son état à chaque tick : la démo l’envoie 20 fois par seconde, comme beaucoup de jeux, pour économiser la bande passante.

Ce client a pourtant une qualité qu’aucune des techniques suivantes n’aura avant la dernière : tous les écrans montrent la même partie. En retard, mais la même.

Prédire son propre joueur

Le client connaît les entrées de son joueur avant tout le monde, et il a le même code de simulation que le serveur. Rien ne l’empêche de les jouer tout de suite, sans attendre la réponse. C’est la prédiction côté client.

  1. à t = 0, le joueur appuie sur droite. Le client envoie l’entrée et fait avancer son personnage immédiatement ;
  2. à t = 50 ms, le serveur reçoit l’entrée et la joue, comme avant ;
  3. à t = 100 ms, l’état du serveur arrive, et il confirme ce que le client montre déjà.

Le délai ressenti tombe à zéro. Mais la troisième étape cache un problème.

Le problème des états en retard

L’état qui arrive à t = 100 ms décrit le monde à t = 50 ms, juste après la première entrée. Entre-temps, le client a continué à prédire : il a joué 6 ticks de plus. Avec un personnage à 8 unités par seconde :

  1. tick 0 : le client joue l’entrée n° 1, le personnage passe de x = 0 à x = 0,13 ;
  2. ticks 1 à 5 : entrées n° 2 à n° 6, le personnage arrive à x = 0,80 ;
  3. tick 6 : l’état du serveur arrive. Il dit « x = 0,13 », parce qu’il n’a encore joué que l’entrée n° 1.

Si le client applique cet état tel quel, son personnage recule de 0,67 unité, puis repart en avant au prochain état. Il tremble en permanence.

La réconciliation

La parade s’appelle la réconciliation. Le client numérote ses entrées et les garde en mémoire tant que le serveur ne les a pas jouées. Le serveur renvoie, avec son état, le numéro de la dernière entrée qu’il a jouée. Le client repart de l’état du serveur, et rejoue par-dessus toutes les entrées que celui-ci n’a pas encore vues :

  1. l’état du serveur dit « x = 0,13, dernière entrée jouée : n° 1 » ;
  2. le client jette l’entrée n° 1 de sa liste. Il lui reste n° 2 à n° 6 ;
  3. il repart de x = 0,13 et rejoue ces cinq entrées : x = 0,80.

Le résultat retombe exactement sur ce que le client montrait. Rien ne bouge à l’écran.

// Chaque tick : mon entrée part au serveur, numérotée, et je la garde tant qu'il ne l'a pas jouée.
pending.push({ seq: seq++, input });

// À chaque état reçu : partir de ce que le serveur a confirmé, rejouer ce qu'il n'a pas encore vu.
pending = pending.filter((m) => m.seq > snapshot.acked[me]);
let s = snapshot.states[me];
for (const m of pending) s = step(s, m.input);
own = s;

Si le serveur a joué les entrées autrement, le client est corrigé, et c’est voulu : le serveur fait foi. Ça arrive quand une entrée arrive trop tard pour son tick, à cause de la gigue, la variation du temps de trajet d’un paquet à l’autre. Le serveur joue alors ce tick sans elle.

Prédiction et réconciliation
Écran du joueur A
Serveur
Écran du joueur B
80 ms
0 ms
1×
  • A
  • B
  • position réelle
  • latence : aller simple
Prédiction et réconciliation. Sans gigue, aucun joueur n'est jamais corrigé. Monte la gigue : le serveur reçoit parfois une entrée trop tard, joue un tick sans elle, et le client est recalé.

La prédiction règle le problème de son joueur. Regarde maintenant B sur l’écran de A, dans la démo ci-dessus. Le client de A n’a pas les entrées de B, il ne peut pas le prédire. Il le montre là où le serveur l’a vu, et B saute d’un état reçu au suivant, 20 fois par seconde.

Interpoler les autres joueurs

Pour les autres joueurs, la technique standard est l’interpolation. Le client ne montre pas le dernier état reçu. Il se place volontairement un peu dans le passé, et il dessine l’autre joueur entre les deux états reçus qui encadrent cet instant.

Le serveur envoie un état toutes les 50 ms, et le client dessine 100 ms dans le passé :

  1. le client a reçu les états de t = 1 000 ms et de t = 1 050 ms ;
  2. à l’instant 1 125 ms, il dessine le monde de t = 1 025 ms ;
  3. 1 025 tombe au milieu de 1 000 et de 1 050 : B est dessiné à mi-chemin entre ses deux positions connues.

Tant que le retard choisi dépasse l’écart entre deux états, il y a toujours deux états autour de l’instant dessiné, et le mouvement est fluide.

// L'autre joueur, dessiné `interpolationMs` dans le passé, entre les deux états reçus qui l'encadrent.
const renderTick = serverTickEstimate() - interpolationMs / TICK_MS;
for (let i = snaps.length - 1; i > 0; i--) {
  const a = snaps[i - 1], b = snaps[i];
  if (a.tick <= renderTick && renderTick <= b.tick) {
    const t = (renderTick - a.tick) / (b.tick - a.tick);
    return lerp(a.states[player], b.states[player], t);
  }
}
Interpolation des autres
Écran du joueur A
Serveur
Écran du joueur B
80 ms
0 ms
100 ms
1×
  • A
  • B
  • position réelle
  • latence : aller simple
Interpolation. B glisse sans à-coups, mais regarde l'écart avec sa position réelle, le contour pointillé. Baisse le retard affiché sous l'écart entre deux états (50 ms) : les saccades reviennent.

Trois moments sur un même écran

L’interpolation a un prix, et il est caché. Sur l’écran de A, trois moments différents cohabitent :

  • le joueur A, au présent, grâce à la prédiction ;
  • les autres joueurs, dans le passé : un aller simple pour que leur état arrive, plus le retard d’interpolation ;
  • les contacts, décidés par le serveur sur sa ligne du temps.

Avec 100 ms de ping et 100 ms d’interpolation, B est montré environ 150 ms dans le passé. S’il court à 8 unités par seconde, il est en réalité 1,2 unité plus loin que là où A le voit. Quand A tire sur B collé à lui, il vise un B qui n’est plus là. Le serveur, lui, voit le tir arriver sur le vrai B, à une autre place. Le tir que A a vu toucher ne touche pas. Pire : un joueur que A voit derrière lui peut être, en réalité, déjà devant.

Dans les jeux où chaque joueur s’occupe surtout de lui-même, c’est acceptable. Dans un jeu où l’on se tire dessus à bout portant, c’est le cœur du gameplay qui casse.

La compensation de latence

Les jeux de tir ont une parade à ce problème : la compensation de latence. Le serveur garde un historique des positions de chaque joueur. Quand un tir arrive, il remonte le temps jusqu’à l’instant où le tireur l’a vu, et vérifie le tir dans ce monde-là. Le tir que A a vu toucher touche.

Le prix est payé par l’autre : B, qui s’était déjà mis à l’abri sur son écran, se fait toucher derrière un mur. La technique favorise toujours celui qui tire. Elle traite aussi les tirs, pas tout le reste : deux items qui se rencontrent, un joueur qui ramasse une boîte que l’autre voit encore pleine. Runner Party ne l’utilise pas.

Tout le monde au présent

L’autre possibilité, c’est de ne plus montrer personne dans le passé. Le client montre les autres joueurs au présent, en prolongeant leur dernier état connu : il répète leur dernière entrée jusqu’à l’instant présent. C’est l’extrapolation, qu’on appelle aussi dead reckoning, l’estime des navigateurs.

Avec 100 ms de ping, le dernier état de B reçu par A date de 6 ticks, et il dit « B court vers la droite » :

  1. au tick 100, B est en x = 10 et court vers la droite. C’est le dernier état reçu ;
  2. le client de A est au tick 106. Il prolonge : six ticks à droite, B est montré en x = 10,8 ;
  3. en réalité, B a fait demi-tour au tick 102. Au tick 106, il est en x = 9,73.

Quand l’état du tick 102 arrive enfin, la prolongation était fausse de 1,07 unité. Téléporter B d’un coup se voit. On garde donc l’écart comme une erreur, et on la fait fondre à l’écran : la simulation est corrigée tout de suite, mais le dessin rejoint la vraie position en quelques dixièmes de seconde.

// L'autre, prolongé de son dernier état connu jusqu'à mon présent, en répétant sa dernière entrée.
let s = snapshot.states[other];
for (let i = 0; i < ticksAhead; i++) s = step(s, { dir: lastInput.dir, jump: false });

// Une nouvelle info contredit l'ancienne prolongation : l'écart est gardé, puis fondu.
error.x += before.x - s.x;
error.y += before.y - s.y;
error.x *= Math.pow(0.5, DT / 0.1); // demi-vie de 0,1 s
shown = { x: s.x + error.x, y: s.y + error.y };
Tout le monde au présent
Écran du joueur A
Serveur
Écran du joueur B
80 ms
0 ms
1×
  • A
  • B
  • position réelle
  • latence : aller simple
Tout le monde au présent. L'écart moyen avec la vérité baisse, mais à chaque changement de direction de l'autre, la prolongation était fausse et il est recalé. Compte les corrections.

L’extrapolation marche bien quand les objets ont de l’inertie. Une voiture lancée à 100 km/h sera, dans 100 ms, à peu près là où on l’attend. C’est le modèle de Rocket League : un serveur qui fait foi, et une prédiction de tout ce qui bouge.

Un personnage de jeu de plateforme n’a presque pas d’inertie. Il saute, fait demi-tour et lance un objet dans la même seconde, et chacune de ces décisions rend la prolongation fausse. Et il y a pire que les mouvements : un tir ne se prolonge pas. On ne peut pas inventer qu’un joueur a tiré. Le tir arrive en retard, et part d’un endroit où son lanceur n’est déjà plus.

Les deux approches ont le même défaut, pris dans deux sens. Dans le passé, chacun voit les autres en retard. Au présent, chacun les devine, et se trompe à chaque décision. Dans les deux cas, deux écrans montrent deux parties différentes.

Une horloge commune et un délai d’entrée

Les jeux de combat vivent ce problème en pire : deux joueurs collés, qui décident tout à l’image près. Leur réponse est connue sous le nom de GGPO, la bibliothèque qui l’a popularisée. Elle repose sur deux idées que les techniques précédentes n’avaient pas.

La première : une horloge du monde commune. Toutes les machines jouent le tick T au même moment. Il n’y a plus de client en avance sur le serveur, ni d’autres joueurs dans le passé : tout le monde est au même tick.

La seconde : un délai d’entrée, le même pour tous. Une entrée appuyée au tick T n’est pas jouée au tick T, mais au tick T + D, sur toutes les machines, celle du joueur comprise. Le délai donne à l’entrée le temps de voyager.

Avec un délai de 3 ticks (50 ms) et 40 ms d’aller simple (2,4 ticks) :

  1. au tick 100, A appuie sur saut. L’entrée est datée « tick 103 » et envoyée ;
  2. vers le tick 102,4, elle arrive chez B ;
  3. au tick 103, A et B jouent le saut de A, au même tick.

Personne n’a rien deviné, et les deux écrans montrent exactement la même partie. Le prix : A voit son personnage sauter 50 ms après avoir appuyé, comme tous les autres joueurs pour leurs propres entrées. Le délai est le même pour tout le monde, et c’est ce qui le rend acceptable : il ne favorise personne.

C’est l’idée du Steam Remote Play, poussée jusqu’au bout. En Remote Play, un seul PC fait tourner le jeu et tout le monde regarde la même image, avec du délai. Personne ne voit une partie différente de celle des autres. François, avec qui je travaille chez Creartstudio, en a parlé le premier : malgré le délai, ce serait bien d’avoir un rendu comme ça.

Le rollback

Le délai ne couvre pas toujours le trajet. Une connexion plus lente, un paquet qui traîne, et l’entrée de l’autre n’est pas là au moment de jouer son tick. La machine ne peut pas attendre : tout le jeu se figerait. Elle devine l’entrée manquante, joue le tick, et corrige plus tard. C’est le rollback :

  1. quand l’entrée d’un joueur pour le tick T n’est pas arrivée, on la devine : on répète sa dernière entrée connue ;
  2. on garde une sauvegarde de l’état du monde à chaque tick ;
  3. quand la vraie entrée arrive, on la compare à la devinette. Si elles sont identiques, il n’y a rien à faire. Sinon, on restaure l’état d’avant le tick T, et on rejoue tous les ticks depuis T jusqu’au présent, avec ce qu’on sait maintenant.

Reprenons avec 80 ms d’aller simple (4,8 ticks) et un délai de 3 ticks :

  1. au tick 100, B appuie sur saut, pour le tick 103 ;
  2. aux ticks 103 et 104, l’entrée n’est pas encore arrivée chez A. A devine : B continue ce qu’il faisait, au sol ;
  3. vers le tick 104,8, l’entrée arrive. Au tick 105, A voit qu’elle diffère de la devinette ;
  4. A restaure le monde de la fin du tick 102, rejoue les ticks 103 et 104 avec le saut, puis joue le tick 105.

À l’écran, B apparaît soudain 0,37 unité au-dessus du sol : deux ticks de saut, joués d’un coup. C’est la seule trace du rollback. La partie, elle, est la même que s’il n’y avait pas eu de retard.

runner-party / online60 ticks/s

Frise des ticks vue depuis ta machine, maintenant au tick 18. Tes entrées sont connues ; l’appui de maintenant est joué au tick 21, 3 ticks plus tard. Les entrées de l’adversaire sont connues jusqu’au tick 15, celle du tick 16 vient d’arriver, et les ticks 17 et 18 ont été devinés : le jeu revient au tick 16 et rejoue jusqu’au 18.

  • connu
  • deviné
  • vient d’arriver
  • appui de maintenant
Vu depuis ta machine, au tick 18. Ton appui de maintenant est joué au tick 21, trois ticks plus tard. Chez l'adversaire, les entrées sont connues jusqu'au tick 15 ; celle du tick 16 vient d'arriver, alors que les ticks 16 à 18 avaient été joués avec une devinette : retour au tick 16, et rejeu jusqu'au 18.

La devinette a raison bien plus souvent qu’on pourrait le croire. La plupart du temps, un joueur fait au tick T ce qu’il faisait au tick T − 1. Il ne faut rejouer que lorsqu’il change d’avis.

// Une entrée arrive pour un tick déjà joué avec une devinette.
function receiveInput(player, tick, input) {
  inputs[player].set(tick, input);
  const used = usedInputs[player].get(tick);
  if (tick <= currentTick && used && !sameInput(used, input)) {
    // Retour à l'état d'avant ce tick, et rejeu jusqu'au présent avec ce qu'on sait maintenant.
    for (let t = tick; t <= currentTick; t++) simulate(t);
  }
}

// Jouer un tick : l'entrée vraie si elle est là, sinon la dernière connue, répétée.
function simulate(tick) {
  const before = history.get(tick - 1);
  const next = {};
  for (const p of players) {
    const input = inputs[p].get(tick) ?? guess(p, tick);
    usedInputs[p].set(tick, input);
    next[p] = step(before[p], input);
  }
  history.set(tick, next);
}
Délai d’entrée et rollback
Écran du joueur A
Vérité : toutes les entrées connues
Écran du joueur B
80 ms
0 ms
3 ticks
1×
  • A
  • B
  • position réelle
  • entrée devinée à ce tick
  • latence : aller simple
Délai d'entrée et rollback. Le cadre bleu marque une entrée devinée à ce tick. À 80 ms et 3 ticks de délai, presque tous les ticks présents sont devinés, mais la devinette a presque toujours raison : peu de rollbacks. Monte le délai à 5 ticks : plus rien à deviner, et les deux écrans sont identiques à la vérité.

Le délai et la latence se partagent le travail :

  • délai supérieur au trajet : rien n’est jamais deviné, rien n’est rejoué, et tous les écrans sont identiques. Le joueur paie tout en délai ressenti ;
  • délai inférieur au trajet : les derniers ticks sont devinés, et chaque changement d’avis d’un joueur coûte un rollback et une petite correction à l’écran ;
  • délai nul : on obtient le rollback pur, sans aucun délai ressenti, mais avec une correction à chaque décision des autres joueurs.

Tout l’art du réglage est de placer ce curseur, et on y revient plus bas.

Dans Runner Party, la même idée tient dans WorldLoop.Rollback. Voici une version simplifiée : la vraie restaure aussi les items, et oublie les contacts et les coups décidés depuis ce tick pour les redécider pendant le rejeu.

// Simplifié : sans les items, les boîtes et les coups déjà décidés.
void Rollback(uint from)
{
    if (from > _tick + 1 || _tick + 1 - from >= History - 1)
    {
        // Too old to go back to: this machine keeps what it played, the server's word will put it right.
        return;
    }
    foreach (Lane lane in _lanes)
    {
        Record before = lane.Records[(from - 1) % History];
        if (before.Tick == from - 1) lane.Simulation.Restore(in before.State);
    }
    IsReplayingWorld = true;
    for (uint tick = from; tick <= _tick; tick++)
    {
        PlayTick(tick, replay: true);
        StepItems(tick);
    }
    IsReplayingWorld = false;
}

History vaut 64 : chaque joueur garde un peu plus d’une seconde d’états, de quoi revenir sur n’importe quelle entrée en retard d’une partie normale.

Que deviner ?

« Répéter la dernière entrée », c’est vrai pour la direction du stick. Pour les boutons, c’est faux : répéter un appui sur saut ferait sauter deux fois un joueur qui n’a sauté qu’une fois. La devinette garde donc la direction et oublie les appuis.

On peut chercher à faire mieux. Sur Runner Party, les devinettes fausses ont été mesurées une par une. La plus fréquente concerne l’amplitude du stick sans changement de direction, qui ne se voit pas à l’écran. Viennent ensuite les appuis, les demi-tours, les boutons tenus. Ce qui se voit est précisément ce qui ne se devine pas, et la devinette la plus simple est restée.

Ce que l’écran dessine

Le jeu avance à 60 ticks par seconde, mais beaucoup d’écrans affichent 144 ou 165 images. Le dessin du personnage est donc placé entre deux positions de tick, un demi-tick derrière la plus récente. Dessiner plus près du présent, en prolongeant le dernier mouvement, a été mesuré : ça doublait les à-coups.

Quand un rollback déplace un joueur, on reprend le principe vu avec l’extrapolation. La simulation est corrigée tout de suite, parce que c’est elle qui décide des contacts. Le dessin garde l’écart et le fait fondre en un dixième de seconde. Seule une vraie téléportation, comme un échange de place ou une réapparition, est montrée d’un coup.

Ce que le rollback demande au reste du jeu

Le rollback tient en trente lignes. Le travail est ailleurs : tout ce qui influence la partie doit pouvoir se sauvegarder, se restaurer et se rejouer à l’identique.

Les objets. Un projectile en vol, un piège posé, un bubble gum qui capture un joueur, un boomerang qui revient vers son lanceur : tout doit revenir en arrière avec les joueurs. Sinon le rejeu repart d’un monde incohérent, où le joueur est revenu au tick 103 mais le projectile est resté au tick 105. Dans Runner Party, un missile à tête chercheuse calcule sa route vers sa cible à chaque instant. Il a fallu que chaque machine calcule la même route, et que l’historique des routes soit restauré lui aussi.

Les effets, en silence. Un son de saut ne doit pas repartir à chaque rejeu, ni une explosion se relancer. Les ticks rejoués coupent donc les effets ponctuels, sons, particules et déclencheurs d’animation. Ils ne partent que la première fois qu’un tick est joué. Dans Runner Party, c’est FxGate.Mute().

Les décisions, datées. Qui est éliminé, quel objet sort d’une boîte, quand la manche se termine : ce sont des faits, pas des mouvements. Une devinette fausse sur une élimination ne se lisse pas à l’écran. Ces décisions, une seule machine les prend, l’hôte, et il les date sur l’horloge commune, un peu dans le futur. Le message a ainsi le temps d’arriver partout, et chaque machine les exécute au même tick.

  1. au tick 500, l’hôte constate qu’un joueur est tombé hors de l’écran ;
  2. il annonce : « élimination au tick 508 ». C’est le tick de la décision, plus le délai, plus la devinette la plus longue possible, plus une marge d’un tick ;
  3. chaque machine reçoit l’annonce avant le tick 508, et retire le joueur au tick 508.

Le même principe vaut pour le tirage d’une boîte. L’objet dépend du classement, et le classement change d’un tick à l’autre. L’hôte tire donc l’objet sur le classement tel qu’il était 30 ticks plus tôt, un classement que toutes les machines connaissent déjà.

La triche. Un joueur malin pourrait couper sa connexion une seconde, voir où sont les autres, puis envoyer des entrées « en retard », datées comme s’il avait réagi à temps. C’est le lag switch. La parade : l’hôte refuse toute entrée qui arrive trop longtemps après son tick, 15 ticks dans Runner Party. Un joueur honnête n’en approche jamais.

Faire voyager les entrées

Le rollback n’envoie presque que des entrées, et une entrée est petite. Dans Runner Party, elle tient en 3 octets : la direction du stick et les boutons.

La redondance. Chaque paquet ne contient pas seulement la nouvelle entrée, mais les 8 dernières. Si un paquet se perd, le suivant contient tout ce qui manquait :

  1. le paquet du tick 200 contient les entrées 193 à 200. Il se perd ;
  2. le paquet du tick 201 contient les entrées 194 à 201. Il arrive : l’entrée 200 aussi.

Une perte ne coûte que le temps d’un paquet. Huit entrées de 3 octets, 60 fois par seconde : environ 1,4 ko par seconde et par joueur, avant les en-têtes.

Les entrées vides. Un joueur immobile envoie une entrée vide, et une couche réseau qui veut économiser la bande passante peut arrêter de l’envoyer. C’est ce que faisait la couche réseau de Runner Party après quelques répétitions. L’autre côté joue alors ce joueur sans savoir à quel tick correspond ce qu’il joue. Tant que le joueur ne bouge pas, rien ne se voit. Le jour où il lui arrive quelque chose pendant qu’il est immobile, les deux machines ne sont plus d’accord sur le moment où c’est arrivé.

Les messages du jeu, à la frontière des ticks. Lire le réseau à chaque image plutôt qu’à chaque tick fait gagner quelques millisecondes. Mais la simulation attend ses messages, une annonce d’item ou une élimination, dans un tick. Un message lu entre deux ticks est donc mis de côté et appliqué au début du tick suivant. Sans ça, dans Runner Party, un item annoncé entre deux ticks était dessiné à sa naissance, puis sautait de 13 unités.

Garder une horloge commune

Il n’y a pas d’heure partagée entre deux PC. Chaque machine estime l’horloge de l’hôte à partir des messages qu’elle reçoit, en tenant compte du temps de trajet. La couche réseau fait déjà cette estimation, et Runner Party s’aligne dessus. Mesurée, l’estimation tombe à environ un tick près sur chaque machine. C’est suffisant : la marge du délai couvre l’écart.

L’estimation bouge, elle. Quand le réseau ralentit, l’horloge estimée peut reculer d’un tick ou deux. Un monde qui recule rejoue des ticks déjà joués, et des entrées déjà envoyées se retrouvent datées deux fois. La règle : le monde ne recule jamais. Il suit l’horloge estimée en ralentissant ou en accélérant un peu, sans jamais revenir en arrière, avec un tick de tolérance.

Deux pièges, vus dans Runner Party, montrent à quel point une horloge fausse ressemble peu à un problème d’horloge à l’écran :

  • les ticks jetés. La couche réseau limite le nombre de ticks joués dans une même image. Sur un hôte à 120 ticks par seconde avec des bots, une image lente en contenait plus que la limite (25 ms de ticks), et les ticks en trop étaient jetés. L’hôte ne jouait que 99 à 106 ticks par seconde, et les clients étaient ramenés de 10 à 23 ticks en arrière chaque seconde. À l’écran, un client figé par moments ;
  • le premier tick. Au tout premier tick, l’horloge lisait encore 0, et le tick précédent, « maintenant moins un », passait sous zéro. Sur un entier non signé, ça donne la plus grande valeur possible, et plus aucun tick n’était joué : les joueurs restaient figés en l’air. Seuls les hôtes rapides (images de 6 ms, un écran à 165 Hz) tombaient dedans. Ceux à 16,7 ms démarraient normalement.

Régler le délai

Tout le compromis du modèle tient dans un chiffre. Trop de délai, et la manette semble molle. Pas assez, et chaque machine devine plus souvent, donc corrige plus souvent.

Ce que ressent un joueur

Le délai ressenti n’est pas le ping. Pour un client, c’est l’aller-retour de ses entrées, plus une marge sur chaque trajet, moins le délai de base, que tout le monde paie déjà, hôte compris. Dans un test réel à deux joueurs sur Unity Relay :

  1. trajet aller du client vers l’hôte : 1,5 tick. Trajet retour : 3,7 ticks ;
  2. marge : 0,5 tick par trajet. Total : 1,5 + 3,7 + 1 = 6,2 ticks ;
  3. délai de base : 4 ticks. Il reste 6,2 − 4 = 2,2 ticks, arrondis au tick entier supérieur : le client ressent 3 ticks, 50 ms.

Un détail compte. Quand le trajet aller d’un client est plus court que le délai de base, ses entrées arrivent en avance, et ce temps est perdu. On lui permet donc une part négative : ses entrées partent un peu moins tôt. Sans ça, le même client ressentait 5 ticks au lieu de 3.

Qui choisit, et quand

  • Avant chaque manche, jamais pendant, l’hôte choisit le délai de base d’après les trajets mesurés : la moitié de l’aller-retour médian des clients, entre 2 et 4 ticks. La médiane plutôt que le pire : un client très loin ne fait pas payer sa distance aux autres.
  • Chaque client reçoit sa part, calculée sur ses trajets à lui. Ses entrées partent un peu plus tôt pour arriver à temps chez les autres, et il joue le monde un peu en arrière pour que les leurs arrivent à temps chez lui.
  • Un plafond : au-delà de 4 ticks ressentis, 67 ms, c’est trop. Passé ce seuil, le client n’attend plus : il devine le reste, 3 ticks au plus, et le rollback corrige.
  • En pleine manche, une seule exception. Un client qui devine trop souvent faux (plus d’une correction visible par seconde, ou plus de 30 % de ticks devinés) prend un tick de plus, chez lui seulement, et le rend après dix secondes calmes. Les autres machines n’en voient rien : ses entrées gardent leur date.

L’objectif est un délai uniforme : pas trop de délai ressenti, et surtout pas une manche où on le sent beaucoup et une autre où on ne le sent pas du tout.

Deux joueurs, quatre joueurs

À deux, le client n’attend que l’hôte. À quatre, si les entrées passent toutes par l’hôte, celles d’un client vers un autre font deux trajets : l’aller de l’un, puis le retour vers l’autre. C’est presque un aller-retour complet, qu’aucun délai de base ne couvre. Les clients s’envoient donc leurs entrées directement, et le chemin par l’hôte reste là en secours.

ticks par seconde
60
ticks de délai, choisis par l'hôte
2–4
délai ressenti max
≤ 67 ms
entrées par paquet
8

Le déterminisme

GGPO pousse l’idée jusqu’au bout : il n’envoie que des entrées, jamais l’état du monde. Chaque machine reconstruit d’elle-même exactement la même partie à partir des mêmes entrées. Ça suppose une simulation déterministe au bit près : mêmes entrées, mêmes nombres, sur toutes les machines.

Un moteur généraliste ne le garantit pas. Les calculs en virgule flottante peuvent donner des résultats légèrement différents d’un processeur à l’autre (x64 sur PC, ARM64 sur Switch et sur les Mac récents) selon les instructions que le compilateur choisit. Les fonctions mathématiques (sinus, racine, puissance) n’ont pas forcément la même implémentation partout. Et un moteur physique ajoute ses propres calculs par-dessus.

Il y a donc deux façons de faire tourner un rollback :

  • le déterminisme strict, façon GGPO. On n’envoie que des entrées, et une empreinte de l’état (un hash) de temps en temps pour détecter une désynchronisation ;
  • le rollback avec référence. Une machine fait foi et envoie régulièrement l’état réel, vers lequel les autres convergent. C’est plus lourd, mais ça tolère de petits écarts.

Pour aller vers le déterminisme strict sous Unity, deux voies existent :

  • les flottants déterministes de Burst. Le compilateur Burst a un mode FloatMode.Deterministic, qui remplace les fonctions mathématiques par des versions identiques partout et coupe les optimisations qui varient selon la plateforme. Il est réservé aux architectures 64 bits, et toute la simulation doit alors passer par du code compilé par Burst ;
  • la virgule fixe. Les positions et les vitesses sont stockées en entiers, avec un nombre fixe de décimales. Les calculs sur entiers donnent le même résultat sur toutes les machines, sans exception. C’est la voie la plus sûre, et la plus coûteuse, puisque tous les calculs du gameplay sont à réécrire.

Dans les deux cas, la physique d’Unity et de Corgi doit sortir de la simulation, remplacée par des requêtes de collision déterministes.

En résumé

  • Un serveur qui fait foi donne une seule version de la partie, au prix d’un aller-retour de délai.
  • La prédiction supprime ce délai pour son propre joueur, et la réconciliation rejoue les entrées non confirmées par-dessus l’état du serveur.
  • L’interpolation rend les autres joueurs fluides en les montrant dans le passé, et crée plusieurs moments sur un même écran.
  • L’extrapolation montre tout le monde au présent, et se trompe à chaque décision d’un joueur sans inertie.
  • L’horloge commune et le délai d’entrée mettent tout le monde au même tick, et le rollback corrige les entrées arrivées trop tard.
  • Le reste du jeu doit suivre : objets rembobinables, effets silencieux au rejeu, décisions datées par l’hôte.