Un moteur, treize jeux : la couche partagée du Labo
Une plateforme mérite son nom quand la treizième chose coûte moins cher à construire que la troisième. Voici le moteur de jeu navigateur qui se trouve sous notre Labo — ce que tous les jeux partagent, ce qu'aucun ne partage, et la poignée de petits contrats qui empêchent treize jouets très différents de se marcher dessus.
Sur cette page
Treize jeux tournent dans le Labo — huit en 3D temps réel sous WebGL, cinq en Canvas 2D. Le décompte n'est pas le chiffre intéressant. Le chiffre intéressant, c'est ce que coûte le suivant, et la réponse tient presque entièrement à ce que nous avons accepté de partager. Nous partageons les parties ennuyeuses : le volume sonore, le nombre de pixels demandés au GPU, ce qui se passe quand un téléphone pivote, ce qui se passe quand le navigateur jette le contexte graphique. Nous ne partageons aucune des parties amusantes. Il n'y a pas de boucle de jeu partagée, pas de physique partagée, pas de système d'entités, pas d'abstraction de graphe de scène entre un jeu et three.js.
- 13
- jeux dans le Labo
- 8
- en 3D temps réel sous WebGL
- 120 Hz
- simulations à pas fixe
- 1
- touche de sourdine pour tous
Le Labo au 27 juillet 2026. Chaque jeu est gratuit, dans le navigateur, sans installation et sans compte.
Huit en WebGL, cinq sur canvas
Un registre unique décrit chaque jeu : slug, titre, accroche, teinte d'accent, appareils pris en charge, statut, points forts, 3D ou 2D, et une courte étiquette en chasse fixe qui nomme la machinerie (« terrain voxel par chunks », ce genre de chose). La grille de /games lit ce registre et se divise en deux — un rail 3D temps réel pour les jeux React Three Fiber, et sous lui une grille pour les originaux, les jeux Canvas 2D qui n'ont besoin d'aucun GPU.
La règle qui rend cette division utile tient en une ligne : chaque composant de jeu, et chaque composant de mode démo, est enveloppé dans React.lazy au niveau du module. three.js se découpe donc par jeu. Quelqu'un qui ouvre un original en Canvas 2D et y joue une heure ne télécharge jamais de moteur de rendu, et quelqu'un qui ouvre l'index des jeux n'en télécharge aucun — il reçoit des affiches. Garder cinq jeux en canvas simple n'est pas de la nostalgie. Certaines idées valent mieux en pixels, et un jeu qui ne coûte rien à charger est un jeu que les gens finissent vraiment.
Les paliers de qualité WebGL, devinés une fois
Chaque jeu 3D pose la même question au montage et ne la repose jamais : sur quelle machine est-ce que je tourne ? La réponse est un palier — low, medium, high — calculé à partir de la détection de pointeur grossier, de navigator.hardwareConcurrency, de navigator.deviceMemory et de la plus petite dimension de l'écran. Un appareil compte comme téléphone quand le pointeur est grossier et que le petit côté fait moins de 700 pixels CSS. La sortie la plus lourde de conséquences du palier est un plafond dur sur la densité de pixels : 1,25, 1,5 ou 2,0. Sur un téléphone moderne, la différence entre un plafond à 1,25 et un 3,0 sans plafond représente environ six fois plus de fragments ombrés, c'est-à-dire la différence entre un jeu et un radiateur.
Ce détail Safari mérite d'être énoncé franchement plutôt que classé comme un bug. Safari n'expose pas deviceMemory, donc un iPhone ne peut jamais argumenter pour monter d'un cran ; il est classé bas par défaut. Safari mobile tient aussi le moins de contextes WebGL vivants parmi les navigateurs que nous visons et récupère agressivement les onglets en arrière-plan : la réponse prudente est la bonne. Quand une heuristique se trompe dans un sens qui vous arrange, laissez-la tranquille.
Deux décisions plus petites, dans le même hook, ont mis plus de temps à se stabiliser que les seuils. Le rendu côté serveur renvoie medium — le serveur ne peut rien mesurer, alors il devine le milieu et laisse le client corriger au montage. Et la préférence de mouvement réduit d'un utilisateur n'abaisse pas le palier. Il est tentant de confondre les deux, mais ce sont des demandes différentes : arrêtez de faire bouger les choses n'est pas donnez-moi moins de pixels, et répondre à une préférence de lisibilité en dégradant la qualité d'image serait une étrange façon d'écouter. Le mouvement réduit est honoré là où il doit l'être, dans l'animation elle-même.
Une seule touche de sourdine pour tout le Labo
Il n'y a aucun fichier audio dans le Labo, parce que la spécification interdit les ressources binaires. Chaque son de chaque jeu sort d'un unique module de synthèse dont la surface tient en deux fonctions : beep() et noiseBurst(). À elles deux, elles couvrent un propulseur d'amarrage, un pion qui se pose, une tourelle qui tire et un niveau qui s'achève, ce qui en dit moins sur notre design sonore que sur la part d'enveloppe dans l'audio de jeu.
L'AudioContext derrière elles est un singleton paresseux, créé au premier usage plutôt qu'à l'import, parce qu'un contexte créé avant un geste de l'utilisateur arrive suspendu et le reste. Les sons demandés pendant que le contexte reprend sont mis en file plutôt qu'abandonnés, si bien que le clic qui déverrouille l'audio est lui-même audible. Quand l'onglet se masque, le contexte est suspendu.
Une clé localStorage pilote un seul nœud de gain maître. Coupez le son d'un simulateur d'amarrage, et l'échiquier se tait aussi.
Cette clé — vaynerov:engine3d:muted:v1 — est délibérément globale plutôt que par jeu. Quinze modules importent le synthétiseur aujourd'hui, dont une page qui n'est pas du tout un jeu, et tous pendent à un unique gain maître. L'alternative, un interrupteur par jeu, semble plus configurable et se révèle pire : personne n'a envie de chasser treize cases à cocher parce qu'une réunion commence. Couper le son est un geste d'urgence, et les gestes d'urgence doivent être totaux.
Le reste de la couche partagée est tout aussi petit, à dessein :
- Les sticks tactiles avec une zone morte de 18 % — assez pour que le tremblement du pouce ne pilote pas, assez peu pour que le contrôle fin survive.
- La détection de balayage à un seuil de 24 px, où un glissement de souris compte comme un balayage, pour que le même gestionnaire fonctionne sur un portable.
- Le stockage par jeu sous
vaynerov:<slug>:v1— réglages et progression, cloisonnés pour qu'aucun jeu ne puisse lire l'état d'un autre. - Le mouvement réduit sous forme de hook qui verrouille une valeur initiale puis s'abonne, pour qu'une préférence changée en cours de session prenne effet sans rechargement.
- La gestion de la perte de contexte, montée à l'intérieur du canvas, qui transforme un contexte WebGL perdu en affiche plutôt qu'en rectangle noir.
Ce dernier point porte un commentaire honnête dans son propre code source : le garde-fou est câblé dans la scène de la vitrine, et les sept scènes restantes, propres à chaque jeu, en ont encore besoin. C'est le plus vieux point ouvert de la couche. Une perte de contexte sur une page de jeu produit aujourd'hui une frontière d'erreur plutôt qu'une affiche élégante — récupérable, pas élégant.
Pas de boucle de jeu partagée
La décision la plus utile de toute la couche a été de refuser d'écrire ce que tout moteur écrit en premier : une boucle de mise à jour partagée.
À la place, chaque jeu possède sa boucle, et ceux qui ont une vraie physique tournent à pas fixe — 120 Hz dans le simulateur d'amarrage et dans l'empileur à grue et pendule, où un pas variable change visiblement la difficulté du jeu. Plus important encore : pour plusieurs jeux, la simulation vit dans un module ordinaire portant en tête le commentaire ni three.js, ni React — le coursier crossy-road, le RTS de poche, le coureur en couloirs et l'île de voxels gardent tous leurs règles dans des fichiers qui n'importent rien. Une physique qu'on peut appeler depuis un fichier de test est une physique digne de confiance ; une physique emmêlée dans un rappel de rendu est une rumeur.
Le bénéfice le plus net est dans Slipstream, le coureur en couloirs. Ses rangées d'obstacles sont générées à partir de motifs, et un motif magnifique peut n'en rester pas moins impossible — un espace où l'on entre tranquillement devient un mur dès que la piste défile à 32 unités par seconde. Le fichier de motifs est donc livré avec un validateur qui parcourt chaque rangée et prouve qu'une voie survivable reste atteignable à la vitesse la plus défavorable, en propageant l'atteignabilité vers l'avant sur les trois voies. Il s'exécute à l'initialisation du module en développement, ce qui veut dire qu'un motif injuste ne survit pas à un chargement de page, encore moins à une livraison.
Ce que nous aimons, c'est la réutilisation : le même modèle de rangées pilote le pilote automatique du mode démo sur la vitrine des jeux. La démo qui se joue toute seule en page d'accueil se faufile exactement dans l'atteignabilité vérifiée par le validateur. Juste pour le validateur, c'est la même chose que juste pour le joueur, et la démo est la preuve qui tourne en public.
Un seul canvas derrière tout
La vitrine des jeux enchaîne des diapositives de démonstration — quelques secondes de chaque jeu, qui se joue tout seul. L'implémentation naïve monte un canvas par diapositive, et sur téléphone elle meurt immédiatement : les navigateurs plafonnent le nombre de contextes WebGL vivants, et sur un téléphone ce plafond est assez bas pour être atteint avant que le rail ait bouclé un tour. La vitrine fait donc tourner un seul canvas partagé derrière chaque diapositive 3D, avec exactement une scène montée à la fois.
Échanger les scènes à l'intérieur d'un canvas vivant est la partie laide, et nous la cachons derrière un voile DOM opaque, avec une chorégraphie de cinq constantes.
| Constante | ms | Rôle |
|---|---|---|
| VEIL_UP | 240 | Un panneau DOM opaque se lève sur le canvas |
| SETTLE | 160 | Un temps derrière le voile pendant l'échange de scène |
| CANVAS_FADE | 700 | Le canvas lui-même fond d'une scène à l'autre |
| VEIL_DOWN | 480 | Le panneau se relève sur le nouveau jeu |
| COOL_DOWN | 400 | Tout autre changement de diapositive est refusé jusqu'à expiration |
L'échange 3D vers 3D. Le délai de refroidissement existe parce qu'un visiteur qui tapote vite le rail peut sinon démonter une scène en pleine construction.
Derrière le voile, une ligne fait un travail disproportionné : la caméra par défaut est remise à ses valeurs d'usine — champ de vision de 75°, plan lointain à 1000 — avant chaque montage. Les jeux laissent la caméra là où leur monde en avait besoin. Le jeu de tir à verrouillage de pointeur laisse un plan lointain à 160, ce qui est correct pour une arène en couloir et catastrophique pour l'île de voxels, dont l'horizon en réclame au moins 260. Sans la remise à zéro, l'ordre des diapositives déciderait si un monde se rend ou s'arrête net dans le brouillard. L'état mutable partagé finit toujours par vous punir ; une remise aux valeurs d'usine est la moins chère des excuses.
Le canvas ne tourne en frameloop="always" que lorsqu'il est réellement à l'écran et qu'une diapositive 3D est active ; sinon il ne rend rien. Et quand le contexte se perd malgré tout, la scène retombe sur une affiche DOM.
Ces affiches méritent un paragraphe. Les treize jeux en ont une, et chaque affiche est du DOM et du SVG purs — pas de WebGL, pas de canvas 2D, pas un seul pixel d'image matricielle. Cette restriction est ce qui les rend sûres à rendre depuis des composants serveur, et elle donne à tout le Labo une seule chaîne de repli pour trois échecs différents : le rendu côté serveur, un navigateur sans WebGL, et un chunk paresseux qui n'est pas encore arrivé. Le même dessin couvre les trois. Les originaux en Canvas 2D partagent un unique module d'affiche, puisque leur illustration de carte forme une famille.
De petits contrats, treize fois
Le reste de la plateforme est une courte liste d'accords, dont chacun existe parce que quelque chose a mal tourné un jour.
La classe d'appareil se verrouille. Un jeu demande une fois, au montage, si l'appareil est pris en charge, et la réponse est épinglée pour la session. Le commentaire du hook l'explique mieux que nous ne saurions le faire : un redimensionnement de fenêtre en pleine partie ne doit pas inverser la classe et remplacer un jeu en cours par l'écran de blocage. La détection lit d'abord le user agent, ensuite la taille du viewport, et renvoie null au tout premier rendu pour que le serveur et le client s'accordent avant que rien ne s'engage. Exactement un jeu sur treize est réservé au bureau — le jeu de tir à verrouillage de pointeur, qui a besoin d'une souris à capturer — et un appareil non pris en charge reçoit un véritable écran de blocage plutôt qu'une expérience cassée.
Il y a une règle que nous avons délibérément évitée : les téléphones ne sont pas forcés sur les commandes tactiles à l'échelle de la plateforme. Chaque jeu verrouille le type de pointeur au montage et décide lui-même quels sticks et quelles indications afficher. Une tablette avec un clavier branché existe vraiment, et une règle globale se serait trompée à son sujet dans les deux sens.
Le plein écran est un contrat, pas un appel d'API. Les jeux ne touchent jamais eux-mêmes à l'API Fullscreen ; ils lisent un contexte. Là où l'API native existe, nous l'utilisons. Sur Safari iPhone, qui ne la propose pas pour un élément arbitraire, nous retombons sur un pseudo-plein-écran CSS : une coque épinglée au viewport à un niveau d'empilement élevé, avec le défilement de page verrouillé pour que l'effet élastique ne fasse pas glisser le plateau en plein coup. Les deux chemins rapportent le même booléen par le même hook, et un jeu qui lie Échap doit sortir tôt tant qu'il est en plein écran, parce que l'hôte possède cette touche à cet endroit.
Les raccourcis clavier se répartissent par intention. Les touches de déplacement se comparent sur e.code, la touche physique, pour que ZQSD reste un losange sous la main gauche en AZERTY. Les lettres mnémoniques — celles où la lettre est le sujet — se comparent sur e.key. Le site existe en russe, et un joueur sur disposition cyrillique doit pouvoir appuyer sur la touche portant la bonne lettre. Quinze fichiers du Labo suivent désormais la convention.
Chaque jeu a sa propre frontière d'erreur, re-clée par slug, pour qu'un plantage dans un jeu ne puisse pas déborder sur le suivant qu'un visiteur ouvre.
Ce que la spécification interdit
La spécification maîtresse du Labo 3D est surtout une liste d'interdictions, ce qui est la moitié utile de toute spécification. Aucune nouvelle dépendance npm : tout le Labo, c'est three.js 0.184 avec React Three Fiber 9.6 et drei 10.7, et rien d'autre. Aucune ressource binaire : chaque texture est procédurale, chaque son sort du synthétiseur. Chaque boucle, écouteur, AudioContext et verrouillage de pointeur doit se nettoyer au démontage. Tout se met en pause sur visibilitychange et après deux secondes sans focus. Le mouvement réduit est honoré partout.
L'en-tête de la spécification dit encore neuf jeux. Nous l'avons écrite en juin, avons livré trois jeux de plus en une seule journée, et n'avons jamais mis le compte à jour — c'est le sort ordinaire de tout document non exécutable. Les contraintes ont tenu malgré tout, parce qu'elles sont imposées par la relecture et par le build, pas par la phrase en haut de page.
Voici l'économie, puisque c'était l'affirmation de départ. Le treizième jeu est arrivé sous forme de son propre répertoire d'une cinquantaine de fichiers, plus des modifications sur quatre choses partagées : le registre, la carte des affiches, les six catalogues de locales, et le résumé de site lisible par les machines. Aucune nouvelle dépendance. Aucun changement aux paliers de qualité, au synthétiseur, au contrat plein écran ou à la vitrine. Il a hérité d'une touche de sourdine, d'un verrou de classe d'appareil, d'une chaîne de repli d'affiches et d'une convention clavier auxquelles il n'a jamais eu à penser, et a dépensé chaque ligne de sa propre complexité sur la seule partie que personne n'avait construite avant lui.
C'est là tout le rendement d'une couche partagée : non pas qu'elle fabrique des jeux, mais qu'elle fabrique les parties d'un jeu que personne n'aurait plaisir à écrire deux fois. Les sept garde-fous de perte de contexte par jeu sont toujours sur la liste, et ils y resteront jusqu'au jour où un navigateur lâchera un contexte sur quelqu'un qui était en train de gagner.