Una grotta, ogni giocatore

Come TUNL costruisce un livello giornaliero da nient'altro che una data, e i due bug che l'hanno silenziosamente rotto per mesi.

Devlog · 15 settembre 2026 · aggiornato il 23 settembre 2026 per TUNL 17.0 · tutti i post

TUNL è un volo in grotta a spinta mantenuta. Tieni premuto per salire, rilascia per cadere, e cerchi di arrivare più lontano in un tunnel che si restringe rispetto a ieri. Ogni giocatore vola nella stessa grotta lo stesso giorno, e al confine della mezzanotte UTC diventa un'altra.

Non c'è nessun file di livello. Nessun server distribuisce una mappa, niente viene scaricato, e il gioco non memorizza il tunnel appena generato. La grotta è una funzione pura della data, e l'intero "mondo" sono circa quaranta righe di aritmetica.

Questo design compra sorprendentemente molto: una classifica globale equa, un replay fantasma che costa poche centinaia di byte, e un'immagine condivisibile della tua partita. Impone anche un vincolo molto più duro di quanto sembri all'inizio, e l'ho infranto due volte senza accorgermene. Questo post parla di entrambe le metà.

Un livello che è una funzione, non un file

La maggior parte dei giochi procedurali genera un mondo all'inizio della partita e lo tiene in memoria. TUNL non lo tiene mai. Il centro e la semilarghezza del corridoio a qualsiasi posizione del mondo wx sono calcolati su richiesta da due onde sinusoidali sovrapposte:

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 };
}

Questo è lo scheletro. La funzione effettiva aggiunge qualche termine in più, come la metamorfosi di forma delle partite profonde descritta più avanti, ma nessuno di essi prende altro che wx e il giorno.

Due onde invece di una, perché un singolo seno è immediatamente leggibile come ritmo e smette di essere interessante dopo circa dieci secondi. Un arco lento e ampio più un'increspatura più veloce e poco profonda a un rapporto di frequenze quasi irrazionale dà lunghi tratti che non si ripetono mai del tutto. La semiapertura si restringe da circa il 68% dell'altezza dello schermo al 33% man mano che si avanza, e lo fa lentamente: niente per il primo tratto, che è una sala aperta, poi una linea retta che raggiunge il minimo solo dopo circa il punteggio 1.500.

Non ha sempre funzionato così. Il corridoio si restringeva inizialmente su una curva a radice quadrata che raggiungeva il minimo al punteggio 233, una rampa veloce e concentrata all'inizio, e sono arrivati feedback che dicevano che il gioco era troppo difficile. Quindi il corridoio e l'ampiezza delle sue onde hanno ricevuto un proprio orologio lento, il g qui sopra. Le frequenze delle onde sono rimaste volutamente sulla vecchia curva a radice: sin(wx * f) a un grande wx è molto sensibile a f, quindi ritmare di nuovo una frequenza a metà partita rimescola la fase di tutto ciò che hai già alle spalle. Ampiezza e larghezza si possono rallentare in sicurezza, la frequenza no.

La proprietà importante non è la forma. È che boundsBase(wx) prende solo wx. Chiedile della posizione del mondo 40.000 al primo frame di una partita e risponde istantaneamente, in modo identico, su qualsiasi dispositivo, senza aver generato nulla nel frattempo.

Dove entra il giorno

La data entra attraverso esattamente due variabili in quello snippet: _wavePhase1 e _wavePhase2. La data UTC diventa un intero come 20260915, che passa attraverso una catena di hash:

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);
}

Date consecutive differiscono di uno, quindi l'hash deve disperdere molto o oggi e domani sembrerebbero vicini di casa. La catena multiply-xorshift lo fa: 20260915 e 20260916 producono fasi completamente indipendenti.

Le variazioni sono volutamente piccole. La sola fase cambia dove cadono le curve ma non come si sente la giornata, e volevo che ogni giorno avesse un carattere leggermente diverso senza che nessun giorno si allontanasse da una base che ha richiesto molto tempo per essere calibrata. L'otto per cento basta per notarsi in una settimana e non abbastanza per far sembrare sbagliato un giorno.

Quattro flussi casuali, non uno

Gli ostacoli sono un problema separato dalla forma del corridoio. Stalattiti, monete, mine e cannoni hanno ciascuno il proprio generatore indipendente, tutti derivati dallo stesso giorno:

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 singolo flusso condiviso sarebbe stato più semplice e sarebbe stato un errore. Il posizionamento delle monete può scartare una posizione candidata, per esempio quando finisce troppo vicino a una stalattite. Su un flusso condiviso ogni scarto consuma un numero e sposta tutto ciò che viene estratto dopo, quindi una moneta il cui posizionamento fallisce sposterebbe silenziosamente la mina successiva. I sali separati rendono i quattro sistemi davvero indipendenti invece di copie sfasate di una sequenza, e significano che posso cambiare come vengono posizionate le monete senza spostare una sola mina.

Archetipi giornalieri

Fase e variazione cambiano la forma di un giorno. Per cambiarne il sapore, il seme sceglie anche uno di quattro archetipi, che scalano le curve di spaziatura già presenti nel gioco:

ArchetipoStalattitiMoneteMineStrettoie
Classico1.001.001.001.00
Giorno delle strettoie0.851.001.001.35
Percorso a mine1.001.000.751.00
Corsa alle monete1.150.721.150.80

Questi sono moltiplicatori di spaziatura, quindi un numero inferiore a uno significa più ravvicinati. La regola che ho fissato per la tabella era che nessuna riga può essere uniformemente più difficile o più facile di Classico. Ogni archetipo spinge una manopola e ne tira indietro un'altra. La corsa alle monete ti dà molte più monete, che in TUNL allargano il corridoio, ma lo paga con stalattiti più strette e più mine. Un giorno dovrebbe leggersi come un giorno diverso, non come un buon o cattivo lancio.

Il vincolo sotto tutto questo

Ecco la parte che si è rivelata contare più del generatore stesso.

Poiché la grotta è condivisa e la classifica è globale, tutto ciò che decide dove viene posizionato un oggetto deve essere una funzione pura della posizione nel mondo e del giorno. Nient'altro può infiltrarsi. Non la dimensione dello schermo del giocatore, non il suo punteggio, non se ha un potenziamento attivo, non da quanto tempo sta giocando.

Sembra ovvio scritto così. In pratica vieta un lungo elenco di cose che sembrano del tutto ragionevoli mentre le scrivi, e le violazioni non si annunciano. Niente va in crash. Il gioco sembra a posto. La grotta ha semplicemente, silenziosamente, smesso di essere la stessa grotta.

Bug uno: sei dispositivi, sei grotte

Le funzioni di spaziatura che decidono quanto distano gli ostacoli sono curve di difficoltà, quindi naturalmente leggono la difficoltà attuale. Le versioni originali leggevano la variabile globale _prog, che è la difficoltà alla posizione attuale del giocatore.

I cicli di generazione sono fatti così:

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

Posizionano ostacoli leggermente oltre il bordo destro dello schermo. Quindi il momento in cui una data posizione del mondo ottiene la sua spaziatura estratta dipende da W, la larghezza dello schermo. Un dispositivo più largo guarda più avanti, estrae quella posizione prima nella partita, e quindi campiona la curva di difficoltà a un valore più basso di _prog rispetto a un dispositivo stretto per lo stesso identico pezzo di mondo.

Spaziatura diversa. Grotta diversa. L'ho misurato su sei dimensioni di dispositivo e ho ottenuto sei grotte diverse, divergenti dalla posizione del mondo 938, circa il punteggio 15. Tutto ciò che seguiva quel punto sulla classifica giornaliera condivisa veniva confrontato tra tunnel che non erano lo stesso tunnel.

La correzione è stata passare la posizione da posizionare a ogni curva, così rispondono a "quanto è difficile là" invece di "quanto è difficile qui, adesso":

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

C'era una seconda, più piccola istanza della stessa classe di bug nascosta nel limite della linea centrale. WALL_PAD, il margine che tiene il corridoio lontano dal bordo dello schermo, era di 8 pixel fissi mentre tutto intorno scalava con l'altezza dello schermo. Su uno schermo più alto quel margine fisso è proporzionalmente più piccolo, il che spostava leggermente il punto di limite, il che bastava a ribaltare un singolo scarto di posizionamento, il che divideva la grotta. Ora è una frazione di H come tutto il resto.

Entrambi i bug condividono una forma che merita un nome: un valore che è quasi una funzione pura della posizione nel mondo, con un contaminante che conta solo su hardware che non possiedi. Nessuno era trovabile giocando. Entrambi sono ora bloccati da test che generano la grotta a diverse dimensioni di schermo e verificano che i risultati siano identici byte per byte.

Bug due: contenuti che nessuno aveva mai visto

Il secondo fallimento non aveva niente a che fare con il determinismo e tutto a che fare con il non aver verificato le mie ipotesi contro la realtà.

Oltre una certa profondità tutte le manopole della geometria del corridoio raggiungono i loro limiti, perché spingerle oltre rende il tunnel davvero impraticabile. Oltre quel punto l'unica cosa che cambia ancora è la velocità di scorrimento, quindi una partita molto lunga era una sola variabile che diventava lentamente più nervosa contro un corridoio congelato. Per risolverlo ho costruito un insieme di funzionalità per le partite profonde: una metamorfosi di forma che ridistribuisce le ampiezze delle due onde attraverso una sequenza di caratteri con seme (grandi curve pulite, tritatura battente, quasi rettilineo), un impulso di velocità che fa respirare il ritmo, ampie camere che arrivano come una pausa, massi come rari pezzi forti.

Ho fissato tutto alla posizione del mondo 54.000, che è intorno al punteggio 900. Quel numero veniva dalla curva di difficoltà, dove il plateau inizia matematicamente. Era il numero giusto per la geometria e il numero sbagliato per i giocatori.

Quando ho finalmente rigiocato dati reali della classifica contro il generatore, la risposta è stata brusca: praticamente nessuno ci si avvicinava. Avevo passato settimane a costruire contenuti che nessun giocatore aveva mai visto, me compreso.

Quindi ho costruito una simulazione red-team: 2.400 partite senza interfaccia su quattro fasce di abilità calibrate, misurando fin dove arriva davvero ogni fascia. La fascia che corrispondeva ai giocatori reali ha raggiunto la posizione del mondo 30.000 nello 0% di 600 partite. Non "raramente". Mai. Anche dopo aver già spostato il cancello una volta, a 30.000, il contenuto era ancora irraggiungibile. Era lo stesso errore, un ordine di grandezza più piccolo.

Il cancello ora è a 9.000, intorno al punteggio 150, dove la simulazione dice che una parte reale delle partite arriva effettivamente:

Fascia di abilitàPartite che raggiungono il cancello
Medio3.3%
Buono24.7%
Esperto63.5%

Questo era sicuro da fare solo perché ogni funzionalità delle partite profonde è delimitata rispetto alla stessa posizione non metamorfosata. Le ripartizioni di ampiezza della metamorfosi sono limitate rispetto alla velocità del corridoio, così un tratto metamorfosato non porta mai più del 3% circa di energia di oscillazione in più rispetto a quello non metamorfosato, e le camere allargano soltanto il corridoio. Niente di tutto questo può rendere una data posizione più difficile della base tarata a mano, a qualsiasi profondità, ed è per questo che spostare il cancello è una decisione di offerta piuttosto che un cambiamento di difficoltà.

Un elemento deliberatamente non si è mosso. Le mine con bias verso l'apice, che scattano verso la linea centrale all'apice di una curva dove il corridoio ti forza già lì, portano un rischio reale di sembrare che il gioco stia barando. Quella resta alla vecchia profondità finché qualcuno non la vola davvero su un dispositivo. Anticipare di 750 punti un rischio di equità non provato non fa parte di una correzione del ritmo.

Cosa compra il determinismo

Tutto questo vincolo paga per tre cose che altrimenti sarebbero molto più difficili.

Un fantasma che non costa quasi nulla

Gareggiare contro un replay della tua migliore partita normalmente significa registrare input o stato degli ostacoli. Qui la grotta è riproducibile da una data, quindi un replay non ha bisogno di nulla se non della posizione verticale della navicella nel tempo. Un byte per 60 pixel del mondo, quantizzato sull'altezza dello schermo, il che rende un fantasma completo poche centinaia di byte e permette a un fantasma registrato su un telefono di essere riprodotto correttamente su un tablet.

È indicizzato per distanza percorsa invece che per tempo trascorso, il che conta perché uno dei tipi di moneta rallenta lo scorrimento e lo riporta su nell'arco di diversi secondi. Un fantasma indicizzato per tempo si desincronizzerebbe dal tunnel nel momento in cui qualcuno ne raccoglie una.

Una scheda di condivisione che è un'immagine della partita

Poiché il corridoio è una funzione pura della posizione, l'intero tunnel volato può essere ridisegnato a posteriori, compresso in una striscia, con il punto di morte e il record personale segnati. La scheda è un'immagine della partita invece di un badge di punteggio, e generarla non richiede alcuna registrazione, solo la distanza raggiunta.

Una classifica che significa qualcosa

Il fatto che tutti volino in una grotta identica è l'intera base per confrontare i punteggi. È anche il motivo per cui la larghezza di rendering è limitata invece di riempire lo schermo: la spaziatura degli ostacoli è fissa nella posizione del mondo, quindi un dispositivo molto più largo vedrebbe la grotta condivisa scorrere proporzionalmente più veloce e affronterebbe gli stessi ostacoli con meno tempo di reazione. La sensazione verticale è normalizzata allo stesso modo, con gravità e spinta scalate per altezza dello schermo rispetto a un riferimento, così una traiettoria copre la stessa frazione del corridoio al secondo su qualsiasi dispositivo.

Se ricominciassi

Il generatore era la parte facile. Due onde e un hash sono un pomeriggio. Tutto ciò che è stato costoso veniva dalla promessa avvolta intorno, che la grotta è la stessa, il che trasforma ogni funzionalità futura in una domanda su se infiltri non determinismo nel posizionamento.

La lezione che terrei è la forma dei test. I bug di determinismo sono invisibili giocando e banali per asserzione. Generare le prime migliaia di posizioni a sei dimensioni di schermo e confrontarle richiede millisecondi e avrebbe individuato entrambi i bug dei dispositivi il giorno in cui sono stati scritti. L'errore del cancello dei contenuti richiedeva uno strumento completamente diverso, una simulazione di quanto arrivano davvero i giocatori, e l'unica vera lezione è che un numero derivato dalla tua curva di difficoltà non è un numero sui tuoi giocatori.

La grotta di oggi è online e si azzera alla mezzanotte UTC. Funziona nel browser, senza installazione:

Gioca la grotta di oggi