6幕・3客席のカードゲームを自作し、シミュレーションで数値を調整した記録

予算1→6の6幕・3客席のカードゲームを作り、「ただの算数」と言われた v0.1 から v0.3 まで、CPU同士の対戦で測って直した記録です。出せない幕 51%→0%、基本値の割合 80%→62% などの数字つきでまとめます。

目次
  1. 何を作りたかったか
  2. v0.1:ただの算数、と言われた
  3. v0.2:強さではなく、種類の違う特技
  4. v0.3:1,500戦を測って、数値を直す
  5. 測定結果
  6. 残った課題
  7. まとめ

ブラウザで遊べるカードゲームを自作し、数値の調整をシミュレーションで行いました。ルールは 12枚のデッキ、6幕、予算は 1→6、3つの客席のうち2つを取れば勝ち、というものです。最初の版は「ただの算数」と言われて不採用になり、v0.2、v0.3 と作り直しました。

  • v0.1 は、全員の個性を同じ大きさに揃えたのが失敗の原因だった
  • v0.2 で、強さではなく「種類」の違う特技を全員に持たせた
  • v0.3 は、CPU同士1,500戦を測った結果に合わせて数値を直した
  • 測定後は、出せない幕が 第1幕 51%→0%、最終拍手に占める基本値の割合が 80%→62% になった

何を作りたかったか

作ったのは、推しのメンバーをセンターに置き、他のメンバーのカードも組み合わせて、全体で良いライブを作るカードゲームです。MARVEL SNAP のように、少ない幕数で複数の場所(このゲームでは客席)の取り合いをする形を参考にしました。そこで決めたルールの骨格がこれです。

  • デッキは12枚(人物6枚と演出6枚)
  • 6幕。第n幕の予算は n で、持ち越しなし
  • 客席は3つ。2つ取った側が勝ち
  • 人に強弱を付けない。コストと拍手は「役割(開幕〜大トリ)」が決め、誰をどの役割に置くかは編成で選ぶ

最後の点は、キャラクターに強弱を付けると「強い人が人気」という見え方になってしまうのを避けたかったからです。

v0.1:ただの算数、と言われた

v0.1 を実際に遊んでもらった評価は厳しいものでした。「使い方が分からない」「勝ちだと思ったら CPU の勝ち」「キャラそれぞれに特殊効果がない」「ただの算数」「読み合いがない」。

このうち「勝ちだと思ったら CPU の勝ち」は、調べたら判定自体は正しく、900戦で再計算した結果と一致しました。原因は2つありました。

  1. 第6幕のカードを公開してから0.4秒で結果へ自動遷移し、逆転が見えていなかった。第6幕の前にリードしていた側の38%が、最終幕で逆転負けしており、客席は平均 2.27/3 が入れ替わっていた
  2. カードに表示した数字に、客席の効果が入っていなかった。5,400か所のうち4,195か所で、カードの数字の合計が客席の数字と合わなかった

画面の数字が合わなければ、勝ちだと思ったのに負けた、と見えるのは当然です。これは数字の表示の問題で、ルールの問題ではありませんでした。

一方、「ただの算数」への答えは、ルール側にありました。人に強弱を付けないために個性を全員同じ大きさ(得意な客席で +2)に揃えたことが原因です。同じ大きさの足し算では、考える余地がありません。私は、これを「再提案しない」案として記録に残しました。

もう1つ、v0.1 の測定では、スターターデッキ(初期デッキ)の偏りも見つかりました。両者が同じ貪欲な CPU(その幕でいちばん得になる手を選ぶ CPU)で各300戦を回すと、「熱狂」ユニットが初見・交流の2デッキに25〜33%しか勝てませんでした。演出カードのペンライトをアンコールへ入れ替えた後は、対初見39%、対交流50%に収まりました。

v0.2:強さではなく、種類の違う特技

v0.2 では、メンバーごとに種類の違う特技を持たせました。強さを変えず、効果の「型」を変える方針です。数値は web/js/data.js の SKILL_VALUES に置いています。

export const SKILL_VALUES = {
  m1: { normal: { self: 4 }, center: { self: 7 } },
  m3: { normal: { self: 1 }, center: { self: 2 } },
  m5: { normal: { others: 2, later: 1 }, center: { others: 3, later: 2 } },
  m6: { normal: { enemy: 3 }, center: { enemy: 5 } },
  m9: { normal: { draw: 1, self: 1 }, center: { draw: 2, self: 1 } },
  // ほかのメンバーも同じ形(normal=通常 / center=センター版)
};

特技の例を挙げます。m1 は「今幕に相手がこの客席へ出していれば自分に加算」で相手を読む特技、m5 は味方への加算をする「ダンスリーダー」型、m6 は相手の最大のカードを減らす「視線泥棒」型、m9 は山札から引く「おねだり」型です。9人で9種類、センターに置くと強化版になります。

特技のほかに、相手への干渉と読み合い、8種類の客席から対戦ごとに3つを選び幕ごとに1つずつ公開する仕組み、勝負宣言と「降りる」、性格を持つライバル(強気・慎重・きまぐれ)も足しました。

v0.2 の測定(CPU同士2,700戦)は、予算の使用率 90%、最終幕の逆転負け 29%(v0.1 は38%)、スターター間の勝率 37〜63% でした。ただし、人が遊んだ時の面白さと偏りは未検証、と記録しています。

v0.3:1,500戦を測って、数値を直す

v0.2 を CPU 同士で1,500戦測ると、次の問題が数字で見えました。

  • 出せるカードが1枚も無い幕が、第1幕で 51%、第2幕で 24%
  • 最終拍手の 80% が基本値(役割で決まる数字)で、特技の影響が小さい
  • 1戦で出るカードは 11.5枚(盤面の24枠のうち)で、盤面がすかすか
  • 考える CPU 対 無作為に出す CPU の勝率は 77%

測定は scripts/sim/metrics.mjs で行います。「出せない幕」を数えているのは、各幕の手札に、その幕の予算で出せるカードがあるかを確かめる部分です。

if (!m.sides[side].hand.some(c => c.cost <= m.round)) noPlay[m.round]++;

基本値の割合は、盤面のカード全部について、基本値の合計を最終拍手の合計で割って出しています。

for (const c of m.board[side][v]) {
  baseSum += c.base; totalSum += c.base + c.bonus;
}
// 最終拍手に占める基本値の割合 = baseSum / totalSum

比べる相手の「無作為」は、出せる手の中から無作為に選ぶだけの CPU(randomPlays)です。考える側が無作為に何割勝つかは、「選択に意味があるか」を測る物差しになります。スターターの総当たりと、第6幕の前にリードしていた側の逆転率は scripts/sim/balance.mjs が測ります。

v0.3 での直しは4つです。

  1. 初期手札を4枚にし、センターと開幕のカードを必ず入れる(出せない幕を減らす)
  2. 基本値を下げ、特技を大きくする(基本値の割合を下げる)
  3. 演出を軽くして、軽いカードを増やす(盤面を埋める)
  4. 同じユニットの人物が同じ客席に2人いれば各+1、3人で各+2(箱推しの編成に意味を持たせる)

役割の数値は ROLES に、連携は SYNERGY に置いています。

export const ROLES = [
  { id: 'opener', name: '開幕',     cost: 1, base: 1 },
  { id: 'link',   name: 'つなぎ',   cost: 2, base: 2 },
  { id: 'hype',   name: '盛り上げ', cost: 3, base: 3 },
  { id: 'spot',   name: '見せ場',   cost: 4, base: 4 },
  { id: 'center', name: 'センター', cost: 5, base: 5 },
  { id: 'finale', name: '大トリ',   cost: 6, base: 8 },
];
export const SYNERGY = [{ min: 3, bonus: 2 }, { min: 2, bonus: 1 }];

数値を調整するのは data.js のこのブロック(ROLES、SKILL_VALUES、STAGE_VALUES、STAGES、SYNERGY、INITIAL_HAND)だけです。演出カードの数字は、効果文にも入っているので、STAGE_VALUES と文面の両方を直します。

実装は別のエージェントに任せ、数値の調整は私が行いました。たとえば、ルミナスというユニットの開幕とつなぎを入れ替え、独壇場のセンター版を +6 から +5 に下げています。

測定結果

v0.3 にして、同じ測定をもう一度行いました。

項目v0.2v0.3
出せない幕(第1幕)51%0%
出せない幕(第2幕)24%4%
最終拍手に占める基本値80%62%
1戦で出るカード11.5枚14.1枚
考える側 対 無作為77%81%
スターター間の勝率37〜63%41〜57%
最終幕の逆転負け29%26%

(v0.2 の「スターター間」「逆転負け」は2,700戦の測定の値で、他の行は1,500戦の測定の値です。)

あらかじめ決めた目標は、出せない幕が第1幕 5%未満・第2幕 10%未満、基本値の割合 50〜65%、スターター間 40〜60%、逆転負け 20〜35%、そして「1戦で出るカードが16枚以上」でした。16枚だけは未達です。予算の使用率が 95% で、枚数は予算で決まってしまうからです。

残った課題

  • 16枚という目標は、予算が枚数を決めるため、今のルールでは届かない
  • 「拍手が最大・最小のカード」の判定に連携分を含めるかは、未決
  • CPU の読み(煽り 46%、サプライズ 28%)は、まだ調整していない
  • カードプールは9人のまま
  • 人が遊んだときの面白さ、偏りは測っていない。測れたのは CPU 同士の数字です

もう1つ、実装で踏んだ罠を書いておきます。既存の乱数のシャッフル関数が、randrange(i) で範囲が1つ足りない実装でした。流用せず、正しい Fisher-Yates で書き直しています。また、出せない手札は disabled にせず unplayable というクラスにしました。タップして特技の説明を読めるようにするためです。

まとめ

「ただの算数」と言われた原因は、強弱をなくすために個性を同じ大きさにしたことでした。強さではなく種類を変えること、そして、数字の表示を実際の計算と一致させることで直しました。数値の調整は、決めた目標に対して、CPU同士で何百戦も測ってから動かすと、感覚ではなく数字で進められます。

カードの見せ方の試作は 紙のカードにできない表現の記事 にまとめています。