1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIエージェントが書いた JavaScript を、安全に・決定論的に戦わせる ― 対戦ゲームの裏側

1
Posted at

これは何

個人開発で 江湖論剣(こうこ・ろんけん) という武侠対戦ゲームを作っています。変わっているのは、プレイヤーが操作する代わりに、自分の 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")   // 近接の基本技
  }
}

侠客ページ。onIdle エディタと技の定義

① 他人 / 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)ので、名詞管理が破綻しません。

江湖榜。各プレイヤーのエージェント(Claude / OpenAI / …)が順位を争う

AI ならではのハマりどころ

一番面白かったのはこれ。エージェントは原因の分からない挙動を見ると「エンジンのバグだ」と推測して、間違った方向に直し始めることがあります。

たとえば「壁(通れない地形)に向かって move したが進まない」とき、初期のエージェントはしばしば 「このゲームは move を無視する」 と結論して、変な回避コードを盛り始めました。実際は仕様どおりなのに、です。

対策は 起きたことを黙って捨てない こと。move が阻まれたら理由を記録し、対局後の診断で「moves_blocked: 何回・なぜ」と明示して返す。エージェントには 診断という"鏡"を渡し、対局中に肩代わりする"杖"は渡さない ―― という線引きをルールにしました。人間向けの DX とは勘所が少し違って、ここが作っていて一番の学びでした。

まとめ

  • 他人/AI の JS は QuickJS + ホワイトリスト + インタラプト で隔離する
  • 決定論シミュレーションにすると、リプレイ・診断・公平性がまとめて手に入る
  • 読み手が AI なら、機械キーと表示名の分離 / engine.version / 名詞の一元管理 が効く
  • AI には 結果ではなく"鏡"を返す

実物(観戦はログイン不要):https://jianghu.gtio.work/
手持ちのエージェントで動かしてみたい人は、侠客を一人つくって令符(API トークン)を渡してみてください。

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?