One Cave, Every Player
How TUNL builds a daily level out of nothing but a date, and the two bugs that quietly broke it for months.
TUNL is a hold-to-thrust cave flyer. You hold to climb, release to fall, and try to get further down a narrowing tunnel than you did yesterday. Every player flies the same cave on the same day, and at the UTC midnight boundary it becomes a different one.
There is no level file. No server hands out a map, nothing is downloaded, and the game does not store the tunnel it just generated. The cave is a pure function of the date, and the entire "world" is about forty lines of arithmetic.
That design buys a surprising amount: a fair global leaderboard, a ghost replay that costs a few hundred bytes, and a shareable picture of your run. It also imposes a constraint that is much harsher than it first looks, and I broke it twice without noticing. This post is about both halves.
A level that is a function, not a file
Most procedural games generate a world at run start and hold it in memory. TUNL never
holds it at all. The corridor's centre and half-width at any world position wx
are computed on demand from two overlapping sine waves:
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 };
}
This is the skeleton. The shipping function adds a few more terms, such as
the deep-run shape morph described further down, but none of them take anything except
wx and the day.
Two waves rather than one, because a single sine is immediately readable as a rhythm and stops being interesting after about ten seconds. A slow wide arc plus a faster shallow ripple at an irrational-ish frequency ratio gives long stretches that never quite repeat. The half-gap narrows from roughly 68% of the screen height down to 33% as you get further in, and it does that slowly: nothing at all for the first stretch, which is an open hall, then a straight line that only bottoms out after around score 1,500.
It did not always work that way. The corridor first narrowed on a square-root curve that
hit its narrowest at score 233, which is a fast, front-loaded ramp, and feedback came back that
the game was far too hard. So the corridor and its wave amplitude got their own
slow clock, g above. The wave frequencies stayed on the old sqrt curve on
purpose: sin(wx * f) at a large wx is very sensitive to f, so
re-pacing a frequency midway through a run scrambles the phase of everything already
behind you. Amplitude and width are safe to slow down, frequency is not.
The important property is not the shape. It is that boundsBase(wx) takes
only wx. Ask it about world position 40,000 on the first frame of a run and
it answers instantly, identically, on any device, without having generated anything in
between.
Where the day comes in
The date enters through exactly two variables in that snippet: _wavePhase1
and _wavePhase2. The UTC date becomes an integer like 20260915,
which is run through a hash chain:
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);
}
Consecutive dates differ by one, so the hash has to scatter hard or today and tomorrow
would look like near neighbours. The multiply-xorshift chain does that: 20260915
and 20260916 produce completely unrelated phases.
The jitters are deliberately small. Phase alone changes where the bends fall but not how the day feels, and I wanted each day to have a slightly different character without any day drifting away from a baseline that took a long time to tune. Eight percent is enough to notice across a week and not enough to make a day feel wrong.
Four random streams, not one
Obstacles are a separate problem from corridor shape. Stalactites, coins, mines and cannons each get their own independent generator, all derived from the same day:
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));
A single shared stream would have been simpler and would have been a mistake. Coin placement can reject a candidate position, for example when it lands too close to a stalactite. On a shared stream every rejection consumes a number and shifts everything drawn afterwards, so a coin that fails placement would silently move the next mine. The separate salts make the four systems genuinely independent rather than phase-shifted copies of one sequence, and they mean I can change how coins are placed without moving a single mine.
Day archetypes
Phase and jitter change the shape of a day. To change its flavour, the seed also picks one of four archetypes, which scale the spacing curves already in the game:
| Archetype | Stalactites | Coins | Mines | Chicanes |
|---|---|---|---|---|
| Classic | 1.00 | 1.00 | 1.00 | 1.00 |
| Chicane Day | 0.85 | 1.00 | 1.00 | 1.35 |
| Mine Gauntlet | 1.00 | 1.00 | 0.75 | 1.00 |
| Coin Rush | 1.15 | 0.72 | 1.15 | 0.80 |
These are spacing multipliers, so a number below one means closer together. The rule I set for the table was that no row is allowed to be uniformly harder or uniformly easier than Classic. Every archetype pushes one knob and pulls another back. Coin Rush hands you far more coins, which in TUNL widen the corridor, but it pays for that with tighter stalactites and more mines. A day should read as a different day, not as a good or bad roll.
The constraint underneath all of this
Here is the part that turned out to matter more than the generator itself.
Because the cave is shared and the leaderboard is global, anything that decides where an object is placed must be a pure function of world position and the day. Nothing else is allowed to leak in. Not the player's screen size, not their score, not whether they happen to have a power-up active, not how long they have been playing.
That sounds obvious written down. In practice it forbids a long list of things that feel completely reasonable while you are writing them, and the violations do not announce themselves. Nothing crashes. The game looks right. The cave has simply, quietly, stopped being the same cave.
Bug one: six devices, six caves
The spacing functions that decide how far apart obstacles sit are difficulty curves, so
naturally they read the current difficulty. The original versions read the
_prog global, which is difficulty at the player's current position.
The spawn loops look like this:
while (nextStalWx < scrollX + W + margin) { ... }
They place obstacles slightly beyond the right edge of the screen. So the moment a given
world position gets its spacing rolled depends on W, the screen width. A wider
device reaches forward further, rolls that position earlier in the run, and therefore samples
the difficulty curve at a lower _prog than a narrow device does for the
exact same piece of world.
Different spacing. Different cave. I measured it across six device sizes and got six different caves, diverging from world position 938, which is roughly score 15. Everything after that point on the shared daily leaderboard was being compared across tunnels that were not the same tunnel.
The fix was to pass the position being placed into every curve, so they answer "how hard is it there" instead of "how hard is it here, now":
function progAt(wx) { return Math.min(Math.sqrt(Math.max(wx, 0) / 14000), 1); }
function prog2At(wx) { return Math.max(wx - 14000, 0) / 40000; }
There was a second, smaller instance of the same class of bug hiding in the centreline
clamp. WALL_PAD, the margin keeping the corridor off the screen edge, was a flat
8 pixels while everything around it scaled with screen height. On a taller screen that flat
padding is proportionally smaller, which moved the clamp point slightly, which was enough to
flip a single placement rejection, which forked the cave. It is now a fraction of
H like everything else.
Both bugs share a shape worth naming: a value that is almost a pure function of world position, with one contaminant that only matters on hardware you do not own. Neither was findable by playing. Both are now locked down by tests that generate the cave at several screen sizes and assert the results are byte-identical.
Bug two: content nobody had ever seen
The second failure had nothing to do with determinism and everything to do with not checking my assumptions against reality.
Past a certain depth all the corridor geometry knobs hit their caps, because pushing them further makes the tunnel genuinely unnavigable. Beyond that point the only thing still changing is scroll speed, so a very long run was one variable getting slowly twitchier against a frozen corridor. To fix that I built a set of deep-run features: a shape morph that redistributes the two waves' amplitudes through a seeded sequence of characters (big clean sweeps, beating chop, near-straight), a speed pulse that makes the pace breathe, wide chambers that arrive as a breather, boulders as rare set pieces.
I gated all of it at world position 54,000, which is around score 900. That number came from the difficulty curve, where the plateau mathematically begins. It was the right number for the geometry and the wrong number for the players.
When I finally replayed real leaderboard data against the generator, the answer was blunt: essentially nobody was getting anywhere near it. I had spent weeks building content that had never been seen by a single player, including me.
So I built a red-team simulation: 2,400 headless runs across four calibrated skill tiers, measuring how far each tier actually reaches. The tier matching real players reached world position 30,000 in 0% of 600 runs. Not "rarely". Never. Even after I had already moved the gate down once, to 30,000, the content was still unreachable. It was the same mistake, one order of magnitude smaller.
The gate now sits at 9,000, around score 150, where the simulation says a real share of runs actually arrive:
| Skill tier | Runs reaching the gate |
|---|---|
| Average | 3.3% |
| Good | 24.7% |
| Expert | 63.5% |
This was safe to do only because every deep-run feature is bounded relative to the same position unmorphed. The morph's amplitude splits are capped against corridor velocity so a morphed stretch never carries more than about 3% more wiggle energy than the unmorphed one, and chambers only ever widen the corridor. None of it can make a given position harder than the hand-tuned baseline, at any depth, which is why moving the gate is a supply decision rather than a difficulty change.
One piece deliberately did not move. Apex-biased mines, which snap toward the centreline at a bend apex where the corridor already forces you, carry a real risk of reading as the game cheating. That one stays at the old depth until someone actually flies it on a device. Pulling an unproven fairness risk 750 points earlier is not part of a pacing fix.
What determinism buys
All of that constraint pays for three things that would otherwise be much harder.
A ghost that costs almost nothing
Racing a replay of your best run normally means recording inputs or obstacle state. Here the cave is reproducible from a date, so a replay needs nothing except the ship's vertical position over time. One byte per 60 world pixels, quantised over the screen height, which makes a full ghost a few hundred bytes and lets a ghost recorded on a phone replay correctly on a tablet.
It is indexed by distance travelled rather than elapsed time, which matters because one of the coin types slows the scroll and ramps it back over several seconds. A time-indexed ghost would desync from the tunnel the moment anyone picked one up.
A share card that is a picture of the run
Because the corridor is a pure function of position, the whole flown tunnel can be redrawn after the fact, compressed into a strip, with the death point and personal best marked. The card is a picture of the run rather than a score badge, and generating it needs no recording at all, just the distance reached.
A leaderboard that means something
Everyone flying an identical cave is the whole basis for comparing scores. It is also why the render width is capped rather than filling the screen: obstacle spacing is fixed in world position, so a much wider device would see the shared cave scroll past proportionally faster and face the same obstacles with less reaction time. Vertical feel is normalised the same way, with gravity and thrust scaled by screen height against a reference, so a trajectory covers the same fraction of the corridor per second on any device.
If I were starting again
The generator was the easy part. Two waves and a hash is an afternoon. Everything expensive came from the promise wrapped around it, that the cave is the same, which turns every future feature into a question about whether it leaks non-determinism into placement.
The lesson I would keep is the testing shape. Determinism bugs are invisible by play and trivial by assertion. Generating the first several thousand positions at six screen sizes and diffing them takes milliseconds and would have caught both device bugs the day they were written. The content-gating mistake needed a different tool entirely, a simulation of how far players actually get, and the only real lesson there is that a number derived from your difficulty curve is not a number about your players.
Today's cave is live and it resets at UTC midnight. It runs in the browser, no install: