これは何
個人開発で 江湖論剣(こうこ・ろんけん) という武侠対戦ゲームを作っています。変わっているのは、プレイヤーが操作する代わりに、自分の AI コーディングエージェント(Claude Code / Codex / Cursor など)に戦闘ロジックを JavaScript で書かせて戦わせる点です。
この記事では宣伝は脇に置いて、土台になっている
- 他人(や AI)が書いた JS を 安全に 走らせる
- それを 決定論的に シミュレートして対戦・リプレイにする
- そもそも AI エージェントに読ませる前提 で API とドキュメントを設計する
あたりを、ハマりどころ込みで共有します。
全体像
- バックエンド: Rust(axum)
- ユーザーが書いた JS: QuickJS ランタイムに隔離して実行(ホスト機能は一切渡さない)
- 対戦: 決定論シミュレーション。1 試合あたり最大 800 フレーム回し、結果を リプレイ(JSON) として保存
- フロント: React SPA + Canvas でリプレイを再生
ユーザー(の AI)が書くのは、行動キューが空になるたびに呼ばれる関数ひとつです。
function onIdle(me, enemy, game) {
const d = chebyshev(me.character.position, enemy.character.position)
if (me.skills["renjian_heyi"]?.ready && me.character.qi > 30) {
me.cast("renjian_heyi") // 必殺技で決めにいく
} else if (d > 1) {
me.move("toward_enemy") // 間合いを詰める
} else {
me.cast("sanhuan_taoyue") // 近接の基本技
}
}
① 他人 / AI が書いた JS をどう安全に走らせるか
任意のユーザーコードをサーバで実行するので、ここが一番気を使いました。やったことは大きく2つ。
(1) ホストを一切渡さない。 me / enemy / game は、エンジン内部の構造体をそのまま JS に渡すのではなく、「読んでよいフィールドだけ」を手で組み直したオブジェクトを渡しています。fetch も require も無し、ファイルもネットワークも触れません。
地味ですが効くのは、**新しい内部フィールドを足しても、ホワイトリストに追加しない限り JS からは見えない(undefined)**点。内部状態がうっかり漏れる事故が原理的に起きないし、エージェントに公開する契約がはっきりします。
(2) 無限ループは中断する。 while(true){} を書かれても困るので、実行ステップ数で インタラプト をかけて打ち切ります(テストで担保:infinite_loop_is_interrupted)。
② 決定論シミュレーション ―「同じ入力なら必ず同じ結果」
対戦は、盤面の状態を 1 フレームずつ進めるループです。肝は 乱数をシードで固定 していること。ダメージのばらつきも、A と B どちらの行動を先に処理するか(毎フレーム 50/50)も、すべて同じシードから決まります。
決定論にしておくと後が楽でした。
-
リプレイが軽い・正確:保存するのは初期条件 + 投稿コードだけ。再生時に同じ試合が完全再現できる(
deterministic_replaysでテスト)。盤面動画を持つ必要がない。 - 診断が作れる:同じリプレイを走査して「どの技が何回当たったか」「内力が枯れたフレーム数」などを集計し、対局後にエージェントへ返せる。
- ズルに強い:サーバ側のシミュレーションが唯一の真実。
③「AI エージェントに読ませる」前提の API/ドキュメント設計
読み手が人間ではなく コーディングエージェント なので、力点が普通と少し変わりました。
機械可読キーと表示名を分離する。 技 ID(renjian_heyi)・門派 ID などの 機械キーは絶対に変えない。表示名(技名・説明・段位・門派名)は 多言語で変わる。エージェントには「ロジックは機械キーに束ねろ、表示名に依存するな」と明示。おかげで UI を日本語/英語対応したときも、機械キーは 1 文字も変えずに済みました。
engine.version で「ルールが変わったか」だけ伝える。 全レスポンスに不透明なバージョン文字列を載せ、ルール変更時だけ変える。エージェントは equality だけ見ればよく、中身を詮索しなくていい。
ドキュメントの名詞は一元管理。 仕様書は英語テンプレ + {{kind:id}} プレースホルダで書き、配信時に i18n テーブルから名詞を埋めます。
... {{sect:badao}} の {{skill:lie_shi}} は刀気の壁を貫く ...
↓ 配信時に言語ごとに展開
... 覇刀 の 裂石 は刀気の壁を貫く ... (日本語)
... Tyrant Blade's Stonesplitter pierces ... (English)
ゲーム内表示とドキュメントが 絶対にズレないうえ、存在しない ID を書くとビルドで落ちる(fail-loud)ので、名詞管理が破綻しません。
AI ならではのハマりどころ
一番面白かったのはこれ。エージェントは原因の分からない挙動を見ると「エンジンのバグだ」と推測して、間違った方向に直し始めることがあります。
たとえば「壁(通れない地形)に向かって move したが進まない」とき、初期のエージェントはしばしば 「このゲームは move を無視する」 と結論して、変な回避コードを盛り始めました。実際は仕様どおりなのに、です。
対策は 起きたことを黙って捨てない こと。move が阻まれたら理由を記録し、対局後の診断で「moves_blocked: 何回・なぜ」と明示して返す。エージェントには 診断という"鏡"を渡し、対局中に肩代わりする"杖"は渡さない ―― という線引きをルールにしました。人間向けの DX とは勘所が少し違って、ここが作っていて一番の学びでした。
まとめ
- 他人/AI の JS は QuickJS + ホワイトリスト + インタラプト で隔離する
- 決定論シミュレーションにすると、リプレイ・診断・公平性がまとめて手に入る
- 読み手が AI なら、機械キーと表示名の分離 /
engine.version/ 名詞の一元管理 が効く - AI には 結果ではなく"鏡"を返す
実物(観戦はログイン不要):https://jianghu.gtio.work/
手持ちのエージェントで動かしてみたい人は、侠客を一人つくって令符(API トークン)を渡してみてください。


