Одна пещера, все игроки

Как TUNL строит ежедневный уровень из одной лишь даты и два бага, которые тихо ломали его месяцами.

Девлог · 15 сентября 2026 · обновлено 23 сентября 2026 для TUNL 17.0 · все посты

TUNL - это игра про полёт по пещере с тягой при удержании. Ты удерживаешь, чтобы подниматься, отпускаешь, чтобы падать, и пытаешься пролететь по сужающемуся туннелю дальше, чем вчера. Каждый игрок летит по одной и той же пещере в один и тот же день, а на границе полуночи UTC она становится другой.

Файла уровня нет. Никакой сервер не раздаёт карту, ничего не скачивается, и игра не хранит только что сгенерированный туннель. Пещера - это чистая функция даты, и весь «мир» - это около сорока строк арифметики.

Этот замысел даёт на удивление много: честный мировой рейтинг, призрак-повтор, который стоит несколько сотен байт, и картинку твоего забега, которой можно делиться. Он также накладывает ограничение, куда более жёсткое, чем кажется на первый взгляд, и я дважды нарушил его, не заметив. Этот пост о обеих половинах.

Уровень, который является функцией, а не файлом

Большинство процедурных игр генерируют мир в начале забега и держат его в памяти. TUNL не держит его вообще. Центр и полуширина коридора в любой мировой позиции wx вычисляются по требованию из двух наложенных синусоид:

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

Это скелет. Реальная функция добавляет ещё несколько членов, например морфинг формы для глубоких забегов, описанный ниже, но ни один из них не принимает ничего, кроме wx и дня.

Две волны, а не одна, потому что одна синусоида сразу читается как ритм и перестаёт быть интересной примерно через десять секунд. Медленная широкая дуга плюс более быстрая мелкая рябь при почти иррациональном соотношении частот даёт длинные участки, которые никогда не повторяются полностью. Полузазор сужается примерно с 68% высоты экрана до 33% по мере продвижения, и делает это медленно: совсем ничего на первом отрезке, который представляет собой открытый зал, затем прямая линия, которая достигает дна лишь примерно после счёта 1 500.

Так было не всегда. Сначала коридор сужался по кривой квадратного корня, которая достигала минимума на счёте 233, то есть по быстрому, сосредоточенному в начале склону, и пришла обратная связь, что игра слишком трудная. Поэтому коридор и амплитуда его волн получили собственные медленные часы, то самое g выше. Частоты волн намеренно остались на старой кривой квадратного корня: sin(wx * f) при большом wx очень чувствителен к f, так что изменение темпа частоты посреди забега путает фазу всего, что уже осталось позади. Амплитуду и ширину можно безопасно замедлять, частоту нельзя.

Важное свойство не в форме. Оно в том, что boundsBase(wx) принимает только wx. Спроси её о мировой позиции 40 000 в первом кадре забега, и она ответит мгновенно, одинаково на любом устройстве, ничего не сгенерировав по пути.

Где появляется день

Дата входит ровно через две переменные в этом фрагменте: _wavePhase1 и _wavePhase2. Дата UTC превращается в целое число вроде 20260915, которое проходит через цепочку хеширования:

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

Последовательные даты отличаются на единицу, поэтому хеш должен сильно рассеивать, иначе сегодня и завтра выглядели бы близкими соседями. Цепочка multiply-xorshift делает это: 20260915 и 20260916 дают совершенно несвязанные фазы.

Отклонения намеренно малы. Одна лишь фаза меняет, где приходятся изгибы, но не то, как ощущается день, а я хотел, чтобы у каждого дня был слегка иной характер, и чтобы ни один день не уходил от базовой линии, на настройку которой ушло много времени. Восьми процентов достаточно, чтобы заметить за неделю, и недостаточно, чтобы день казался неправильным.

Четыре потока случайных чисел, а не один

Препятствия - отдельная задача от формы коридора. Сталактиты, монеты, мины и пушки получают каждый свой независимый генератор, все они выводятся из одного и того же дня:

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

Один общий поток был бы проще и был бы ошибкой. Размещение монет может отклонить позицию-кандидата, например когда она оказывается слишком близко к сталактиту. В общем потоке каждое отклонение расходует число и сдвигает всё, что извлекается после, так что монета, размещение которой не удалось, тихо сдвинула бы следующую мину. Раздельные соли делают четыре системы по-настоящему независимыми, а не сдвинутыми по фазе копиями одной последовательности, и это значит, что я могу менять способ размещения монет, не сдвигая ни одной мины.

Архетипы дня

Фаза и отклонение меняют форму дня. Чтобы изменить его вкус, сид также выбирает один из четырёх архетипов, которые масштабируют кривые расстояний, уже существующие в игре:

АрхетипСталактитыМонетыМиныСужения
Классика1.001.001.001.00
День сужений0.851.001.001.35
Минное поле1.001.000.751.00
Монетная лихорадка1.150.721.150.80

Это множители расстояний, поэтому число меньше единицы означает ближе друг к другу. Правило, которое я установил для таблицы: ни одна строка не должна быть однородно труднее или однородно легче Классики. Каждый архетип давит на один регулятор и отводит назад другой. Монетная лихорадка даёт тебе намного больше монет, которые в TUNL расширяют коридор, но платит за это более плотными сталактитами и большим числом мин. День должен читаться как другой день, а не как удачный или неудачный бросок.

Ограничение под всем этим

Вот часть, которая оказалась важнее самого генератора.

Поскольку пещера общая, а рейтинг мировой, всё, что решает, где будет размещён объект, должно быть чистой функцией мировой позиции и дня. Ничто другое не должно просачиваться. Ни размер экрана игрока, ни его счёт, ни то, активен ли у него случайно бонус, ни то, как долго он играет.

Написанное звучит очевидно. На практике оно запрещает длинный список вещей, которые кажутся вполне разумными, пока ты их пишешь, а нарушения не объявляют о себе. Ничего не падает. Игра выглядит правильно. Пещера просто, тихо, перестала быть той же пещерой.

Баг первый: шесть устройств, шесть пещер

Функции расстояний, определяющие, как далеко друг от друга стоят препятствия, - это кривые сложности, так что естественно, что они читают текущую сложность. Исходные версии читали глобальную переменную _prog, которая является сложностью в текущей позиции игрока.

Циклы появления выглядят так:

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

Они размещают препятствия чуть правее правого края экрана. Поэтому момент, когда для данной мировой позиции разыгрывается расстояние, зависит от W, ширины экрана. Более широкое устройство заглядывает дальше вперёд, разыгрывает эту позицию раньше в забеге и поэтому снимает кривую сложности при меньшем значении _prog, чем узкое устройство для точно того же куска мира.

Другое расстояние. Другая пещера. Я измерил это на шести размерах устройств и получил шесть разных пещер, расходящихся с мировой позиции 938, это примерно счёт 15. Всё после этой точки в общем ежедневном рейтинге сравнивалось между туннелями, которые не были одним и тем же туннелем.

Исправление состояло в том, чтобы передавать размещаемую позицию в каждую кривую, чтобы они отвечали «насколько трудно там», а не «насколько трудно здесь и сейчас»:

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

Был второй, меньший случай того же класса бага, спрятанный в ограничении центральной линии. WALL_PAD, отступ, удерживающий коридор от края экрана, был фиксированными 8 пикселями, тогда как всё вокруг масштабировалось с высотой экрана. На более высоком экране этот фиксированный отступ пропорционально меньше, что слегка сдвигало точку ограничения, чего хватило, чтобы перевернуть одно отклонение размещения, что раскололо пещеру. Теперь это доля H, как и всё остальное.

У обоих багов есть форма, которую стоит назвать: значение, которое почти является чистой функцией мировой позиции, с одной примесью, важной только на железе, которого у тебя нет. Ни один нельзя было найти игрой. Оба теперь заперты тестами, которые генерируют пещеру при нескольких размерах экрана и утверждают, что результаты идентичны побайтово.

Баг второй: контент, которого никто никогда не видел

Второй сбой не имел ничего общего с детерминизмом и имел всё общее с тем, что я не проверил свои предположения на реальности.

После определённой глубины все регуляторы геометрии коридора упираются в свои потолки, потому что дальнейшее их повышение делает туннель по-настоящему непроходимым. Дальше меняется только скорость прокрутки, так что очень долгий забег был одной переменной, медленно становящейся нервнее на фоне замороженного коридора. Чтобы это исправить, я построил набор возможностей для глубоких забегов: морфинг формы, перераспределяющий амплитуды двух волн через посеянную последовательность характеров (большие чистые размахи, бьющая рубка, почти прямая), пульс скорости, заставляющий темп дышать, широкие камеры, приходящие как передышка, валуны как редкие эффектные сцены.

Я включил всё это на мировой позиции 54 000, это около счёта 900. Это число пришло из кривой сложности, где плато математически начинается. Оно было правильным для геометрии и неправильным для игроков.

Когда я наконец воспроизвёл реальные данные рейтинга против генератора, ответ был прямым: практически никто и близко не подбирался. Я потратил недели на создание контента, которого не видел ни один игрок, включая меня.

Тогда я построил симуляцию красной команды: 2 400 безголовых забегов по четырём откалиброванным уровням навыка, измеряющих, как далеко на самом деле доходит каждый уровень. Уровень, соответствующий реальным игрокам, достиг мировой позиции 30 000 в 0% из 600 забегов. Не «редко». Никогда. Даже после того, как я уже один раз опустил порог до 30 000, контент всё ещё был недостижим. Это была та же ошибка, только на порядок меньше.

Порог теперь стоит на 9 000, около счёта 150, где, по данным симуляции, реально доходит заметная доля забегов:

Уровень навыкаЗабеги, достигающие порога
Средний3.3%
Хороший24.7%
Эксперт63.5%

Это было безопасно сделать только потому, что каждая возможность глубоких забегов ограничена относительно той же позиции без морфинга. Распределения амплитуды морфинга ограничены относительно скорости коридора, так что участок с морфингом никогда не несёт более чем примерно на 3% больше энергии колебаний, чем без морфинга, а камеры только расширяют коридор. Ничто из этого не может сделать данную позицию труднее, чем настроенная вручную базовая линия, на любой глубине, поэтому перемещение порога - это решение о поставке, а не изменение сложности.

Одна часть намеренно не сдвинулась. Мины со смещением к вершине, которые защёлкиваются к центральной линии на вершине поворота, где коридор и так тебя вынуждает, несут реальный риск прочитаться как жульничество игры. Эта остаётся на старой глубине, пока кто-нибудь на самом деле не полетит по ней на устройстве. Вытягивание недоказанного риска справедливости на 750 очков раньше не входит в исправление темпа.

Что покупает детерминизм

Всё это ограничение окупается тремя вещами, которые иначе были бы гораздо труднее.

Призрак, который почти ничего не стоит

Гонка с повтором твоего лучшего забега обычно означает запись вводов или состояния препятствий. Здесь пещера воспроизводима из даты, так что повтору не нужно ничего, кроме вертикальной позиции корабля во времени. Один байт на 60 мировых пикселей, квантованный по высоте экрана, что делает полный призрак несколькими сотнями байт и позволяет призраку, записанному на телефоне, корректно воспроизводиться на планшете.

Он индексируется пройденным расстоянием, а не прошедшим временем, что важно, потому что один из типов монет замедляет прокрутку и возвращает её обратно в течение нескольких секунд. Призрак, индексированный по времени, рассинхронизировался бы с туннелем в тот миг, когда кто-нибудь подберёт такую монету.

Карточка для шаринга, которая является картинкой забега

Поскольку коридор - чистая функция позиции, весь пролетенный туннель можно перерисовать постфактум, сжав в полосу, с отмеченной точкой смерти и личным рекордом. Карточка - это картинка забега, а не значок счёта, и для её генерации вообще не нужна запись, только достигнутая дистанция.

Рейтинг, который что-то значит

То, что все летят по одинаковой пещере, - вся основа для сравнения счетов. Это также причина, по которой ширина отрисовки ограничена, а не заполняет экран: расстояние между препятствиями зафиксировано в мировой позиции, так что гораздо более широкое устройство увидело бы общую пещеру, прокручивающуюся пропорционально быстрее, и столкнулось бы с теми же препятствиями с меньшим временем реакции. Вертикальное ощущение нормализовано так же, с гравитацией и тягой, масштабируемыми высотой экрана относительно эталона, так что траектория покрывает одну и ту же долю коридора в секунду на любом устройстве.

Если бы я начинал снова

Генератор был лёгкой частью. Две волны и хеш - это один вечер. Всё дорогое пришло от обещания, обёрнутого вокруг него, что пещера та же самая, а это превращает каждую будущую возможность в вопрос о том, не просачивается ли из неё недетерминизм в размещение.

Урок, который я бы сохранил, - это форма тестирования. Баги детерминизма невидимы при игре и тривиальны при проверке утверждением. Генерация первых нескольких тысяч позиций при шести размерах экрана и сравнение их занимает миллисекунды и поймала бы оба бага устройств в день их написания. Ошибка с порогом контента требовала совершенно другого инструмента, симуляции того, как далеко игроки реально доходят, и единственный настоящий урок здесь в том, что число, выведенное из твоей кривой сложности, - это не число про твоих игроков.

Сегодняшняя пещера доступна и сбрасывается в полночь UTC. Она работает в браузере, без установки:

Играть в пещеру дня