はじめに
最近、とあるシリーズの最新作が発売になりました。ふと昔ハマっていた“乱数調整”をもう一度やってみたくなり過去作を探してきたのですが、「そもそも乱数って何だっけ?」という根本に立ち返り、実装のロジックとして理解してみたくなったので調べたことを残しておこうと思います。
※特定タイトルや手順名は挙げません。
ステップ0:直感の整理——“運”と“作為”
「ランダム」と聞くと、私たちはサイコロやコイン投げのような予測不可能なものを想像します。これらの現象は、投げる角度や力、空気抵抗といった無数の要因が絡み合うため、結果を正確に予測するのは事実上不可能です。物理法則に従う決定論的な現象でありながら、初期条件の完全な把握が困難なため、実用上「ランダム」に見えます。
プログラムの世界では、このような真のランダム性を再現するのは得意ではありません。代わりに、計算によってランダムに見える数値の列を生成するアルゴリズム、すなわち**擬似乱数生成器(Pseudo-Random Number Generator, PRNG)**が使われます。これは“作為的”な乱数ですが、多くのアプリケーションではこれで十分なのです。
ステップ1:乱数の種類(TRNG vs PRNG)
乱数には、その性質から大きく分けて二種類が存在します。
-
真性乱数(True Random Number Generator, TRNG):
物理現象のノイズ(例:大気ノイズ、熱雑音、放射性崩壊)を元に乱数を生成します。結果は予測不可能で、再現性もありません。暗号技術や高度な科学技術計算など、予測不可能性が絶対に求められる場面で利用されます。まさに“ガチ”のランダムです。 -
擬似乱数(Pseudo-Random Number Generator, PRNG):
ある**初期値(seed)**から、特定の計算式を用いて数値の列を生成します。計算に基づいているため、同じseedを使えば必ず同じ数列が再現されます。この「決定論的」な性質が、ゲーム開発やシミュレーション、デバッグにおいて極めて有用です。高速に生成でき、問題を再現させたいときに同じ状況を作り出せるからです。
多くのゲームやアプリケーションがPRNGを採用しているのは、この速度と再現性という大きなメリットがあるためです。
ステップ2:PRNGの心臓——seedと状態遷移
PRNGは、**内部状態(state)**と呼ばれる、現在の数値を記憶する変数を持っています。そして、next()のような関数が呼ばれるたびに、この内部状態を更新し、新しい数値を返します。
すべての始まりはseedです。seedをPRNGに与えると、それが最初の内部状態となります。その後の出力列は、アルゴリズムによって完全に決定されます。
代表的なアルゴリズムに**線形合同法(Linear Congruential Generator, LCG)**があります。その抽象的な形は次の通りです。
X_{n+1} = (a * X_n + c) mod m
-
X_n: n番目の内部状態。この値をもとに、アルゴリズムが出力乱数を計算します(単純なLCGではこの状態自体が乱数として利用されます)。 -
X_{n+1}: 次の内部状態 -
a: 乗数 (multiplier) -
c: 加数 (increment) -
m: 法 (modulus)
これらのa, c, mの値の選び方で、生成される乱数列の周期や統計的性質(どれだけランダムに見えるか)が大きく変わります。
ポイント:アルゴリズム自体は完全に決定的です。私たちが「乱数」を不確かだと感じるのは、多くの場合、seedが何であるか、そしてプログラムの内部でPRNGが何回進んだ(advanceした)かを正確に知らないからです。
ステップ3:状態機械としての“乱数”
PRNGの振る舞いは、“運”や“偶然”ではなく、完全に**状態機械(State Machine)**の遷移として説明できます。ある状態から次の状態への移行ルールが固定されているからです。
ゲーム内での乱数の使われ方は、次の三段階に分解して考えることができます。
-
seedの決定:
ゲームの起動時刻、プレイヤーID、特定の操作など、何らかの値が乱数の初期状態(seed)として設定されます。 -
advance(状態遷移):
メニューを開く、キャラクターが動く、ボタンを押すといったあらゆる処理の裏で、PRNGのnext()関数が呼ばれ、内部状態が1つ先に進みます。これが「乱数を消費する」という行為の実体です。 -
map(写像):
PRNGが出力した生の数値(例:0から2^32-1までの整数)を、ゲーム内で意味のある結果(例:サイコロの1〜6、アイテムの抽選、敵の行動パターン)に変換します。
このseed → advance → mapというモデルで考えると、特定のゲームタイトルに触れなくても、内部で何が起きているかをロジックで追いかけることができます。
ステップ4:写像(map)のコツと落とし穴
PRNGが生成した数値を特定の範囲に変換(map)する際には、注意が必要です。単純な方法には落とし穴があります。
例えば、0から65535(2^16-1)の範囲の乱数 x を、サイコロの目 1〜6 に変換したいとします。単純に剰余(%)を使うとどうなるでしょうか。
result = (x % 6) + 1
この計算では、65536 / 6 = 10922 あまり 4 となり、0, 1, 2, 3 に対応する乱数の個数が、4, 5 に対応する個数よりも1つ多くなります。つまり、サイコロの1〜4の目が出る確率が、5〜6の目よりもわずかに高くなるという剰余バイアスが発生します。
この偏りをなくすための一般的な手法が**リジェクションサンプリング(棄却サンプリング)**です。
-
2^wをNで割った余りを切り捨て、Nの倍数になる最大値limitを求める。
(例:limit = floor(65536 / 6) * 6 = 10922 * 6 = 65532) - PRNGで数値
xを生成する。 - もし
x >= limitなら、その数値を捨てて2からやり直す。 -
x < limitなら、x % Nを結果として採用する。
これにより、0からN-1までの各数値が完全に均等な確率で出現するようになります。
また、LCGなどの一部のPRNGでは、生成される数値の下位ビットに周期性が見られやすいという弱点があります。そのため、よりランダム性の高い上位ビットを使うか、ビットをかき混ぜる(xorshiftなど)のが定石です。
ステップ5:“乱数を使う前に検証する”
良いPRNGを設計・選択するには、その品質を検証することが不可欠です。主な検証項目には以下のようなものがあります。
- 一様性: 生成される数値が特定の範囲に偏っていないか。ヒストグラムを作成したり、カイ二乗(χ²)検定を行ったりして評価します。
- 独立性: ある数値が次の数値に影響を与えていないか。自己相関を調べたり、ラン検定(生成された数列で数値の増減が連続する部分を調べる)を行ったりします。
- 再現性: 同じseedから常に同じ数列が生成されるか。これはPRNGの定義そのものであり、デバッグやテストの容易性に直結します。
- 効率: 速度、メモリ使用量、並列計算への適性などが、アプリケーションの要件を満たしているか。
これらの検証を経て初めて、そのPRNGは「質の良い」乱数と見なされます。
ステップ6:ミニ実験——擬似乱数の“再現性”を体感
PRNGの最も重要な特性である「同じseedなら同じ結果になる」ことを、JavaScriptコードで確認してみます。
※ブラウザの開発者コンソールなどで確認できます。
目的: seedの再現性を確認する。
// シンプルな32bit PRNG(xorshift32)
class PRNG {
constructor(state) {
// stateが0だと出力が0のままになるため、0の場合は別の値に設定
if (state === 0) state = 0x6d2b79f5;
this.state = state >>> 0; // 符号なし32bit整数に変換
}
nextU32() {
let x = this.state;
x ^= x << 13;
x ^= x >>> 17;
x ^= x << 5;
this.state = x;
return x >>> 0;
}
}
// 剰余バイアスを避けて 0 から N-1 の整数を生成
function uniformInt(prng, N) {
const limit = Math.floor(0x100000000 / N) * N;
let x;
do {
x = prng.nextU32();
} while (x >= limit);
return x % N;
}
// --- 実験 ---
// 1. 同じseedから同じ乱数列が生成されることを確認
const prngA = new PRNG(123456);
const diceA = Array.from({ length: 10 }, () => uniformInt(prngA, 6) + 1);
const prngB = new PRNG(123456);
const diceB = Array.from({ length: 10 }, () => uniformInt(prngB, 6) + 1);
console.log("Dice A:", diceA);
console.log("Dice B:", diceB);
console.assert(
JSON.stringify(diceA) === JSON.stringify(diceB),
"Test Failed: diceA and diceB should be identical"
);
console.log("✅ Test Passed: 同じseedから同じ乱数列が生成されました。");
// 2. 違うseedから違う乱数列が生成されることを確認
const prngC = new PRNG(999999);
const diceC = Array.from({ length: 10 }, () => uniformInt(prngC, 6) + 1);
console.log("Dice C:", diceC);
console.assert(
JSON.stringify(diceA) !== JSON.stringify(diceC),
"Test Failed: diceA and diceC should be different"
);
console.log("✅ Test Passed: 違うseedから違う乱数列が生成されました。");
これより、seedが同じであれば、生成される乱数列は常に同一になることがわかります。なお、uniformIntはステップ4で説明した剰余バイアスを回避する関数です。
ステップ7:観測→仮説→補正という考え方
ゲームの乱数調整は、動き方を観察して中身のルールを推し量る感じですね。仮説を立てて試して直す、地味な作業に似ていますね…。
- 観測: 同じ状況(同じセーブデータ、同じ場所)で、特定の操作(ボタンを押すタイミング、メニューを開閉する回数)と、その結果(出現する敵、ドロップするアイテム)のログを大量に取ります。
-
仮説: 観測結果から、「ゲーム起動時に時刻をseedにしているのではないか?」「この操作をすると乱数が
k回進む(advanceする)のではないか?」といったモデル(仮説)を立てます。 - 検証: 立てた仮説に基づいて、特定のseedと操作手順で望む結果が再現できるか試します。
- 補正: もし結果がズレるなら、それは仮説が間違っているか、見落としているadvance要因があるということです。モデルを修正し、再度検証します。
“乱数調整”とは、この「観測→仮説→検証→補正」のサイクルを回し、ゲームというブラックボックスの内部にある状態機械の振る舞いを解明していく知的パズルみたいです。
まとめ
- 乱数は“運”ではなく、アルゴリズムと状態で説明できる状態機械でした。
-
seed→advance→mapの三段階で分解すれば、挙動を論理的に理解できるような気がします。
この決定論的な性質を理解し、利用することが“乱数調整”への本当の意味での使い方を知るということなのかもしれません。
採用拡大中!
アシストエンジニアリングでは一緒に働くフロントエンド、バックエンドのエンジニア仲間を大募集しています!
少しでも興味ある方は、カジュアル面談からでもぜひお気軽にお話ししましょう!
お問い合わせはこちらから↓
https://official.assisteng.co.jp/contact/
参考
-
PRNGの基礎・古典:
- Knuth, TAOCP Vol.2 (著者公式ページ)
- 乱数調整とは何か: