Eine Höhle, jeder Spieler
Wie TUNL ein tägliches Level aus nichts als einem Datum baut, und die zwei Bugs, die es monatelang still und leise kaputt machten.
TUNL ist ein Halten-zum-Schub-Höhlenflieger. Du hältst zum Steigen, lässt los zum Fallen, und versuchst, weiter durch einen sich verengenden Tunnel zu kommen als gestern. Jeder Spieler fliegt dieselbe Höhle am selben Tag, und an der UTC-Mitternachtsgrenze wird sie zu einer anderen.
Es gibt keine Level-Datei. Kein Server gibt eine Karte heraus, nichts wird heruntergeladen, und das Spiel speichert den gerade erzeugten Tunnel nicht. Die Höhle ist eine reine Funktion des Datums, und die gesamte „Welt" sind etwa vierzig Zeilen Arithmetik.
Dieses Design erkauft einiges Überraschendes: eine faire globale Bestenliste, ein Geister-Replay, das ein paar hundert Bytes kostet, und ein teilbares Bild deines Laufs. Es erzwingt auch eine Einschränkung, die viel härter ist, als sie zunächst aussieht, und ich habe sie zweimal gebrochen, ohne es zu merken. Dieser Beitrag handelt von beiden Hälften.
Ein Level, das eine Funktion ist, keine Datei
Die meisten prozeduralen Spiele erzeugen eine Welt beim Laufstart und halten sie im Speicher. TUNL hält sie überhaupt nie. Die Mitte des Korridors und die Halbbreite an jeder Weltposition wx werden bei Bedarf aus zwei überlagerten Sinuswellen berechnet:
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 };
}
Das ist das Skelett. Die tatsächliche Funktion fügt noch ein paar Terme hinzu, wie den weiter unten beschriebenen Deep-Run-Form-Morph, aber keiner davon nimmt etwas anderes als wx und den Tag entgegen.
Zwei Wellen statt einer, weil ein einzelner Sinus sofort als Rhythmus erkennbar ist und nach etwa zehn Sekunden aufhört, interessant zu sein. Ein langsamer weiter Bogen plus eine schnellere flache Welligkeit bei einem fast irrationalen Frequenzverhältnis ergibt lange Strecken, die sich nie ganz wiederholen. Der Halbabstand verengt sich von etwa 68% der Bildschirmhöhe auf 33%, je weiter man kommt, und das tut er langsam: gar nichts für die erste Strecke, die eine offene Halle ist, dann eine gerade Linie, die erst nach etwa Punktzahl 1.500 ihren Tiefpunkt erreicht.
Es war nicht immer so. Der Korridor verengte sich zuerst auf einer Quadratwurzelkurve, die bei Punktzahl 233 ihr Schmalstes erreichte, was eine schnelle, vorn-lastige Rampe ist, und Feedback kam zurück, dass das Spiel viel zu schwer sei. Also bekamen der Korridor und seine Wellenamplitude ihre eigene langsame Uhr, das g oben. Die Wellenfrequenzen blieben absichtlich auf der alten sqrt-Kurve: sin(wx * f) bei großem wx reagiert sehr empfindlich auf f, sodass eine Frequenz mitten in einem Lauf neu zu takten die Phase von allem, was bereits hinter dir liegt, durcheinanderbringt. Amplitude und Breite lassen sich sicher verlangsamen, Frequenz nicht.
Die wichtige Eigenschaft ist nicht die Form. Es ist, dass boundsBase(wx) nur wx entgegennimmt. Frag sie nach Weltposition 40.000 im ersten Frame eines Laufs, und sie antwortet sofort, identisch, auf jedem Gerät, ohne dazwischen irgendetwas erzeugt zu haben.
Wo der Tag ins Spiel kommt
Das Datum kommt genau über zwei Variablen in diesem Ausschnitt herein: _wavePhase1 und _wavePhase2. Das UTC-Datum wird zu einer Ganzzahl wie 20260915, die durch eine Hash-Kette läuft:
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);
}
Aufeinanderfolgende Daten unterscheiden sich um eins, also muss der Hash stark streuen, sonst würden heute und morgen wie nahe Nachbarn aussehen. Die Multiply-Xorshift-Kette tut das: 20260915 und 20260916 erzeugen völlig unabhängige Phasen.
Die Schwankungen sind absichtlich klein. Die Phase allein ändert, wo die Biegungen fallen, aber nicht, wie sich der Tag anfühlt, und ich wollte, dass jeder Tag einen leicht anderen Charakter hat, ohne dass irgendein Tag von einer Basislinie abdriftet, die lange zum Abstimmen brauchte. Acht Prozent reichen, um über eine Woche hinweg aufzufallen, und nicht genug, um einen Tag falsch wirken zu lassen.
Vier Zufallsströme, nicht einer
Hindernisse sind ein von der Korridorform getrenntes Problem. Stalaktiten, Münzen, Minen und Kanonen bekommen jeweils ihren eigenen unabhängigen Generator, alle vom selben Tag abgeleitet:
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));
Ein einziger geteilter Strom wäre einfacher gewesen und wäre ein Fehler gewesen. Die Münzplatzierung kann eine Kandidatenposition ablehnen, zum Beispiel wenn sie zu nah an einem Stalaktiten landet. Bei einem geteilten Strom verbraucht jede Ablehnung eine Zahl und verschiebt alles danach Gezeichnete, sodass eine Münze, deren Platzierung fehlschlägt, stillschweigend die nächste Mine verschieben würde. Die getrennten Salts machen die vier Systeme wirklich unabhängig statt phasenverschobener Kopien einer Sequenz, und sie bedeuten, dass ich ändern kann, wie Münzen platziert werden, ohne eine einzige Mine zu bewegen.
Tagesarchetypen
Phase und Schwankung ändern die Form eines Tages. Um seinen Geschmack zu ändern, wählt der Seed auch einen von vier Archetypen aus, die die bereits im Spiel vorhandenen Abstandskurven skalieren:
| Archetyp | Stalaktiten | Münzen | Minen | Engstellen |
|---|---|---|---|---|
| Klassisch | 1.00 | 1.00 | 1.00 | 1.00 |
| Engstellen-Tag | 0.85 | 1.00 | 1.00 | 1.35 |
| Minen-Spießrutenlauf | 1.00 | 1.00 | 0.75 | 1.00 |
| Münzrausch | 1.15 | 0.72 | 1.15 | 0.80 |
Das sind Abstandsmultiplikatoren, also bedeutet eine Zahl unter eins enger zusammen. Die Regel, die ich für die Tabelle festgelegt habe, war, dass keine Zeile durchgängig schwerer oder durchgängig leichter als Klassisch sein darf. Jeder Archetyp schiebt einen Regler nach oben und zieht einen anderen zurück. Münzrausch gibt dir viel mehr Münzen, die in TUNL den Korridor verbreitern, zahlt dafür aber mit engeren Stalaktiten und mehr Minen. Ein Tag sollte sich wie ein anderer Tag lesen, nicht wie ein guter oder schlechter Wurf.
Die Einschränkung darunter
Hier ist der Teil, der sich als wichtiger herausstellte als der Generator selbst.
Weil die Höhle geteilt und die Bestenliste global ist, muss alles, was entscheidet, wo ein Objekt platziert wird, eine reine Funktion der Weltposition und des Tages sein. Nichts anderes darf hineinsickern. Nicht die Bildschirmgröße des Spielers, nicht seine Punktzahl, nicht ob er zufällig ein aktives Power-up hat, nicht wie lange er schon spielt.
Das klingt naheliegend, wenn man es aufschreibt. In der Praxis verbietet es eine lange Liste von Dingen, die sich völlig vernünftig anfühlen, während man sie schreibt, und die Verstöße kündigen sich nicht an. Nichts stürzt ab. Das Spiel sieht richtig aus. Die Höhle hat einfach, still und leise, aufgehört, dieselbe Höhle zu sein.
Bug eins: sechs Geräte, sechs Höhlen
Die Abstandsfunktionen, die entscheiden, wie weit Hindernisse auseinanderliegen, sind Schwierigkeitskurven, also lesen sie natürlich die aktuelle Schwierigkeit. Die ursprünglichen Versionen lasen die globale Variable _prog, die die Schwierigkeit an der aktuellen Position des Spielers ist.
Die Spawn-Schleifen sehen so aus:
while (nextStalWx < scrollX + W + margin) { ... }
Sie platzieren Hindernisse etwas jenseits des rechten Bildschirmrands. Also hängt der Moment, in dem eine gegebene Weltposition ihren Abstand ausgewürfelt bekommt, von W ab, der Bildschirmbreite. Ein breiteres Gerät reicht weiter nach vorn, würfelt diese Position früher im Lauf und tastet die Schwierigkeitskurve daher bei einem niedrigeren _prog ab als ein schmales Gerät für exakt dasselbe Stück Welt.
Unterschiedlicher Abstand. Unterschiedliche Höhle. Ich habe es über sechs Gerätegrößen gemessen und sechs verschiedene Höhlen erhalten, divergierend ab Weltposition 938, was etwa Punktzahl 15 ist. Alles nach diesem Punkt auf der geteilten täglichen Bestenliste wurde über Tunnel hinweg verglichen, die nicht derselbe Tunnel waren.
Die Lösung war, die zu platzierende Position an jede Kurve zu übergeben, sodass sie „wie schwer ist es dort" statt „wie schwer ist es hier, jetzt" beantworten:
function progAt(wx) { return Math.min(Math.sqrt(Math.max(wx, 0) / 14000), 1); }
function prog2At(wx) { return Math.max(wx - 14000, 0) / 40000; }
Es gab eine zweite, kleinere Instanz derselben Bug-Klasse, versteckt in der Mittellinien-Begrenzung. WALL_PAD, der Rand, der den Korridor vom Bildschirmrand fernhält, war flache 8 Pixel, während alles darum herum mit der Bildschirmhöhe skalierte. Auf einem höheren Bildschirm ist dieses flache Padding proportional kleiner, was den Begrenzungspunkt leicht verschob, was ausreichte, um eine einzelne Platzierungsablehnung umzukippen, was die Höhle spaltete. Sie ist jetzt ein Bruchteil von H wie alles andere.
Beide Bugs teilen eine benennenswerte Form: ein Wert, der fast eine reine Funktion der Weltposition ist, mit einem Verunreiniger, der nur auf Hardware zählt, die man nicht besitzt. Keiner war durch Spielen auffindbar. Beide sind jetzt durch Tests abgesichert, die die Höhle bei mehreren Bildschirmgrößen erzeugen und bestätigen, dass die Ergebnisse byte-identisch sind.
Bug zwei: Inhalt, den niemand je gesehen hatte
Der zweite Fehler hatte nichts mit Determinismus zu tun und alles damit, meine Annahmen nicht gegen die Realität geprüft zu haben.
Ab einer gewissen Tiefe stoßen alle Korridor-Geometrie-Regler an ihre Obergrenzen, weil sie weiter zu schieben den Tunnel wirklich unbefliegbar machen würde. Jenseits dieses Punkts ändert sich nur noch die Scrollgeschwindigkeit, sodass ein sehr langer Lauf eine Variable war, die gegen einen eingefrorenen Korridor langsam zappeliger wurde. Um das zu beheben, baute ich eine Reihe von Deep-Run-Features: einen Form-Morph, der die Amplituden der beiden Wellen durch eine geseedete Sequenz von Charakteren umverteilt (große saubere Schwünge, stotterndes Gehacke, fast gerade), einen Geschwindigkeitspuls, der das Tempo atmen lässt, breite Kammern, die als Verschnaufpause ankommen, Felsbrocken als seltene Set-Pieces.
Ich schaltete das alles bei Weltposition 54.000 frei, was um Punktzahl 900 herum liegt. Diese Zahl kam von der Schwierigkeitskurve, wo das Plateau mathematisch beginnt. Es war die richtige Zahl für die Geometrie und die falsche Zahl für die Spieler.
Als ich schließlich echte Bestenlisten-Daten gegen den Generator abspielte, war die Antwort unverblümt: praktisch niemand kam auch nur annähernd dorthin. Ich hatte Wochen damit verbracht, Inhalte zu bauen, die kein einziger Spieler je gesehen hatte, mich eingeschlossen.
Also baute ich eine Red-Team-Simulation: 2.400 kopflose Läufe über vier kalibrierte Könnensstufen, die maßen, wie weit jede Stufe tatsächlich kommt. Die Stufe, die echten Spielern entsprach, erreichte Weltposition 30.000 in 0% von 600 Läufen. Nicht „selten". Nie. Selbst nachdem ich das Gate schon einmal auf 30.000 heruntergesetzt hatte, war der Inhalt immer noch unerreichbar. Es war derselbe Fehler, eine Größenordnung kleiner.
Das Gate liegt jetzt bei 9.000, um Punktzahl 150, wo die Simulation sagt, dass ein echter Anteil der Läufe tatsächlich ankommt:
| Könnensstufe | Läufe, die das Gate erreichen |
|---|---|
| Durchschnitt | 3.3% |
| Gut | 24.7% |
| Experte | 63.5% |
Das war nur deshalb sicher zu tun, weil jedes Deep-Run-Feature relativ zur selben unmorphten Position begrenzt ist. Die Amplitudenaufteilungen des Morphs sind gegen die Korridorgeschwindigkeit gedeckelt, sodass eine morphte Strecke nie mehr als etwa 3% mehr Wackelenergie trägt als die unmorphte, und Kammern verbreitern den Korridor immer nur. Nichts davon kann eine gegebene Position schwerer machen als die handabgestimmte Basislinie, bei jeder Tiefe, weshalb das Verschieben des Gates eine Angebotsentscheidung ist, keine Schwierigkeitsänderung.
Ein Teil bewegte sich absichtlich nicht. Apex-vorgezogene Minen, die sich an einem Kurvenscheitel zur Mittellinie hin ausrichten, wo der Korridor dich ohnehin schon dorthin zwingt, tragen ein echtes Risiko, sich wie Betrug des Spiels anzufühlen. Die bleibt in der alten Tiefe, bis sie jemand tatsächlich auf einem Gerät fliegt. Ein unbewiesenes Fairness-Risiko 750 Punkte früher zu ziehen, ist nicht Teil einer Tempokorrektur.
Was Determinismus erkauft
All diese Einschränkung zahlt sich für drei Dinge aus, die sonst viel schwerer wären.
Ein Geist, der fast nichts kostet
Ein Replay des besten Laufs zu bestreiten bedeutet normalerweise, Eingaben oder Hindernis-Status aufzuzeichnen. Hier ist die Höhle aus einem Datum reproduzierbar, sodass ein Replay nichts braucht außer der vertikalen Position des Schiffs über die Zeit. Ein Byte pro 60 Weltpixel, quantisiert über die Bildschirmhöhe, was einen vollständigen Geist ein paar hundert Bytes kostet und einen auf einem Handy aufgezeichneten Geist auf einem Tablet korrekt abspielen lässt.
Er ist nach zurückgelegter Distanz statt nach verstrichener Zeit indiziert, was wichtig ist, weil einer der Münztypen den Scroll verlangsamt und über mehrere Sekunden wieder hochfährt. Ein zeitindizierter Geist würde sich in dem Moment vom Tunnel entsynchronisieren, in dem jemand eine aufsammelt.
Eine Share-Karte, die ein Bild des Laufs ist
Weil der Korridor eine reine Funktion der Position ist, kann der ganze geflogene Tunnel im Nachhinein neu gezeichnet werden, komprimiert in einen Streifen, mit markiertem Todespunkt und persönlichem Bestwert. Die Karte ist ein Bild des Laufs statt eines Punktzahl-Abzeichens, und sie zu erzeugen braucht überhaupt keine Aufzeichnung, nur die erreichte Distanz.
Eine Bestenliste, die etwas bedeutet
Dass alle eine identische Höhle fliegen, ist die gesamte Grundlage für den Vergleich von Punktzahlen. Es ist auch der Grund, warum die Render-Breite gedeckelt ist, statt den Bildschirm zu füllen: Der Hindernisabstand ist in Weltposition fest, sodass ein viel breiteres Gerät die geteilte Höhle proportional schneller vorbeiscrollen sähe und dieselben Hindernisse mit weniger Reaktionszeit konfrontieren würde. Das vertikale Gefühl wird auf dieselbe Weise normalisiert, mit Schwerkraft und Schub, skaliert nach Bildschirmhöhe gegen eine Referenz, sodass eine Flugbahn auf jedem Gerät denselben Anteil des Korridors pro Sekunde abdeckt.
Würde ich noch einmal anfangen
Der Generator war der leichte Teil. Zwei Wellen und ein Hash sind ein Nachmittag. Alles Teure kam von dem Versprechen, das drum herum gewickelt ist, dass die Höhle dieselbe ist, was jedes zukünftige Feature in eine Frage darüber verwandelt, ob es Nicht-Determinismus in die Platzierung sickern lässt.
Die Lektion, die ich behalten würde, ist die Testform. Determinismus-Bugs sind durch Spielen unsichtbar und durch Assertion trivial. Die ersten paar tausend Positionen bei sechs Bildschirmgrößen zu erzeugen und sie zu vergleichen, dauert Millisekunden und hätte beide Gerätebugs an dem Tag erwischt, an dem sie geschrieben wurden. Der Content-Gating-Fehler brauchte ein völlig anderes Werkzeug, eine Simulation davon, wie weit Spieler tatsächlich kommen, und die einzige echte Lektion dabei ist, dass eine aus deiner Schwierigkeitskurve abgeleitete Zahl keine Zahl über deine Spieler ist.
Die heutige Höhle ist live und setzt sich um UTC-Mitternacht zurück. Sie läuft im Browser, keine Installation nötig: