正しい乱数は理不尽に感じられる

確率 20 パーセントの攻撃が 10 回連続で外れる。これは一様乱数を使っていれば十分に起こりうる出来事で、確率としてはおよそ 10 パーセントです。10 人に 1 人が体験する計算になります。

ところが、実際にこれを体験したプレイヤーの多くは「確率がおかしい」「表示が嘘だ」と受け取ります。統計的には何も間違っていないのに、体験としては破綻しているわけです。

この乖離が生まれるのは、人が連続や偏りを実際より起こりにくいものだと見積もる傾向を持っているためです。真にランダムな系列には必然的に塊ができますが、人はその塊を「ランダムではない」と判断します。
したがってゲームにおける乱数の設計目標は「正しくランダムであること」ではなく「ランダムだと感じられること」になります。この記事では、そのための代表的な手法を整理します。

手法 1 : シャッフルバッグ

結果の集合をあらかじめ用意し、それをシャッフルして順番に取り出す方式です。袋から玉を取り出すイメージから、バッグ方式とも呼ばれます。

広く知られている例が、落ち物パズルにおけるブロックの出現順です。7 種類のブロックを 1 セットとして袋に入れ、シャッフルして 7 個すべてを出し切ってから次の袋を用意します。この方式なら、同じブロックが極端に長く来ないことが保証されます。

import random


class ShuffleBag:
    """要素を一巡させてから補充する抽選器"""

    def __init__(self, items):
        self.source = list(items)
        self.bag = []
        return

    def draw(self):
        if not self.bag:
            self.bag = list(self.source)
            random.shuffle(self.bag)
        return self.bag.pop()

実装が単純で、最悪の待ち時間が数式ではなく袋のサイズで直接決まるのが利点です。ドロップアイテムの種類、敵の出現パターン、ボイスの再生順など、「同じものが続くと目立つ」場面全般に使えます。

欠点は、次に何が来るかを数えれば読めてしまうことです。読まれて困る用途では、袋を複数用意して混ぜる、袋の境目でシャッフル範囲を重ねるといった工夫が必要になります。

手法 2 : 疑似ランダム分布

試行のたびに確率そのものを動かす方式です。外れが続くほど当たりやすくなり、当たった瞬間に確率が初期値へ戻ります。

この仕組みは、対戦型アクションゲームにおけるクリティカル判定などで採用されています。1 回目の判定では表示確率よりかなり低い確率で判定し、外れるたびに一定量ずつ確率を加算していきます。長期的な平均が目標の確率と一致するように、加算量となる定数を調整するのがポイントです。

import random


class PseudoRandomChance:
    """外れが続くほど当たりやすくなる抽選器"""

    def __init__(self, constant):
        self.constant = constant     # 1 回外れるごとに加算される確率
        self.failure_count = 0
        return

    def roll(self):
        current_chance = self.constant * (self.failure_count + 1)
        if random.random() < current_chance:
            self.failure_count = 0
            return True
        self.failure_count += 1
        return False

この方式の効果は二方向に働きます。外れの連続が短くなるだけでなく、当たりの連続も起こりにくくなります。攻撃側と防御側の双方にとって、結果の振れ幅が小さくなるということです。

注意点として、目標確率と定数の対応は単純な比例ではありません。狙った平均確率になる定数は数値的に求める必要があり、実装時は事前に表を作っておくのが現実的です。

手法 3 : 天井

一定回数はずれ続けたら次は必ず当たる、という保証を入れる方式です。抽選の上限が明示されるため、プレイヤーは最悪の場合にかかるコストを計算できるようになります。

import random


class CeilingChance:
    """指定回数はずれると次で必ず当たる抽選器"""

    def __init__(self, chance, ceiling):
        self.chance = chance
        self.ceiling = ceiling
        self.failure_count = 0
        return

    def roll(self):
        self.failure_count += 1
        if self.failure_count >= self.ceiling or random.random() < self.chance:
            self.failure_count = 0
            return True
        return False

天井の価値は確率の調整そのものよりも、精神的な負荷の軽減にあります。終わりの見えない抽選は、期待値が同じでも体験としては著しく苦しくなります。あと何回で確実に手に入るかが分かるだけで、同じ確率でも受け止め方が変わります。

設計上の判断としては、天井の存在と回数を公開するかどうかが分かれ目です。隠していると、天井に到達した人としていない人の体験が食い違い、かえって不信を招きます。仕組みとして入れるなら、明示するのが基本です。

手法 4 : 決定論的なシード

乱数の種を固定し、同じ入力からは常に同じ結果が出るようにする方式です。日替わりのステージや、リプレイの再現、対戦相手との条件統一に使われます。

この方式の利点は、運の要素を残しながら公平性を担保できることです。全員が同じ乱数列を引くので、結果の差はプレイヤーの選択によるものだと説明できます。乱数への不満は「自分だけ運が悪い」という感覚から来ることが多いため、条件が同じだと分かるだけで不満はかなり減ります。

実装上は、言語標準の乱数生成器の実装がバージョンによって変わりうる点に注意が必要です。再現性が要件になるなら、生成アルゴリズムは自前で持つか、実装が保証されたものを選びます。

やりすぎると別の問題が起きる

体感を整える方向に踏み込みすぎると、逆の副作用が出ます。

  1. 隠れた難易度補正は信頼を損なう
    負けが続いたプレイヤーをこっそり有利にする仕組みは、気付かれた瞬間に「自分の勝利は与えられたものだったのか」という疑念を生みます。導入するなら、気付かれても納得できる範囲にとどめる判断が必要です。
  2. 表示確率と実装が食い違ってはいけない
    疑似ランダム分布のように 1 回あたりの確率が変動する仕組みでは、表示する数値が何を意味しているのかを定義しておく必要があります。長期平均なのか初回の確率なのかで、受け取られ方はまったく違います。
  3. ランダム性が失われると読み合いが壊れる
    補正を強くしすぎると次の結果が予測可能になり、緊張感が消えます。バッグ方式で読まれる問題と同じ構造です。

まとめ

一様乱数は数学的に正しくても、人の直感とは合いません。ゲームで扱う乱数は、公平さを保ちながら体感の振れ幅を抑える方向に加工するのが基本方針になります。
同じものが続くと困る場面ではシャッフルバッグ、当たり外れの連続を均したい場面では疑似ランダム分布、長期の抽選には天井、公平性の説明が必要な場面では固定シード。用途ごとに使い分けるのが実務的です。
そして、どの手法を採る場合でも、仕組みを隠さないことが最も効きます。プレイヤーが不満を持つのは偏りそのものではなく、偏りの理由が分からないときだからです。