LLM 只写 JSON 房间规格:程序化地牢的可玩性,交给代码判
LLM 可以当地牢设计师——前提是你不让它画地图,只让它交一份 JSON 房间规格:一张瓦片网格,加上玩家出生点、敌人、终点这些实体。能不能玩,由你自己的代码判:边界、危险物连排、有没有一条走得通的路;判过了,Phaser 再把这份规格画成可玩的房间。
下面这个 demo 不需要任何 API key。下面这篇把三件事讲清楚:JSON 契约长什么样、拒绝坏规格的校验器怎么写、Phaser 场景怎么把「通过校验的规格」变成网格地牢。全部跑在一个 HTML 文件里,内置一份样例顶替模型,所以不做任何配置就能玩。
关于这次实现
这次实现是一个 HTML 文件。它从 jsDelivr 的 CDN 加载 Phaser 3.90.0(https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.min.js),用纯 JavaScript 校验房间规格,然后画出一个网格地牢:方向键移动、两个巡逻敌人、一个终点瓦片、胜利与失败状态,外加一个「重新生成」按钮。v3.90 的发布公告说 3.90「应当是任何 v3.88 游戏的直接替换」,API 参考是 3.90.0 的文档。那些文档同时注明 3.90 已不再维护,所以要钉住你测过的那个版本。
样例 LLM 响应是模拟的。作者手写,存成页面里的一个 JSON 字符串——它不是线上模型的输出,没有调用任何模型,也不需要 API key。规格从不离开浏览器:页面唯一一次网络请求是为了加载 Phaser 脚本。
这篇不报告试玩、设备或浏览器测试矩阵、性能测量,也不给真实模型输出的基准。作者没有测过真实 LLM 产出畸形或不可达房间的频率,所以这篇说不出任何特定模型的失败率。校验器是一张你可以自己测的安全网,不是关于某个模型的证据。
概念:LLM 当地牢设计师
程序化生成常产出重复的布局。VibeGames.Ninja 那篇 LLM 关卡设计指南正是从这个痛点出发:「用程序化方式生成关卡,往往导致布局重复。」它建议的做法是把游戏的解析器和 LLM 输出的结构化 JSON 配对——模型提议一张地图,你的代码决定游戏能不能用它。这个分工就是本文的核心:模型是提议的设计师,你的代码是守门人。
让模型吐出 JSON 是第一道坎。只靠一句「请输出 JSON」的提示,什么也保证不了。DevToolLab 那篇讲结构化输出的指南把纯提示词 JSON 描述为「没有保证;复杂度上升、模型变小时失败率随之上升」。JSON 模式能拿到可解析的输出,但同一篇也警告:「它解析得了,但模型仍会漏掉必填字段、凭空造新字段,或返回错误的类型。」
模式约束生成(schema-constrained generation) 是更强的那件工具。你把一份 JSON Schema 交给 API,响应的形状就有保证。这解决了「形状」问题,没解决「真值」问题,那篇指南说得很直白:「约束解码保证的是形状,不是真相。」一个房间可以完美匹配 schema,同时把终点砌在墙后面。你的游戏需要自己的检查来抓这件事。
这个 demo 里的样例就是一份「模式约束响应」的替身。之后的一切都把它当不可信输入处理——这对任何模型输出都是合理的默认值。
房间规格的 schema
契约刻意做得小:
gridWidth与gridHeight:8 到 24 的整数。这个范围是这个 demo 自己的设计选择,不是任何来源给出的规则。tiles:恰好gridHeight行,每行恰好gridWidth个整数。0是空地,1是砖块,2是危险物。这与 VibeGames.Ninja 指南里的地图形状一致。entities:对象列表,每个对象有type、x、y。type必须是player_start、enemy_patrol或goal。一份合法规格恰好有一个player_start、恰好一个goal,enemy_patrol零个或多个。
顶层只有 gridWidth、gridHeight、tiles、entities 四个键;实体只有 type、x、y 三个键。不许别的。
schema 保持扁平有个很实际的理由。DevToolLab 那篇提到,Gemini 风格的 schema 支持「只接受 JSON Schema 的一个子集,且可能拒绝非常大或嵌套很深的 schema,所以要让结构保持扁平」。四个顶层键、每个实体三个键、一个二维数组,即使你从不用 Gemini,这也是个合理的默认值。
下面是一份模式约束的 LLM 调用可能返回的响应。再次说明,这一份是模拟的:作者手写,没有模型产出它。页面把它存成常量 SAMPLE_LLM_RESPONSE(一个 JSON 字符串),启动时加载。
{
"gridWidth": 16,
"gridHeight": 12,
"tiles": [
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1],
[1,1,1,1,1,1,0,1,1,1,0,1,1,1,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1],
[1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1],
[1,0,0,1,1,1,0,1,0,0,0,0,0,0,0,1],
[1,0,0,0,1,0,0,0,0,2,2,2,0,0,0,1],
[1,0,1,1,1,1,0,0,1,0,1,0,0,0,0,1],
[1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1],
[1,0,0,1,0,0,1,0,0,0,0,0,0,0,0,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
],
"entities": [
{ "type": "player_start", "x": 1, "y": 1 },
{ "type": "enemy_patrol", "x": 4, "y": 3 },
{ "type": "enemy_patrol", "x": 10, "y": 9 },
{ "type": "goal", "x": 14, "y": 10 }
]
}
自上而下读这张布局,坐标记作 (x, y),y 从最上面一行往下数。最外圈是砖块。玩家从 (1, 1) 出发,终点在 (14, 10)。第 7 行横着恰好三个危险物。一条走得通的路绕开了所有危险物:沿第 1 行、下到第 6 列、沿第 3 行、下到第 10 列、横穿第 5 行、再由第 14 列往下到终点。两个敌人分别在第 3 行和第 9 行巡逻。
校验器,逐条来
校验器是纯 JavaScript,在浏览器里、在构建任何东西之前运行。它把每一处违规都收进列表而不是遇到第一个就停,所以你一次就能把整份规格改对。它按顺序跑四项检查,最后一步处理失败。
规则 1:不许有未知键。 正如 DevToolLab 指南所说,模型仍会「凭空造新的」,所以校验器拒绝契约之外的任何键——顶层和每个实体上都拒。
Object.keys(spec).forEach((key) => {
if (!TOP_KEYS.includes(key)) errors.push(`Unknown top-level key "${key}".`);
});
规则 2:边界与类型。 在这个 demo 里,尺寸必须是 8 到 24 的整数。这个区间是这个 demo 的设计选择,不是任何来源的规则。每个瓦片必须是 0、1 或 2。每个实体需要位于网格内的整数坐标、一个已知类型,且脚下必须是空地——出生点、终点或敌人绝不该生成在墙里或危险物里。尺寸检查就一个辅助函数:
const inSize = (n) => isInt(n) && n >= MIN_SIZE && n <= MAX_SIZE;
规则 3:危险物连排上限。 VibeGames 那篇引了一条提示规则,要模型「不要挨着放超过 3 个危险物」。校验器两个方向都查:逐行、逐列扫,计数器遇到任何非危险物就归零,连排一到四个就报错一次。下面是行扫描:
for (let y = 0; y < H; y++) {
let run = 0;
for (let x = 0; x < W; x++) {
run = tiles[y][x] === TILE.HAZARD ? run + 1 : 0;
if (run === MAX_HAZARD_RUN + 1) errors.push(`More than ${MAX_HAZARD_RUN} hazards in a row in row ${y}, ending at x=${x}.`);
}
}
规则 4:用广度优先搜索查可达性。 这条对可玩性最重要。检查从 player_start 出发做一次 BFS,走上、下、左、右四个邻格,走到 goal 就算通过。demo 的实现如下:
function reachable(tiles, start, goal) {
const H = tiles.length;
const W = tiles[0].length;
const seen = tiles.map((row) => row.map(() => false));
const queue = [{ x: start.x, y: start.y }];
seen[start.y][start.x] = true;
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
for (let i = 0; i < queue.length; i++) {
const { x, y } = queue[i];
if (x === goal.x && y === goal.y) return true;
for (const [dx, dy] of dirs) {
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
if (seen[ny][nx] || tiles[ny][nx] !== TILE.EMPTY) continue;
seen[ny][nx] = true;
queue.push({ x: nx, y: ny });
}
}
return false;
}
BFS 是普通算法,这个 demo 把它用在普通数组上,没有引用任何来源。这里的设计决定是:危险物也算墙。如果危险物算可通行,检查就可能放行一个「只有踩危险物才走得通」的房间——而在这个游戏里踩上去就是立刻失败。VibeGames 的提示规则只要求「从 player_start 到 goal 有一条合法、不被堵住的路」,这条检查比那句话更严,因为它同样把危险物当障碍。
规则 5:失败就什么都不构建。 任何一项检查失败,校验器返回完整错误列表,场景完全不动。页面在消息区列出这些错误。下面是一条示例消息,不是实测结果:
Spec rejected. Nothing was built. Fix these and try again:
• entities[2] has unknown key "color".
• Expected exactly one goal; found 0.
既然 API 能强制 schema,为什么还要自己校验?DevToolLab 那篇建议无论如何都在自己这边校验一次:「它只花几微秒,却能覆盖 SDK 的怪癖、代理重写,以及那个没有原生支持的模型。」约束解码给你形状,你的校验器决定这个房间到底能不能玩。
能玩的地牢
整个游戏就是一个 HTML 文件。原文页面内嵌了它,可以直接跑;想在自己机器上试,把同一个文件存成 index.html 用浏览器打开即可。原文的可玩 demo 也能直接玩。页面启动时样例规格已经校验通过,所以你可以改完 JSON 再按「用粘贴的规格构建」。
页面用 Phaser 的缩放设置让画布尺寸保持正确。配置是 scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH, width: 1024, height: 768 }。按缩放管理器的文档,FIT 表示画布「自动调整以放进给定的目标区域,同时保持宽高比」,CENTER_BOTH 表示画布「在父容器中水平与垂直都居中」。这里的父容器是 #game 这个 div,所以窗口尺寸变化时画布在它内部做信箱式留白。
场景只消费通过校验的数据。activeSpec 在 validateRoomSpec 通过之后才被赋值,场景在 create() 里读它。场景用 this.add.rectangle 为每个瓦片画一个矩形。单元格尺寸取 floor(min(1024 / W, 768 / H)),这是为了让网格落在 1024×768 的设计尺寸里的一个 demo 选择。每个实体按类型处理:player_start 放玩家,goal 放金色标记,每个 enemy_patrol 从所在行开始朝 +1 方向移动。
操作与状态是这样工作的:
- 方向键让玩家走一格。走进砖块没有任何效果。
- 踩到危险物,本局以「你踩到了危险物」结束。
- 敌人和玩家在同一格,本局结束——不论玩家走进去,还是敌人走进来。
- 走到终点显示「房间已清空!」
- 一个计时器每 600 毫秒驱动一次敌人。每个敌人沿自己那一行移动,碰到砖块或网格边缘就折返。
「重新生成」按钮一直显示在画布上方。它对当前生效的那份规格重新跑解析、校验、构建。因为样例是固定的、也没有调用模型,所以「重新生成」只会重放同一个房间。想换房间就粘贴一份你自己的合法规格,按「用粘贴的规格构建」。粘贴的规格不合法时,页面列出完整错误,屏幕上当前房间保持不变。
这篇讲了什么
- schema 就是契约。 四个顶层键、三种实体、整数瓦片、扁平结构,让输出容易检查。
- 校验器是一条流水线。 它检查未知键、边界与类型、危险物连排、BFS 可达性;要么建出房间,要么列出全部错误。
- Phaser 只从数据渲染。 一个通过校验的对象驱动整个场景,缩放设置负责不同的窗口尺寸。
- 形状不等于真相。 模式约束的输出只保证形状(DevToolLab 那篇),房间能不能玩要由你自己的检查决定。
常见问题
这个 demo 会调用真实 LLM 吗?
不会。页面用的是 SAMPLE_LLM_RESPONSE,一个手写的字符串,充当模式约束响应的替身。不调用任何模型,不要 API key,规格留在浏览器里。接一个真实模型是你自己另外要加的一步,这篇也没有测任何真实模型的行为。
既然 API 保证 schema,为什么还要校验?
那个保证覆盖的是形状,不是真相。DevToolLab 那篇建议你无论如何都在自己这边校验,因为这项检查只花几微秒,却能覆盖 SDK 怪癖、代理重写,以及没有原生支持的模型。哪怕 schema 完美匹配,终点也可能被砌在墙后面——抓住这一点的正是可达性检查。
我能换成自己的规格吗?
可以。把 JSON 粘进输入框,按「用粘贴的规格构建」。在这个 demo 里,规格必须符合契约:8 到 24 的整数尺寸、完整的矩形瓦片矩阵、恰好一个 player_start 和一个 goal、危险物连排不超过三个。这些限制是这个 demo 自己的规则,你的游戏需要不同的规则就去改校验器。路径检查把砖块和危险物都当墙,所以务必留一条通路。规格不通过时会给出完整错误列表,屏幕上当前房间保持不变。
来源
- AI Gaming Dev,《LLM-Written Room Specs for Procedural Dungeon Generation in Phaser》,2026-11-06:https://aigamingdev.com/blog/llm-written-room-specs-for-procedural-dungeon-generation-in-phaser/
- 本文为全文翻译:概念、schema、五条校验规则、Phaser 场景、小节小结与 FAQ 均按原文完整译出,代码块原样保留,仅修正原文中被页面混淆的 CDN 版本号(
phaser@3.90.0) - 原文引用的两份指南:VibeGames.Ninja《LLM level design》、DevToolLab《structured outputs》,文中引语按原文译出
- 原文明确声明:样例是手写的模拟响应,没有调用模型,也不含任何性能或失败率测量——这个边界在译文里原样保留
相关阅读:three.js 成了 AI 生成 3D 的默认输出:四条路,验收还缺一把尺——同样是"AI 出规格、代码出结果",那篇讲的是 3D 场景这条线上的四条路。|拆开 Fable 5.2 的"直出":18 个文件、一次 NaN,换来 Three.js 3D 场景——模型自己写验收、自己读像素找 bug,是同一套"守门人"思路的另一端。|一个换热器 600 个零件、一天做完:Text-to-CAD 别让 AI 画模型——让模型写工具而不是画模型,与本文的 JSON 契约是同一种分工。
在线工具:想要浏览器里看模型、不装软件就把文件拖进去,用 3D Viewer 打开 glb / gltf / stl / obj;工具全表在 Apps。
本文属于「AI × 3D」专题。查看专题全部文章 →