Skip to content

Tunnel Generator — Rules & How It Actually Works

Reference for CFG.map.style === "tunnel". Code: game.js:514–948 (tunnelPlan, genTunnelMap); knobs: config.jsCONFIG.tunnel. Companion doc: docs/superpowers/specs/2026-07-30-tunnel-mapgen-audit.md (100-floor structural audit).

Measurements in this doc come from two places, both labelled at the point of use: (audit) = the 100-floor structural audit of the whole generator; (sim) = a 4000-floor standalone replica of tunnelPlan + the pass-1 rung loop run while writing this doc, which covers the plan and spine only, not carving or population.


1. The one-sentence version

The topology is fixed; only the metrics roll. Every tunnel floor is the same shape — a boustrophedon (switchback) spine climbing bottom-to-top into a boss chamber. What the dice decide is how long, how wide, how tall, where the rooms bud off, and where the loot lands. There is no maze algorithm, no cellular automaton, no connectivity search: the spine is the solution path, so a floor is solvable by construction.

If floors feel random, it is not because the layout is generated randomly — it is because several rolled quantities have a wide spread and nothing downstream adapts to them (§6).


2. The contract

genTunnelMap() returns exactly the same object as genMap() (the open-zone generator) and buildTutorialMap():

{ tiles, at, spawn, chests, enemies, extracts, items, gate, pois, zones,
  tunnel: { rungs, rooms, cw } }      // ← the only tunnel-specific extra

Nothing downstream — AI, fog, extraction scheduling, rendering, save — knows which generator built the level. zones is returned empty and gate is parked off-map at -9999; tunnel floors have no colored zones and no vault gate.


3. Generation order, pass by pass

Pass 0 — the plan, before the map exists (tunnelPlan, game.js:536)

This is the one structural inversion worth knowing: map height is derived, never authored. The plan is rolled first, then setMapSize sizes the map to fit it (game.js:338). Doing it the other way round truncated the climb — the rung loop ran out of vertical room and produced 2–7 rungs where 6–9 were asked for.

Rolled Formula Default range
cw corridor width randi(corridorW) 3–4 tiles
bossH chamber height randi(bossRoom) 15–20 tiles
nRungs max(2, randi(rungs) + round((stage−1) × rungsPerStage)) 6–9, +0.6/stage → 6–11
gaps[i] randi(rungGap)one independent roll per rung 3–6 tiles each
W map width clamp(randi(width), 18, 64) 34–46 tiles
H map height 4 + Σ(gaps[i] + cw) + bossH + 3, clamped [24, MAP_CAP/W] derived (~70–110)

MAP_CAP is 120×120 = 14400 tiles (the pre-allocated buffer). At W≈40 that caps H at 360, so the clamp never binds in practice.

Pass 1 — the spine (game.js:574–610)

Rungs are laid bottom to top, y starting at MAP_H − 3 − cw and decreasing by cw + gaps[i] each step. Rules:

  1. startRight (coin flip, T.startRight) picks which wall the climb begins from. It mirrors the whole switchback, including the spawn.
  2. Direction alternates: goRight = (i % 2 === 0) !== startRight. The alternation is the switchback — it is not rolled.
  3. Rung 0 starts at its wall (prevEnd = startRight ? RM : LM, where LM = 2, RM = W − 3).
  4. Every later rung starts where the previous one ended. entryX(i+1) = exitX(i). This is the load-bearing invariant: the two rungs share that column by construction, so the vertical connector between them can never miss, and no clamping/overlap search is needed.
  5. Rung length is a fraction of the distance remaining to the opposite wall, not a fixed span: endX = prevEnd + (target − prevEnd) × rand(rungLen) with rungLen 0.55–0.9.
  6. Guards: minRun = cw + 6 forces a degenerate stub out to a usable length; endX is clamped to [LM, RM].

Why rungs never collapse toward one wall. Because rungLen ≥ 0.55, a rung starting at position p (normalised 0…1) always lands past the midline: from p = 1 going left, p' = p(1 − f) ≤ 0.45. So every rung crosses the centre of the map — this is the "centre-crossing invariant" the audit names, and it is why the spine self-corrects into a steady oscillation instead of drifting. (sim: 0 of 34,872 rungs stayed on one half; the minRun guard fired on ~0.03% of floors; the wall clamp and the y > bossH + 4 truncation guard never fired at all across 4000 floors.)

It is also the reason the map reads as ~41% open floor (audit): a corridor that must cross the midline can't be short relative to map width. Narrowing corridors means widening the map, not lowering rungLen.

Pass 2 — carving (game.js:611–635)

The tile array starts 100% WALL and is carved (the inverse of genMap, which starts open and adds clutter).

  • Solid band first: carveRect(x0, y, x1, y + cw − 1) — the corridor is guaranteed cw tall end to end.
  • Then a random-walk edge seam (jitter, default 1): walking left to right, a 30% chance per column to shift the offset by ±1, capped at ±jitter. It only ever adds floor, never removes it, so it cannot break the corridor. (The first version offset each column independently — white noise, which produced disconnected slivers rather than a wave.)
  • Connectors: a cw-wide vertical rect from rung i's exit column up to rung i+1's midline.

Pass 2b — dead-end stubs (game.js:636–656)

deadEnds (2–4) short vertical spurs carved into the rock band above or below a random rung. Length is min(randi(deadEndLen), band − 1)capped to one tile less than the gap, so a stub physically cannot reach the neighbouring rung and therefore cannot create a loop. Width is min(2, cw) so it reads as a crack, not a room.

loops (default 0) would carve extra shortcut links between non-adjacent rungs. At 0 the floor is strictly linear.

Pass 3 — the boss chamber (game.js:665–674)

Always: axis-aligned rectangle, bossW = min(W − 6, round(bossH × 1.25)), centred on the top rung's midpoint, entered by a cw-wide stub from directly below. Named "The Brute's Lair" on the last stage of a biome, "The Gate Chamber" otherwise. Nothing about its shape or entry rolls — only its dimensions (audit ISSUE 7, open).

Pass 4 — rooms (game.js:676–714)

Rooms are the arenas — this is a design rule enforced in code, not a tuning hope. Corridors are too narrow to circle anything (a 3-wide corridor is 120 px; a boss is 52 px across), so heavies and flankable fights are placed room-only. That is also what stops a heavy corking a corridor you can't out-damage.

  • Count: min(randi(rooms), nRungs − 2); a random preferred set is chosen before carving, then extended by a fallback list of remaining rungs sorted by how much rock sits beside them. (Pure pre-selection produced zero-room floors when short rungs left no space.)
  • Side: whichever side of the rung has more rock (spaceLeft vs spaceRight).
  • Width: min(randi(roomSize), available space); height is clamped to the gap bands above and below, minus a tile each side: rh = clamp(cw + 2×(min(gapBelow, gapAbove) − 1), 3, 13). Unclamped, a square room punched through the band, merged two rungs into one hall and silently created a loop.
  • Skipped entirely if rw < 5 || rh < 3 — a nub is worse than nothing.
  • roomSpur (0.7) decides label only — dead-end "Side Cache" vs on-path "Old Chamber". The carve is the same; the room always attaches via a stub on the rung's midline.

Pass 5 — spawn and exits (game.js:716–732)

  • Spawn: on rung 0's entry column, 2 tiles inward. startRight moves it.
  • Descend portal: always at the chamber centre, kind: "progress".
  • Safe exits: clamp(round(nRungs / 3), minSafe, maxSafe), spread over intermediate rungs low first, so the closing scheduler (nearest-to-spawn shuts first) seals the way back behind you rung by rung.

Pass 6 — population (game.js:734–768)

  • Corridors get trash only — light roster (availableMobs(step, heavyTier − 1)), count max(1, round((x1 − x0)/14 × (1 + i/nRungs))), so density ramps with height. Progress along the spine replaces the zone-colour tier gradient. 70% chance of one ground drop per rung.
  • Rooms get the full roster on the shared threat budget (threatBudgetBase + step × threatBudgetPerStep), each mob leashed to the room bounds + leashPad. Chest rarity by position: rungIdx / nRungs > 0.66 → epic, > 0.33 → rare, else common. Plus 2 ground drops.
  • Chamber: an epic (or legendary on a boss floor) chest, chamberTrash 2–4 light mobs leashed to the chamber, and the guard — gatekeeper, or boss on the last stage of the biome. Below guardsFromStep the portal is unguarded.

Pass 6b — dead-end payouts, measured rather than authored (game.js:769–862)

This is the least obvious pass and worth understanding, because it is what actually places most of the chests.

The generator does not reward the stubs it deliberately carved. It finds every dead end after the fact, because carving produces incidental ones too (a rung overshooting its connector, a bay opened by the jitter seam, a room's far corner). Method:

  1. BFS from spawn and BFS from the portal.
  2. Score every walkable tile by detour cost: detour(t) = dist(spawn→t) + dist(t→portal) − dist(spawn→portal). A corridor's far wall costs ~0. A 3-tile stub costs 6. (Two cheaper metrics failed first: distance-from-spawn fires on every one-tile jitter bay; distance-from-route flags the entire far side of a 4-wide corridor — 19–50 phantom dead ends per floor.)
  3. Mask out rooms and the chamber — they are destinations, not dead ends, and already hold caches.
  4. Keep local maxima with detour ≥ 2 × deadEndMinDepth, then keep only the deepest tip per pocket (Manhattan distance < 6 dedupe).
  5. Pay out, deepest first, capped at deadEndPays[1], skipping tips within 3 tiles of existing loot. Payout scales with route fraction frac: chestOdds = deadEndChest × (1 − bias/2 + bias × frac) with deadEndDepthBias 0.8, and rarity steps at frac 0.42 / 0.72. Non-chest tips get a single ground drop.

Result (audit, 100 floors): back half of the route carries 1.52× the chests and 2.68× the total reward value of the front half.

Pass 7 — cover and rivals (game.js:902–944)

  • Bushes in corridors at bushDensity (0.09) of corridor tiles, seeded as 1–3 tile clumps — with one route through the floor, a corridor bush is the only way a patrol can walk past you. Rooms and the chamber get coverBushClusters × 2.5.
  • Rivals: clamp(round(nRungs/3), 1, map.bots), each sampled up to 60 times keeping the roomiest spot. Currently disabled globally by prog.botsFromBiome: 99.

4. What is fixed vs what rolls

Fixed by construction (cannot vary, ever):

  • Bottom-to-top climb; alternating switchback direction.
  • Spawn on rung 0's entry; portal at the chamber centre.
  • Strict linearity — one route, no loops (while loops: 0).
  • Every rung crosses the map midline.
  • Rung i+1 enters where rung i exits.
  • Boss chamber: rectangle, top-centred on the last rung, entered from below.
  • Heavies in rooms only; trash in corridors only.
  • Safe exits ordered low → high.

Rolled per floor: start side · rung count · every gap independently · corridor width · each rung's length · map width (→ map height) · chamber size · room count, side, size and spur flag · stub count/length/position · jitter seam · all mob picks and positions · dead-end payout rarity.


5. Invariants the audit confirmed (100/100 floors)

Portal reachable · every safe exit reachable · every chest reachable · every ground drop reachable · zero walled-off carved regions · zero loops · rung count matches the plan. Generation cost: 0.8 ms median.

These are not at risk. Everything in §6 is a quality problem on a sound skeleton.


6. Why the results feel random — the honest list

The layout logic is tight. What produces the "each floor is a different amount of game" feeling is spread in the rolled quantities that nothing downstream compensates for.

6.1 Floor length varies ~3× and the clock does not follow (the big one — audit ISSUE 2, open)

(sim) Spine length: 129 tiles minimum, 383 maximum, median 241 — a 2.97× spread, 1.64× even between the 10th and 90th percentile. At baseMove: 100 px/s and 40 px tiles that is 52 s to 153 s of pure walking, median ~96 s, before a single fight or pickup.

Every one of those floors gets the same flat map.roundTime: 300. Two floors on the same stage can therefore differ by ~100 seconds of walking against an identical extraction schedule. This reads as "sometimes the floor is fine, sometimes it drags" — which is exactly what randomness feels like from the player seat.

Three independent rolls compound into it: rungs (6–9) × rungsPerStage (+0.6/stage) → 6–11 rungs; rungLen 0.55–0.9 (sim: rung length spans 0.30–0.91 of usable width, median 0.575); width 34–46. Nothing multiplies them back down.

Fix (already specified in the audit): tunnelPlan knows the rung count, gaps and width, so it can compute spine length and set the floor's clock as base + spineTiles × perTile. Same place the map height is already derived.

6.2 Gaps roll independently per rung

gaps[i] is one roll each, 3–6. A floor can have a 3-tile band next to a 6-tile band, so the vertical rhythm is uneven — and because room height is clamped to min(gapBelow, gapAbove), a single tight gap silently shrinks or kills the room on that rung. There is no smoothing and no "gaps should be similar within a floor" rule.

6.3 Most chests are placed by measurement, not by design

Pass 6b puts loot wherever the carve happened to leave a detour, so chest positions are a downstream consequence of jitter seams and rung overshoot rather than authored beats. That is deliberate and it works (§3, pass 6b, and the depth gradient is real) — but it means the loot skeleton genuinely differs floor to floor in a way the player cannot learn.

Also note deadEndPays: [0, 24] no longer binds: connector-to-connector rungs cut dead ends from ~23 to ~14 per floor (audit). The cap is vestigial and could be lowered to become a real control.

6.4 Room presence is not guaranteed

Rooms are the only place heavies and arena fights exist. After the fallback fix, 1 in 100 floors still generates with zero rooms and 4 in 100 with zero spur rooms (audit §4). A zero-room floor is a structurally different experience — pure corridor trash, no arena beat — and it is not signposted.

6.5 The one thing that never varies is the one you see last

Every floor ends with the same axis-aligned chamber, centred on the top rung, entered from directly below (audit ISSUE 7). So variety is high where it is hard to perceive (corridor lengths) and zero at the memorable moment.

6.6 There is no seed

rand/randi/pick (game.js:83–85) wrap bare Math.random(). There is no seeded RNG anywhere in the generator. Consequences:

  • A floor you liked cannot be reproduced. "Re-roll run" in the tuning panel gives you a new floor, never the same one again.
  • Tuning cannot be A/B'd. Change rungLen and re-roll and you are comparing two different random draws, not the same layout under two settings — which makes the whole generator feel more random than it is when you are the one testing it.

Adding a seed is small: one mulberry32-style PRNG, a seed field on the plan, and route rand/randi/pick through it during generation. That single change would probably move the subjective "it's random" reading more than anything else on this list.


7. Knob cheat-sheet

Want Turn
Longer / shorter floors rungs, rungsPerStage
Tighter, more claustrophobic warren rungGap down, width up (not rungLen down — see §3 pass 1)
Less open floor % width up, corridorW down
Corridors that can be circled in corridorW up — this quietly turns tunnels back into arenas
Machined vs hand-dug edges jitter (0 = straight, 1–2 = warren)
More arena beats rooms, roomSize
Fewer detour rewards, worth more each deadEndPays[1] down, deadEndChest up
Front-loaded vs back-loaded reward deadEndDepthBias (0 = flat, 1 = last dead end ≈ 3× the first)
Routing choice instead of commitment loops > 0 — breaks the strictly-linear guarantee
Stealth-lite beats in corridors bushDensity

Every value is authored as [min, max] and exposed in Developer → 🎛 Tuning. Tags in config.js say when each takes effect: live (immediately), re-roll (next floor), reload (page refresh — width and map.tile/w/h only).