En bref — quand un jeu multijoueur se désynchronise, le réflexe est d'ajouter du code de rattrapage : un polling de secours, une correction d'horloge, un événement de plus. J'ai fait exactement ça, et ça n'a jamais suffi. Ce qui a réglé le problème, c'est de changer de modèle : le serveur détient l'unique vérité, il la numérote, et le client ne fait que la refléter. Plus de reconstruction d'état côté navigateur, plus de dépendance à l'horloge du joueur, et une reconnexion qui devient un non-événement.

Le premier modèle, et pourquoi il cassait

Mon premier jeu temps réel était un bingo en ligne. L'architecture de départ semblait raisonnable : le serveur envoyait des événements (« tel numéro vient d'être tiré »), le navigateur les appliquait à son état local, un polling de secours rattrapait ce qui avait pu se perdre, et le rythme du tirage était calculé en partie à partir de l'heure du client.

Chacun de ces choix a produit son lot de bugs. Un événement perdu pendant une micro-coupure réseau, et le joueur n'avait plus la même grille que les autres. Un onglet mis en veille par le navigateur, et les minuteries dérivaient. Une horloge système décalée de quelques secondes, et l'affichage du prochain tirage mentait. Chaque correctif bouchait un trou et en révélait un autre, parce que le problème n'était pas dans les détails : il était dans le modèle. Quand le client reconstruit l'état à partir d'une suite d'événements, il suffit d'en rater un pour diverger — et rien ne lui dit qu'il a divergé.

Un état, une version, un seul propriétaire

La réécriture repose sur trois idées simples.

Le serveur est l'unique propriétaire de l'état. La partie — joueurs, tour en cours, dés, scores — vit côté serveur, persistée dans un stockage clé-valeur. Le client n'en possède qu'une copie en lecture, qu'il n'a jamais le droit de modifier lui-même.

Chaque changement incrémente un numéro de version. À chaque transition, le serveur fait passer la partie de la version n à n+1. Ce compteur monotone est la colonne vertébrale de tout le reste.

Le serveur envoie l'état complet, pas des différences. Plutôt que « le joueur 2 a marqué 18 points », le serveur pousse l'instantané entier de la partie, accompagné de sa version. Le client applique une règle unique :

function onState(snapshot) {
  if (snapshot.rev <= current.rev) return; // déjà vu ou plus ancien : on ignore
  current = snapshot;                       // sinon on remplace, sans calcul
}

C'est tout. Un message en double est ignoré. Un message arrivé dans le désordre est ignoré s'il est plus ancien. Un message perdu n'a aucune importance : le suivant contient l'état complet. Il n'y a plus d'« état intermédiaire » à reconstruire, donc plus rien à rater.

Le client envoie des intentions, pas des résultats

L'autre moitié du modèle concerne ce qui remonte du client. Le navigateur n'envoie jamais « j'ai obtenu trois six » : il envoie une intention — « je relance ces deux dés » — accompagnée de la version de l'état sur laquelle il s'appuie. Le serveur vérifie alors trois choses, dans l'ordre : est-ce bien le tour de ce joueur ? la version annoncée est-elle la version courante ? le coup est-il légal selon les règles ? Si tout passe, il applique, incrémente la version, persiste et diffuse. Sinon, il refuse proprement, avec une erreur typée plutôt qu'une exception.

La vérification de version règle au passage les doubles clics et les requêtes concurrentes : deux commandes basées sur la même version ne peuvent pas être appliquées toutes les deux, la seconde arrive forcément sur un état qui a déjà bougé. Et parce que tous les tirages de dés sont faits côté serveur, avec un générateur aléatoire cryptographique, un joueur qui modifie le code de sa page ne peut rien obtenir d'autre que ce que le serveur lui accorde. Dans un jeu avec classement et boutique, ce n'est pas un raffinement : c'est la base.

La reconnexion devient un non-événement

Avec ce modèle, la partie la plus redoutée du temps réel — la reconnexion — se réduit à une ligne : à la reconnexion, le client redemande l'état courant, et le serveur le renvoie. Même chose quand l'onglet revient au premier plan après une mise en veille : on redemande un instantané, et la règle « la version la plus haute gagne » fait le reste.

Il n'y a pas de protocole de rattrapage, pas de journal d'événements à rejouer, pas de fenêtre de tolérance à régler. Le client ne se demande jamais « qu'est-ce que j'ai manqué ? » : il reçoit ce qui est vrai maintenant.

Des règles pures, donc testables

Un effet secondaire de cette architecture m'a fait gagner énormément de temps : le cœur du jeu devient une fonction pure. Un état et une commande en entrée, un nouvel état — ou un refus motivé — en sortie. Pas de réseau, pas de base, pas d'horloge.

applyCommand(state, command) → { ok: true, state } | { ok: false, reason }

Tout le moteur de règles se teste donc comme n'importe quelle fonction, en quelques millisecondes : chaque combinaison de score, chaque coup interdit, chaque fin de partie. La couche réseau, elle, n'a plus rien d'intelligent à faire : recevoir une intention, appeler cette fonction, diffuser le résultat. Les tests d'intégration peuvent se concentrer sur ce qu'elle fait réellement — connecter, rejoindre, reconnecter — au lieu de tester les règles à travers une socket.

Ce que ça coûte

Ce modèle n'est pas gratuit, et il vaut mieux savoir où sont ses limites.

La bande passante. Envoyer l'état complet à chaque changement est plus lourd qu'envoyer une différence. Pour une partie de dés ou de bingo, l'état pèse quelques kilo-octets et change quelques fois par seconde au pire : c'est négligeable. Pour un jeu d'action à soixante mises à jour par seconde, ce serait une autre histoire, et il faudrait passer à des différences numérotées.

Le passage à plusieurs instances. Tant qu'un seul processus sert le jeu, la diffusion est locale et triviale. Dès qu'on veut en faire tourner deux — ne serait-ce que quelques secondes pendant un déploiement —, il faut un canal partagé entre instances (un adaptateur pub/sub branché sur le cache), des transitions protégées par un verrou, et s'assurer qu'une tâche périodique comme le tirage d'un numéro ne s'exécute pas deux fois. Rien d'insurmontable, mais c'est à penser dès le départ plutôt qu'à découvrir en production.

La latence perçue. Comme le client attend la confirmation du serveur avant d'afficher un résultat, chaque action coûte un aller-retour réseau. Dans un jeu au tour par tour, personne ne le remarque. Dans un jeu nerveux, il faudrait ajouter de la prédiction côté client — et on retrouverait une partie de la complexité qu'on vient de supprimer.

Ce que j'en retiens

La fiabilité en temps réel ne vient pas de l'accumulation de mécanismes de secours, mais d'un modèle dans lequel il n'y a presque plus rien à rattraper. Un seul propriétaire de l'état, un numéro de version qui ne fait que croître, des instantanés complets, des intentions validées côté serveur : chacune de ces décisions supprime une catégorie entière de bugs au lieu de les corriger un par un.

Le plus parlant, c'est la comparaison entre mes deux jeux : le premier a accumulé des mois de correctifs sans jamais être tout à fait fiable ; le second, conçu dès le départ sur ce modèle, n'a pas eu un seul bug de synchronisation. Et quand j'ai fini par réécrire le premier sur la même base, ses problèmes chroniques ont disparu avec l'ancien code.