Tetris 7-bag:I 方塊乾旱的 12 顆上界與乾旱機率怎麼數

三種發牌器長期平均都是每 7 顆一個 I,差別在等得最久時會遇到什麼:純隨機與理想化 NES 重抽都沒有乾旱上限,7-bag 則保證兩個 I 之間最多隔 12 顆。

連「12 顆沒有 I」也得先說從哪裡開始數,理想化 NES 模型剛出 I 後往下數,機率約 14.0%,任取連續 12 顆則約 12.5%。重抽規則會壓低剛出 I 後再出 I 的機率,從這個起點往下數,遇到乾旱的機率較高。

三種發牌器

純隨機最容易定義,每顆獨立抽一次,七種方塊各有 1/7 機率,上一顆出了什麼不影響下一顆。

理想化 NES 重抽先均勻擲出 0 到 7,遇到 7 或與上一顆相同,就在 0 到 6 之間重擲一次,這次照單接受。因此連續重複的機率是 (2/8) × (1/7) = 1/28,比純隨機的 1/7 低,仍然可能連續拿到同一種方塊。

7-bag 每袋放七種方塊各一顆,洗好後依序發出,整袋發完才洗下一袋。Random Generator 最早為 N64《The New Tetris》開發,當時一袋有 63 顆,每種各 9 顆,後來七顆版本成為 Tetris Guideline 的標準。

其他設計還有 TGM 的歷史重抽與 TGM3 的 35 顆池。

7-bag 的 12 顆上界

把 I 放在前一袋第一顆、下一袋最後一顆,中間會隔著前一袋剩下的 6 顆,加上下一袋開頭的 6 顆,合計 12 顆非 I。每袋都有 I,間隔再長就違反規則。

每袋獨立均勻洗牌時,前一袋 I 在袋首的機率是 1/7,下一袋 I 在袋尾也是 1/7,所以 P(gap = 12) = 1/49 ≈ 0.0204,模擬量到 0.0200。列舉相鄰兩袋內 I 的位置,數出每種 gap 對應多少組位置,就能得到完整分布。

S/Z 連續出現也最多 4 顆,因為每袋只有 S、Z 各一顆,最長的連段會接在前一袋袋尾與下一袋袋首。

三種發牌器的乾旱分位數

gap 是「兩個 I 之間的非 I 顆數」,每次出 I 就重新開始數,等下一個 I 出現才記下一筆,開頭與結尾不完整的間隔不收進統計。

把程式存成 drought.py,三種發牌器各產生 200 萬顆,seed 42 只在程式開頭設定一次。接進遊戲時,用 gen = bag7() 建立發牌器,每次出塊呼叫 next(gen),持續使用同一個 gen;每顆都重建會丟掉尚未發完的袋子,NES 重抽也會丟掉上一顆的紀錄。回傳值 0 代表 I,其餘值各對應一種方塊。

# Gap between consecutive I pieces under three randomisers (2,000,000 pieces each, seed 42).
#   pure random: each piece uniform over 7, independent
#   NES (idealised): roll 0..7; if 7 or equal to the previous piece, roll again 0..6 uniformly
#                    (the real NES reroll is biased, see meatfighter.com; this is the textbook model)
#   7-bag: shuffle all seven pieces, deal them, repeat
# "gap" = number of non-I pieces between one I and the next, measured right after an I.
import random

random.seed(42)
N = 2_000_000
I = 0

def pure():
    while True:
        yield random.randrange(7)

def nes():
    prev = 7
    while True:
        r = random.randrange(8)
        if r == 7 or r == prev:
            r = random.randrange(7)
        prev = r
        yield r

def bag7():
    while True:
        bag = list(range(7))
        random.shuffle(bag)
        yield from bag

def gaps(gen, n):
    out, gap = [], None
    for _ in range(n):
        if next(gen) == I:
            if gap is not None:
                out.append(gap)
            gap = 0
        elif gap is not None:
            gap += 1
    return out

print(f"{'randomiser':12s} {'mean gap':>8} {'p95':>4} {'p99':>4} {'max':>4} {'P(gap>=12)':>11} {'P(gap>=20)':>11}")
for name, gen in (("pure random", pure()), ("NES reroll", nes()), ("7-bag", bag7())):
    g = sorted(gaps(gen, N))
    n = len(g)
    print(f"{name:12s} {sum(g) / n:8.2f} {g[int(.95 * n)]:4d} {g[int(.99 * n)]:4d} {g[-1]:4d} {sum(x >= 12 for x in g) / n:11.4f} {sum(x >= 20 for x in g) / n:11.4f}")
randomiser   mean gap  p95  p99  max  P(gap>=12)  P(gap>=20)
pure random      5.99   19   29   82      0.1569      0.0453
NES reroll       5.98   17   26   77      0.1385      0.0341
7-bag            6.00   11   12   12      0.0200      0.0000

平均 gap 都接近 6,也就是把下一個 I 算進去,平均每 7 顆出現一次。NES 那列的 5.98 是這次抽樣結果,理想模型的穩態 P(I) 恰為 1/7。

p95 表示至少 95% 的完整 gap 不超過這個長度,純隨機是 19 顆非 I,NES 重抽是 17 顆,7-bag 是 11 顆。純隨機這次最長等了 82 顆非 I,NES 是 77,兩者都是抽樣最大值;7-bag 的 max 12 則碰到規則上界。

純隨機的 gap 服從幾何分布,P(gap ≥ 12) = (6/7)^12 ≈ 0.1573,與實測 0.1569 接近。幾何分布的分位數怎麼讀,見〈掉落率 1%,為什麼挖一百次還是沒有〉。

乾旱怎麼數:兩個事件、兩個數字

Gamertag Mythras 的文章把接下來 12 顆沒有 I 寫成約 12%,模擬卻是約 13.9%;20 顆則是約 3% 與 3.4%。「沒有 I」可以數兩種事件:

  • after-I:剛出現一個 I,接下來 k 顆都沒有 I。
  • window:在穩態序列中任取連續 k 顆,這一段沒有 I。

理想化 NES 只要用「上一顆是 I」與「上一顆非 I」兩個狀態,就能算出這兩種機率。上一顆是 I 時,第一擲遇到 I 或 7 都要重抽,各占 1/8,重抽再拿到 I 的機率是 1/7,所以 P(I | 上一顆 I) = (2/8) × (1/7) = 1/28。

上一顆非 I 時,第一擲直接出 I 有 1/8 機率,另外還有「擲到上一顆」與「擲到 7」兩條重抽路徑,因此 P(I | 上一顆非 I) = 1/8 + (2/8) × (1/7) = 9/56。下一顆繼續非 I 的機率是 47/56。把穩態下出 I 的機率記作 p,它滿足 p = p × (1/28) + (1-p) × (9/56),解出 P(I) = 1/7,兩個事件可寫成:

after-I(k) = (27/28) × (47/56)^(k-1)
window(k)  = (6/7)   × (47/56)^(k-1)

after-I 已知起點剛出過 I,第一步沒有 I 的機率是 27/28;window 的第一顆沒有任何這種條件,在穩態下非 I 的機率是 6/7。後面都已經處於非 I 狀態,所以剩下的乘數相同。

nes_events.py 用三個 seed 各跑 500 萬顆,以 Monte Carlo 對照閉式解,delta 是輸出四捨五入後的模擬值減去閉式解:

# Two different "drought" events under the idealised NES model, same 12 and 20 piece cut-offs:
#   after-I : an I has just appeared and the next k pieces contain no I
#   window  : pick any k consecutive pieces in the steady state; none is an I
# Exact values come from the two-state Markov chain: P(I | previous I) = 1/28,
# P(I | previous non-I) = 1/8 + 2/8 * 1/7 = 9/56, stationary P(I) = 1/7.
#   after-I : (27/28) * (47/56)^(k-1)
#   window  : (6/7)   * (47/56)^(k-1)
# Monte Carlo is run with several seeds so a single unlucky seed is visible.
import random

N = 5_000_000
P_II, P_IN = 1 / 28, 9 / 56

def simulate(seed):
    random.seed(seed)
    prev, seq = 7, []
    for _ in range(N):
        r = random.randrange(8)
        if r == 7 or r == prev:
            r = random.randrange(7)
        prev = r
        seq.append(r == 0)
    return seq

def after_i(seq, k):
    gaps, gap = [], None
    for is_i in seq:
        if is_i:
            if gap is not None:
                gaps.append(gap)
            gap = 0
        elif gap is not None:
            gap += 1
    return sum(g >= k for g in gaps) / len(gaps)

def window(seq, k):
    run = hits = total = 0
    for i, is_i in enumerate(seq):
        run = 0 if is_i else run + 1
        if i >= k - 1:
            total += 1
            hits += run >= k
    return hits / total

for k in (12, 20):
    print(f"k={k}: exact after-I={(1 - P_II) * (1 - P_IN) ** (k - 1):.5f}   exact window={(6 / 7) * (1 - P_IN) ** (k - 1):.5f}")
for seed in (42, 7, 1):
    seq = simulate(seed)
    print(f"  seed {seed:2d}: " + "   ".join(f"k={k}: after-I={after_i(seq, k):.5f} window={window(seq, k):.5f}" for k in (12, 20)))
k=12: exact after-I=0.14035   exact window=0.12476
k=20: exact after-I=0.03455   exact window=0.03071
  seed 42: k=12: after-I=0.13885 window=0.12351   k=20: after-I=0.03397 window=0.03028
  seed  7: k=12: after-I=0.13975 window=0.12380   k=20: after-I=0.03409 window=0.03035
  seed  1: k=12: after-I=0.14071 window=0.12464   k=20: after-I=0.03433 window=0.03058

閉式解算出的 12 顆 after-I 約 14.0%,window 約 12.5%;20 顆則分別約 3.5% 與 3.1%。約 12% 與 3% 的數字接近 window 的結果,這是依數值做的對照,原文沒有明確交代起算條件。同一模型光是換個起點,12 顆那項就差約 1.6 個百分點。

seedafter-I,k = 12window,k = 12after-I,k = 20window,k = 20
42-0.00150-0.00125-0.00058-0.00043
7-0.00060-0.00096-0.00046-0.00036
1+0.00036-0.00012-0.00022-0.00013

每個 seed 約有 71 萬個完整 gap,k = 12 的 after-I 比例標準誤約 0.0004。seed 42 低了約 0.0015,約為 3.7σ,seed 7 與 1 則都在 1.5σ 內。

真機 NES 的重抽流程

NES Tetris 的選塊流程用 16-bit Fibonacci LFSR,每幀至少步進一次。第一擲將 LFSR 高位元組與更新後的 spawn counter 相加,取 mod 8,遇到 7 或與上一顆相同就重抽;重抽會先推進 LFSR,再將新高位元組取 mod 8,加上上一顆生成時的 orientation ID,最後取 mod 7。

重抽把八種輸入映成七種方塊,總有兩個輸入得到同一個結果,orientation ID 決定哪種方塊多拿到這份機率。Meat Fighter 把 PRNG 輸入視為獨立均勻,並對前一顆方塊採等權近似,估算 T 約占 14.73%,I 約占 13.84%,I 比理想模型的 1/7 少一點,平均乾旱也會略長;長乾旱的機率還取決於出塊順序。

前面的 NES 模擬只保留上一顆方塊,並採均勻重抽;要重現真機,還得保留 LFSR 與 spawn counter,按兩次出塊之間經過的幀數推進狀態。

平均一樣、分位數不一樣

純隨機的平均 gap 是 6,p95 是 19;7-bag 的 p95 是 11。玩家留著直井等 I 清行時,還得有空間安置接下來的其他方塊,等得越久,能安置其他方塊的空間就越吃緊。

7-bag 是每袋七種各一顆的無放回抽樣,與失敗越多、命中機率越高的軟保底屬於不同設計,後者見〈用狀態機算抽卡保底與裝備強化〉。

怎麼選發牌器

規則要求每次出塊獨立,就用純隨機,遊戲也得容得下偶爾很長的等待。允許依歷史調整分布時,需要等待上限就選 7-bag,代價是袋尾較容易預測;只想減少連續重複,又能接受乾旱沒有上限,可以選理想化 NES 重抽。篩選候選實作時,把發牌器接進 gaps(gen, n),用完整 gap 的 p95、p99 對照遊戲能接受的等待長度,超出需求就淘汰該候選;若候選聲稱符合袋子規則,任何 gap 超過上界都應直接判定實作有誤。

我自己的設計紅線是博弈類不照歷史調機率,每局獨立。

補充筆記

  • 篩選發牌器時,把完整 gap 的分位數接成淘汰條件,平均值接近仍可能等得太久。
  • 測袋子邊界時,把 I 放在前一袋袋首與下一袋袋尾,檢查 gap 是否為 12;獨立均勻洗牌時,這種 gap 的出現比例應接近 1/49。
  • 比較乾旱機率前先固定起點,理想化 NES 的 12 顆 after-I 約 14.0%,window 約 12.5%,要依玩家實際等待的情境選擇。
  • k = 12 的 after-I 在 seed 42 低了約 3.7σ,查這種偏差時,先看它換 seed 後是否仍持續出現。
  • 做 NES 重現工具時,把生成時的 orientation ID 與 LFSR 狀態一起保留下來,出塊間隔的幀數也要算進去。

延伸閱讀

  • Tetris Wiki〈Random Generator〉:7-bag 的 12 顆間隔上限與 S/Z 連續上限,也記錄《The New Tetris》的 63 顆袋起源,篇幅短。
  • Gamertag Mythras〈Tetris randomizers explained〉:各代發牌器整理,本文依該文描述建立理想化 NES 模型,並對照其約 12%/3% 的說法。
  • Meat Fighter〈Picking Tetriminos〉:長篇 NES Tetris 反組譯分析中的一節,說明 LFSR 與實際選塊流程,以及重抽為何有偏差。

想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 Mac 上以 Python 3.15.0 實測。