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回の入力で最大3回まで)。止められた呼び出しのトークンも費用に数える。
- 会話 AI だけ、
HARM_CATEGORY_SEXUALLY_EXPLICITをBLOCK_ONLY_HIGHにする(判定 AI はそのまま)。 - それでも止められたとき、写真の回は「えっと……これで見える?」のような写真に合う短い文にする。
プレイヤーの入力は、先に判定 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話は登録なしで遊べます。よければ遊んでみてください。