AIゲームの作り方 約10分で読めます

ゲームバランスの調整方法|戦闘を1万回自動で戦わせて、勝率を合わせるコード

「ゲームバランスを数える」の文字と、背景に映る、敵のHPと勝率の棒グラフ

ゲームのバランス調整は、同じ戦いをプログラムで1万回戦わせて、勝率を数えれば、遊ばなくても進められます。この記事のコードは、ターン制の1対1の戦いを自動で繰り返し、敵のHPを変えながら、目標の勝率に合う値を探します。

コードはNodeで動かして確かめました。数字はすべて、このコードを実行して数えた値です。ゲームのバランスの正解は、ゲームごとに違うので、ここで扱うのは、調整のしかたです。

この記事で分かること

  • 戦闘を自動で繰り返して、勝率・ターン数を数えるコード全体
  • 敵のHPと勝率の関係(実測)
  • 何回戦えば、勝率が安定するか(100回・1,000回・1万回の比較)
  • 目標の勝率になる敵のHPを、9回の探索で見つける方法
  • どの数字を動かすと、勝率が大きく変わるか
  • 確かめられていないこと

ゲームの企画を決める手順は、ゲームの企画書をAIで作る方法にあります。この記事は、企画で決めた数字を、実際に試して調整する段階の内容です。

この記事の目次
  1. 01バランス調整を数える3段
  2. 02コード全体
  3. 03敵のHPと勝率
  4. 04何回戦えば、勝率が安定するか
  5. 05目標の勝率に合わせる
  6. 06どの数字を動かすと、勝率が変わるか
  7. 07AIにシミュレーションを書かせるときの確認点
  8. 08確かめられていないこと
  9. 09うまくいかないとき
  10. 10よくある疑問
  11. 11この記事のまとめ

バランス調整を数える3段

バランス調整を自動で数える3段の図。1は戦いをコードにする。命中、ばらつき、会心を乱数で決める。2は1万回戦って数える。勝率、平均ターン、残りHPを出す。3は目標の勝率に合わせる。敵のHPを二分探索で動かし、9回で収束する。同じ乱数の並びを再現できるシード付きの乱数を使うので、結果は毎回同じ
人が何度も遊ぶ代わりに、プログラムで繰り返します

戦いのルールは、次のとおりです。プレイヤーが先に攻撃し、そのあと敵が攻撃します。攻撃は、命中するかどうかで分かれ、命中したら、攻撃力の80〜120%のダメージです。会心が出ると、ダメージは2倍になります。HPが0になった側が負けです。

コード全体

次のコードを、sim.jsという名前で保存して、「node sim.js」で実行します。ブラウザでの実行は、確かめていません。

// ターン制の1対1バトルを、何度も自動で戦わせて、勝率を数える
// 実行: node sim.js

// 同じ乱数の並びを再現できる、シード付きの乱数(mulberry32)
function makeRng(seed) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6D2B79F5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// 1回の戦い。攻撃は、命中・ばらつき(±20%)・会心(2倍)で決まる
function attack(atk, acc, crit, rnd) {
  if (rnd() >= acc) return 0;
  let dmg = atk * (0.8 + rnd() * 0.4);
  if (rnd() < crit) dmg *= 2;
  return Math.round(dmg);
}

function battle(p, e, rnd) {
  let ph = p.hp, eh = e.hp, turns = 0;
  while (ph > 0 && eh > 0 && turns < 200) {
    turns++;
    eh -= attack(p.atk, p.acc, p.crit, rnd);   // プレイヤーが先
    if (eh <= 0) break;
    ph -= attack(e.atk, e.acc, e.crit, rnd);
  }
  return { win: eh <= 0, turns, left: Math.max(ph, 0) };
}

function simulate(p, e, n, seed) {
  const rnd = makeRng(seed);
  let wins = 0, turns = 0, left = 0;
  for (let i = 0; i < n; i++) {
    const r = battle(p, e, rnd);
    if (r.win) { wins++; left += r.left; }
    turns += r.turns;
  }
  return { winRate: wins / n, avgTurns: turns / n, avgLeftWhenWin: wins ? left / wins : 0 };
}

// 目標の勝率になる、敵のHPを探す(二分探索。敵が強いほど勝率が下がる前提)
function tuneEnemyHp(p, e, target, n, seed, lo = 10, hi = 400) {
  let steps = 0;
  while (hi - lo > 1) {
    const mid = Math.floor((lo + hi) / 2);
    const wr = simulate(p, { ...e, hp: mid }, n, seed).winRate;
    steps++;
    if (wr > target) lo = mid; else hi = mid;
  }
  return { hp: lo, steps };
}

if (typeof module !== 'undefined') module.exports = { makeRng, battle, simulate, tuneEnemyHp };

if (typeof require !== 'undefined' && require.main === module) {
  const player = { hp: 100, atk: 12, acc: 0.9, crit: 0.1 };
  const enemy = { hp: 80, atk: 9, acc: 0.85, crit: 0.05 };

  console.log('敵のHPごとの結果(1万回)');
  for (const hp of [40, 60, 80, 100, 120, 140]) {
    const r = simulate(player, { ...enemy, hp }, 10000, 1);
    console.log(hp, '勝率', (r.winRate * 100).toFixed(1) + '%', '平均ターン', r.avgTurns.toFixed(1), '勝ったときの残りHP', r.avgLeftWhenWin.toFixed(1));
  }

  const hard = { ...enemy, hp: 140 }; // 勝率が7割前後になる敵
  console.log('試行回数ごとの、勝率のばらつき(敵HP140・シードを20通りに変えて、最小〜最大)');
  for (const n of [100, 1000, 10000]) {
    const v = [];
    for (let s = 1; s <= 20; s++) v.push(simulate(player, hard, n, s * 7919).winRate);
    console.log(n, '回:', (Math.min(...v) * 100).toFixed(1) + '〜' + (Math.max(...v) * 100).toFixed(1) + '%', '幅', ((Math.max(...v) - Math.min(...v)) * 100).toFixed(1) + '点');
  }

  console.log('目標の勝率になる敵のHPを探す(1万回ずつ)');
  for (const target of [0.9, 0.7, 0.5]) {
    const t0 = Date.now();
    const r = tuneEnemyHp(player, enemy, target, 10000, 1);
    const check = simulate(player, { ...enemy, hp: r.hp }, 10000, 99);
    console.log('目標', target * 100 + '%', '→ 敵HP', r.hp, '(探索', r.steps, '回・', Date.now() - t0, 'ms)別のシードで確認した勝率', (check.winRate * 100).toFixed(1) + '%');
  }

  console.log('パラメータを1つずつ10%上げたときの勝率の変化(敵HP140のとき)');
  const base = simulate(player, hard, 10000, 1).winRate;
  console.log('もとの勝率', (base * 100).toFixed(1) + '%');
  for (const [name, p2, e2] of [['プレイヤー攻撃+10%', { ...player, atk: player.atk * 1.1 }, hard], ['プレイヤーHP+10%', { ...player, hp: player.hp * 1.1 }, hard], ['プレイヤー会心率+10%(相対)', { ...player, crit: player.crit * 1.1 }, hard], ['敵の攻撃+10%', player, { ...hard, atk: hard.atk * 1.1 }], ['敵のHP+10%', player, { ...hard, hp: hard.hp * 1.1 }]]) {
    const r = simulate(p2, e2, 10000, 1).winRate;
    console.log(name, ((r - base) * 100).toFixed(1) + '点');
  }
}

乱数は、シード付きのものを使いました。同じシードなら、毎回同じ結果になるので、数字を変えたときの違いを、乱数のぶれと区別して比べられます。

敵のHPと勝率

プレイヤーはHP100・攻撃12・命中90%・会心10%、敵は攻撃9・命中85%・会心5%として、敵のHPを変えながら、1万回ずつ戦わせました。

敵のHPと勝率の図。敵HP40は勝率100パーセントで4ターン。60は100パーセントで5.6ターン。80は99.9パーセントで7.3ターン。100は98.3パーセントで9ターン。120は90パーセントで10.5ターン。140は69.6パーセントで11.7ターン
敵のHPが100を超えたあたりから、勝率が下がります

敵のHPが100までは、勝率は98%以上でした。120で90.0%、140で69.6%と、100を超えると急に下がります。勝ったときの残りHPも、敵のHP40で平均75.6、140で18.3と、大きく減りました。敵のHPを10〜20上げるだけで、体感がかなり変わる範囲があります。

何回戦えば、勝率が安定するか

戦いには乱数が入るので、少ない回数では、勝率がぶれます。敵のHP140(勝率が約7割)で、乱数の並びを20通りに変えて、勝率の最小と最大を比べました。

試行回数と勝率のばらつきの図。100回は58.0パーセントから74.0パーセントで幅16.0点。1000回は66.2パーセントから72.2パーセントで幅6.0点。1万回は68.2パーセントから69.7パーセントで幅1.5点
100回では、数字が16点も動きます

100回では58.0〜74.0%と、16点も動きました。1,000回で6.0点、1万回で1.5点です。数十回の試し遊びで「勝率7割くらい」と感じても、本当は6割にも8割にもなり得ます。この点が、人が遊んで調整する場合の弱いところです。

目標の勝率に合わせる

敵が強いほど勝率が下がります。この性質を使って、目標の勝率になる敵のHPを、二分探索で探しました。範囲の真ん中を試して、勝率が目標より高ければ、HPを上げる側へ、低ければ下げる側へ絞ります。

  • 目標90%:敵HP119(8回の探索)。別のシードで確認すると90.7%
  • 目標70%:敵HP139(9回の探索)。確認すると70.3%
  • 目標50%:敵HP153(9回の探索)。確認すると51.2%

探索は、どれも約50〜60ミリ秒で終わりました。確認の勝率が目標から最大1.2点ずれているのは、乱数のぶれです。

どの数字を動かすと、勝率が変わるか

敵HP140(勝率69.6%)のときに、数字を1つずつ10%上げて、勝率の増減を比べました。同じシードで比べているので、違いは、数字の変更によるものです。

数字を10パーセント上げたときの勝率の増減の図。敵のHPはマイナス21.7点、敵の攻撃はマイナス19.1点、プレイヤーのHPはプラス16.2点、プレイヤーの攻撃はプラス14.5点、プレイヤーの会心率はプラス1.6点
会心率の10パーセントは、勝率にほとんど効きませんでした

勝率に大きく効いたのは、敵のHPと攻撃、プレイヤーのHPと攻撃でした。プレイヤーの会心率は、10%(0.10から0.11へ)の上げ幅が小さく、1.6点しか動きませんでした。「会心率を少し上げる強化」は、この設定では、効果が小さいという結果です。この結果は、この戦闘ルールでのものです。ルールが違えば、順番も変わります。

AIにシミュレーションを書かせるときの確認点

このようなコードは、AIに書かせても作れます。出てきたコードで、次の3点を確かめると、数字を信じてよいかが分かります。

  • 乱数にシードがあるか。ないと、実行のたびに数字が変わり、比べられません
  • 試行回数が十分か。この記事の測定では、勝率が約7割のとき、1万回で1.5点の幅でした
  • 比べる実行で、同じシードを使っているか

AIに、このコードを書かせた場合の出来は、確かめていません。

確かめられていないこと

  • 実際のプレイヤーの体感との一致勝率が7割になる設定を、人が遊んで「ちょうどよい」と感じるかは、確かめていません。
  • 回復・防御・スキルなど、行動の選択がある戦闘(このコードは、毎ターン攻撃だけです)
  • 複数の敵や、パーティーでの戦い
  • プレイヤーの上手さによる勝率の違い
  • ブラウザでの実行と、その速さ

確かめていないものは、動かして確かめてから、別の記事で扱います。

うまくいかないとき

症状 確認すること
実行するたびに勝率が変わる シードを固定しているかを確かめます。固定していても、試行回数が少ないと、設定を変えたときのぶれが残ります
勝率が100%か0%ばかり 敵のHPの範囲が広すぎます。この記事の設定では、100〜150のあたりが変わり目でした
探索が止まらない 敵が強いほど勝率が下がる前提が崩れていないかを確かめます。ルールを変えると、勝率が単調に下がらない場合があります
数字を変えても勝率が動かない 勝率が、100%や0%に近すぎないかを見ます。変わり目の近くで比べます

関連記事ゲームの企画書をAIで作る方法【2026年】テンプレートと仕様書への変え方

よくある疑問

勝率は、何%にすればよいですか

ゲームの狙い次第で、この記事では決められません。コードは、目標の勝率に合う敵のHPを探す道具として使います。

ブラウザで動かせますか

コードに、ブラウザ専用の命令は入っていませんが、ブラウザでは確かめていません。この記事の結果は、Nodeで実行したものです。

プレイヤーの選択が入るゲームは、どうしますか

選択の方針を、プログラムで決めて試す形が考えられます。この記事では、確かめていません。

この記事のまとめ

この記事のまとめ

  • 戦闘をコードにして1万回戦わせると、遊ばなくても勝率を数えられる
  • 100回では勝率が16点もぶれた。1万回で1.5点だった
  • 目標の勝率になる敵のHPは、二分探索で9回の探索で見つかった
  • 10%の変更が勝率に効く大きさは、数字ごとに違う。会心率は1.6点だった
  • 人が遊んだときの体感との一致は、確かめていない