「同じものが3つ揃うと消える」を数で設計する:連鎖の確率
遊びをつくる執筆にClaudeを使用しています。

3つ揃えて消すパズルを作るとき、盤面を「見た目のバランスで」8×8、色数を5色と決めたくなります。ところがこの2つの数字は、開始直後に勝手に消える組の数や、1手で連鎖が起きる頻度を大きく左右します。ここで考えるのは、盤面の大きさと色数を決める前に、消去の確率を紙の上と短いコードで見積もれるか、という問いです。 最初の見積もりは暗算でできます。ランダムに色を並べたとき、決まった3マスが同じ色になる確率は、色数をKとして1/K²です。5色なら1/25、つまり4%です。盤面には3マスの並び(窓)がたくさんあるので、全部の窓について足し合わせると、最初から揃っている窓の数の期待値が出ます。 ただし期待値は「平均」でしかなく、連鎖が何回続くかの分布は教えてくれません。そこでこの記事では、期待値の計算と、短いシミュレーションの設計をセットにします。結果は載せません。手元で動かして、自分のゲームの目標値と照らし合わせるための設計書です。
問いを数に落とす
盤面の幅をW、高さをHとします。横向きの3マス窓は H×(W−2) 個、縦向きは W×(H−2) 個あり、合計は 2×H×(W−2)(正方形の場合)です。完全にランダムな盤面での「揃っている窓の期待値」は、窓の数÷K² で求まります。
- 6×6、窓の数:48、4色:3、5色:1.92、6色:約1.33
- 8×8、窓の数:96、4色:6、5色:3.84、6色:約2.67
- 10×10、窓の数:160、4色:10、5色:6.4、6色:約4.44
数字は「ランダムに並べた直後に、すでに揃っている窓がいくつあるか」の平均です。4個並びは窓が2つ重なるので2個と数えます。
もう一つ覚えておきたいのは、すでに2つ同じ色が並んでいる場所に、新しい1マスが来て揃う確率は1/Kだということです。3マスとも未確定なら1/K²ですが、2マスが確定済みなら1/Kに跳ね上がります。連鎖は、補充された新しいマスが、既存の「同色の2連」を完成させることで起きます。盤面が大きく色が少ないほど、同色の2連が多く、連鎖が起きやすくなります。
この見積もりを決める前に、目標も数で置きます。たとえば次のように決めておきます。
- 1手で連鎖が1回以上起きる割合を、何%にしたいか
- 3連鎖以上は、何手に1回くらいにしたいか
準備と手順
準備するものは、ブラウザの開発者ツールのコンソール(またはNode.js)と、手元のメモだけです。外部ライブラリは使いません。
次のコードは、連鎖のモデルを最小限に絞ったものです。まず、揃いが一つもない安定した盤面を作ります。次に、横3マスをランダムに消し、上のマスを落とし、空いたところを乱数で補充します。そこから揃いがなくなるまでの追加の連鎖回数を数えます。
const rnd = K => Math.floor(Math.random() * K);
function marks(b, W, H) {
const s = new Set();
for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) {
const v = b[r][c];
if (c + 2 < W && b[r][c+1] === v && b[r][c+2] === v)
[0,1,2].forEach(i => s.add(r * W + c + i));
if (r + 2 < H && b[r+1][c] === v && b[r+2][c] === v)
[0,1,2].forEach(i => s.add((r + i) * W + c));
}
return s;
}
function fall(b, W, H, K) {
for (let c = 0; c < W; c++) {
const col = [];
for (let r = H - 1; r >= 0; r--) if (b[r][c] >= 0) col.push(b[r][c]);
while (col.length < H) col.push(rnd(K));
for (let r = 0; r < H; r++) b[H - 1 - r][c] = col[r];
}
}
function stable(W, H, K) {
const b = Array.from({length: H}, () => Array.from({length: W}, () => rnd(K)));
let m;
while ((m = marks(b, W, H)).size)
m.forEach(k => { b[Math.floor(k / W)][k % W] = rnd(K); });
return b;
}
function sim(W, H, K, N) {
const h = {};
for (let t = 0; t < N; t++) {
const b = stable(W, H, K);
const r = rnd(H), c = rnd(W - 2);
for (let i = 0; i < 3; i++) b[r][c + i] = -1;
let n = 0;
for (;;) {
fall(b, W, H, K);
const m = marks(b, W, H);
if (!m.size) break;
n++;
m.forEach(k => { b[Math.floor(k / W)][k % W] = -1; });
}
h[n] = (h[n] || 0) + 1;
}
return h;
}
for (const K of [4, 5, 6]) console.log('8x8 色' + K, sim(8, 8, K, 10000));出力は、追加の連鎖回数ごとの試行回数です。キーが0なら、最初の消去だけで盤面が落ち着いたことを表します。
手順は次のとおりです。
- 目標(連鎖率など)を先にメモへ書く
- 盤面を8×8に固定し、色数だけを4・5・6と変えて実行する
- 色数を5に固定し、盤面を6×6・8×8・10×10と変えて実行する(sim の引数を書き換えます)
- 各条件を3回ずつ実行し、毎回の値を記録する
一度に変えるのは盤面か色数のどちらか一つだけにします。両方を同時に変えると、どちらが効いたのか分からなくなります。
記録の取り方
条件ごとに、次の表を作ります。
- 8×8・5色、実行回:1回目、連鎖0:、連鎖1:、連鎖2:、連鎖3以上:
記録の単位は、1条件につき10,000回の試行を1セットとし、3セット行います。割合は「各回数の試行数÷10,000」で出します。
試行回数が10,000回なら、割合pのばらつき(標準誤差)は √(p(1−p)/10000) で、最大でも0.5ポイント程度です。3セットの値が1ポイント以上離れていたら、試行回数を増やします。
結果の読み方
出てきた数字は、次の順で判断します。
- 連鎖率が目標より高い場合:色数を1つ増やすのが最も素直な調整です。盤面を小さくしても下がりますが、手を打てる選択肢も減ります。
- 連鎖率が目標より低い場合:色数を減らすか、盤面を広げます。ただし色が少なすぎると、手を打たなくても勝手に揃う場面が増えます。
- 3連鎖以上の割合が極端に小さい場合:連鎖はほぼ「おまけ」の演出になります。連鎖を狙う遊びにしたいなら、補充の色を偏らせる、特殊なマスを作るなど、確率以外の設計が必要です。
- 3セットのばらつきが大きい場合:結論を出さず、試行回数を増やします。
あわせて、表の期待値(たとえば8×8・5色の3.84)と、実際の盤面生成の方法を見比べます。「開始直後に揃っている窓がほぼゼロの盤面」を保証したいのか、「多少揃っていて自動で消える」ことを許すのか。この選択で、盤面生成の処理が変わります。
起こりやすい誤り
- 安定盤面の偏りを忘れる:揃った場所を再抽選して作った盤面は、完全な乱数とは分布が少し違います。期待値の表は「完全ランダム」の値なので、シミュレーションの結果と一致しなくても、すぐに不具合とは判断できません。
- 実際の操作を省いている:コードは「ランダムな横3マスの消去」で代用しています。実際のゲームの入れ替え操作を再現しているわけではないので、本番の操作に近づけたい場合はコードを拡張します。
- 4つ並び・L字・T字の扱い:このコードは3つ以上の並びをすべて消す簡易判定です。自分のルールで特別な扱いをするなら、判定を書き換えます。
- 連鎖回数の数え方がずれる:最初の消去を含めるかどうかで、数字が1ずれます。画面に出す「COMBO」の表示と同じ数え方に揃えてください。
- 補充の色を均等に決めている前提:特定の色が出やすいルールにすると、1/Kの見積もりは使えなくなります。
次の行動
目標の連鎖率を数字でメモに書き、コードを実行して8×8の4〜6色の3セットを記録してみてください。結果に合わせて盤面と色数を決めたら、1つのHTMLにまとめます。直下の index.html を入口にしたZIPにして、アソバースに投稿すれば、数で設計した盤面を実際に遊んでもらえます。

