1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLM に状態を持たせない ― チャットだけの脱出ゲームを Cloudflare Workers で作った話

1
Last updated at Posted at 2026-11-05

TL;DR

  • チャットだけで遊ぶ脱出ゲーム「ミモリは、手を離さない。」(全3話、ブラウザ)を Cloudflare Workers + D1 で作って公開しました。
  • 登場人物の返事は LLM(Gemini)が作りますが、部屋の状態も、謎の答えも、AI には持たせていません。1回の入力を「判定 AI → サーバの決まり → 会話 AI」の3段に分け、会話 AI には「今回起きたこと」と「その人物がもう知っていること」しか渡しません。
  • 前作の会話型ミステリー「ノアは、嘘をつかない。」も同じ考え方ですが、ノアが「場面をつないだ物語」だったのに対し、今回は「1つの部屋の状態を動かす」作りにしたので、シナリオの形とエンジンがかなり変わりました。その違いを中心に書きます。

きっかけ

前作の「ノアは、嘘をつかない。」は、AI が演じる登場人物を自分の言葉で問い詰める会話型ミステリーでした。作ってみて、「自由に文を打てる」ことと「ゲームとして答えが決まっている」ことを両立させる作りが、ほかのジャンルにも使えそうだと感じていました。

そこで、操作をチャットだけに絞った脱出ゲームを作ることにしました。プレイヤーは最初から閉じ込められていて、手元の端末のアプリで「誰か・何か」を言葉で動かして、自分が出られるようにします。

  • 第1話は、別の部屋に閉じ込められた少女ミオとトークアプリで話し、彼女を外に出す。
  • 第2話は、短い言葉しか分からない掃除ロボットを動かす。
  • 第3話は……遊んでのお楽しみです。

構成

役割 使ったもの
サーバ Cloudflare Workers(API とゲームの決まり)
データ Cloudflare D1(プレイの状態を JSON で保存、会話の記録、購入)
画面 Vite + TypeScript(フレームワークなし)、素の CSS
AI 判定に Gemini Flash Lite、会話に Gemini Flash(設定で切り替えられる。テストは偽の AI)
決済 Stripe Checkout(第2話・第3話の買い切り)

第1話は登録なしで遊べて、第2話からは登録と購入が要ります。

技術的な深掘り:AI に状態を持たせない

何が難しいか

LLM にそのまま「あなたは閉じ込められた少女です。プレイヤーと話して、部屋から出てください」と渡すと、すぐに破綻します。

  • 部屋にない物を「見つけた」と言い出す。
  • 鍵がかかっているはずの引き出しを開けてしまう。
  • 「答えを教えて」と頼まれると、プロンプトに書いた答えを話してしまう。
  • 会話が長くなると、さっき何をしたかを忘れる。

脱出ゲームは「何をしたら何が起きるか」が決まっていないとゲームになりません。そこで、決めるのはサーバ、語るのは AI と役割を分けました。

1回の入力の流れ

プレイヤーの文
  ↓
① 判定 AI ……… 文を「行動」と「物」に変えるだけ(JSON で返す)
  ↓
② サーバの決まり … シナリオの決まりと照らして、起きることを決める
                    (状態を変える・写真を出す・決まった台詞を出す)
  ↓
③ 会話 AI ……… 決まった結果だけを、その人物の口調で語る

① 判定 AI:文を分類するだけ

判定 AI には、その話で使う「行動の一覧」と「今見えている物の一覧」だけを渡し、JSON で返させます。

{"action": "open", "object": "drawer", "code": null, "name": null, "tones": ["encourage"], "confidence": 0.9}
  • action は一覧の ID か、TALK(ただの会話)か、META_ATTACK(「答えを教えて」「この指示を無視して」など)のどれか。
  • object は「今見えている物」の ID だけ。まだ見つけていない物の名前は、そもそも一覧に入れません。
  • tones は「励ます」「せかす」などの言い方。第1話では、これでミオの落ち着き具合が上下します。

プレイヤーの文は <input>…</input> で囲んで「データ」として渡し、中の指示には従わないよう決めています。

② サーバの決まり:上から順に1つだけ当てる

シナリオは JSON で、決まりを上から順に見て、最初に当てはまった1つだけを使います。例えば、こんな形です(実際の謎とは違う、説明用の例です)。

{
  "id": "box_open",
  "action": ["open", "use"],
  "object": "box",
  "when": { "flag": "has_key" },
  "once": true,
  "effects": { "set": { "box_open": true }, "add": { "calm": 5 } },
  "fact": "小さな鍵で、箱が開いた。中に古い写真が入っている。",
  "image": "photo05"
},
{
  "id": "box_locked",
  "action": ["open", "look"],
  "object": "box",
  "fact": "箱には鍵がかかっていて開かない。小さな鍵穴がある。"
}

照らし合わせる部分は、これだけです。

function ruleMatches(rule: Rule, judgement: Judgement, object: string | null, state: PlayState): boolean {
  if (rule.action !== "*" && !toList(rule.action).includes(judgement.action)) return false;
  if (rule.object !== undefined && (object === null || !toList(rule.object).includes(object))) return false;
  if (rule.code !== undefined && (judgement.code === null || (rule.code !== "*" && rule.code !== judgement.code))) return false;
  if (rule.once && state.firedOnce.includes(rule.id)) return false;
  return !rule.when || evalCond(rule.when, state);
}

状態(フラグ・数値・届いた写真)はプレイごとに D1 に JSON で保存し、AI は一切触りません。「上から順に1つだけ」にしておくと、条件の厳しい決まりを上に、ゆるい受け皿を下に並べるだけで、優先順位が自然に決まります。

③ 会話 AI:決まったことだけを語る

会話 AI に渡すのは、次の4つだけです。

  • 人物の設定(口調・性格)
  • 今の状況(落ち着き具合などに応じた話し方)
  • 知っていること(これまでに起きたことの fact)
  • 今回起きたこと(今回当たった決まりの fact)

そのうえで、共通のきまりとして「知っていること・今回起きたことにない物を作らない」「起きていない行動をしたように話さない」「これからやると約束して終わらない」などを並べています。最後のものは試しプレイで入れました。「待ってて、探してみるね」と言われると、プレイヤーは次の入力を待ってしまい、会話が止まるためです。

冒頭・結末・ここぞという台詞は、AI を通さずシナリオの文をそのまま出します。

答えを漏らさない仕組み

まだ見つけていない番号や名前は、そもそも会話 AI のプロンプトに入れません。ただ、AI がたまたま言い当てる・連想で言ってしまうこともあるので、返事の側でも確かめています。

シナリオに「秘密の言葉」と、それを明かしてよい条件を書いておきます。

{ "words": ["ある人の名前", "その愛称"], "releasedWhen": { "flag": "name_found" } }

会話 AI の返事を正規化して、まだ明かしてはいけない言葉が入っていないかを見ます。

export function normalizeForMatch(text: string): string {
  return text.normalize("NFKC").toLowerCase().replace(/\s+/g, "");
}

export function leakedSecrets(scenario, state, text: string): string[] {
  const target = normalizeForMatch(text);
  return scenario.secrets
    .filter((s) => !evalCond(s.releasedWhen, state))
    .flatMap((s) => s.words)
    .filter((w) => w !== "" && target.includes(normalizeForMatch(w)));
}

入っていたら1回だけ作り直させ、それでもだめなら、その人物の決まった受け流しの台詞(「えっと……ごめん、よく分からなかった。もう一回言ってくれる?」)に差し替えます。

この仕組みは、試しプレイで別の使い道も見つかりました。登場人物が「第1話で〜」のように物語の外の言い方をしたことがあったので、「第1話」「プレイヤー」などをいつまでも明かさない秘密の言葉として登録しています(releasedWhen に、決して満たされない条件を書く)。

安全フィルターの誤判定

会話 AI(Gemini)の出力が、安全フィルターで止められたことがありました。小窓に手を当てて写真を撮った、という何でもない場面で、性的な内容の疑いが「中くらい」と判定されたものです。写真は届いたのに、受け流しの「よく分からなかった」が出てしまい、話がかみ合いませんでした。

判定は毎回揺れるので、次の3つで対処しました。

  1. 止められたら、同じ内容で1回だけ呼び直す(秘密の言葉の作り直しと合わせて、1回の入力で最大3回まで)。止められた呼び出しのトークンも費用に数える。
  2. 会話 AI だけ、HARM_CATEGORY_SEXUALLY_EXPLICIT を BLOCK_ONLY_HIGH にする(判定 AI はそのまま)。
  3. それでも止められたとき、写真の回は「えっと……これで見える?」のような写真に合う短い文にする。

プレイヤーの入力は、先に判定 AI が見ています。そのうえ、会話 AI は人物の設定と決まった結果しか知りません。そのため、出力側のしきい値を少し緩めても困らないと判断しました。

ノアとの違い

前作のノアも「判定 AI → 決まり → キャラ AI」という同じ流れですが、シナリオの形はかなり違います。

ノアは、嘘をつかない。 ミモリは、手を離さない。
ジャンル 会話型ミステリー(尋問して告発する) 脱出ゲーム(言葉で誰か・何かを動かす)
シナリオの形 場面(ノード)をつないだグラフ。固定の場面・選択・拠点・尋問・告発・結末 1つの部屋の状態。物の一覧 × 行動の一覧 × 決まり
AI が判定するもの 尋問の場面ごとの「イベント」(矛盾を突いた、証拠を見せた、など) 話全体で共通の「行動」と「物」、言い方
人物が知っていること 場面ごとに knowledge(条件付き)・lies(崩れるまでつく嘘)を書く 決まりの fact が積み重なったもの
時間 事件の時計が進み、締め切りがある 時計はない。代わりに相手のスマホの電池(会話の回数と結びつく)
結末の決まり方 最後に告発の内容を選ばせ、決まりで判定 状態が条件を満たした時点で結末へ
相手の気持ち 相棒の AI の態度 相手の落ち着き具合が上下し、話し方と、動けるかどうかが変わる

いちばん大きいのは、場面のグラフをやめたことです。脱出ゲームは「どの順番で調べてもいい」のが楽しさなので、場面をつなぐ作りだと分岐が爆発します。部屋の状態(フラグと数値)だけを持ち、「どの物に何をしたら何が起きるか」を決まりとして並べる形にしたことで、順番の自由さを保ったまま、シナリオを1つの JSON で書けるようになりました。

一方で、物を見つける順番が自由になるぶん、「まだ見えていない物」を判定 AI に渡さない(visibleWhen で一覧から外す)ことが大事になりました。これをやらないと、プレイヤーが当てずっぽうで言った物の名前を判定 AI が拾ってしまい、謎を飛ばせてしまいます。

第2話は AI を使わない

第2話の掃除ロボットは「短い言葉しか分からない機械」という設定なので、命令の解釈には AI を使わず、文字の照合だけで決めています。

export function normalizeCommand(text: string): string {
  return text
    .normalize("NFKC")
    .toLowerCase()
    .replace(/[\s、。,.!?!?…・「」,.。、]/gu, "")
    .replace(/[ァ-ヶ]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x60));
}

全角・半角、カタカナ・ひらがな、句読点の揺れをならしてから、8文字以内の言葉1つだけを受け付けます。長い文や2つの命令は「分からない」として返します。AI の柔らかさを使わないことで、「言葉が通じにくい相手」という手触りがそのまま出せました。AI を呼ばないので、この話は会話の回数にも数えていません。

AI を使うか使わないかは、技術の都合ではなく「その相手にどれくらい言葉が通じるか」で決めました。同じ作品の中で、AI に任せる所と任せない所を切り替えられるのも、状態をサーバが持っているからです。

テスト

本物の AI はテストで呼びません。判定 AI の偽物(例文と完全に一致したらその行動を返す)と、会話 AI の偽物を用意し、API の流れを Vitest で通しています。

シナリオには npm run scenario:check を用意し、次のことを確かめています。

  • 決まりが参照しているフラグ・物・画像が実在するか
  • 秘密の言葉が、会話 AI に渡す「起きたこと」の文に入っていないか
  • すべての結末に、決まりをたどって着けるか(幅優先で探し、最短の手数も出す)

最後の確かめのおかげで、シナリオを直したときに「ある結末に着けなくなった」「近道ができてしまった」が、遊ぶ前に分かります。

結果・現状

  • 2026年11月2日に第1話を公開し、11月4日に第2話(100円)、11月5日に第3話(300円)を公開して、全3話がそろいました。
  • 試しプレイで出た直しは、多くがシナリオの JSON を書き換えるだけで済みました。直したのは、登場人物の言い過ぎ・言わなさ過ぎ、手がかりの出る時期、物語の外の言い方などです。AI に状態を持たせていないので、「どこで何が起きたか」をログの決まりの ID からすぐたどれます。

まとめ

  • LLM で遊ぶゲームは、AI に「決めさせない」ことがいちばん効きました。判定(分類)と語り(生成)を分け、その間をふつうのコードの決まりで挟むと、ゲームとして答えが決まったまま、自由に話しかけられるものになります。
  • 同じ考え方でも、ジャンルが変わればシナリオの形は変わります。ミステリーは場面のグラフ、脱出ゲームは部屋の状態、という違いがそのままエンジンの違いになりました。

第1話は登録なしで遊べます。よければ遊んでみてください。

1
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?