AI人狼ゲームのアーキテクチャ
2025-12-11 | Ayumu
AIプレイヤー同士が人狼ゲームをプレイするシステムを作った。 LLM(Gemini)が各プレイヤーの思考・発言・投票を担当し、人間は観戦者として楽しむ形式。
全体アーキテクチャ
ステートレス設計の工夫
Vercelはサーバーレスなので、リクエスト間でメモリ上の状態を保持できない。 そこでクライアント側で全GameStateを保持し、毎回APIに送る方式を採用。
// クライアント
const handleStep = async () => {
const res = await fetch('/api/werewolf', {
method: 'POST',
body: JSON.stringify({
action: 'step',
gameState: game.gameState // 全状態を送る
}),
});
const data = await res.json();
setGame(data); // 更新された状態を保存
};
デメリット: 通信量が増える、クライアント側で改ざん可能(観戦専用なので問題なし)
情報非対称性の設計
人狼ゲームでは「誰が何を知っているか」が重要。このシステムでは3種類の視点がある:
| 視点 | 見える情報 | 見えない情報 |
|---|---|---|
| AIプレイヤー | 公開ログ、自分の役職情報 | 他人の役職、systemメッセージ |
| 観戦者 | 全員の役職、公開ログ、systemメッセージ | (なし) |
| 人狼プレイヤー | 公開ログ、仲間の人狼、人狼チャット | 村人の役職 |
実装: メッセージタイプによる分離
// メッセージの種類
type MessageType = 'gm' | 'player' | 'system' | 'wolf_chat';
// AIプレイヤーに渡す情報(systemを除外)
export function getPlayerView(game: GameState, playerId: string) {
// systemメッセージを除外
const filteredLog = game.publicLog.filter(m => m.type !== 'system');
return {
publicLog: filteredLog,
privateInfo, // 役職に応じた秘密情報
wolfChat // 人狼のみ
};
}
type: 'system'のメッセージは観戦者向けの状況整理。
AIプレイヤーのプロンプトには含まれないので、「神視点のネタバレ」を見せずに済む。
フェーズ管理
ゲームは以下のフェーズで進行:
議論フェーズの「挙手制」
議論では「誰から発言するか」を決める必要がある。 全員に「発言したいか?」を聞いて、挙手した人だけが発言する方式を採用。
// 挙手判定
export async function runRaiseHandsPhase(game: GameState) {
const eligiblePlayers = getAlivePlayers(game)
.filter(p => p.id !== game.lastSpeaker); // 連続発言防止
// 全員に並列で挙手判定
const handsResults = await Promise.all(
eligiblePlayers.map(async p => ({
player: p,
wants: await checkWantToSpeak(p, game),
}))
);
const speakers = handsResults.filter(r => r.wants).map(r => r.player);
shuffleArray(speakers); // ランダム順
game.pendingSpeakers = speakers.map(p => p.id);
}
文字数制限による議論終了
人狼ゲームでは「議論時間」が重要。このシステムでは時間の代わりに文字数制限を採用。
// 生存者数 × 300文字 が上限
export function getDayCharLimit(game: GameState): number {
const aliveCount = getAlivePlayers(game).length;
return aliveCount * 300;
}
// 発言のたびにカウント
game.dayCharCount += speech.length;
// 上限に達したら投票へ
if (game.dayCharCount >= charLimit) {
advancePhase(game);
}
人狼の議論時間は人数×30〜60秒が目安。発話速度は約300〜400文字/分。 よって人数×300文字で約1人30秒相当の議論量。
並列 vs 直列処理
LLM呼び出しは遅い。並列化できる場面では積極的にPromise.allを使う。
並列処理が有効な場面
- 挙手判定(全員に同時に聞く)
- 投票(全員同時に投票)
- 夜の行動(占い/護衛/襲撃)
直列処理が必要な場面
- 議論中の発言(前の発言を見て反応)
- 感想戦(1人ずつ順番に)
観戦者向け状況整理
観戦者は全員の役職を知っているが、ゲームが進むと状況が複雑になる。 そこで各日の終わりに「観戦者メモ」を自動生成。
export function generateObserverSummary(game: GameState): void {
const lines: string[] = [];
lines.push(`【観戦者向け状況整理】`);
lines.push(`生存者: ${alivePlayers.length}人(人狼: ${aliveWolves.length}人)`);
// 占い結果のまとめ
if (game.seerResults.length > 0) {
const seerSummary = game.seerResults.map(r => {
const target = game.players.find(p => p.id === r.target);
return `${target?.name}=${r.isWolf ? '黒' : '白'}`;
}).join('、');
lines.push(`占い結果: ${seerSummary}`);
}
// 偽占い師COの検出
const fakeSeerCOs = game.players.filter(p =>
p.role !== 'seer' &&
game.publicLog.some(m =>
m.speaker === p.name &&
m.content.includes('占い師CO')
)
);
if (fakeSeerCOs.length > 0) {
const fakers = fakeSeerCOs.map(p =>
`${p.name}(${ROLE_NAMES[p.role]})`
).join('、');
lines.push(`偽占い師CO: ${fakers}`);
}
// type: 'system' で記録(AIプレイヤーには見えない)
addMessage(game, 'system', 'system', lines.join('\n'));
}
プロンプト設計のポイント
1. ルールと戦略を分離
最初はルールと戦略を混ぜていたが、AIが混乱した。 「人狼は人狼を襲撃できない」はルール、「占い師COは状況を見て判断」は戦略。
2. 全役職の戦略を共有
人狼ゲームでは「他の役職がどう動くか」を推測することが重要。 そのため、AIプレイヤーには全役職の基本戦略を教える(自分の役職だけでなく)。
3. 発言長の調整
// 発言プロンプトの例
const prompt = `
あなたは${player.name}(${ROLE_NAMES[player.role]})です。
【発言のルール】
- 1〜2文で短く(長い演説は怪しまれます)
- 推理や質問を入れる
- 過度な敬語は不要
【議論残り】約${charRemaining}文字
`;
maxOutputTokensを小さくしすぎると発言が途中で切れる。
逆に大きすぎると演説調になる。2000程度で「短く」と指示するのが良いバランス。
まとめ
- ステートレス設計: クライアントで全状態を保持、Vercelでも動作
- 情報非対称性: メッセージタイプで観戦者/プレイヤーの視点を分離
- フェーズ管理: 挙手制議論、文字数制限、感想戦
- 並列/直列: 場面に応じて使い分け、UX向上
- プロンプト: ルール/戦略分離、全役職の戦略共有、発言長調整
人狼ゲームは「情報の非対称性」と「推理・駆け引き」が面白さの核心。 AIにそれを再現させるには、何を見せて何を隠すかの設計が重要だった。