「ランダム出題」を作り込む:偏りを抑える乱数
遊びをつくる執筆にClaudeを使用しています。

クイズやタイピングのゲームで、同じ問題が2回続けて出る場面を考えてみます。遊ぶ側は「ランダムなのだから仕方ない」と思う前に、「手を抜かれた」と感じやすいものです。ところが、問題を5つから毎回等確率で選ぶ素直な実装は、バグではなく仕様どおりに動いています。直前と同じ問題が出る確率は、計算上ちょうど5分の1、つまり20%です。10問続ければ、連続の組が9つあるので、平均して1.8回ほど「また同じ」が起きる計算になります。 この記事の問いは、乱数の性質を変えずに、同じ出題が続く体験だけをどこまで減らせるかです。答えの中心は「シャッフル袋」という仕組みです。全問題を一度シャッフルして袋に入れ、1問ずつ取り出し、空になったら入れ直します。これに、袋の入れ直しの境目だけ特別に気をつける処理を足します。 以下の手順は、HTMLファイル1枚で動く最小の形です。アソバースに投稿するゲームも外部通信なしの単独HTMLで動かす前提なので、そのまま自作ゲームに移せます。
この記事の目次
手順1:素のランダムの「連続率」を計算で見積もる
実装の前に、直したい現象を数字で捉えておきます。問題数をNとすると、素のランダムで直前と同じ問題が出る確率は1/Nです。
- 問題数が5なら20%です。
- 問題数が10なら10%です。
- 問題数が3なら約33%で、3回に1回は連続します。
問題数が少ないゲームほど「同じ出題が続く」体験が目立ちます。自作ゲームの問題数Nと、1回のプレイで出す問題数を書き出し、(出題数−1)÷N が「1プレイで連続が起きる回数の期待値」だと把握してください。この値が1を超えるなら、対策する価値が高いと判断できます。
手順2:シャッフル袋を作る
次に、袋の仕組みを作ります。要点は2つです。
- 袋が空のときだけ、全問題を複製してシャッフルして詰め直します。
- 出題は袋の末尾からpop()で1つ取り出します。
シャッフルには、Fisher-Yates法(末尾から順に、自分より前のランダムな位置と入れ替える方法)を使います。sort(() => Math.random() - 0.5)で並べ替える書き方は、結果に偏りが出ることが知られているので避けてください。
手順3:入れ直しの境目を守る
袋の仕組みだけでは、まだ穴があります。袋の中は重複しないので、袋の途中で連続は起きません。ただし、前の袋の最後に出た問題が、次の袋の最初に来ると連続します。そのため、詰め直した直後に「取り出し口(末尾)が直前の問題と同じか」を確かめ、同じなら別の位置の要素と入れ替えます。
手順2と3をまとめたコードが次のとおりです。問題の文字列は自作のものに置き換えてください。問題はすべて互いに異なる前提です。
<!doctype html>
<meta charset="utf-8">
<button id="next">次の問題</button>
<p id="out"></p>
<script>
const items = ["りんご", "みかん", "ぶどう", "もも", "なし"];
let bag = [], last = null;
function shuffle(a) {
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function refill() {
bag = shuffle(items.slice());
const end = bag.length - 1;
if (end > 0 && bag[end] === last) {
const k = Math.floor(Math.random() * end);
[bag[k], bag[end]] = [bag[end], bag[k]];
}
}
function draw() {
if (bag.length === 0) refill();
last = bag.pop();
return last;
}
function naive() {
return items[Math.floor(Math.random() * items.length)];
}
function countRepeats(fn, n) {
let c = 0, prev = null;
for (let i = 0; i < n; i++) {
const v = fn();
if (v === prev) c++;
prev = v;
}
return c;
}
document.getElementById("next").onclick = () => {
document.getElementById("out").textContent = draw();
};
console.log(countRepeats(naive, 1000), countRepeats(draw, 1000));
</script>手順4:自分で数えて確かめる
ファイルをブラウザで開き、開発者ツールのコンソールを見てください。最終行が、素のランダム(左)と袋方式(右)の「連続回数」を1000回の出題で数えて表示します。確認は次のように進めます。
- ページを再読み込みして、同じ計測を5回ほど繰り返し、左右の数字をそれぞれ控えます。
- 左の値が、理屈どおり1000回の約5分の1(200前後)に近いかを見ます。大きく外れるなら、itemsに同じ文字列が重複していないか確認します。
- 右の値が0かどうかを見ます。袋の途中でも境目でも連続しない設計なので、0以外なら実装の誤りです。入れ替えの条件式とpop()の位置を疑ってください。
- itemsの数を2、3、10と変えて、同じ計測をしてみます。問題数が少ないほど、左の連続回数が増えることを自分の目で確認できます。
なお、計測用のdrawは袋の状態を共有しています。ボタンを押して遊んだ後に計測すると、続きの状態から数え始めます。厳密に比べたいときは、再読み込み直後に測ってください。
手順5:袋方式の弱点と、使い分けの判断基準
袋方式は、連続を防ぐ代わりに「予測しやすさ」を生みます。5問の袋なら、4問目を終えた時点で、残りの1問が何かを覚えている人には読めてしまいます。何度も遊ばれるゲームでは、次の判断で調整します。
- 問題数が少なく、全問を均等に見せたい、向く方法:袋方式(そのまま)
- 先読みされたくない、向く方法:袋を2セット分(各問題2枚)にして混ぜ、隣り合う同一問題だけ入れ替えて避ける
- 問題数が多い(数十以上)、向く方法:直近のK問を履歴に覚え、その中にないものから素のランダムで選ぶ
- 苦手な問題を多めに出したい、向く方法:間違えた問題の重みを上げる(ただし直前と同じは除外する)
履歴方式を使うときは、Kを問題数より小さくしてください。Kが問題数以上だと、選べる候補が空になって止まります。Kは「問題数の半分以下」を目安に始め、遊んで調整するのが安全です。
もう一点、状態の持ち越しにも注意が必要です。袋の中身とlastは、画面を閉じると消えます。アソバースにアップロードした作品内のlocalStorageは一時メモリーの互換処理で、永続の保存先にはなりません。毎回の起動で袋を作り直して問題がない作りにするのが、基本の考え方です。進行を次回に引き継ぎたい場合は、作品ごとにJSON 128KiBまで使えるクラウドセーブを、現在の案内で確認したうえで検討してください。
起こりやすい誤り
- 同じ問題がitemsに重複していると、袋の中で連続が起きます。問題の重複チェックを先に済ませてください。
- 問題数が1だと、境目の入れ替え先がありません。コードではend > 0で防いでいますが、そもそも1問のゲームでは出題の工夫は不要です。
- 問題を途中で追加・削除する場合、袋の中身が古いままになります。変更したらbag = []で空にしてから使ってください。
- 乱数の検証では、見た目の印象でなく回数で確かめます。「最近同じ問題ばかり」という感覚は、偏りがなくても生まれやすいからです。
次の行動
自作ゲームの出題部分をdraw()に置き換え、手順4の計測を問題数を変えて実行し、連続回数を紙に控えてください。そのうえで、袋の中が読まれやすいか、苦手問題を重くしたいかを遊びながら判断し、上の表から次の方式を一つ選んで試してください。完成したゲームは、アソバースの投稿画面から公開できます。

