Une grotte, tous les joueurs

Comment TUNL construit un niveau quotidien à partir de rien d'autre qu'une date, et les deux bugs qui l'ont silencieusement cassé pendant des mois.

Devlog · 15 septembre 2026 · mis à jour le 23 septembre 2026 pour TUNL 17.0 · tous les billets

TUNL est un jeu de vol en grotte à poussée maintenue. Tu maintiens pour monter, tu relâches pour tomber, et tu essaies d'aller plus loin dans un tunnel qui se rétrécit qu'hier. Chaque joueur vole dans la même grotte le même jour, et à la limite de minuit UTC elle devient une autre.

Il n'y a pas de fichier de niveau. Aucun serveur ne distribue de carte, rien n'est téléchargé, et le jeu ne stocke pas le tunnel qu'il vient de générer. La grotte est une fonction pure de la date, et tout le « monde » représente une quarantaine de lignes d'arithmétique.

Cette conception achète étonnamment beaucoup : un classement mondial équitable, un rejeu fantôme qui coûte quelques centaines d'octets, et une image partageable de ta partie. Elle impose aussi une contrainte bien plus dure qu'il n'y paraît, et je l'ai brisée deux fois sans m'en apercevoir. Ce billet parle des deux moitiés.

Un niveau qui est une fonction, pas un fichier

La plupart des jeux procéduraux génèrent un monde au début de la partie et le gardent en mémoire. TUNL ne le garde jamais. Le centre et la demi-largeur du corridor à n'importe quelle position du monde wx sont calculés à la demande à partir de deux ondes sinusoïdales superposées :

function boundsBase(wx) {
  const p = Math.min(Math.sqrt(wx / 14000), 1);            // wave frequency ramp, sqrt eased
  const g = Math.min(Math.max(wx - 3000, 0) / 90000, 1);   // corridor ramp, slow and linear
  const wA1 = lerp(H * 0.07,  H * 0.12,  g);       // slow wide arc
  const wA2 = lerp(H * 0.035, H * 0.055, g);       // faster shallow ripple
  const wF1 = lerp(0.0025,    0.0048,    p);
  const wF2 = lerp(0.0060,    0.0115,    p);
  const hg  = lerp(H * 0.34,  H * 0.163, g);       // half-gap: 68% -> 33% of the height

  const raw = H / 2
    + wA1 * Math.sin(wx * wF1 + _wavePhase1)
    + wA2 * Math.sin(wx * wF2 + 1.57 + _wavePhase2);

  const cy = clamp(raw, hg + WALL_PAD, H - hg - WALL_PAD);
  return { top: cy - hg, bot: cy + hg };
}

Voilà le squelette. La fonction réelle ajoute quelques termes de plus, comme la métamorphose de forme des parties profondes décrite plus bas, mais aucun ne prend autre chose que wx et le jour.

Deux ondes plutôt qu'une, parce qu'un seul sinus se lit immédiatement comme un rythme et cesse d'être intéressant au bout d'une dizaine de secondes. Un arc large et lent plus une ondulation plus rapide et peu profonde à un rapport de fréquences quasi irrationnel donne de longs tronçons qui ne se répètent jamais tout à fait. La demi-largeur se rétrécit d'environ 68% de la hauteur de l'écran à 33% à mesure que tu avances, et elle le fait lentement : rien du tout pour le premier tronçon, qui est une salle ouverte, puis une ligne droite qui n'atteint son minimum qu'après environ le score 1 500.

Ça n'a pas toujours fonctionné ainsi. Le corridor se rétrécissait d'abord sur une courbe en racine carrée qui atteignait son minimum au score 233, une rampe rapide et concentrée au début, et les retours sont arrivés disant que le jeu était bien trop difficile. Donc le corridor et l'amplitude de ses ondes ont reçu leur propre horloge lente, le g ci-dessus. Les fréquences des ondes sont restées volontairement sur l'ancienne courbe en racine : sin(wx * f) à un grand wx est très sensible à f, donc changer le rythme d'une fréquence au milieu d'une partie brouille la phase de tout ce qui est déjà derrière toi. L'amplitude et la largeur peuvent être ralenties sans risque, la fréquence non.

La propriété importante n'est pas la forme. C'est que boundsBase(wx) ne prend que wx. Interroge-la sur la position du monde 40 000 à la première image d'une partie et elle répond instantanément, de façon identique, sur n'importe quel appareil, sans avoir rien généré entre-temps.

Où intervient le jour

La date entre par exactement deux variables dans cet extrait : _wavePhase1 et _wavePhase2. La date UTC devient un entier comme 20260915, qui passe par une chaîne de hachage :

function seedDailyVariety(dayInt) {
  let h = Math.imul(dayInt ^ 0x9e3779b9, 0x45d9f3b) >>> 0;
  h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0;
  h = (h ^ (h >>> 16)) >>> 0;
  _wavePhase1 = (h % 6283) / 1000;                 // 0 .. 2pi

  h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0;
  _wavePhase2 = (h % 6283) / 1000;

  _waveJitterA  = 1 + (draw() * 2 - 1) * 0.08;     // +/- 8% amplitude
  _waveJitterF  = 1 + (draw() * 2 - 1) * 0.08;     // +/- 8% frequency
  _dayArchetype = Math.floor(draw() * DAY_ARCHETYPES.length);
}

Des dates consécutives diffèrent de un, donc le hachage doit fortement disperser sinon aujourd'hui et demain sembleraient proches voisins. La chaîne multiply-xorshift fait cela : 20260915 et 20260916 produisent des phases totalement indépendantes.

Les variations sont volontairement petites. La phase seule change où tombent les courbes mais pas la sensation du jour, et je voulais que chaque jour ait un caractère légèrement différent sans qu'aucun jour ne s'éloigne d'une base qui a pris longtemps à régler. Huit pour cent suffisent pour se remarquer sur une semaine et pas assez pour qu'un jour paraisse faux.

Quatre flux aléatoires, pas un

Les obstacles sont un problème séparé de la forme du corridor. Stalactites, pièces, mines et canons ont chacun leur propre générateur indépendant, tous dérivés du même jour :

rngStal   = makeRngStream(Math.imul(dayInt ^ 0x5741, 0x2545F491));
rngCoin   = makeRngStream(Math.imul(dayInt ^ 0xC01D, 0x9E3779B1));
rngMine   = makeRngStream(Math.imul(dayInt ^ 0x4D19, 0x85EBCA6B));
rngCannon = makeRngStream(Math.imul(dayInt ^ 0xCA77, 0xC2B2AE35));

Un seul flux partagé aurait été plus simple et aurait été une erreur. Le placement des pièces peut rejeter une position candidate, par exemple quand elle tombe trop près d'une stalactite. Sur un flux partagé, chaque rejet consomme un nombre et décale tout ce qui est tiré ensuite, donc une pièce dont le placement échoue déplacerait silencieusement la mine suivante. Les sels séparés rendent les quatre systèmes réellement indépendants plutôt que des copies décalées en phase d'une même séquence, et ils signifient que je peux changer la façon dont les pièces sont placées sans déplacer une seule mine.

Archétypes de jour

La phase et la variation changent la forme d'un jour. Pour changer sa saveur, la graine choisit aussi l'un des quatre archétypes, qui modifient l'échelle des courbes d'espacement déjà présentes dans le jeu :

ArchétypeStalactitesPiècesMinesChicanes
Classique1.001.001.001.00
Jour des chicanes0.851.001.001.35
Gantelet de mines1.001.000.751.00
Ruée vers les pièces1.150.721.150.80

Ce sont des multiplicateurs d'espacement, donc un nombre inférieur à un signifie plus rapprochés. La règle que j'ai fixée pour le tableau était qu'aucune ligne n'a le droit d'être uniformément plus difficile ou uniformément plus facile que Classique. Chaque archétype pousse un curseur et en ramène un autre. La ruée vers les pièces te donne bien plus de pièces, qui dans TUNL élargissent le corridor, mais elle le paie avec des stalactites plus serrées et plus de mines. Un jour devrait se lire comme un jour différent, pas comme un bon ou un mauvais tirage.

La contrainte qui sous-tend tout cela

Voici la partie qui s'est avérée compter plus que le générateur lui-même.

Parce que la grotte est partagée et que le classement est mondial, tout ce qui décide de l'endroit où un objet est placé doit être une fonction pure de la position du monde et du jour. Rien d'autre n'a le droit de s'y infiltrer. Ni la taille d'écran du joueur, ni son score, ni le fait qu'il ait un bonus actif, ni depuis combien de temps il joue.

Ça semble évident une fois écrit. En pratique, cela interdit une longue liste de choses qui paraissent parfaitement raisonnables quand on les écrit, et les violations ne s'annoncent pas. Rien ne plante. Le jeu a l'air correct. La grotte a simplement, silencieusement, cessé d'être la même grotte.

Bug un : six appareils, six grottes

Les fonctions d'espacement qui décident de la distance entre les obstacles sont des courbes de difficulté, donc naturellement elles lisent la difficulté actuelle. Les versions originales lisaient la variable globale _prog, qui est la difficulté à la position actuelle du joueur.

Les boucles d'apparition ressemblent à ceci :

while (nextStalWx < scrollX + W + margin) { ... }

Elles placent des obstacles légèrement au-delà du bord droit de l'écran. Donc le moment où une position du monde donnée voit son espacement tiré dépend de W, la largeur de l'écran. Un appareil plus large regarde plus loin devant, tire cette position plus tôt dans la partie, et échantillonne donc la courbe de difficulté à une valeur plus basse de _prog qu'un appareil étroit pour exactement le même morceau de monde.

Espacement différent. Grotte différente. Je l'ai mesuré sur six tailles d'appareil et j'ai obtenu six grottes différentes, divergeant à partir de la position du monde 938, soit environ le score 15. Tout ce qui suivait ce point sur le classement quotidien partagé était comparé à travers des tunnels qui n'étaient pas le même tunnel.

La correction a été de passer la position à placer à chaque courbe, pour qu'elles répondent à « quelle est la difficulté là-bas » au lieu de « quelle est la difficulté ici, maintenant » :

function progAt(wx)  { return Math.min(Math.sqrt(Math.max(wx, 0) / 14000), 1); }
function prog2At(wx) { return Math.max(wx - 14000, 0) / 40000; }

Il y avait une seconde instance, plus petite, de la même classe de bug cachée dans le plafonnement de la ligne centrale. WALL_PAD, la marge qui garde le corridor à l'écart du bord de l'écran, était de 8 pixels fixes alors que tout autour évoluait avec la hauteur d'écran. Sur un écran plus grand, ce remplissage fixe est proportionnellement plus petit, ce qui déplaçait légèrement le point de plafonnement, ce qui suffisait à inverser un seul rejet de placement, ce qui scindait la grotte. C'est maintenant une fraction de H comme tout le reste.

Les deux bugs partagent une forme qui mérite d'être nommée : une valeur qui est presque une fonction pure de la position du monde, avec un contaminant qui n'a d'importance que sur du matériel que tu ne possèdes pas. Aucun n'était détectable en jouant. Les deux sont maintenant verrouillés par des tests qui génèrent la grotte à plusieurs tailles d'écran et affirment que les résultats sont identiques octet par octet.

Bug deux : du contenu que personne n'avait jamais vu

Le second échec n'avait rien à voir avec le déterminisme et tout à voir avec le fait de ne pas confronter mes hypothèses à la réalité.

Passé une certaine profondeur, tous les réglages de géométrie du corridor atteignent leurs plafonds, parce que les pousser plus loin rend le tunnel réellement impraticable. Au-delà de ce point, la seule chose qui change encore est la vitesse de défilement, donc une très longue partie était une seule variable devenant lentement plus nerveuse contre un corridor figé. Pour corriger cela, j'ai construit un ensemble de fonctionnalités de parties profondes : une métamorphose de forme qui redistribue les amplitudes des deux ondes à travers une séquence de caractères ensemencée (grands balayages nets, hachage battant, quasi rectiligne), une pulsation de vitesse qui fait respirer le rythme, de larges chambres qui arrivent comme un répit, des rochers comme rares pièces maîtresses.

J'ai tout verrouillé à la position du monde 54 000, soit environ le score 900. Ce nombre venait de la courbe de difficulté, où le plateau commence mathématiquement. C'était le bon nombre pour la géométrie et le mauvais nombre pour les joueurs.

Quand j'ai enfin rejoué de vraies données de classement contre le générateur, la réponse était brutale : pratiquement personne n'en approchait. J'avais passé des semaines à construire du contenu qu'aucun joueur n'avait jamais vu, moi compris.

J'ai donc construit une simulation red-team : 2 400 parties sans interface à travers quatre paliers de compétence calibrés, mesurant jusqu'où chaque palier arrive réellement. Le palier correspondant aux vrais joueurs a atteint la position du monde 30 000 dans 0% de 600 parties. Pas « rarement ». Jamais. Même après avoir déjà abaissé la porte une fois, à 30 000, le contenu restait inatteignable. C'était la même erreur, d'un ordre de grandeur plus petite.

La porte est maintenant à 9 000, environ le score 150, là où la simulation dit qu'une vraie part des parties arrive effectivement :

Palier de compétenceParties atteignant la porte
Moyen3.3%
Bon24.7%
Expert63.5%

Cela n'était sûr à faire que parce que chaque fonctionnalité de partie profonde est bornée relativement à la même position non métamorphosée. Les répartitions d'amplitude de la métamorphose sont plafonnées par rapport à la vitesse du corridor, donc un tronçon métamorphosé ne porte jamais plus d'environ 3% d'énergie d'oscillation de plus que le non métamorphosé, et les chambres ne font qu'élargir le corridor. Rien de tout cela ne peut rendre une position donnée plus difficile que la base réglée à la main, à aucune profondeur, ce qui explique pourquoi déplacer la porte est une décision d'approvisionnement plutôt qu'un changement de difficulté.

Un élément n'a volontairement pas bougé. Les mines biaisées vers l'apex, qui s'alignent sur la ligne centrale à l'apex d'un virage où le corridor t'y force déjà, comportent un vrai risque de passer pour de la triche du jeu. Celle-là reste à l'ancienne profondeur jusqu'à ce que quelqu'un la vole vraiment sur un appareil. Tirer un risque d'équité non prouvé 750 points plus tôt ne fait pas partie d'une correction de rythme.

Ce que le déterminisme rapporte

Toute cette contrainte paie pour trois choses qui seraient autrement bien plus difficiles.

Un fantôme qui ne coûte presque rien

Affronter un rejeu de ta meilleure partie signifie normalement enregistrer des entrées ou l'état des obstacles. Ici la grotte est reproductible à partir d'une date, donc un rejeu n'a besoin de rien d'autre que de la position verticale du vaisseau au fil du temps. Un octet par 60 pixels du monde, quantifié sur la hauteur de l'écran, ce qui fait d'un fantôme complet quelques centaines d'octets et permet à un fantôme enregistré sur un téléphone d'être rejoué correctement sur une tablette.

Il est indexé par la distance parcourue plutôt que par le temps écoulé, ce qui compte parce que l'un des types de pièces ralentit le défilement et le remonte sur plusieurs secondes. Un fantôme indexé sur le temps se désynchroniserait du tunnel à l'instant où quelqu'un en ramasserait une.

Une carte de partage qui est une image de la partie

Parce que le corridor est une fonction pure de la position, tout le tunnel parcouru peut être redessiné après coup, compressé en une bande, avec le point de mort et le record personnel marqués. La carte est une image de la partie plutôt qu'un badge de score, et la générer n'exige aucun enregistrement, juste la distance atteinte.

Un classement qui a un sens

Que tout le monde vole dans une grotte identique est toute la base de la comparaison des scores. C'est aussi pourquoi la largeur de rendu est plafonnée plutôt que de remplir l'écran : l'espacement des obstacles est fixe en position du monde, donc un appareil bien plus large verrait la grotte partagée défiler proportionnellement plus vite et affronterait les mêmes obstacles avec moins de temps de réaction. La sensation verticale est normalisée de la même façon, avec la gravité et la poussée mises à l'échelle par la hauteur d'écran par rapport à une référence, de sorte qu'une trajectoire couvre la même fraction du corridor par seconde sur n'importe quel appareil.

Si je recommençais

Le générateur était la partie facile. Deux ondes et un hachage, c'est un après-midi. Tout ce qui a été coûteux venait de la promesse qui l'enveloppe, que la grotte est la même, ce qui transforme chaque future fonctionnalité en une question de savoir si elle laisse s'infiltrer du non-déterminisme dans le placement.

La leçon que je garderais est la forme des tests. Les bugs de déterminisme sont invisibles en jouant et triviaux par assertion. Générer les premiers milliers de positions à six tailles d'écran et les comparer prend des millisecondes et aurait attrapé les deux bugs d'appareil le jour où ils ont été écrits. L'erreur de porte de contenu demandait un tout autre outil, une simulation de la distance qu'atteignent réellement les joueurs, et la seule vraie leçon là-dessus est qu'un nombre dérivé de ta courbe de difficulté n'est pas un nombre sur tes joueurs.

La grotte d'aujourd'hui est en ligne et elle se réinitialise à minuit UTC. Elle tourne dans le navigateur, sans installation :

Joue la grotte du jour