一個洞穴,所有玩家

TUNL 如何仅凭一个日期构建每日关卡,以及悄悄破坏它长达数月的两个 bug。

开发日志 · 2026 年 9 月 15 日 · 为 TUNL 17.0 于 2026 年 9 月 23 日更新 · 所有文章

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

相邻日期只相差 1,所以哈希必须充分打散,否则今天和明天会看起来像近邻。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 中金币会拓宽通道,但它的代价是更密的钟乳石和更多的地雷。一天应该读起来像是不同的一天,而不是一次好运或厄运的掷骰。

这一切之下的约束

下面这部分被证明比生成器本身更重要。

由于洞穴是共享的,排行榜是全球性的,任何决定物体放置位置的东西都必须是世界位置和日期的纯函数。不允许有其他任何东西渗入。不能是玩家的屏幕尺寸,不能是他们的得分,不能是他们碰巧是否有道具在生效,也不能是他们已经玩了多久。

写下来听起来很显然。在实践中,它禁止了一长串在你写的时候感觉完全合理的东西,而违规不会自己宣告。什么都不会崩溃。游戏看起来一切正常。洞穴只是悄悄地不再是同一个洞穴了。

Bug 一:六台设备,六个洞穴

决定障碍物间隔的间距函数是难度曲线,所以它们自然会读取当前难度。最初的版本读取的是全局变量 _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; }

同一类 bug 还有第二个更小的实例,藏在中心线钳位中。WALL_PAD 是让通道远离屏幕边缘的边距,它是固定的 8 像素,而它周围的一切都随屏幕高度缩放。在更高的屏幕上,这个固定的填充按比例更小,这让钳位点稍微移动了一点,足以翻转一次放置拒绝,从而让洞穴分叉。现在它和其他一切一样,是 H 的一个比例。

这两个 bug 有一个值得点明的共同形态:一个几乎是世界位置的纯函数的值,混进了一个只在你没有的硬件上才有影响的污染物。两者都无法通过玩游戏发现。现在它们都被测试锁定,这些测试会在多种屏幕尺寸下生成洞穴,并断言结果逐字节完全一致。

Bug 二:从没有人见过的内容

第二个失败与确定性毫无关系,而完全在于我没有对照现实检验自己的假设。

超过某个深度,所有通道几何旋钮都会触顶,因为再往上推会让隧道真的无法通行。超过这一点后,唯一还在变化的就是滚动速度,所以一局很长的对局,就是一个变量对着冻结的通道慢慢变得更紧张。为了解决这个问题,我构建了一套深入对局的功能:一种形状变形,通过一串带种子的特征序列(大幅干净的扫掠、跳动的短促切割、近乎笔直)来重新分配两条波的振幅;一种让节奏呼吸的速度脉冲;作为喘息出现的宽敞洞室;以及作为罕见压轴场景的巨石。

我把这一切都设在世界位置 54,000 处开启,大约是得分 900。这个数字来自难度曲线,即平台期在数学上开始的位置。对于几何来说这是对的数字,对于玩家来说却是错的数字。

当我最终把真实的排行榜数据对着生成器重放时,答案很直白:基本上没有人能接近那里。我花了好几周构建内容,而没有一个玩家见过,包括我自己。

于是我构建了一个红队模拟:在四个经过校准的技能段位上跑了 2,400 局无界面对局,衡量每个段位实际能到达多远。与真实玩家匹配的段位,在600 局中有 0% 到达了世界位置 30,000。不是"很少"。是从未。即使在我已经把开启门槛降到 30,000 之后,这些内容仍然无法到达。这是同一个错误,只是小了一个数量级。

现在门槛设在 9,000,大约是得分 150,模拟表明在这里确实有相当一部分对局能到达:

技能段位到达门槛的对局
平均3.3%
优秀24.7%
专家63.5%

之所以这样做是安全的,只是因为每个深入对局功能都是相对于同一位置未变形的情况来设限的。变形的振幅分配相对于通道速度设有上限,所以一段变形后的路段所带的抖动能量绝不会比未变形的多出大约 3%,而洞室只会拓宽通道。这些都不可能在任何深度让某个位置比手工调好的基线更难,这就是为什么移动门槛是一个供给上的决定,而不是难度上的改变。

有一块刻意没有移动。偏向弯道顶点的地雷会在弯道顶点处吸附到中心线,而那里通道本来就在强迫你,这带来了被读成游戏在作弊的真实风险。那一块会留在原来的深度,直到有人真正在设备上飞过它。把一个未经证实的公平性风险提前 750 分,不属于节奏修复的一部分。

确定性换来了什么

所有这些约束,换来了三样否则会困难得多的东西。

几乎不花任何代价的幽灵

与你最佳对局的回放竞速,通常意味着要记录输入或障碍物状态。在这里,洞穴可以由日期重现,所以回放除了飞船随时间变化的垂直位置之外什么都不需要。每 60 个世界像素一个字节,按屏幕高度量化,这让完整的幽灵只有几百字节,并使在手机上录制的幽灵能在平板上正确回放。

它是按行进距离而不是经过的时间来索引的,这很重要,因为其中一种金币会减缓滚动,并在几秒内让它恢复。一个按时间索引的幽灵会在有人拾取那枚金币的瞬间与隧道失去同步。

一张就是对局图片的分享卡片

由于通道是位置的纯函数,整条飞过的隧道都可以事后重新绘制,压缩成一条带状,并标出死亡点和个人最佳。这张卡片是对局的图片,而不是得分徽章,生成它完全不需要任何记录,只需要到达的距离。

有意义的排行榜

每个人都飞同一个洞穴,是比较得分的全部基础。这也是渲染宽度被限制而不是铺满屏幕的原因:障碍物间距在世界位置上是固定的,所以宽得多的设备会看到共享洞穴按比例更快地滚过,并以更少的反应时间面对同样的障碍物。垂直手感也以同样的方式被归一化,重力和推力按屏幕高度相对于一个参考值来缩放,因此在任何设备上,轨迹每秒都覆盖通道的同一比例。

如果让我重新开始

生成器是容易的部分。两条波和一个哈希,一个下午的事。所有昂贵的东西都来自包裹在它周围的那个承诺,即洞穴是同一个,这让每一个未来的功能都变成了一个问题:它是否会把非确定性泄漏到放置中。

我会保留的教训是测试的形态。确定性 bug 在游玩中不可见,在断言中却很简单。在六种屏幕尺寸下生成前几千个位置并做对比只需几毫秒,本可以在这两个设备 bug 被写下的当天就抓住它们。内容门槛错误则需要完全不同的工具,即模拟玩家实际能走多远,而其中唯一真正的教训是,从你的难度曲线推导出来的数字,并不是关于你的玩家的数字。

今天的洞穴已上线,并在 UTC 午夜重置。它在浏览器中运行,无需安装:

遊玩今天的洞穴