なぜ要るか
3-4で相手の思考を作ったとき、強さの判定はこうしました。
3回遊んでみてください。
一度も勝てない → 強すぎます
必ず勝てる → 弱すぎます
3回では足りません。 3回勝てたのが実力なのか運なのか、区別が付きません。
この術でやるのはシミュレーションです。人が遊ぶ代わりに、決まった打ち方で数千回自動で遊ばせて、結果を数えます。
そして正直に書きますが、僕はこれをやって、自分のゲームが壊れていることを見つけました。 その過程をそのまま出します。
使う道具: なし
AIプロンプト
この術の作業をAIへ任せるときは、これをそのまま貼ってください。
index.html の takerOf を使って、まとめて対戦させるスクリプトを作って。
打ち方を関数で渡せるようにして、散らす・砦2つ・1点集中・でたらめ・場を見て判断 の5つを用意して。
全組み合わせで2万戦ずつ、乱数はseed固定で測って、勝ち越し率の表で出して。
引き分け率も別の列で必ず出して。
0%か100%、または引き分けが5割を超えた組み合わせは目立つように書いて。
5つの打ち方の部分は、自分で書き出した攻略法の候補に置き換えてください。
これはカードゲームの話ではありません
先に言っておきます。この術で扱うのは「シミュレーションで壊れを見つける」という道具で、対戦ゲーム専用の話ではありません。
やることは、どのジャンルでも同じ3手順です。
- 1回の単位を決める(1試合、1ステージ、1周、1日)
- 決まった進め方を数種類用意する(=攻略法)
- それで数千回回して、数字の偏りを見る
自分の作っているものに当てはめる表を先に出します。
| ジャンル | 1回の単位 | 測る数字 | 壊れているサイン |
|---|---|---|---|
| 対戦・カード | 1試合 | 勝率、決着までの手数 | 決まった手順で100%勝てる |
| アクション | 1ステージ | クリア率、被弾数、残機 | 特定の動きで無敵になる |
| パズル | 1問 | 手数の分布、詰み率 | 解が1つだけ/解けない問題が混じる |
| 育成・放置 | 1日 | 到達までの日数、増え方 | 数字が指数的に発散する |
| ローグライク | 1周 | 生存率、到達階層 | 最初の引きで結果が決まる |
| 経済・取引 | 1循環 | 所持金の増減 | 無限に増える組み合わせがある |
「勝率」はその一例です。 大事なのは「1回の単位」と「測る数字」を自分で決めること。ここを決めないと測れません。
以下は三つの的を例にしますが、読むときは自分のジャンルの列に置き換えてください。
まず、まとめて対戦させる仕組みを作る
画面は使いません。判定だけを何千回も回します。
function simulate(times, policyA, policyB) {
var win = 0, lose = 0, draw = 0;
for (var i = 0; i < times; i++) {
var a = policyA(deal()); // 手札3枚を渡して「打ち方」を作る
var b = policyB(deal());
var sumA = [0, 0, 0], sumB = [0, 0, 0];
for (var turn = 0; turn < 3; turn++) {
var ma = a(sumA, sumB); // 盤面を見て {card, target} を返す
var mb = b(sumB, sumA);
sumA[ma.target] += ma.card;
sumB[mb.target] += mb.card;
}
var ta = 0, tb = 0;
for (var k = 0; k < 3; k++) {
if (sumA[k] > sumB[k]) ta++;
else if (sumB[k] > sumA[k]) tb++;
}
if (ta > tb) win++; else if (tb > ta) lose++; else draw++;
}
return {
勝ち: (win / times * 100).toFixed(1) + '%',
負け: (lose / times * 100).toFixed(1) + '%',
ひきわけ: (draw / times * 100).toFixed(1) + '%',
};
}
読み方はこうです。1回の対戦ごとに両者へ手札を配り、3手番ぶん打たせて、的ごとの合計を比べます。取った的の多い方が勝ちで、同数なら引き分けです。それを times 回くり返して、最後に勝ち・負け・引き分けの割合を出します。toFixed(1) は小数第1位までに丸める指定です。
「打ち方」を引数で渡すのが要点です。 ここを固定してしまうと、あとで比べられません。
そして打ち方は関数を返す関数にしています。手札を受け取って作戦を組み立て、毎手番その作戦から1手出す形です。「配られた札を見て方針を決める」までを打ち方に含めるためです。
言い換えると、外側の関数が「配られた札を見て作戦表を作る係」で、返ってくる内側の関数が「その表から1手ずつ読み上げる係」です。作戦を1回だけ立てて、あとはそれに従う——人が遊ぶときの手順をそのまま形にしています。
打ち方は「攻略法の候補」を書きます
ここが人間の仕事です。遊ぶ人がやりそうな攻略法を並べます。
| 打ち方 | 中身 |
|---|---|
| 散らす | 3枚を別々の的へ1枚ずつ |
| 砦2つ | 最大札を単騎、残り2枚を重ねる。1つの的は捨てる |
| 1点集中 | 上位2枚を重ねて1つの的へ、最弱を別の的へ |
| でたらめ | 基準線 |
| 場を見て判断 | 毎手番、盤面を見ていちばん良い手を選ぶ |
上4つは目隠しの固定プランです。ゲーム開始時に決めて、相手を見ずに実行します。最後の1つだけが盤面を見ます。
// 砦2つ: 最大札を単騎、残り2枚を重ねて別の的へ。1的は空け捨て
function twoForts(hand) {
var sorted = hand.slice().sort(function (a, b) { return b - a; });
var t = shuffle([0, 1, 2]);
var plan = shuffle([
{ card: sorted[0], target: t[0] },
{ card: sorted[1], target: t[1] },
{ card: sorted[2], target: t[1] },
]);
return function () { return plan.pop(); };
}
中身は3段です。sort で手札を強い順に並べ替え、shuffle で的の並びをばらして偏りを消し、その3手を作戦表にします。返している関数は pop で作戦表から1手ずつ取り出すだけなので、相手の出方は一切見ません。これが「目隠しの固定プラン」という意味です。
「思いつく攻略法を全部書き出す」がこの術で一番大事な作業です。 書き漏らした攻略法は、測っても出てきません。
測った結果
全組み合わせ、各2万戦です。勝ち越し率(引き分けを除いた、勝ちの割合)で見ます。
| 打ち方 | 勝ち越し率 | 読み方 |
|---|---|---|
| 散らす | 51.5% | 五分。必勝ではない |
| 砦2つ | 50.3% | 五分。必勝ではない |
| 1点集中 | 27.2% | 目隠しでやると最弱 |
| でたらめ | 38.4% | 基準線 |
| 場を見て判断 | 81.1% | 固定プラン全部に勝つ |
採用基準は先に決めておきました。 「どの固定方針も勝ち越し6割未満なら合格」です。決めてから測らないと、出た数字を見て基準の方を動かしてしまいます。
結果は合格でした。必勝の固定プランはありません。
極端な数字を見て、慌てて直さない
ここが読み方の本番です。表には極端な数字が2つあります。
27.2%(1点集中が弱すぎる) —— これは壊れていません。相手を見ずに2枚重ねるのは、単に下手な打ち方だからです。下手な打ち方が弱いのは正常です。
81.1%(場を見て判断が強すぎる) —— これも壊れていません。盤面を見て考える人が、暗記で置く人に勝つという意味だからです。ゲームとしてはむしろ望ましい性質です。
判定に使うのは0%か100%かどうかです。
| 出た数字 | 意味 |
|---|---|
| 0% か 100% | 決まった手で結果が決まる。壊れている |
| どの打ち方でも同じ数字 | その要素は勝敗に効いていない |
| 30〜80%に散らばる | 上手い下手が結果に出ている。健全 |
「勝率が偏っている」ことと「壊れている」ことは別です。 ここを混同すると、直さなくていいものを直して、ゲームを平板にします。
引っかかったのは、別の数字でした
合格した表の裏に、まずいものが混ざっていました。
「場を見て判断」同士の対戦が、引き分け70%。
同じくらいの腕前の2人が戦うと、7割が決着つかず。これは退屈です。 しかも上の表は勝ち越し率(引き分けを除外)で見ていたので、この問題は表に出ていませんでした。
除外した数字にこそ問題が隠れます。 見やすくするために落とした列を、1回は必ず見てください。
原因を切り分けました。同じ思考ロジックのAI同士が鏡写しの手を打ち続けるのが主因ではないか、という仮説です。
// 最善から300点以内(=リード数が同じ圏内)の手を等確率で選ぶ
var near = options.filter(function (o) { return options[0].score - o.score <= 300; });
var pick = near[randInt(near.length)];
思考に少し揺らぎを入れて測り直すと、引き分けは 70% → 10% に落ちました。大部分はシミュレーションの人工物だったわけです。人間同士なら打ち筋は揃いません。
AI対AIの数字を鵜呑みにしない。 これも「測り方を疑う」の実例です。
それでもルールを1行直しました
揺らぎで10%まで落ちたので、放置してもよかった数字です。それでも直しました。「同点の的は誰のものでもない」というルール自体が、引き分けの温床だったからです。
function takerOf(side) {
var mine = sumOf(side.mine);
var theirs = sumOf(side.theirs);
if (mine === 0 && theirs === 0) return 'none';
if (mine > theirs) return 'me';
if (theirs > mine) return 'cpu';
var myLast = lastTurnOf(side.mine);
var cpuLast = lastTurnOf(side.theirs);
if (myLast === cpuLast) return 'none'; // 同じ手番なら誰のものでもない
return myLast > cpuLast ? 'me' : 'cpu'; // 後詰め: あとに置いた方が取る
}
足したのは下の3行です。lastTurnOf は「その的へ最後に置いたのが何手目か」を返します。合計が同じときだけこれを見て、あとに置いた方が的を取ります。同じ手番に置き合った場合だけ、引き続き誰のものでもありません。
「後詰め」は実在の言葉です。 後から入れた援軍が場を制する、という意味の軍事用語。自分で発明した理屈ではないので、遊ぶ人にも説明しやすくなります。
6-4で盤面の札に turn を持たせておいたのが、ここで効きます。「何手目に置いたか」を保存していなかったら、このルールは実装できませんでした。
再実測すると引き分けは全組み合わせで減り、おまけが1つ付いてきました。「同点に追いつくなら後出しが得」という、強い札をいつ切るかの駆け引きです。
欠陥を直したら面白さが増えた。 これが理想的な直り方です。逆に、直して面白さが減ったなら、直し方が間違っています。
ここまでを、他のジャンルに移します
三つの的で起きたことは、言い方を変えるとどのジャンルでも起きます。
| 三つの的で起きたこと | 一般化すると |
|---|---|
| 採用基準を先に決めた | 測る前に合格ラインを決める。あとで決めると数字に合わせてしまう |
| 27.2%を見ても直さなかった | 偏っていることと、壊れていることは別 |
| 勝ち越し率では引き分け70%が見えなかった | 見やすくするために落とした列に、問題が隠れる |
| ミラー対戦の引き分けは人工物だった | AI同士の数字を、人同士の数字として読まない |
| ルールを1行変えたら駆け引きが増えた | 欠陥の修正が、面白さの追加になることがある |
2番目と3番目が実務で効きます。数字を見て慌てて調整すると、たいてい平板になります。 そういうときは、その数字が「下手だから低い」のか「壊れているから低い」のかを先に切り分けてください。
構造に不確実性を足す例を並べます。
| 足すもの | 例 |
|---|---|
| 配られるものを絞る | 全部使わせず、一部だけ配る(今回やったこと) |
| 情報を隠す | 相手の残りを見せない、伏せて出す |
| 順番を変える | 先手後手を入れ替える、行動順を乱数で決める |
| 選択を分岐させる | 同じ状況でも複数の有効手がある形にする |
逆に、これを足しすぎると運ゲーになります。 だから足したあとに測り直します。50%へ近づけばよく、どの手順でも同じ数字になったら足しすぎです。
「終わらない」も測ると見つかります
対戦以外だと、こちらの方が多い壊れ方です。数字が積み上がっていく型(育成、経済、放置系)で出やすい壊れ方です。
シミュレーションに打ち切りを入れておくと見つかります。
var LIMIT = 1000; // これを超えたら異常とみなす
var steps = 0;
while (!finished() && steps < LIMIT) { step(); steps++; }
if (steps >= LIMIT) timeouts++; // 打ち切った回数を数える
while は「条件が成り立っている間くり返す」書き方です。ここでは「まだ終わっていない、かつ1000歩を超えていない」間だけ進めます。1000歩に達したら諦めて、その回数を数えておきます。
timeouts が0でなければ、終わらない組み合わせが存在します。 数字が発散する(無限に増える)場合も同じ形で捕まえられます。
if (money > 1e12) diverged++; // 現実的でない額に達した回数
1e12 は1兆という書き方です。所持金が1兆を超えたら「現実的でない」とみなして数えています。
「たまに終わらない」は手で遊んでいると絶対に見つかりません。 数千回回すと出てきます。
乱数を固定して比べる
条件を変えて比べるとき、乱数が毎回違うと差が見えません。
同じ乱数の並びを使い回せるようにしておくと、条件だけを変えて比べられます。
var seed = 20260801;
function rand() { // 同じseedなら同じ並びが出る
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 4294967296;
}
seedは乱数の出発点になる数字です。コンピュータの乱数は本当のでたらめではなく、出発点から決まった計算で次々に数を作っていくだけなので、同じ出発点なら毎回同じ並びが出ます。掛け算や >>> の中身は理解しなくて構いません。大事なのは「seedを固定すれば結果が再現する」ことだけです。
「AとBのどちらが強いか」を測るときは、同じseedで両方回します。 そうすると差が乱数のせいでないと言えます。
今回はこれをやりました。「同点は誰のものでもない」と「後詰め」を比べるのに、乱数が違っていたら比較になりません。同じseedで両方回して、変えたのはルール1行だけ、という状態を作っています。
回数は各組み合わせ2万戦です。seedを固定していれば、回数はぶれの許容量で決めればいいので、微妙な差を見たいときだけ増やします。
溜まった実データも同じように数えます
シミュレーションは「作った側の想定」です。実際に遊ばれた記録は、もっと強い証拠になります。
僕のCNPトレカアプリでは、6-5で残した対戦記録を数えて勝率を出しています。別に勝率を持たず、記録を数える形にしてあります。理由は6-1と同じで、同じ意味の数字を2箇所に持たないためです。
勝った件数 ÷ 対戦した件数 = 勝率
シミュレーションと実データは役割が違います。
| 分かること | 弱いところ | |
|---|---|---|
| シミュレーション | 公開前に壊れを見つけられる | 想定した打ち方しか試さない |
| 実データ | 本当に何が起きたか | 遊ばれてからしか手に入らない |
公開前はシミュレーション、公開後は実データ。 両方使います。実データの方は9章で扱います。
やってみる
まとめて対戦させる関数を書く
上の
simulateを足します。画面には出しません。遊ぶ人に見せるものではなく、自分が数字を確かめるための道具なので、開発者ツールのコンソールから呼んで結果を見ます。攻略法の候補を書き出す
ここだけは自分でやってください。遊ぶ人が思いつきそうな打ち方を、3つ以上。
紙に書くだけで構いません。「強いのを固める」「バラす」「1つ捨てる」。書き出せた数が、この術で見つけられる壊れの上限です。
合格ラインを先に決める
測る前です。「どの固定プランも勝ち越し6割未満なら合格」のように書いておきます。
後から決めると、出た数字を見て基準の方を動かします。自分は必ずそうすると思っておいてください。
測る
simulate(20000, 散らす, 反応型)全部の組み合わせを回します。5つの打ち方なら25通りです。
0%か100%が出ていないか見る
ここが判定です。
出た数字 意味 0% か 100% 決まった手で結果が決まる。壊れている どの打ち方でも同じ数字 その要素は勝敗に効いていない 30〜80%に散らばる 上手い下手が結果に出ている。健全 除外した数字を1回見る
引き分け、打ち切り、異常終了。見やすくするために表から落とした列を必ず1回開いてください。今回の引き分け70%はここで見つかりました。
直す。ただしパラメータから触らない
順番はこうです。
- ルールを疑う(配る枚数、同点の扱い、情報を隠すか)
- 相手の思考を疑う
- 最後にパラメータ(確率や数値)
多くの人が3から触ります。 効かないことが多いので、上から見てください。今回直したのも1でした。
直したら、同じseedで全部測り直す
1つ直すと他が動きます。全部の組み合わせで測り直してください。
保存する
git add . git commit -m "勝率を測り、同点の的を後詰め優先にして引き分けを減らした" git push
AIに測らせる
この作業はAIに向いています。判断ではなく作業だからです。
claude -p "index.html の takerOf を使って、まとめて対戦させるスクリプトを作って。
打ち方を関数で渡せるようにして、散らす・砦2つ・1点集中・でたらめ・場を見て判断 の5つを用意して。
全組み合わせで2万戦ずつ、乱数はseed固定で測って、勝ち越し率の表で出して。
引き分け率も別の列で必ず出して。
0%か100%、または引き分けが5割を超えた組み合わせは目立つように書いて。"
要点は2つです。「引き分け率も別の列で必ず出して」 と 「0%か100%を目立たせて」。
前者が無いと、AIは見やすい表を作ろうとして引き分けを畳みます。今回の一番大きな発見は、その畳まれた列にありました。 後者が無いと、数字の羅列の中で一番大事な行を人間が見落とします。
つまずきどころ
測るたびに数字がぶれる
seedを固定していないか、回数が少なすぎます。条件を比べるなら必ずseedを固定してください。
勝率50%を目指してしまう
50%が正解とは限りません。 遊んでもらう相手が初心者なら、勝てる方が続きます。僕は「読めば勝てる」を狙って、6割くらい勝てる状態を目標にしています。
大事なのは数字を知っていることです。知らずに5割だと思っていた、が一番危ない状態です。
ひきわけが多すぎる
同じ強さ同士がぶつかると引き分けになる構造が原因です。今回はそれをルールで解きました。同点の的に「あとに置いた方が取る」という決着を足すやり方です。
引き分けを減らす手は3つあります。決着ルールを足す/同じ値を減らす/勝敗の数え方を変える。 順番に試して、1つずつ測り直してください。
測ったが直し方が分からない
まずルールを1つだけ変えて測り直してください。 2つ同時に変えると、どちらが効いたか分からなくなります。今回も「同点の的の扱い」だけを変えました。
遊んだ感じでは面白いのに、数字が偏っている
作った本人は抜け道を使いません。でも遊ぶ人は必ず見つけます。 特に強い抜け道は、見つかった瞬間にゲームが終わります。数字を信じてください。
そもそも面白さは数字で測れないのでは
測れません。測れるのは「壊れていないか」だけです。 面白さは9章で、遊ばれ方を見ながら扱います。ここでやったのは、面白さを判断する前提として「勝敗が読み合いで決まる状態」を作ることです。
ゲーム以外でも同じ形です
この考え方はゲーム専用ではありません。「決まったやり方で数千回試して、偏りを見る」は仕事でもそのまま使えます。
| 場面 | 1回の単位 | 測る数字 |
|---|---|---|
| 在庫の発注ルール | 1週間 | 欠品した回数、余った量 |
| 価格の決め方 | 1か月 | 売上、値下げ回数 |
| 受付や窓口の並び | 1日 | 待ち時間、あふれた人数 |
| 通知を送る時間帯 | 1回の配信 | 開封率、解除数 |
やることは同じです。 ルールを関数にして、条件を変えて、何千回回して数を数える。0%か100%が出たら、そこに抜け道か行き止まりがあります。
AIに任せられる作業でもあります。 判断ではなく計算なので、頼めばそのまま書いてくれます。
Windows の場合
開発者ツールは F12 で開きます。コンソールでの呼び出し方は同じです。
次の一歩
自分の作っているもので「1回の単位」と「測る数字」を書き出してください。
上の表の自分の行を埋めるだけです。ゲームなら1試合と勝率、放置系なら1日と増え方。ここが決まらないうちは測れません。
決まったら、決まった進め方を3つ考えて回してください。0%か100%が出たら、それがあなたの作品の抜け道です。
見つかったら、そこが直す場所です。見つからなければ、ここまでの作りは大丈夫だという根拠になります。
ただし、測って合格しても「面白い」とは言えません。 壊れていないだけです。次の術では、壊れていないのに面白くなかったときに何をするかをやります!
ここまでの三つの的
この講座は、見本ゲーム「三つの的」を術ごとにすこしずつ育てながら進みます。下にあるのはこの術を終えた時点の実物です。そのまま遊べます。