博客开发指南 (十):随机不等于坑——洗牌、种子与“第 29 根管道”事故复盘

27% 的玩家在第 29 根管道必死:一次从用户反馈到 BFS 证明的完整排查

Posted by LH on August 29, 2026

这是整个街机厅系列里我最想讲的一个故事。它有一切好 bug 故事的要素:玩家只留下一句模糊反馈、现象看起来像玄学、排查走了模拟器、时刻表取证、最后用图搜索给出了数学级别的证明。更重要的是,它的根因——“全局帧号对变化的除数取模”——是一个极其容易复发的反模式,值得专门写下来。

一、现象:一句模糊的反馈

Flappy 咖啡杯上线后收到反馈:

第 29 个位置有概率过不去。有的时候刷新到这儿没有路径,管道的位置卡住了。这是代码逻辑而不是技术的问题吧?

注意两个关键词:“第 29 个位置”(为什么偏偏是 29?)和“有概率”(如果必死,早就被当成关卡设定了;有概率,说明和随机数有关)。直觉的下一步是背锅随机数——缺口位置是 Math.random() 生成的,随机出”没路”的布局,好像说得通。

但直觉是错的。 排查的过程就是不断把”说得通”变成”证明得了”。

二、取证:把排管时刻表打印出来

Flappy 的管道由这段代码生成(修复前):

// SPACING=160, speed() 随分数上涨从 2.3 涨到 4.4
if (frame % Math.round(SPACING / speed() * 2.1) === 0 || (pipes.length === 0 && frame > 30)) {
  pipes.push({ x: W + 30, top: 44 + Math.random() * (GROUND - g - 108), ... });
}

意图很好理解:想要管道间距恒定约 336 像素,游戏越快、生成周期(帧数)就该越短,于是每帧检查 全局帧号 % 生成周期 === 0。

写了个模拟器把真实游戏的排管时刻表打出来(用种子固定随机数,保证可重放),第 25 根管道之后出现了惊人的数据:

pipe#25: 与上一管窗口间隔=154帧(≈613px)   ← 突然超远
pipe#26: 与上一管窗口间隔=37帧(≈147px)
pipe#27: 与上一管窗口间隔=150帧(≈597px)
pipe#28: 与上一管窗口间隔=24帧(≈96px)     ← 已经开始贴脸
pipe#29: 与上一管窗口间隔=145帧(≈577px)   ← 计划外!兜底条款生成
pipe#30: 与上一管窗口间隔=11帧(≈44px)     ← 和 #29 只隔 44 像素!!

每根管道的碰撞窗口宽 48 像素,而 #29 和 #30 只隔 44 像素——两根管道的判定区重叠,组成一堵”双管墙”。两段缺口的上下位置各自独立随机,没有公共走廊的概率约 27%。这就是”有概率过不去”的全部真相。

三、根因:取模混叠(Modulo Aliasing)

问题出在 frame % N === 0 这个调度方式。N = round(SPACING / speed() × 2.1) 不是常数——它随分数从 146 一路缩到 76。用一个不断变化的除数去对一个全局计数器取模,生成的时刻表会错位:

想象钟表走针:除数是 146 时,闹钟定在 146、292、438……走到某刻除数变成 84,而全局帧号恰好已经过了某个 84 的倍数——下一个 84 的倍数可能近在眼前,也可能远在天边。于是间距变成 44~589 像素的抽签。更糟的是中间一次长达 159 帧的空窗让屏幕上管道清空,触发了兜底条款 pipes.length === 0(本意是开局补第一根管),凭空插进一根计划外的 #29;11 帧后取模时刻表恢复,又刷出 #30。墙就这么砌起来了。

📌 知识点:取模(%)
a % b 是 a 除以 b 的余数。frame % 146 === 0 意思是"frame 是 146 的整数倍",天然形成每隔 146 帧触发一次的节拍器——但前提是除数恒定。除数一旦随游戏状态变化,每个节拍器的相位就对不齐了。这类 bug 的通用名字叫混叠(aliasing):两个周期性信号叠加出了预料外的拍频。

四、修复:一行代码的哲学转变

修复不改任何数值,只换调度方式——从”问全局帧号”改成”自己记上次”:

// 修复前:全局帧号对变化除数取模(混叠)
if (frame % Math.round(SPACING / speed() * 2.1) === 0 || ...) { ... }

// 修复后:距上次生成的帧计数(恒定间隔)
if (frame - lastPipeAt >= Math.round(SPACING / speed() * 2.1) || (pipes.length === 0 && frame > 30)) {
  lastPipeAt = frame;
  ...
}

lastPipeAt 记录上一次生成发生在哪一帧,比较”已经等了多少帧”。除数再怎么变,间隔都不会小于一个完整周期。修复后全部种子下相邻管道间距稳定在 333~337 像素。调度类逻辑(生成、刷新、出怪),一律用计数器,不用全局取模——这条经验后来在浓缩时序、小丑杯里都被主动遵守了。

五、证明:BFS,让”过不去”变成数学事实

怎么确认修复有效、且”第 29 根管道”真的曾经无解?玩家测试给不出数学答案——再强的玩家也只代表一种操作序列。于是我写了一个”完美玩家”:用 BFS(广度优先搜索) 穷举所有物理上可能的操作。

Flappy 的物理只有两个自由度:高度 y 和速度 vy,每帧可选”扇翅”或”不扇”。把 (y, vy) 的所有组合看成迷宫节点,每一帧的移动是边,管道是墙——“有没有活路”就变成了图上的连通性问题:

// 每帧从当前所有可达状态出发,尝试两种操作,筛掉撞管道/撞地的状态
for (所有当前可达状态 (y, vy)) {
  for (flap of [true, false]) {
    var v2 = flap ? JUMP : vy + GRAVITY;
    var y2 = y + v2;
    if (撞管道(y2) || 出界(y2)) continue;   // 死路,剪掉
    下一帧可达集.add(y2, v2);
  }
}
if (下一帧可达集为空) return '第 ' + pipeNo + ' 根管道无解';

结论:修复前的代码,400 局里 108 局(27%)连帧级完美的操作都无法通过第 29 根管道;修复后 0/600 局无解。这不再是”我玩不过去”的体感,而是可重放的证明。

📌 知识点:BFS(广度优先搜索)
把所有可能的状态想成迷宫房间,BFS 就是"水淹法":从起点灌水,水按步数一层层漫开,凡是能被淹到的房间就是可达的。永远漫不到的房间,就是数学意义上的死路。它慢得诚实——把整张可达性地图穷举一遍,没有任何运气成分。游戏里验证关卡是否有解、推箱子判断死局,都是它的用武之地。

六、番外:随机与种子——让实验可重放

整个排查里还有个隐形功臣:种子随机数。排查时模拟器不能每局都随机——你要能”回到出事的那个随机序列”反复实验。于是用了一个 10 行的伪随机数生成器(PRNG),给每次模拟发一个整数种子:

function mulberry32(seed) {           // 种子相同 → 产生的数列完全相同
  return function () {
    seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
    var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
var rand = mulberry32(4);   // 种子 4:就是那次无解事故的“案发序列”
rand(); rand();             // 每次调用都产出 0~1 的数,序列永远一致

Math.random() 给不出这个保证——它没有种子,每次运行都是新的宇宙。小丑杯发牌、浓缩时序的判定区位置,只要需要”可重放的实验”,都换成了带种子的生成器。

📌 知识点:伪随机数生成器(PRNG)与种子
计算机里的"随机"几乎都是伪随机:从一个种子出发,用确定性公式不断变形,产出一串看起来毫无规律、实际完全由种子决定的数列。这恰恰是优点:种子在手,随机实验可以重放、调试可以复现、联机对战可以两端同步。Math.random() 相当于"每次开机换一个种子的 PRNG"——适合游戏玩法的调味,不适合实验与调试。

七、可视化:两种排班算法的赛跑

左边是事故现场的”取模调度”,右边是修复后的”计数器调度”。两边用完全相同的种子和速度曲线,注意观察左边的间距抽签和右边的稳如老狗:

修复前:frame % 变化的 N

…

修复后:frame - lastPipeAt ≥ N

…
当前种子:4(两边同种子同速度曲线)

八、跟着复现:重现一次混叠

// 反模式:除数随进度缩小,节拍错位
var N1 = 146, N2 = 84;
for (var f = 1; f <= 600; f++) {
  var N = f < 300 ? N1 : N2;         // 中途换了节拍
  if (f % N === 0) console.log('生成 @' + f, f > 1 ? '距上次 ' + (f - last) : '');
  if (f % N === 0) var last = f, P = last;
}
// 观察换节拍后的头几组间隔——你会看到远小于 N2 的间隔凭空出现

// 正模式:自己记上次
var last2 = 0;
for (var f2 = 1; f2 <= 600; f2++) {
  var N3 = f2 < 300 ? N1 : N2;
  if (f2 - last2 >= N3) { last2 = f2; console.log('生成 @' + f2); }
}

跑一遍对比两边的间隔序列,混叠一目了然。

九、小结

  • 现象 → 取证 → 根因 → 修复 → 证明:模糊反馈(”第 29 个位置有概率”)也能走完一条严密的排查链
  • 调度类逻辑禁用”全局计数器 % 变化除数”,改用”距上次的帧计数”
  • BFS 是验证”有没有路”的终极手段;种子 PRNG 是让随机实验可重放的基础设施
  • 随机数常常是无辜的——确定性调度 bug 才惯于伪装成玄学

下一篇回到质量保障:既然一个游戏能有这么多隐蔽路径,靠手测根本扫不完——无头测试与遥测机器人,是怎么在浏览器之外把整局游戏跑起来的。

本篇所属系列:博客开发指南 · 上一篇:(九)别让指针穿墙 · 下一篇:(十一)看不见的玩家——无头测试与遥测机器人