Reflect - Un framework pour applications web multijoueurs avec synchronisation de style jeu
(rocicorp.dev)- Reflect est un framework conçu pour créer rapidement des applications collaboratives comme Figma, Notion ou Google Sheets, en ajoutant à son serveur entièrement managé le moteur de synchronisation inspiré du jeu de Replicache
- Une interface collaborative doit afficher immédiatement les changements locaux sans attendre la réponse du serveur ; la gestion des conflits lorsque plusieurs personnes modifient les mêmes données en même temps détermine donc largement l’expérience produit
- Au lieu d’un CRDT, Reflect choisit la Transactional Conflict Resolution : clients et serveur rejouent le même historique d’appels de mutators, et le serveur construit l’état faisant autorité dans l’ordre d’arrivée
- Les sequence CRDT comme Yjs excellent sur le texte, les listes et les maps, mais dans des cas qui demandent des règles de fusion spécifiques, comme un compteur, des incréments peuvent disparaître ; Reflect gère l’arithmétique, les opérations sur listes et les invariants de plus haut niveau par réexécution des mutators
- Comme le serveur ne reçoit que le nom de la mutation et ses arguments avant de recalculer le résultat, il n’a pas à faire confiance aux résultats calculés côté client, ce qui facilite l’intégration des contrôles d’accès, de la validation de schéma et des migrations dans la conception
L’expérience de développement présentée par Reflect
- Reflect propose une nouvelle manière de créer des applications web multijoueurs comme Figma, Notion ou Google Sheets
- C’est une évolution de Replicache, framework historique de synchronisation côté client, et il utilise le même moteur de synchronisation inspiré du jeu vidéo
- Contrairement à Replicache, il inclut un serveur entièrement managé avec pour objectif de permettre de créer en quelques minutes des applications multijoueurs de haute qualité
- Reflect est disponible publiquement pour la première fois ; on peut découvrir la présentation sur reflect.net et démarrer sur hello.reflect.net
Pourquoi les conflits apparaissent en édition collaborative
- En édition collaborative, les conflits sont inévitables
- Pour produire une interface réactive immédiatement, il est impossible d’attendre le serveur, et les changements doivent d’abord se produire localement sur le client
- Comme plusieurs utilisateurs peuvent modifier le même élément en même temps, il faut synchroniser les conflits et les résoudre naturellement pour que tout le monde voie le même résultat
- Le moteur de synchronisation détermine l’expérience développeur, l’expérience utilisateur, les performances possibles et jusqu’au type d’applications qu’il est possible de construire
Les CRDT et l’exemple du compteur dans Yjs
- Dans l’écosystème web, les CRDT sont largement utilisés comme méthode de synchronisation des données
- Un CRDT est une structure de données qui converge vers la même valeur dès lors que tous les changements entre collaborateurs ont été échangés ; Yjs et Automerge en sont des bibliothèques open source emblématiques
- Reflect n’est pas un CRDT ; il utilise une Transactional Conflict Resolution, adaptation du Server Reconciliation employé depuis longtemps dans l’industrie du jeu vidéo
-
Pourquoi un compteur simple se casse dans Yjs
- Stocker une valeur
countdans une Yjs Map puis réécrireprev + 1peut faire perdre des incréments en situation de concurrence - L’exemple de compteur correct dans la documentation Yjs consiste à ajouter les nombres dans un tableau puis à calculer la somme
- Yjs est un sequence CRDT : il est donc très bon pour les listes, les fragments de texte et les maps, mais il modélise difficilement un compteur de façon naturelle
- L’algorithme de fusion de la Yjs Map fonctionne clé par clé en last-write wins ; si deux utilisateurs incrémentent en même temps, l’un des changements peut disparaître
- Les CRDT conviennent très bien à certains problèmes, mais deviennent difficiles à étendre dès qu’on sort de ce cadre
- Stocker une valeur
Comment fonctionne la Transactional Conflict Resolution
- Dans Reflect, les changements sont implémentés via des fonctions JavaScript spéciales appelées mutators
- Une copie de chaque mutator existe sur tous les clients et sur le serveur
- Lorsqu’un utilisateur produit un changement, Reflect crée une mutation, c’est-à-dire un enregistrement d’appel de mutator
- Une mutation ne contient que le nom du mutator et ses arguments, comme
increment(delta: 1) - Le contenu des changements produits n’est pas inclus dans la mutation
- Une mutation ne contient que le nom du mutator et ses arguments, comme
- Reflect applique immédiatement la mutation localement pour mettre à jour l’interface, ce qui permet à l’utilisateur de voir son changement sans délai
-
Linéarisation côté serveur et réexécution
- Chaque client continue d’ajouter des mutations sans attendre le serveur
- Les mutations sont envoyées en flux vers le serveur, qui les linéarise dans leur ordre d’arrivée afin de produire l’état faisant autorité suivant
- Par exemple, si le client 1 exécute
increment(1)et le client 2increment(2)au même moment, le compte final est construit côté serveur selon l’ordre d’arrivée - Le serveur fusionne les conflits en linéarisant l’historique d’exécution, sans connaissance spécifique de ce que fait
incrementni de la manière de le fusionner - L’état faisant autorité le plus récent est ensuite diffusé en continu vers chaque client
- Lorsqu’un client voit qu’une de ses mutations en attente a été appliquée à l’état faisant autorité, il la retire de sa file locale
- Les mutations restantes en attente sont rebasées par réexécution du code du mutator au-dessus du dernier état faisant autorité
- Ce cycle complet peut se produire jusqu’à 120 fois par seconde et par client
Coût d’implémentation et avantages généralisables
- Mettre en œuvre cette approche nécessite un datastore rapide capable de remonter le temps, de créer des forks et des branches
- Côté serveur aussi, il faut un stockage rapide capable de traiter les mutations entrantes
- Il faut également un mécanisme de synchronisation des mutators et une logique de récupération quand des conflits apparaissent pendant la synchronisation côté client ou serveur
- En échange, la linéarisation de fonctions arbitraires devient une stratégie de synchronisation assez générale
-
Exemples gérés sans code de synchronisation séparé
- Les opérations arithmétiques sont gérées naturellement
setHighScoreenregistre la plus grande valeur entrehigh-scoreexistant et le score proposé- La plupart des manipulations de listes fonctionnent aussi
appendajoute un élément à la fin d’une liste de coursesinsertAtinsère un élément à une position donnée, tandis quesplice()ajuste la positionremovedoit prendre en argument l’élément lui-même ou un identifiant stable, car l’index peut changer- Il est également possible d’imposer des invariants de plus haut niveau
addChildmet à jour ensemble leschildIDsdu parent et leparentIDde l’enfant afin qu’ils restent toujours cohérents- Ces exemples se fusionnent raisonnablement sans code supplémentaire spécialement pensé pour la synchronisation
Autorité du serveur et contrôle d’accès
- Dans Reflect, le serveur fait autorité
- Ce que le client pense être le résultat d’un changement n’est partagé ni avec le serveur ni avec les autres clients
- Seuls le nom de la mutation et ses arguments sont envoyés au serveur, qui recalcule lui-même le résultat
- Le serveur n’a même pas besoin d’exécuter exactement le même code que le client ; il peut aussi interroger des services externes ou utiliser des nombres aléatoires
-
Autorisations fines
- Cette conception permet d’intégrer naturellement des contrôles d’accès fins
- Exemple : dans un outil de design collaboratif, on peut autoriser un invité à commenter et surligner, tout en lui interdisant de modifier réellement le design
- Avec un CRDT, il est difficile d’implémenter ce comportement car il n’existe pas d’endroit clair où rejeter un changement non autorisé
- Avec Reflect, le mutator exécuté sur le serveur peut vérifier une valeur comme
tx.user.canEditet lever une erreurunauthorizedsi l’utilisateur n’a pas les droits - Il n’y a aucun problème à ce qu’un mutator exécute un code différent sur le serveur et sur le client, puisque le serveur prend la décision finale
Validation de schéma et indications d’usage
- Dans l’approche de Reflect, la validation de schéma et les migrations s’intègrent aussi naturellement à la conception
- Le choix d’une stratégie de synchronisation est au cœur des systèmes multijoueurs, et Reflect considère la Transactional Conflict Resolution, héritée de l’industrie du jeu, comme une approche simple, flexible et puissante
- Si vous développez une application multijoueurs, vous pouvez l’essayer depuis la page de démarrage de Reflect
- Pour parler avec l’équipe de développement, vous pouvez utiliser discord.reflect.net ou @hello_reflect
1 commentaires
Avis sur Hacker News
La démo en haut de la page d’accueil (https://reflect.net/) est assez amusante.
Quand on la regarde, à chaque fois qu’un puzzle est terminé, les gens semblent agiter leur curseur en mode « on l’a fait ! »
Pour les autres lettres, le contour reste visible, donc ça ne marche pas de la même façon.
Quand on attrape une pièce, elle semble être verrouillée pour cet utilisateur, donc il y a très peu de conflits à résoudre ; il ne reste guère que le cas où deux personnes la prennent en même temps et où elle revient au premier.
J’aimerais voir une meilleure démo où une vraie résolution de conflits se produit.
La vidéo de la scène décrite est ici : https://streamable.com/asu261
Vous vous souvenez peut-être que c’était déjà passé une ou deux fois sous le nom Replicache.
Reflect y ajoute un serveur de synchronisation entièrement managé et très rapide.
Le domaine local-first / temps réel est assez encombré en ce moment, mais Replicache/Reflect vaut le détour parce que son modèle de données et son modèle de programmation sont magnifiquement simples.
Par rapport aux CRDT, l’avantage est de pouvoir gérer soi-même les conflits avec du simple code séquentiel ; ajouter une résolution de conflits propre à l’application à des CRDT peut devenir complexe quand les règles intégrées ne conviennent pas.
PowerSync a aussi choisi une architecture à réconciliation côté serveur plutôt que des CRDT, et pour les applications avec un serveur central, la simplicité de cette structure est très séduisante.
Je tiens à créditer Aaron pour avoir popularisé et diffusé le concept de réconciliation côté serveur : https://www.gabrielgambetta.com/client-side-prediction-serve...
A JavaScript framework to build offline-first but collaborative webapp - https://news.ycombinator.com/item?id=33269440 - octobre 2022
Linear clone, with realtime sync and instant UI - built with Replicache - https://news.ycombinator.com/item?id=31331660 - mai 2022
Replicache: Easy Offline-First for Existing Applications - https://news.ycombinator.com/item?id=22173500 - janvier 2020
Il y a environ 15 ans, chez ma grand-mère, j’avais creusé ce sujet assez loin pour tromper l’ennui, et j’en avais même fait une implémentation JavaScript.
Dire que « les opérations arithmétiques fonctionnent tout simplement » est évidemment assez facile à obtenir avec des opérations idempotentes, mais j’ai du mal à accepter l’idée que « les opérations sur les listes fonctionnent aussi tout simplement ».
Par exemple, prenons un tableau comme [1, 2, 3, 4, 5] : A supprime la plage 2–4, B supprime la plage 3–5, et C insère quelque chose entre 3 et 4. Si les trois mises à jour arrivent simultanément au serveur, la solution est ambiguë.
S’appuyer sur des timestamps ou sur le « dernier écrivain gagne » (Last Write Wins) casse le modèle transactionnel, et si l’on choisit un gagnant, il reste à savoir ce que les autres utilisateurs verront et comment le leur communiquer.
Si la réponse est « renvoyer tout le tableau », alors là encore le modèle transactionnel est cassé.
En réalité, la formulation visée était plutôt « beaucoup d’opérations sur les listes fonctionnent tout simplement ».
Dans Reflect, on ne peut pas utiliser des index de liste comme identifiants pour modifier/supprimer, car les index ne sont pas stables ; c’est pourquoi il est recommandé d’utiliser l’élément lui-même s’il est atomique, ou généralement un ID stable : https://i.imgur.com/IKzmf0q.png
Il existe aussi le problème où l’insertion de C peut être supprimée, mais dans un contexte de collaboration temps réel, aucun protocole ne peut le résoudre complètement.
Du point de vue de C, voir disparaître ce qu’il vient d’écrire peut être triste ; ce genre de chose vient du fait que des humains travaillant simultanément dans le même espace ont des intentions différentes, et des dispositifs sociaux comme l’annulation et l’indication des personnes actuellement actives aident.
Pendant ce temps, chaque client peut avoir appliqué localement son propre ajout : A voit [« a »], B voit [« b »], C voit [« c »]. Quand le serveur envoie l’état [« c », « b », « a »], j’imagine que les clients abandonnent les changements en attente et prennent l’état du serveur comme vérité du monde.
Mais si chaque ajout a un effet du type « je gagne si mon changement est appliqué en premier », je me demande si, pendant les 300 ms d’attente de la mise à jour serveur, tout le monde voit « you win ».
On peut alors insérer en toute sécurité derrière ou entre des éléments supprimés.
Les trois utilisateurs verront le résultat, comprendront qu’ils ont modifié les mêmes données simultanément et que l’état est devenu bancal, puis le corrigeront.
Pensez à l’édition de documents.
S’ils ne peuvent pas voir que les autres sont en train de travailler, cela peut surprendre, mais ils peuvent au final corriger l’état souhaité.
Si le résultat n’est pas vérifié, ou ne peut pas l’être, l’état ne sera pas correct ; mais dans des applications interactives comme l’édition collaborative ou les jeux, les choses ne se passent généralement pas ainsi.
Je fais partie des personnes qui ont participé à ce projet, et je peux répondre aux questions s’il y en a
J’ai aussi créé une bibliothèque « redux-pubsub » avec rebase et source de vérité serveur ; d’après ce que j’en comprends, c’est similaire à TCR
Il y a beaucoup de choses que j’aime dans ce modèle, et l’article lié est aussi très clair
Vous dites que « la validation de schéma et les migrations viennent naturellement, presque gratuitement, de la conception » ; je me demande quelle approche a réellement bien fonctionné pour les migrations
Et, pour un cas d’usage TCR qui traite une quantité importante d’édition de texte partagée, on pense généralement par défaut à Yjs et Tiptap/ProseMirror ; je me demande si le mieux est de conserver un document CRDT et un document TCR en parallèle
J’aimerais savoir si, comme la base de code côté client est en grande partie partagée, on peut s’attendre à ce qu’elle continue d’être mise à jour, ou s’il est plus probable que Reflect devienne l’objectif principal à sa place
La terminologie prête un peu à confusion
Dans les jeux, il y a des « joueurs », donc on appelle le système de synchronisation « multijoueur » ; mais dans les logiciels généraux, il y a des « utilisateurs », donc multi-utilisateur me semblerait plus juste
Sur la page, l’alternance entre « utilisateur » et « multijoueur » se lit de manière un peu étrange
HN est un service multi-utilisateur, mais il serait bizarre de qualifier HN de multijoueur
L’interaction simultanée en temps réel a quelque chose de plus fort que le simple multi-utilisateur, et multijoueur, au sens où plusieurs utilisateurs agissent et interagissent, me semble un usage acceptable
À l’inverse, tous les logiciels web sont multi-utilisateurs, donc ce terme seul n’apporte aucune information
Multijoueur désigne du multi-utilisateur live où l’on visualise ce que font les autres utilisateurs à chaque instant
Cela fait environ deux ans que je regarde les CRDT de loin en loin, et je me suis toujours demandé comment se gérait l’autorisation
Cet article semble suggérer qu’avec de simples bibliothèques CRDT comme Y.js, il est difficile de gérer correctement des applications où les changements doivent passer des contrôles d’autorisation
Parce qu’il n’y a pas d’autorité centrale, c’est-à-dire de serveur ; et Reflect semble partir du principe que le serveur arbitre les interactions client. Est-ce bien la bonne compréhension ?
Avec des efforts, on peut gérer les autorisations avec des CRDT ; par exemple, en plaçant un serveur entre tout le monde et en lui faisant annuler les changements non autorisés qu’il observe
Mais à mesure que l’application grossit, cela demande plus de maintenance, devient fragile, et on perd d’emblée une partie des avantages des CRDT
Si le serveur est déjà au milieu, il est beaucoup plus simple d’utiliser dès le départ un protocole où le serveur peut rejeter les messages
Par exemple, le serveur peut se charger de l’authentification des connexions
Si quelqu’un se connecte en P2P et revendique certains droits, on peut vérifier cette revendication auprès du serveur
De plus, CRDT ne signifie pas forcément P2P : on peut faire transiter les messages par un serveur central tout en conservant un modèle CRDT où l’état courant est résolu à la fois côté serveur et côté client
Cette approche permet aux CRDT de fonctionner aussi dans un contexte P2P
Cela dit, si le serveur fait autorité, il suffit de rejeter les messages des clients non autorisés
Je me demande comment sont gérées les mises à niveau de mutators
Si un client exécute du vieux code, ses opérations divergeront de celles du serveur ; la réponse évidente est de versionner indépendamment, comme
increment_v1,increment_v2, mais je me demande s’il existe une meilleure méthodeComme nous n’avons pas encore activé la persistance, cette période est actuellement assez courte
Félicitations pour le lancement
Je me demande si cela appartient à la même famille que https://partykit.io/
La différence clé, c’est le degré auquel chacun adopte une conception opinionated
PartyKit est très peu prescriptif, et ressemble davantage à un serveur JavaScript léger, permettant de démarrer vite et de passer automatiquement à l’échelle
La plupart des gens semblent exécuter yjs sur PartyKit, mais on peut aussi y faire tourner automerge ou Replicache
Reflect se concentre entièrement sur la meilleure expérience multijoueur possible, avec l’objectif d’intégrer fortement de nombreux choix sur toute la stack afin que le multijoueur fonctionne tout simplement, et que l’on puisse se concentrer sur l’implémentation de l’application
À noter que cette stratégie est appelée lockstep déterministe dans le développement de jeux, et qu’elle est particulièrement utilisée dans les jeux comportant de nombreuses entités dont l’état doit être synchronisé.
L’exemple typique, ce sont les jeux de stratégie en temps réel, qui utilisent presque tous cette approche.
Il existe une excellente présentation GDC qui explique en profondeur les implémentations de Mortal Kombat et Injustice 2 : https://youtu.be/7jb0FOcImdg
Le lockstep déterministe est un algorithme de jeu P2P où chaque participant attend les entrées de tous les autres joueurs avant de faire avancer la simulation du jeu.
Il est « déterministe » parce qu’on partage les entrées, et non le résultat de la simulation, et que la simulation est déterministe pour des entrées identiques ; il est en « lockstep » parce que tous les clients avancent à un rythme coordonné.
La série Age of Empires utilise cette méthode, ce qui explique que les unités ne se déplacent pas immédiatement quand on clique ; StarCraft l’utilise aussi, avec des astuces pour rendre la sensation de jeu plus fluide.
Reflect s’apparente plutôt à une simulation à autorité serveur qu’à du P2P.
Le client envoie ses entrées au serveur, mais prédit localement sans attendre le résultat ; le serveur remonte le temps et rejoue les entrées pour compenser la latence de chaque client.
Ensuite, quand le client reçoit le résultat du serveur qui inclut ses propres entrées, il corrige sa simulation locale.
Les mots-clés de cet algorithme sont : autorité serveur, prédiction, compensation de latence et réconciliation de prédiction.
Je ne sais pas si Reflect l’implémente, mais dans les FPS, il est aussi courant d’utiliser une interpolation côté client, où, lorsqu’une mise à jour du monde est reçue, les entités sont interpolées pendant un certain temps vers leur nouvelle position et rotation.
Comme il n’y a qu’une seule autorité — le serveur —, le déterminisme n’est pas absolument crucial, mais il est utile pour réduire les mauvaises prédictions côté client.
Les mauvaises prédictions surviennent quand la simulation n’est pas déterministe, par exemple parce que les entrées d’autres clients modifient fortement l’état du monde ou que la génération de nombres aléatoires n’est pas synchronisée.
Counter Strike offre aussi un exemple où l’aléa de dispersion des balles n’est pas synchronisé afin d’empêcher les cheats « nospread ».
https://www.gabrielgambetta.com/client-side-prediction-live-...
https://developer.valvesoftware.com/wiki/Latency_Compensatin...
https://developer.valvesoftware.com/wiki/Source_Multiplayer_...
Reflect est excellent.
Nous utilisons actuellement la version alpha en production, et nous sommes très satisfaits non seulement du système, mais aussi d’Aaron et de son équipe.
Je peux répondre aux questions du point de vue d’un client.