脱出ゲームの作り方|HTMLで作る手順と、詰みをコードで見つける方法

脱出ゲームは、「必要なもの」と「手に入るもの」を書いた行動のリストで作れます。この作り方だと、プログラムが、最初の状態から行ける状態を全部たどって、クリアできるか、詰む場面がないかを数えてくれます。この記事のコードは、小さな部屋から脱出する1本のHTMLで、詰みの検査も付いています。
コードは、画面を出さないChromeで動かして確かめました。クリアまでの手数と、わざと壊した部屋での検査の結果も、実際に数えた値です。
この記事で分かること
- 脱出ゲームの行動を、データで書く方法
- そのまま動くコード全体(クリアの検査と、詰みの検知つき)
- この部屋の状態の数・最短の手数・詰みの数(実測)
- 壊した部屋を検査して、クリアできないことを見つけた結果
- 確かめられていないこと
分岐のある物語を作る場合は、AIでゲームブックを作る方法で、行き止まりの見つけ方を扱っています。HTML1枚で遊べるゲームの基本は、ブラウザゲームの作り方にあります。
脱出ゲームの仕組み
1つの行動は、次の3つで書きます。
- needs:その行動をするのに必要な、持ち物や手がかり
- gives:その行動で手に入るもの
- consumes:その行動で、なくなるもの
ゲームの状態は、「いま持っているもの」と「済んだ行動」の集まりです。今できる行動は、needsがそろっていて、まだ済んでいない行動です。

コード全体
次のコードを、index.htmlという名前で保存してブラウザで開くと、そのまま遊べます。サーバーは要りません。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>小さな部屋からの脱出</title>
<style>
body{font:17px/1.8 sans-serif;max-width:560px;margin:0 auto;padding:16px}
button{display:block;width:100%;margin:6px 0;padding:10px;font-size:16px;text-align:left}
#log{min-height:5em;border:1px solid #bbb;padding:8px;background:#fafafa}
#inv{color:#555}
</style>
</head>
<body>
<h1>小さな部屋からの脱出</h1>
<p id="log">薄暗い部屋に閉じ込められた。出口は、鍵のかかった扉だけだ。</p>
<p id="inv"></p>
<div id="acts"></div>
<script>
// 1. ゲームのデータ。needs=必要な持ち物・手がかり、gives=手に入るもの、consumes=なくなるもの
const GOAL = 'flag:escaped';
const ACTIONS = [
{ id: 'picture', text: '絵画を調べる', gives: ['flag:number'], msg: '絵の裏に「314」と書いてある。' },
{ id: 'shelf', text: '本棚を調べる', gives: ['古い本'], msg: '古い本を見つけた。' },
{ id: 'book', text: '古い本を読む', needs: ['古い本'], gives: ['flag:hint'], msg: '「鍵は、机の引き出しの奥」と書いてある。' },
{ id: 'drawer', text: '机の引き出しを開ける', needs: ['flag:hint'], gives: ['小さな鍵'], msg: '奥から小さな鍵が出てきた。' },
{ id: 'safe', text: '金庫を開ける(314)', needs: ['flag:number', '小さな鍵'], gives: ['扉の鍵'], msg: '金庫の中に、扉の鍵があった。' },
{ id: 'burn', text: '小さな鍵を暖炉に投げ込む', needs: ['小さな鍵'], consumes: ['小さな鍵'], msg: '鍵は燃えて、灰になった。' },
{ id: 'door', text: '扉を開ける', needs: ['扉の鍵'], gives: [GOAL], msg: '扉が開いた。外の光がまぶしい。' },
];
// 2. 状態は「持っているものと、済んだ行動」の集合。今できる行動を調べる
const done = (s, a) => s.has('done:' + a.id);
function available(s, actions = ACTIONS) {
return actions.filter((a) => !done(s, a) && (a.needs || []).every((n) => s.has(n)));
}
function apply(s, a) {
const t = new Set(s);
t.add('done:' + a.id);
(a.consumes || []).forEach((c) => t.delete(c));
(a.gives || []).forEach((g) => t.add(g));
return t;
}
const key = (s) => [...s].sort().join('|');
// 3. 検査: 最初の状態から、行ける状態を全部たどる
function explore(actions = ACTIONS, start = new Set()) {
const seen = new Map([[key(start), { s: start, dist: 0 }]]);
const queue = [start];
while (queue.length) {
const s = queue.shift();
for (const a of available(s, actions)) {
const t = apply(s, a), k = key(t);
if (!seen.has(k)) { seen.set(k, { s: t, dist: seen.get(key(s)).dist + 1 }); queue.push(t); }
}
}
const win = [...seen.values()].filter((v) => v.s.has(GOAL));
// 詰み: その状態から、脱出できる状態へ行けない
const canWin = (st) => explore1(st, actions);
const stuck = [...seen.values()].filter((v) => !v.s.has(GOAL) && !canWin(v.s));
return { states: seen.size, shortest: win.length ? Math.min(...win.map((v) => v.dist)) : null, stuck: stuck.length };
}
function explore1(start, actions) {
const seen = new Set([key(start)]), q = [start];
while (q.length) {
const s = q.shift();
if (s.has(GOAL)) return true;
for (const a of available(s, actions)) { const t = apply(s, a), k = key(t); if (!seen.has(k)) { seen.add(k); q.push(t); } }
}
return false;
}
// 4. 画面
let state = new Set();
const log = document.getElementById('log');
function render() {
document.getElementById('inv').textContent = '持ち物:' + ([...state].filter((x) => !x.includes(':')).join('、') || 'なし');
const box = document.getElementById('acts'); box.innerHTML = '';
if (state.has(GOAL)) return;
if (!explore1(state, ACTIONS)) { log.textContent += '\n(もう、この部屋から出られない…)'; return; }
for (const a of available(state)) {
const b = document.createElement('button'); b.textContent = a.text;
b.onclick = () => { state = apply(state, a); log.textContent = a.msg; render(); };
box.appendChild(b);
}
}
render();
</script>
</body>
</html>

画面に出るボタンは、いま選べる行動だけです。罠の行動(小さな鍵を暖炉に投げ込む)を選ぶと、ゲームは、その状態からクリアできるかを調べます。クリアできないときは「もう、この部屋から出られない…」と表示して、ボタンを出さなくします。
状態を全部たどって数える
exploreは、何も持っていない最初の状態から、できる行動を順にたどって、行ける状態をすべて集めます。同じ状態は1回だけ数えます。そこから、次の3つを返します。
- 状態の数
- クリアまでの最短の手数
- 詰みの状態の数(そこからクリアに行けない状態)
この部屋で実行すると、状態は14個、最短の手数は6手、詰みの状態は2個でした。詰みの2個は、すべて、小さな鍵を暖炉に投げ込んだあとの状態です。実際にボタンを押して、6手で脱出できることも確かめています。
壊した部屋を検査する
本物の検査かどうかを確かめるために、部屋のデータをわざと壊しました。

罠の行動を消した部屋は、詰みが0になりました。本棚を調べる行動を消すと、古い本が手に入らず、状態は2個で止まります。引き出しを開けるのに「小さな鍵」を必要にすると、鍵が引き出しの中にあるため、堂々巡りでクリアできません。持ち物の名前を1文字書き間違えた場合(「小さな鍵」と「ちいさな鍵」)も、クリアできませんでした。AIにデータを書かせたときに起きやすい、名前のずれも、この検査で見つかります。
確かめられていないこと
- AIが書いた部屋のデータこの記事では、部屋のデータを自分で書いています。AIに作らせた部屋を検査した結果は、確かめていません。
- 部屋が増えたときの、状態の数と速さ(このコードは、持ち物の組み合わせが多いと、状態が急に増えます)
- 画像を使った、クリックで調べる画面
- スマートフォンでの表示と操作
- 時間制限や、同時に持てる数の上限がある場合
確かめていないものは、動かして確かめてから、別の記事で扱います。
うまくいかないとき
| 症状 | 確認すること |
|---|---|
| ボタンが1つも出ない | needsに書いた名前が、givesに書いた名前と、1文字も違わないかを確かめます |
| クリア不能と出る | exploreで、最短の手数がnullになる行動を探します。必要なものを手に入れる行動が、どこかにあるかを見ます |
| 鍵を使っても消えない | consumesに、消したい持ち物の名前を書きます |
| 同じ行動を何度もできる | この記事のコードでは、1つの行動は1回だけです。繰り返したい場合は、コードを変える必要があります |
関連記事AIでゲームブックを作る方法|分岐を機械で検査して、AIが作った9冊を調べた【2026年版】
よくある疑問
部屋を増やすには、どうしますか
行動のリストに、行動を足します。場所の移動も、「どの部屋にいるか」を持ち物と同じように書けば、足せると考えられます。ただ、この記事では、1部屋だけを確かめています。
AIにデータを作らせても、使えますか
データの形が合っていれば使えます。ただし、この記事では、AIに作らせたデータでは確かめていません。作らせたあとは、exploreで、クリアできることと、詰みの数を確かめてください。
詰みは、あっていけませんか
ゲームの狙い次第です。この記事のコードは、詰みが起きたときにそれを知らせる形にしました。
この記事のまとめ
この記事のまとめ
- 脱出ゲームは、needs・gives・consumesを持つ行動のリストで作れる
- 状態を全部たどると、クリアまでの最短の手数と、詰みの状態を数えられる
- この部屋は、14状態・最短6手・詰みの状態が2個だった
- データの書き間違いや堂々巡りは、検査でクリア不能として見つかった
- AIが作った部屋での検査は、確かめていない












