JavaScriptで乱数を扱う:シード付き乱数で再現可能にする
遊びをつくる執筆にClaudeを使用しています。

ゲームを作っているとき、「同じ操作をしたのに結果が毎回変わってしまい、バグを再現できない」という場面に出くわすことがあります。あるいは、「同じシード値を入力すると毎回同じダンジョンが生成される」という仕組みをゲームで見たことがあるかもしれません。どちらの問題も、シード付き乱数(Seeded PRNG)で解決できます。 Math.random() はブラウザが内部で管理する乱数列を返すため、呼び出し側からシード(初期値)を指定する手段がありません。再現性が必要な場面では、シードを受け取って決定的な乱数列を返す関数を自分で用意する必要があります。この記事では、なぜ再現性が必要なのか、どのアルゴリズムを選べばよいのか、そして実際にゲームへ組み込む手順を順番に説明します。
なぜ「再現可能な乱数」が必要なのか
Math.random() を使う限り、同じコードを実行しても結果が毎回異なります。これは通常の用途では望ましい性質ですが、ゲーム開発では次の三つの場面で困ります。
バグ再現。「敵の行動がこのパターンのときにだけフリーズする」という不具合は、乱数が毎回変わると再現できません。乱数列をシードで固定できれば、同じシード値で何度でも同じ状況を再生できます。
プロシージャル生成の共有。マップやダンジョンをランダムに生成するとき、シード値をユーザーに見せて入力させる設計にすると、「このシードのマップが面白い」という情報を友人と共有できます。Minecraftのワールドシードが有名な例です。
リプレイ機能。プレイヤーの入力だけを記録し、乱数を同じシードで再生すれば、完全なリプレイを少ないデータ量で実現できます。入力列と初期シードを保存するだけで済みます。
アルゴリズムの選び方
シード付き乱数生成(PRNG:Pseudorandom Number Generator)のアルゴリズムはいくつか知られています。ゲーム用途で実用的な選択肢を整理します。
Mulberry32
32ビット整数のシードを受け取り、1回の呼び出しで32ビットの乱数を生成します。実装が非常に短く、ゲームの乱数用途であれば統計的品質も十分です。ブラウザゲームへの組み込みに最も手軽な選択肢です。
状態: 32ビット整数1個
速度: 非常に速い
周期: 約42億(2^32)
実装の難しさ: 低xoshiro128(クロシロ128)
128ビットの状態を持つアルゴリズムで、周期が非常に長く(2^128 − 1)、統計的品質も高い評価を受けています。「多数の乱数列を並列に走らせたい」「乱数の品質が結果に影響するシミュレーション系のゲームを作りたい」という場合に向いています。状態が32ビット整数4個なので、Mulberry32より少し実装量が増えます。
LCG(線形合同法)
古典的な手法で、次の値 = (a * 現在値 + c) mod m という計算です。パラメータ選択を誤ると品質が低く、連続する値に相関が出やすい欠点があります。歴史的な文脈やとても軽量な組み込み環境では登場しますが、ブラウザゲームであれば Mulberry32 や xoshiro128** を選ぶほうが無難です。
Mulberry32 の実装と使い方
まず関数を定義します。
function mulberry32(seed) {
return function () {
seed |= 0;
seed = seed + 0x6D2B79F5 | 0;
let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}mulberry32 にシード値(整数)を渡すと、Math.random() と同じ「0以上1未満の小数」を返すクロージャが得られます。
const rand = mulberry32(12345);
console.log(rand()); // 常に同じ値
console.log(rand()); // 常に同じ値
console.log(rand()); // 常に同じ値シード値が同じであれば、どの環境・どのタイミングで実行しても同じ数列が得られます。
整数範囲・配列シャッフルへの応用
Math.random() を使っていた箇所をそのまま rand に差し替えるだけで再現性が付きます。
// 0〜max-1 の整数
function randInt(rand, max) {
return Math.floor(rand() * max);
}
// 配列のシャッフル(Fisher-Yates)
function shuffle(rand, array) {
const a = [...array];
for (let i = a.length - 1; i > 0; i--) {
const j = randInt(rand, i + 1);
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}ゲームへ組み込むときの設計判断
シードをどこから取るか
- 固定値:開発中のデバッグ専用。コードに直接書いて、問題が再現できたら修正後に削除します。
- Date.now() や performance.now():毎回異なるシードを生成したいが、記録しておけば再現もできます。ゲーム開始時にシードを決めてコンソールやセーブデータに残すと便利です。
- ユーザー入力文字列:文字列をハッシュして整数に変換する処理が別途必要です。簡易なハッシュ関数(djb2など)を組み合わせることで、ダンジョン名やプレイヤー名をシードにできます。
インスタンスをいくつ用意するか
乱数生成器を一つだけ使い回すと、「マップ生成で何回引いたか」が変わるだけで敵の行動や戦闘結果が変化してしまいます。用途ごとにインスタンスを分けることで、それぞれの系列が独立します。
const mapRand = mulberry32(seed);
const enemyRand = mulberry32(seed + 1);
const lootRand = mulberry32(seed + 2);シードをオフセットでずらす方法は手軽ですが、異なる用途のインスタンスが同じシードから生成した数列の「途中」に偶然重なる可能性がゼロではありません。厳密に独立させたい場合は xoshiro128** の「ジャンプ」機能(一定ステップ先に状態を飛ばす)を使う方法があります。ゲームの規模に応じて判断してください。
`Math.random()` を完全に置き換えるかどうか
再現性が必要な処理(マップ生成・戦闘・AI行動)は PRNG インスタンスを使い、演出エフェクトのような「再現しなくていいランダム性」は Math.random() のままにしておく、という使い分けが現実的です。すべてを置き換えようとすると、ライブラリ内部の Math.random() まで追いかける必要が出て複雑になります。
よくある失敗と対策
シードを 32 ビット整数の範囲に収めていない。JavaScript の数値は 64 ビット浮動小数点なので、大きな数値をそのままシードに渡すと内部で精度が落ちます。Mulberry32 の実装で seed |= 0 しているのは 32 ビット整数に切り捨てるためですが、渡す前から >>> 0 で明示的に変換しておくと安心です。
同一フレームで複数のインスタンスを同じシードで作ってしまった。コピーペーストミスでインスタンスが全部同じシードになると、それぞれが同じ数列を返します。インスタンスごとにシードを変えているか確認してください。
乱数消費回数がバージョンアップで変わった。ゲームロジックを改修して乱数を引く回数が変わると、同じシードでも以前と異なる結果になります。セーブデータにシードを記録してリプレイ再現を保証したい場合、バージョンをシードと一緒に保存し、旧バージョンの乱数生成器を維持しておく設計が必要です。リプレイの厳密な互換性が求められるかどうかを、設計の早い段階で決めておくと後で楽です。
次の行動
Mulberry32 の実装はコードが30行以下なので、今開発中のゲームに今すぐ組み込んでテストできます。まず Math.random() を使っている箇所を一か所だけ置き換えて、同じシードで同じ結果が返るかをコンソールで確認してみてください。再現性が取れたら、バグを踏んだときにシード値をメモする習慣を付けると、デバッグ効率が大きく上がります。アソバースにゲームを投稿する際も、プロシージャル生成をシードで管理しておくと、プレイヤーがシードを共有して楽しめる仕組みを後から追加しやすくなります。
参考資料
- MDN Web Docs「Math.random()」
- "Mulberry32: A fast high quality 32-bit PRNG" (tommyettinger, GitHub)
- "Xoshiro / Xoroshiro generators and the PRNG shootout" (Vigna, Blackman)
