EP.11 は プレイヤーで自動プレイテスト。人間プレイテストの代替ではなく、初期スクリーニング・回帰テスト・難所検出を 24 時間動かす自動化の話。完全情報ゲームは + ハイブリッドが強力。「インディー個人で 100 ステージ作ったけど全部手でテストできない」 を解決します。
1. なぜ LLM プレイヤーが要るか
- 人間テスター調達コスト: フルプレイテストは 1 人 8 時間 × 3-5 人 × 週単位
- コンテンツ量が膨大: 100 ステージのバランス、各ペルソナで 20 回試行 = 2000 セッション
- アップデート時の回帰: 数値を 1 つ変えたら全ステージ再テスト
- 早期検出: 開発の早い段階で 「絶対詰むポイント」 を弾きたい
- 統計的議論: 「主観的に難しい」 ではなく「Aパターンのクリア率 35%、Bパターン 78%」 と数字で語る
2. ゲームを LLM が遊べる形に抽象化
from dataclasses import dataclass, asdictfrom typing import Literal
@dataclassclass GameState: player_hp: int player_max_hp: int player_mp: int enemies: list[dict] # [{"name": str, "hp": int}] items: list[str] location: str turn: int
@dataclassclass Action: type: Literal["attack", "magic", "item", "flee"] target: str | None = None item_name: str | None = None magic_name: str | None = None
def legal_actions(state: GameState) -> list[Action]: actions = [] for enemy in state.enemies: actions.append(Action("attack", target=enemy["name"])) if state.player_mp >= 5: actions.append(Action("magic", magic_name="fire")) for item in state.items: actions.append(Action("item", item_name=item)) actions.append(Action("flee")) return actions3. LLM に行動を選ばせる
from anthropic import Anthropicimport jsonimport os
client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
PERSONAS = { "speedrunner": "最短でクリア、回復は最小限、危険でも前進。", "collector": "全アイテム回収、隠し要素探索、戦闘は丁寧に。", "cautious": "HP 半分以下なら必ず回復、危険な敵は逃げる。", "berserker": "常に攻撃魔法、回復軽視、最大火力。", "newbie": "あまり考えずランダム寄り、たまに操作ミスで弱い行動。",}
def llm_choose_action(state: GameState, persona: str) -> Action: actions = legal_actions(state) actions_text = "\n".join( f"{i}: {asdict(a)}" for i, a in enumerate(actions) )
prompt = f"""あなたは {persona} 型のプレイヤー: {PERSONAS[persona]}
現在の状態:{json.dumps(asdict(state), ensure_ascii=False, indent=2)}
選択可能な行動:{actions_text}
理由を一言と、選んだ番号を JSON で返してください:{{"reason": "...", "choice": 0}}"""
response = client.messages.create( model="claude-haiku-4-5-20251001", max_tokens=100, messages=[{"role": "user", "content": prompt}], ) text = response.content[0].text
# JSON 抽出 (失敗時はランダム) try: decision = json.loads(text[text.index("{"):text.rindex("}")+1]) return actions[decision["choice"]] except Exception: import random return random.choice(actions)4. プレイテストを統計化
import statisticsfrom collections import defaultdict
def simulate_run(stage_id: str, persona: str) -> dict: """1 試行のシミュレーション (結果を辞書で返す)""" state = load_stage(stage_id) # ステージ初期状態 turn = 0 death_locations = [] while not is_clear(state) and not is_dead(state) and turn < 200: action = llm_choose_action(state, persona) state = apply_action(state, action) if is_dead(state): death_locations.append(state.location) turn += 1 return { "stage_id": stage_id, "persona": persona, "cleared": is_clear(state), "turns": turn, "remaining_hp": state.player_hp, "death_locations": death_locations, }
# 全ステージ × 全ペルソナで 20 試行results = []for stage_id in ["stage_01", "stage_02", "stage_03"]: for persona in PERSONAS: for trial in range(20): results.append(simulate_run(stage_id, persona))
# 集計summary = defaultdict(lambda: {"clear": 0, "total": 0, "turns": []})for r in results: key = (r["stage_id"], r["persona"]) summary[key]["total"] += 1 if r["cleared"]: summary[key]["clear"] += 1 summary[key]["turns"].append(r["turns"])
print(f"{'Stage':12} {'Persona':12} {'ClearRate':10} {'AvgTurns':10}")for (sid, p), s in summary.items(): rate = s["clear"] / s["total"] * 100 avg = statistics.mean(s["turns"]) if s["turns"] else 0 print(f"{sid:12} {p:12} {rate:>8.1f}% {avg:>8.1f}")5. 結果から読み取る (実例パターン)
| 症状 | 想定原因 | 改善案 |
|---|---|---|
| 全ペルソナで低クリア率 | 難度過大 | 敵 HP 下げる / 回復配置追加 |
| speedrunner のみ低 | 回復不可避な設計 | ショートカット用回復追加 |
| cautious だけ高 | 戦闘より回避が強い | 戦闘報酬を強化 |
| 平均ターン数が想定の 2 倍 | 冗長 / 詰みポイント | MAP を整理 |
| 特定 location で死亡集中 | トラップ強すぎ | 警告サイン追加 |
| newbie が全滅 | 難度上昇カーブ急 | チュートリアル / ゆとり追加 |
6. ヒートマップで難所可視化
import matplotlib.pyplot as pltimport numpy as npfrom collections import Counter
# 全試行から死亡地点を集計all_deaths = Counter()for r in results: for loc in r["death_locations"]: all_deaths[loc] += 1
# マップを 2D グリッドに見立ててヒートマップ# (実際はマップ座標 → グリッドインデックスの変換が要る)grid = np.zeros((20, 30))LOC_TO_XY = { "entry": (0, 0), "corridor_a": (5, 10), "trap_room": (10, 15), # ...}for loc, count in all_deaths.items(): if loc in LOC_TO_XY: y, x = LOC_TO_XY[loc] grid[y, x] = count
plt.figure(figsize=(10, 6))plt.imshow(grid, cmap='hot', interpolation='nearest')plt.colorbar(label='Death count')plt.title('Death locations heatmap')plt.show()7. MCTS + LLM ハイブリッド
完全情報ゲーム (盤面ゲーム / カードゲーム) は が圧倒的に強い。 は評価関数やペルソナ説明に使い、探索本体は という分担。現代アルゴリズム図鑑 EP.14 (MCTS) との組合せで AlphaGo 的アプローチが個人開発でも手の届く距離に。
# MCTS の rollout で 「完全ランダム」 でなく# 「LLM が選んだ妥当な手」 でロールアウトすると精度向上# (AlphaGo の policy network の代わりに LLM を使う)
def llm_rollout(state): """MCTS の simulation 段階で LLM に方策を提供""" while not is_terminal(state): # 簡易 LLM (Haiku) で手を選ばせる action = llm_choose_action(state, persona="optimal") state = apply_action(state, action) return evaluate(state) # 勝敗 / スコア8. CI/CD への組込み
- GitHub Actions で PR ごとに自動プレイテスト: 全ステージ × 全ペルソナ × 10 試行 (ローカル LLM 推奨)
- 閾値で fail: クリア率が <30% に下がる PR は merge ブロック
- 実行時間目標: 30 分以内 (並列化 + ローカル LLM で実現)
- 結果ダッシュボード: Grafana / Datadog にメトリクス送信
9. 注意点
LLM は 「楽しさ」 を判定できない。クリア率が高くても「単調でつまらない」 のは検出できない。人間プレイテストの代わりではなく、初期スクリーニング / 回帰防止に使う ものと割り切る。最終判断は必ず人間で。
10. 次の話
EP.12 は プレイログ解析 ── BigQuery + LLM でユーザー行動を理解する ── リリース後のプレイデータを LLM で分析し、難所・離脱ポイント・予期せぬプレイスタイルを発見する話。
この記事の感想を教えてください
あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。