マガジン

囲む遊び:線で領域を閉じると自分のものになるルールで、取り合いは成立するか

遊びをつくる

執筆にClaudeを使用しています。

速さ、選択肢、ミスの許容を三つの異なる仕掛けで表したゲーム模型のイラスト。
記事のテーマをもとに制作したAI生成イラスト。

辺を一本ずつ引くだけのルールで、面積の取り合いは本当に成立するのでしょうか。駒の動きも、石を返す仕組みもありません。交互に線を引いて、四方が線で閉じたマスを閉じた人のものにする。ルールはこれだけです。 こういうルールは、紙とペンで遊ぶ「ドット&ボックス」として一般に知られています。仕組みは単純ですが、盤が埋まってくると「この線を引くと、相手にマスを取られる」という状況が生まれます。そこで手が止まる面白さが、このルールの核です。 ただし、ルールを書いただけでは、その緊張が生まれるかどうかは分かりません。そこで、数十行のコードで自動対戦を回し、ルールの違いが得点にどう出るかを記録する実験を設計します。結果は読者のマシンで出てくるものなので、この記事には書きません。用意するのは、手順、記録の取り方、結果の読み方、誤りやすい点です。

この記事の目次
  1. 問いを三つに分ける
  2. 準備するもの:最小のシミュレーター
  3. 手順:四つの条件を同じ回数ずつ回す
  4. 記録の取り方と結果の読み方
  5. 発展:マスではなく「閉じた領域」で取るルールを設計する
  6. 誤りやすい点
  7. 次の行動

問いを三つに分ける

「面白いか」は測れないので、測れる問いに分解します。

  • 問い1 先手と後手は公平か。 ランダムに線を引き合うだけで、先手と後手の平均得点に差が出るかを見ます。
  • 問い2 「閉じたらもう一手」は結果を変えるか。 マスを閉じた人がそのまま次も引ける規則を入れたとき、得点の偏りが変わるかを見ます。
  • 問い3 「取れるなら取る」「相手に差し出さない」という判断は、実際に効くか。 判断を持つCPUと持たないCPUを対戦させ、差が出るかを見ます。

問い3が、緊張が実際に成立しているかを調べる本題です。「差し出さない」判断が有利に働くなら、ルールの中に考える余地があります。ランダムと変わらないなら、盤が小さすぎるか、考えるべき対象を取り違えている可能性があります。

準備するもの:最小のシミュレーター

用意するのはブラウザの開発者ツールのコンソール、またはNode.jsです。ファイルに保存して実行してもかまいません。外部ライブラリは使いません。

盤は4×4マスです。水平の辺を h、垂直の辺を v の二次元配列で持ち、マス(r, c)の四辺は h[r][c]、h[r+1][c]、v[r][c]、v[r][c+1] です。辺を引く前に、その辺に接するマスのうち三辺が埋まっているものを数えれば、その手で閉じるマスの数が分かります。

js
const rand = m => m[Math.floor(Math.random() * m.length)];
const greedy = (m, takes) => { const g = m.filter(takes); return rand(g.length ? g : m); };
const careful = (m, takes, gives) => {
  const g = m.filter(takes);
  if (g.length) return rand(g);
  const s = m.filter(x => !gives(x));
  return rand(s.length ? s : m);
};

function play(N, extra, players) {
  const h = Array.from({ length: N + 1 }, () => Array(N).fill(false));
  const v = Array.from({ length: N }, () => Array(N + 1).fill(false));
  const sides = (r, c) => (r < 0 || r >= N || c < 0 || c >= N) ? 0 : h[r][c] + h[r+1][c] + v[r][c] + v[r][c+1];
  const nb = (t, r, c) => t === 'h' ? [[r-1, c], [r, c]] : [[r, c-1], [r, c]];
  const takes = ([t, r, c]) => nb(t, r, c).some(([a, b]) => sides(a, b) === 3);
  const gives = ([t, r, c]) => nb(t, r, c).some(([a, b]) => sides(a, b) === 2);
  const score = [0, 0];
  let turn = 0;
  for (let left = 2 * N * (N + 1); left > 0; left--) {
    const moves = [];
    for (let r = 0; r <= N; r++) for (let c = 0; c < N; c++) if (!h[r][c]) moves.push(['h', r, c]);
    for (let r = 0; r < N; r++) for (let c = 0; c <= N; c++) if (!v[r][c]) moves.push(['v', r, c]);
    const [t, r, c] = players[turn](moves, takes, gives);
    const got = nb(t, r, c).filter(([a, b]) => sides(a, b) === 3).length;
    if (t === 'h') h[r][c] = true; else v[r][c] = true;
    score[turn] += got;
    if (!(extra && got > 0)) turn = 1 - turn;
  }
  return score;
}

function run(label, extra, players, G = 3000) {
  let a = 0, b = 0;
  for (let i = 0; i < G; i++) { const s = play(4, extra, players); a += s[0]; b += s[1]; }
  console.log(label, '先手', (a / G).toFixed(2), '後手', (b / G).toFixed(2));
}
run('乱数同士・連続なし', false, [rand, rand]);
run('乱数同士・連続あり', true, [rand, rand]);
run('貪欲(先) 対 乱数', true, [greedy, rand]);
run('慎重(先) 対 貪欲', true, [careful, greedy]);

登場する三つのCPUは次のとおりです。

  • rand:残っている辺からランダムに選びます。
  • greedy(貪欲):マスを閉じられる辺があれば必ず引き、なければランダムに選びます。
  • careful(慎重):閉じられるなら閉じます。閉じられなければ、三辺目を作って相手に差し出す辺を避けます。避けられる辺がなくなったら、やむなくランダムに選びます。

手順:四つの条件を同じ回数ずつ回す

  1. コードを実行し、四行の出力が出ることを確認します。
  2. 最初の一回は検算に使います。どの行でも、先手と後手の平均得点を足すと16になるはずです。盤が4×4マスで、全マスがどちらかのものになるからです。16にならない行があれば、結果を読む前にコードを疑います。
  3. 四行を 3回 繰り返して実行し、毎回の数字を記録します。1回の実行は3,000局です。同じ条件でも数字が変わる幅を知るために、同じ実行を複数回行います。
  4. 盤の大きさを変えてみます。play(4, ...) の 4 を 3 や 5 に書き換えて、同じ四条件を再度回します。
  5. 余力があれば、G を 500 と 20000 にして数字の安定の仕方を見比べます。

記録の取り方と結果の読み方

記録は次の表の形で、1実行を1行にします。

  • 乱数同士・連続なし、実行:1回目、先手平均:、後手平均:、合計(検算):
  • 乱数同士・連続なし、実行:2回目、先手平均:、後手平均:、合計(検算):
  • 乱数同士・連続なし、実行:3回目、先手平均:、後手平均:、合計(検算):

他の三条件も同じ形で埋めます。読み方は、問いごとに次の基準で判断します。

問い1(公平さ):先手と後手の差が、同じ条件の3回の間のぶれと同じくらいなら、偏りはないと見ます。3回とも同じ向きで、ぶれより明らかに大きい差が出るなら、先手か後手に構造的な有利があります。その場合は、left の数え方や手番の切り替えを見直し、バグでないか確かめてから、ルールの性質と判断します。

問い2(連続手番):「連続なし」と「連続あり」で先手・後手の差の大きさや向きが変わるかを見ます。変わらないなら、ランダムな手の範囲では連続手番の影響は目立たないということです。変わるなら、連続手番がルールの重要な要素だということです。

問い3(判断の効果):「貪欲(先) 対 乱数」で、貪欲側の平均が16の半分である8を上回るかを見ます。次に「慎重(先) 対 貪欲」で、慎重側がさらに上回るかを見ます。

  • 慎重が上回るなら、「相手に差し出さない」という判断に意味があります。盤が埋まる終盤に、誰が差し出さざるを得なくなるかという取り合いが成立している証拠として読めます。
  • 差がほとんどないなら、次の三つを疑います。盤が小さすぎる。careful が差し出さない手を使い切ったあとランダムに戻るので、終盤の判断が粗い。あるいは、貪欲の「取れるなら取る」だけでも十分に強い。盤を5×5に広げて、差が出るか確かめます。

最後に、play の先頭の引数(先手・後手の割り当て)を入れ替えて、[rand, greedy] のようにも回してみます。強い側が先手でも後手でも勝てるなら、判断力の差が先手後手の差を上回ると読めます。

発展:マスではなく「閉じた領域」で取るルールを設計する

ここまでは「1マスの四辺が揃ったら取る」でした。題名にある「線で領域を閉じる」を広く取るなら、複数のマスにまたがる領域を取るルールも考えられます。コードは上限があるので、設計だけ示します。

  • 盤の外を一つの「外」と見なします。
  • 線に遮られない隣接マスをたどって、外から届くマスを「外とつながっている」とします。
  • 線を引いた結果、外から届かなくなった空きマスのかたまりを、その線を引いた人のものにします。

この判定には、flood fill(塗りつぶし)、つまり外から隣へ隣へと広げていく探索を使います。実験としては、次の三つを記録します。

  • 1局あたりの手数
  • 一手で取れた最大の面積
  • 先手と後手の最終面積の差

マス単位のルールと比べて、一手で動く面積の幅が大きくなります。その結果、逆転が起きやすくなるのか、勝負が早く決まりやすくなるのかを見ます。

決めておくべき設計判断が一つあります。盤の外周を、最初から線が引かれているものとするか、外周にも線を引く必要があるものとするかです。前者では囲みが盤の端を使って簡単に成立し、後者では端の領域が取りにくくなります。この選択だけで遊びの性格が変わるため、条件を分けて記録します。

誤りやすい点

  • ブール値の足し算に頼っている。 コードでは true + true が2になることを利用して辺の数を数えています。型を厳密に扱う言語に移すときは、数値に変えて数えます。
  • 辺を引いたあとに数えてしまう。 「閉じたマス」は、引く前に三辺が埋まっていたマスです。引いたあとで三辺のマスを数えると、数え方がずれます。
  • 盤の端の範囲外を忘れる。 盤の外周にある辺は、接するマスが一つしかありません。sides が範囲外で0を返すのはそのためです。
  • 連続手番で手番を切り替えてしまう。 「連続あり」では、マスを閉じたら手番を渡しません。extra && got > 0 の条件を崩すと、問い2の比較が成り立ちません。
  • 1回の数字で結論を出す。 ランダムが混じるので、同じ条件でも毎回数字が動きます。3回の幅を見て、その幅を超える差だけを意味のある差として扱います。
  • CPUの強さを人間の強さと同一視する。 careful は「差し出さない」の初歩だけを実装しています。人間同士の対局で見られる、先に一連のマスを相手に渡す戦い方などは入っていません。結果は、あくまでこのCPUに対しての答えです。

次の行動

まず run の四行を3回ずつ回して、先手と後手の合計が16になることを確かめ、問い1から順に読んでみてください。数字が出たら、次はマスを一つずつ表示する画面を足して人間が遊べる形にします。ブラウザで遊べるHTMLにして直下の index.html に置けば、アソバースへの投稿は「入力→表示の確認→公開」の3段階で進められます。公開にはメールアドレスの確認が必要で、公開前に下書き保存もできます。