第6章 ゲーム内容の作り込み/6 本目 / 全 10/約30分

バランスを調整する

こより

偏っているのと壊れているのは別。合格の線を先に決めて、畳んだ列も一度のぞいてみて。

案内役 こより
この術でできるようになること

勝率が偏らない

なぜ要るか

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日)
  2. 決まった進め方を数種類用意する(=攻略法)
  3. それで数千回回して、数字の偏りを見る

自分の作っているものに当てはめる表を先に出します。

ジャンル 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章で扱います。

やってみる

  1. まとめて対戦させる関数を書く

    上の simulate を足します。画面には出しません。遊ぶ人に見せるものではなく、自分が数字を確かめるための道具なので、開発者ツールのコンソールから呼んで結果を見ます。

  2. 攻略法の候補を書き出す

    ここだけは自分でやってください。遊ぶ人が思いつきそうな打ち方を、3つ以上。

    紙に書くだけで構いません。「強いのを固める」「バラす」「1つ捨てる」。書き出せた数が、この術で見つけられる壊れの上限です。

  3. 合格ラインを先に決める

    測る前です。「どの固定プランも勝ち越し6割未満なら合格」のように書いておきます。

    後から決めると、出た数字を見て基準の方を動かします。自分は必ずそうすると思っておいてください。

  4. 測る

    simulate(20000, 散らす, 反応型)
    

    全部の組み合わせを回します。5つの打ち方なら25通りです。

  5. 0%か100%が出ていないか見る

    ここが判定です。

    出た数字 意味
    0% か 100% 決まった手で結果が決まる。壊れている
    どの打ち方でも同じ数字 その要素は勝敗に効いていない
    30〜80%に散らばる 上手い下手が結果に出ている。健全
  6. 除外した数字を1回見る

    引き分け、打ち切り、異常終了。見やすくするために表から落とした列を必ず1回開いてください。今回の引き分け70%はここで見つかりました。

  7. 直す。ただしパラメータから触らない

    順番はこうです。

    1. ルールを疑う(配る枚数、同点の扱い、情報を隠すか)
    2. 相手の思考を疑う
    3. 最後にパラメータ(確率や数値)

    多くの人が3から触ります。 効かないことが多いので、上から見てください。今回直したのも1でした。

  8. 直したら、同じseedで全部測り直す

    1つ直すと他が動きます。全部の組み合わせで測り直してください。

  9. 保存する

    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%が出たら、それがあなたの作品の抜け道です。

見つかったら、そこが直す場所です。見つからなければ、ここまでの作りは大丈夫だという根拠になります。

ただし、測って合格しても「面白い」とは言えません。 壊れていないだけです。次の術では、壊れていないのに面白くなかったときに何をするかをやります!

ここまでの三つの的

この講座は、見本ゲーム「三つの的」を術ごとにすこしずつ育てながら進みます。下にあるのはこの術を終えた時点の実物です。そのまま遊べます。

別の画面でこのゲームを開く

術の一覧へ戻る

メルマガ(無料)忖度なしのAI事情を、メールでお届けします。ご登録の方に、プレゼントをお渡ししています