はじめに
この解法でいちばん伝えたいのは、派手なアルゴリズムではなく、234試合分の負け方を丁寧に眺めたところから改善が生まれたことです。
今回紹介するのは、私がKaggleのゲームAIコンペ「Orbit Wars」へ提出した v2early です。最終順位は381位でした。ベースとなったのは、強化学習でもゲーム木探索でもなく、候補手を一つずつ採点する貪欲法です。
最終的な変更は、驚くほど小さなものです。
最初の50ターンだけ、攻撃先を広く探し、少し低い評価の攻撃も許し、一度に出せる艦隊を増やす。
ただし、いつでも攻撃的にしたわけではありません。実際、ゲーム全体を攻撃的にした派生版は成績を落としています。なぜ「序盤だけ」なら効いたのか。そこには、リプレイを使って「なんとなく弱い」を具体的な失敗に分解していく、きれいな流れがありました。
この記事では、その流れを中心に解法をたどります。
この記事は、Kaggleへ投稿した英語の解法記事を、日本語で読みやすいように構成し直したものです。元の記事も末尾に載せています。
234試合の診断から、序盤50ターンだけ攻撃機会を広げるまでの流れを一枚にまとめました。
まず、Orbit Warsはどんなゲームなのか
Orbit Warsは、恒星の周りを公転する惑星へ艦隊を送り、領土と船を増やしていくリアルタイムの戦略ゲームです。2人戦と4人のFree For Allがあり、1試合は500ターン。マップ上には20〜40個の惑星が存在します。
惑星は船を生産するので、早く領土を増やせば、その後の生産量で有利になります。とはいえ、目についた惑星を片っ端から取ればよいわけではありません。遠征のために守備隊を減らせば、今度は出発元が狙われます。せっかく占領しても、別のプレイヤーにすぐ奪い返されることもあります。
とくに4人戦では、正面の相手だけを見ていればよい2人戦と違い、攻撃が複数方向から飛んできます。早く広がりたい。でも、広がりすぎると守れない。この悩ましさが、今回の改善にもそのまま表れています。
最終順位を決めるのは、500ターン終了時の総船数です。惑星の守備隊だけでなく、飛行中の艦隊も数えられます。
ベースラインは「未来を試してから選ぶ」貪欲法
v2early の土台になった main_v2 は、毎ターン作戦を一から探索するのではなく、攻撃候補を作って点数を付け、良いものから順に実行します。
「貪欲法」と聞くと、近くの惑星を順番に攻めるような単純なものを想像するかもしれません。しかし、この解法は候補となる艦隊を実際に盤面へ置き、戦闘シミュレータで未来を計算してから採点します。貪欲なのは選び方であって、評価そのものはかなり手厚いのです。
全体を一枚にすると、次のような流れです。
開発中には、PPOによる自己対戦や上位リプレイからの模倣学習、1手のBest Response探索、相手を考慮した多段ロールアウトも試しました。それでも、1ターン1秒という制約の中では、この貪欲法を上回れませんでした。
ここからは、採点までに何を見ていたのかを、順に見ていきます。
何もしなかった場合の未来を先に作る
最初に計算するのは、「ここから誰も新しい艦隊を出さなかったら、盤面はどうなるか」です。
すでに飛んでいる艦隊による戦闘と、各惑星での船の生産を反映し、惑星ごとの所有者と船数を未来へ進めます。予測する長さは2人戦で18ターン、4人戦で13ターンです。
この基準となる未来があるおかげで、「攻撃したらどれだけ得をするか」を差分で測れます。同時に、このままでは敵に落とされる味方惑星も見つけられます。
候補を絞り、安全に出せる船だけを使う
詳細なシミュレーションは高価なので、すべての惑星の組み合わせを無条件に調べるわけではありません。
出発元になるのは、最低限の守備隊を持つ自分の惑星。目的地には、到着時間の短い敵・中立惑星に加えて、先ほどの予測で敵に奪われると分かった味方惑星も入ります。後者は救援候補です。
送る船数には safe_drain という上限があります。これは、予測期間中に攻撃を受けても出発元が生き残れる範囲で、外へ出せる最大船数です。
つまり、候補を評価する前から「攻撃に夢中になって足元を空にしない」という歯止めがかかっています。
自分の得だけでなく、相手の損も見る
各候補を戦闘シミュレータへ通したら、何もしなかった未来との差を採点します。中心となる評価値は、整理すると次の形です。
$$\text{competitive score} = \Delta \text{自分の純船数} - \sum_i \Delta \text{相手}_i\text{の純船数}$$
自分の船を増やす行動だけでなく、相手の成長を止める行動にも価値が付きます。4人戦ではさらに、現在の首位プレイヤーや、生産力の高い惑星を攻撃する候補へボーナスを加えます。
放っておくと独走しそうな相手を、「その瞬間の効率が一番ではないから」という理由だけで無視しないための工夫です。Free For Allらしい評価項目だと感じます。
「取れる」ではなく「取った直後も残れる」船数を送る
到着時の守備隊を1隻だけ上回る艦隊では、占領できてもすぐに奪い返されます。そこで capture_floor は、敵の増援リスクまで含めて最低船数を決めます。
$$\text{必要船数} = \text{到着時の予測守備数} + \beta \cdot \rho(\mathrm{ETA}) \cdot \text{敵の到達可能船数}$$
$\beta$ は2.2。$\rho(\mathrm{ETA})$ は飛行時間に応じて0から1へ増える係数です。
近距離なら、敵がこちらの動きを見てから対応できる時間はあまりありません。反対に、長い航路では増援を用意される可能性が高くなります。そのぶん余裕を持った船数を要求するわけです。
ここは後の話にもつながります。v2early は序盤の攻撃を増やしましたが、この安全装置には触れていません。
最後は、点数の高い候補から発射する
採点が終わると、roi_threshold を超えた候補を高得点順に実行します。1ターンの発射数には上限があり、同じ惑星を出発元と救援先の両方に使わない、といった制約もあります。
同点ならスロット番号の小さい候補を選ぶため、CPUとCUDAでも同じ結果を再現できます。同じ設定とシードから同じ試合を作れることは、後でリプレイを診断するときに効いてきます。
攻撃に使わなかった船は、敵の圧力が強い味方惑星へ送ります。ここまでが main_v2 と v2early に共通する骨格です。
もともと、終盤には専用の作戦があった
ベースラインは、全500ターンを同じ設定で戦っていたわけではありません。
残り35ターンになると、roi_threshold を0.6へ下げ、1ターンの最大発射数を10へ増やします。ゲーム終了時に船を余らせないよう、守備隊を占領へ変えていくためです。
一方、4人戦の通常設定は2人戦より慎重でした。
| 設定 | 2人戦 | 4人戦 |
|---|---|---|
roi_threshold |
1.50 | 1.55 |
max_waves_per_turn |
6 | 6 |
max_offensive_targets |
12 | 7 |
min_ships_to_launch |
4 | 5 |
4人戦では攻撃を採用する基準を少し上げ、探す攻撃先を減らし、小さな出撃も抑えています。三方向へ目を配らなければならないぶん、慎重にするのは自然な設計です。
ところが、実戦リプレイを調べると、この慎重さを序盤だけ緩めた方がよいのではないか、という数字が見えてきました。
234試合のリプレイが教えてくれたこと
そこで、main_v2 がLeaderboardで戦った234試合を集め、25ターンごとの勢力推移を勝ち試合と負け試合に分けました。
まず目につくのは、2人戦で62.4%だった勝率が、4人戦では35.2%まで落ちていたことです。しかも4人戦の敗北の48%は、中央値でおよそ100ターンまでに事実上脱落していました。
勢力が最も大きくなる時点も対照的です。
- 負け試合では、中央値で98ターン
- 勝ち試合では、中央値で241ターン
500ターンのゲームではありますが、負け試合の多くはずっと早い段階で大勢が決まっていました。そこから決着までに時間がかかっているだけだったのです。
では、序盤に何が起きていたのでしょうか。
敗北の65%では、一度は5惑星ほどまで広がり、その後に領土を失っていました。最初からほとんど拡大できなかった敗北は27%です。
この差は大切です。見た目にはどちらも「終盤の惑星が少ない」という同じ結果になります。しかし、前者の問題は占領できないことではなく、占領した場所を守れないことです。
一方、勝った4人戦では、50ターンまでに5〜7惑星を確保し、増えた生産力から雪だるま式に伸びていました。のちに v2early 自身のLeaderboardリプレイ19試合を調べると、勝利時には50ターンで平均9.5惑星に達していました。ベースラインの勝利時は約5惑星だったので、序盤の立ち上がりにはかなりの差があります。
ここから出てきた仮説は、単純な「もっと攻めよう」ではありませんでした。
4人戦では、最初の50ターンで4〜7個目の惑星を取れるかどうかが、その後の成長を左右している。ならば、その時間帯だけ攻撃機会を増やしてみる。
変更する時間帯と目的が、リプレイからかなり具体的に絞られています。
変更したのは、序盤の4項目だけ
v2early が加えたのは、run_turn() 内の小さな設定上書きです。
early_phase_turns: int = 50
early_roi_threshold: float = 1.15
early_max_waves: int = 9
early_max_offensive_targets: int = 12
early_min_ships_to_launch: float = 4.0
if step < early_phase_turns:
config = dataclasses.replace(
config,
roi_threshold=early_roi_threshold,
max_waves_per_turn=early_max_waves,
max_offensive_targets=max(
max_offensive_targets,
early_max_offensive_targets,
),
min_ships_to_launch=min(
min_ships_to_launch,
early_min_ships_to_launch,
),
)
4人戦の通常設定と比べてみます。
| 設定 | 通常時 | 最初の50ターン | 何が変わるか |
|---|---|---|---|
roi_threshold |
1.55 | 1.15 | 少し低い評価の攻撃も選ぶ |
max_waves_per_turn |
6 | 9 | 同じターンに多く発射する |
max_offensive_targets |
7 | 12 | より広く攻撃先を探す |
min_ships_to_launch |
5 | 4 | 小さな初期占領も許す |
reinforce_size_beta |
2.2 | 2.2 | 奪還リスクへの余裕は変えない |
言い換えると、「見る候補を増やし、採用の敷居を下げ、同時に実行できる数を増やした」だけです。safe_drain や capture_floor、正確な戦闘計算は残っています。
序盤だから無謀に突っ込むのではありません。今まで安全性の判定を通っていたのに、4人戦用の厳しい候補数や閾値のために見送られていた手を、最初の50ターンだけ拾いやすくした、というのが実際のところです。
max() と min() の使い方も地味ですが丁寧です。序盤設定は攻撃候補を狭めず、最低船数を引き上げません。そのため、4人戦を意識した上書きによって、もともと候補が広い2人戦を誤って慎重にすることがありません。
ローカル対戦は、何も答えてくれなかった
ここまで仮説がはっきりしていれば、次はローカル対戦で新旧を比べたくなります。ところが、この開発環境ではローカル評価と実際のLeaderboardが逆相関することが、すでに分かっていました。
実際、ローカル159試合で平均5.8ポイント改善した派生版が、Leaderboardでは425ポイント悪化した例もありました。
v2early の比較は、さらに厄介でした。2人戦では座席0がほぼ必ず勝つため、新旧を入れ替えても結果は50対50。4人戦では v2early を2体、main_v2 を2体置き、座席を回しながら比較しましたが、平均順位は2.508対2.492でした。ほとんど完全な引き分けです。
設定が動いていないわけではありません。同じシードで再生すると、両者の行動が途中から分かれることは確認できました。ある2人戦では、50ターン時点で v2early が18惑星、main_v2 が8惑星まで広がっています。
つまり、行動は変わっているのに、対称なミラーマッチではその良し悪しを判定できませんでした。ここで無理にローカルの数字を作るのではなく、「この実験に対しては、評価環境の方が答えを持っていない」と認めた点も、この取り組みの興味深いところです。
最終的には、Leaderboardへの提出そのものを、従来版との一回の比較実験として扱いました。
最初の成功と、「もっと攻めればいい」という失敗
v2early はLeaderboardスコアを1111.9から1113.1へ伸ばし、派生版の中で暫定トップになりました。
ここで私は、「攻撃の敷居を下げるのが効くなら、ゲーム全体で下げたらもっと強いのでは」と考えました。そこで作った v2early5 は、通常時の roi_threshold を1.55から1.35へ下げています。
結果は985.7から991.3。改善はしたものの、v2early より明らかに低いスコアでした。
負け試合のリプレイを見ると、理由がよく分かります。
| 指標 | v2early |
v2early5 |
|---|---|---|
| 占領した惑星数 | 21.9 | 28.4 |
| 失った惑星数 | 22.3 | 29.3 |
| 差し引き | -0.4 | -0.9 |
v2early5 は、狙いどおり多くの惑星を取っています。ただ、それ以上に多く失っていました。占領数は増えたのに、領土は増えていません。増えたのは、占領しては奪われる往復でした。
しかも、失った惑星の75%は30ターン未満で奪還され、56%は最終的な勝者に取られていました。
ここで、最初のリプレイ診断へ戻ってきます。もともと多かったのは「取れずに負ける」試合ではなく、「取った後に守れず負ける」試合でした。全期間で攻撃を増やす v2early5 は、まさにその弱点を広げてしまったことになります。
序盤50ターンという制限は、単なる調整値ではありませんでした。生産基盤を作るためにリスクを取る時間と、取った領土を守る時間を分ける境界だったのです。
理にかなった防御策も、最後には残らなかった
v2early5 の失敗を受けて、次は占領先の守りやすさを評価するペナルティが加えられました。
考え方は素直です。対象惑星へ、相手の増援が自分の再増援より早く届くなら、その攻撃候補の点数を下げます。
-
v2early6は、元のv2earlyに防御可能性ペナルティを追加 -
v2early7は、攻撃的なv2early5に同じペナルティを追加
この指標にはオフラインでも信号がありました。30ターン以内に奪還された惑星の「防御不能度」は平均0.51、維持できた惑星は0.25。差は0.59標準偏差で、短期的に失う惑星をきちんと見分ける方向に働いています。
ここまで聞くと、こちらが完成版に思えます。問題の診断とも合い、数字でも確かめられているからです。
それでも、Private Leaderboardで同系統の中から最終的に残ったのは、ペナルティを持たない素の v2early でした。
筋のよい仮説も、オフラインで見える差も、本番での改善を約束してはくれません。少し拍子抜けする結末ですが、だからこそ実戦的です。説明が美しい派生版ではなく、実際にスコアを残した最小の変更を採用しています。
この試行錯誤から伝えたいこと
この事例は、パラメータ調整の話に見えて、実は「問いをどう小さくするか」の話なのだと思います。
最初の問いが「4人戦で弱いのはなぜか」だけなら、触れそうな設定は無数にあります。そこから234試合を勝敗別、さらに時間帯別に見ることで、問いは「最初の50ターンに4〜7個目の惑星を取れるか」まで絞られました。だから、変更も4項目の一時的な上書きで済みました。
もう一つ忘れたくないのは、占領できない問題と、占領後に守れない問題は、最終盤面だけでは見分けにくいことです。どちらも領土が少ない状態で終わりますが、前者には積極性が、後者には選別と防衛が必要です。途中経過を見なければ、正反対の薬を出してしまいます。
そして、評価環境にも得意不得意があります。ローカル対戦を何試合増やしても、座席で結果が決まる2人戦や、対称な2対2が消してしまう差は測れません。「テストしたから安心」ではなく、そのテストが自分の問いに答えられるかを考える必要があります。
最後に残った v2early は、もっとも複雑な案ではありませんでした。でも、実戦から問題を見つけ、変更する時間帯を限定し、一つずつ提出して確かめた。その過程には、ゲームAIに限らず、モデルやプロダクトを改善するときにも通じるものがあります。
大きな仕組みを足す前に、まず負け方を見にいく。
今回いちばん伝えたいのは、その素朴で難しい姿勢です。
元の英語記事
- Hikari_30, 381th Place Solution, Kaggle, 2026-07-14
