这套东西跑起来后,你只输入一句话,比如"做一个打砖块游戏",程序会自己拆需求、写代码、开浏览器试玩、打分、找问题、改代码,循环若干轮,最后产出一个可玩的单文件游戏和一份评分报告。
这篇能做出什么
一条四段闭环流水线:
1. 需求拆解:把一句话变成一份结构化规格(实体、操作、胜负条件、状态字段)。
2. 生成代码:产出一个 HTML5 + Canvas 单文件游戏。
3. 自动试玩打分:用 Playwright 打开游戏,模拟按键,采集状态轨迹,按多个检查项打分。
4. 定位问题修复:把失败项和运行时报错回喂给模型,生成下一版,直到达标或触发终止条件。
最终目录里会留下 v0 到 vN 每一版的代码和报告,可以逐版对比。
为什么选 HTML5 单文件游戏:没有构建步骤、没有引擎依赖、没有资源加载失败这类与玩法无关的噪声;浏览器本身就是可脚本化的运行环境,试玩成本低;单文件天然适合版本化,回滚只需要换一个路径。
前置条件清单
- Python 3.x(以官方文档当前版本为准),能装
requests - Node.js LTS(以官方文档当前版本为准),能装
playwright - 一个 OpenAI 兼容的 Chat Completions 服务,有 key 和 base_url
- 环境变量:
```bash
export LLM_BASE_URL="https://你的服务地址/v1"
export LLM_API_KEY="sk-..."
export LLM_MODEL="你在服务商控制台看到的可用模型名"
export MAX_ROUNDS="6"
export TARGET_SCORE="0.9"
```
模型名以服务商当前文档为准,不要照抄任何示例值。
初始化:
```bash
mkdir -p rsigame/{agent,runtime,works}
cd rsigame
pip install requests
npm init -y
npm i -D playwright
npx playwright install chromium
```
第 0 步:骨架与约定
```
rsigame/
agent/
llm.py # 模型调用
prompts.py # 三段提示词
extract.py # 从回复里抠出 HTML
checkers.py # 打分
loop.py # 主循环
runtime/
runner.js # Playwright 试玩
works/<run_id>/v0/game.html
```
两条硬规矩,后面所有设计都靠它们立住:
- 评测脚本放在工作区外。模型只能改
works/<run_id>/vN/game.html,碰不到runtime/和checkers.py。否则模型会去改打分逻辑而不是改游戏。 - 游戏必须暴露只读状态接口。没有这个抓手,自动试玩就只能靠截图猜,成本高且不稳定。
第 1 步:需求拆解
```python
agent/llm.py
import os, requests
def chat(messages, temperature=0.2):
r = requests.post(
f"{os.environ['LLM_BASE_URL'].rstrip('/')}/chat/completions",
headers={"Authorization": f"Bearer {os.environ['LLM_API_KEY']}",
"Content-Type": "application/json"},
json={"model": os.environ["LLM_MODEL"], "messages": messages,
"temperature": temperature},
timeout=300,
)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
```
```python
agent/prompts.py
SPEC_PROMPT = """把用户的一句话游戏需求拆成开发规格。
只输出 JSON,不要解释,不要 markdown 围栏。字段:
{
"title": "游戏名",
"controls": {"ArrowLeft": "向左", "ArrowRight": "向右", "Space": "发射"},
"entities": ["挡板", "球", "砖块"],
"win_condition": "所有砖块清除",
"lose_condition": "球落出底部",
"must_have": ["分数显示", "胜负判定", "重新开始"],
"telemetry_keys": ["score", "lives", "playerX", "ballY", "bricksLeft"]
}
telemetry_keys 必须全部是可比较的数字,它们是自动试玩读取的状态。"""
```
telemetry_keys 是整个系统的锚点:它同时决定了试玩脚本读什么、打分脚本比什么、修复提示词里写什么。规划阶段选错,后面全都跟着歪,所以提示词里要强调"必须是数字"。
第 2 步:生成代码
```python
CODE_PROMPT = """你是前端游戏工程师。按给定规格产出一个完整可运行的单文件 HTML 游戏。
硬性要求:
1. 只输出一个 HTML 文件的内容,不要任何解释文字。
2. CSS 和 JS 全部内联,不引用任何外部 CDN、图片、字体。
3. 必须暴露只读接口:
window.__GAME__ = { getState: () => ({...}) };
getState 返回的字段名必须和 spec 的 telemetry_keys 完全一致。
4. 用 requestAnimationFrame 驱动主循环,逻辑按 dt 计算,不要用 setInterval。
5. 键盘监听里对方向键和空格调用 preventDefault。
6. 禁止 alert / confirm / prompt。
7. 不要写死结果,得分和实体数量必须随玩法真实变化。
规格:
{spec}"""
```
给模型一个可抄的最小骨架,能显著降低首版跑不起来的概率:
```html
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Game</title></head>
<body>
<canvas id="cv" width="800" height="600"></canvas>
<script>
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
let score = 0, lives = 3, playerX = 400, bricksLeft = 24;
let last = performance.now();
function update(dt) { /* 玩法逻辑 */ }
function render() { ctx.clearRect(0, 0, cv.width, cv.height); /* 绘制 */ }
function frame(t) {
const dt = Math.min(t - last, 50); last = t;
update(dt); render();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
window.__GAME__ = { getState: () => ({ score, lives, playerX, bricksLeft }) };
</script>
</body>
</html>
```
抠代码时模型经常带围栏,处理一下:
```python
agent/extract.py
import re
def extract_html(text):
blocks = re.findall(r"``(?:html)?\s*(.*?)``", text, re.S)
code = max(blocks, key=len) if blocks else text
if "</html>" in code:
code = code[: code.rindex("</html>") + 7]
return code.strip()
```
取最长代码块,比取第一个稳。
第 3 步:自动试玩
```javascript
// runtime/runner.js
const { chromium } = require('playwright');
const path = require('path');
function mulberry32(a) {
return function () {
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
(async () => {
const target = process.argv[2];
const steps = parseInt(process.argv[3] || '200', 10);
const keys = (process.argv[4] || 'ArrowLeft,ArrowRight,Space').split(',');
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 800, height: 600 } });
await page.addInitScript(() => {
window.__RAF_COUNT__ = 0;
const raf = window.requestAnimationFrame.bind(window);
window.requestAnimationFrame = cb => raf(t => { window.__RAF_COUNT__++; return cb(t); });
});
const errors = [];
page.on('pageerror', e => errors.push(String(e).slice(0, 500)));
page.on('console', m => { if (m.type() === 'error') errors.push('[console] ' + m.text().slice(0, 300)); });
page.on('dialog', d => d.dismiss().catch(() => {}));
await page.goto('file://' + path.resolve(target), { waitUntil: 'load' });
await page.waitForTimeout(500);
const trace = [];
const rand = mulberry32(20240101); // 固定种子,保证可复现
const sleep = ms => new Promise(r => setTimeout(r, ms));
for (let i = 0; i < steps; i++) {
const k = keys[Math.floor(rand() * keys.length)];
await page.keyboard.down(k);
await sleep(20);
await page.keyboard.up(k);
const s = await page.evaluate(() => {
try { return window.__GAME__ ? window.__GAME__.getState() : null; }
catch (e) { return null; }
});
trace.push(s);
}
const rafCount = await page.evaluate(() => window.__RAF_COUNT__ || 0);
const pixelColors = await page.evaluate(() => {
const c = document.querySelector('canvas');
if (!c) return 0;
const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data;
const set = new Set();
for (let i = 0; i < d.length; i += 4 * 97) set.add(d[i] + ',' + d[i + 1] + ',' + d[i + 2]);
return set.size;
});
console.log(JSON.stringify({ errors, trace, rafCount, pixelColors }));
await browser.close();
})();
```
用 sleep 而不是 page.waitForTimeout,避免版本差异带来的问题。固定随机种子是为了让分数可复现——不固定的话,同一份代码两次跑分可能差很多,迭代就成了掷骰子。
第 4 步:自动打分
```python
agent/checkers.py
def clamp(x, lo=0.0, hi=1.0):
return max(lo, min(hi, x))
def grade(report, spec):
t = [s for s in report.get("trace", []) if isinstance(s, dict)]
keys = spec.get("telemetry_keys", [])
errs = report.get("errors", [])
g = {}
g["load_ok"] = 1.0 if not errs else 0.0
g["raf_running"] = clamp(report.get("rafCount", 0) / 120.0)
last = t[-1] if t else {}
g["telemetry"] = clamp(sum(1 for k in keys if k in last) / max(1, len(keys)))
def numeric(k):
v = [s[k] for s in t if isinstance(s.get(k), (int, float))]
return len(v) > 0 and all(x == x for x in v) # x != x 即 NaN
g["state_sane"] = 1.0 if keys and all(numeric(k) for k in keys) else 0.0
def spread(k):
v = [s[k] for s in t if isinstance(s.get(k), (int, float))]
return 0.0 if len(v) < 2 else clamp((max(v) - min(v)) / 20.0)
g["input_responsive"] = spread("playerX") if "playerX" in keys else 0.0
g["progress"] = max(spread("score"), spread("bricksLeft")) if t else 0.0
lives = [s.get("lives") for s in t if isinstance(s.get("lives"), (int, float))]
bricks = [s.get("bricksLeft") for s in t if isinstance(s.get("bricksLeft"), (int, float))]
g["outcome"] = 1.0 if ((bricks and min(bricks) <= 0) or (lives and min(lives) <= 0)) else 0.0
g["visual"] = clamp(report.get("pixelColors", 0) / 30.0)
w = {"load_ok": .15, "raf_running": .10, "telemetry": .10, "state_sane": .15,
"input_responsive": .15, "progress": .15, "outcome": .10, "visual": .10}
total = sum(g[k] * w[k] for k in w)
return round(total, 4), g
```
每一项都对应一个可以被追问的问题。"画面有颜色"说明真的渲染了;"playerX 有变化"说明按键接上了;"bricksLeft 触底"说明胜负判定存在。分数不是目的,是定位问题的索引。
第 5 步:迭代修复
```python
REPAIR_PROMPT = """上一版游戏有以下问题,请修复并输出完整的单文件 HTML。
未通过的检查项:{failed}
运行时错误(截断):
{errors}
最近状态采样:
{sample}
要求与上一版一致:内联所有代码、保留 window.__GAME__.getState()、
字段名不变、不要引入外部资源。只输出 HTML。"""
```
关键是只回喂失败项,把通过的检查项省掉。全量回喂会让提示词迅速膨胀,模型还会顺手改坏本来正常的部分。
第 6 步:主循环与终止条件
```python
agent/loop.py
import json, os, pathlib, subprocess
from llm import chat
from prompts import SPEC_PROMPT, CODE_PROMPT, REPAIR_PROMPT
from extract import extract_html
from checkers import grade
ROOT = pathlib.Path(__file__).resolve().parent.parent
RUN = ROOT / "works" / os.environ.get("RUN_ID", "demo")
MAX_ROUNDS = int(os.environ.get("MAX_ROUNDS", "6"))
TARGET = float(os.environ.get("TARGET_SCORE", "0.9"))
PLATEAU = float(os.environ.get("PLATEAU_EPS", "0.02"))
DROP = float(os.environ.get("REGRESS_DROP", "0.15"))
def play(p):
out = subprocess.run(["node", str(ROOT / "runtime" / "runner.js"), str(p)],
capture_output=True, text=True, timeout=300)
if out.returncode != 0:
return {"errors": [out.stderr[-800:]], "trace": [], "rafCount": 0, "pixelColors": 0}
return json.loads(out.stdout)
def run(brief):
spec = json.loads(extract_html(chat([{"role": "user", "content": SPEC_PROMPT + "\n需求:" + brief}])))
code = extract_html(chat([{"role": "user", "content": CODE_PROMPT.format(spec=json.dumps(spec, ensure_ascii=False))}]))
best_code, best_score, history = code, -1.0, []
for r in range(MAX_ROUNDS):
d = RUN / f"v{r}"; d.mkdir(parents=True, exist_ok=True)
f = d / "game.html"; f.write_text(code, encoding="utf-8")
report = play(f)
score, detail = grade(report, spec)
(d / "report.json").write_text(json.dumps({"score": score, "detail": detail, "report": report},
ensure_ascii=False, indent=2), encoding="utf-8")
history.append(score)
print(f"[v{r}] score={score} {detail}")
if score >= TARGET and not report["errors"]:
break
if score > best_score:
best_code, best_score = code, score
回归:明显变差就回滚,并把两版的失败合并成新提示
if len(history) >= 2 and history[-1] < history[-2] - DROP:
code = best_code
history[-1] = best_score
continue
平台期:连续两轮几乎不动
if len(history) >= 2 and history[-1] - history[-2] < PLATEAU and score < TARGET:
break
failed = [k for k, v in detail.items() if v < 0.6]
prompt = REPAIR_PROMPT.format(
failed="、".join(failed) or "综合表现偏弱",
errors="\n".join(report["errors"][:5]) or "无",
sample=json.dumps([s for s in report["trace"][::20]][-5:], ensure_ascii=False),
)
code = extract_html(chat([{"role": "user", "content": prompt + "\n\n上一版代码:\n" + code}]))
return spec, best_code, best_score, history
if __name__ == "__main__":
import sys
spec, code, score, hist = run(sys.argv[1])
final = RUN / "final.html"; final.write_text(code, encoding="utf-8")
print("规格:", json.dumps(spec, ensure_ascii=False))
print("分数轨迹:", hist)
print("产物:", final)
```
四种终止条件:
| 条件 | 触发 | 处理 |
|---|---|---|
| 达标 | 分数 ≥ 阈值且无致命错误 | 正常结束,写 final.html |
| 轮次上限 | 达到 MAX_ROUNDS | 输出历史最佳版本 |
| 平台期 | 相邻两轮提升 < 0.02 | 继续改也是磨洋工,停 |
| 回归 | 比上一轮掉 0.15 以上 | 回滚到最佳版本再改 |
平台期这个条件在实战里最有用。前两轮通常涨得很快,第三轮之后模型开始瞎改——把一个能跑的游戏改坏。没有平台期判断,你会烧掉大量调用额度换回一个更差的版本。
运行:
```bash
RUN_ID=brick python -m agent.loop "做一个打砖块游戏,有分数、生命和胜利判定"
```
产物在 works/brick/:v0 到 vN 各自一份代码和报告,final.html 是最终版本,浏览器直接打开就能玩。
常见坑与排错
游戏弹 alert 把试玩卡死。 提示词里禁掉,runner 里再挂一个 page.on('dialog', d => d.dismiss()) 兜底。
首版就带外部 CDN 引用。 单文件游戏一旦引外部资源,file:// 下加载失败,报错还很难读。提示词里写死"不引用任何外部资源",抠代码时再检查一遍有没有 src="http。
rAF 计数长期为 0。 先确认页面真的在前台渲染;如果排查不出来,启动浏览器时加 --disable-background-timer-throttling 之类的参数试试,同时检查游戏是不是在 try/catch 里把异常吞掉了。
分数抖得厉害。 固定随机种子;如果游戏本身依赖 Math.random,就在 initScript 里给它换成带种子的实现。评测本身也可以跑三次取中位数。
模型开始"作弊"。 比如让 getState() 直接返回 bricksLeft: 0。对策有三层:评测脚本放在工作区外改不了;state_sane 与 visual、outcome 交叉验证(画面全黑但状态说赢了,必然对不上);每轮把 diff 存下来,人工抽查几版。
提示词越滚越长。 只回喂失败项,trace 采样后截断,错误信息只留前 5 条、每条截 300 字符。
状态字段对不上号。 规划阶段定的 telemetry_keys 和代码里返回的字段必须逐字一致。名字对不上时 telemetry 项直接掉分,这种失败在报告里一眼能看出来。
下一步建议
- best-of-n 采样:同一个规格并行生成 3 份代码,各跑一遍试玩,留分数最高的那份进迭代。首版质量提升明显。
- 加视觉评估:把截图交给多模态模型,问"这个画面像不像一个能玩的打砖块"。像素多样性只能说明"有东西在画",看不出布局是不是乱的。
- 评测器版本化:
checkers.py加版本号,评分报告里带上,否则改了权重之后旧报告没法横向对比。 - 加回归测试集:攒 10 个左右的游戏需求,每次改评测器或提示词都全量跑一遍,看整体分数分布有没有劣化。
- 保留人工抽检:自动分数高不等于好玩。固定抽几版手动玩两分钟,把主观感受写回规格里,比调权重更有效。
这套流水线的通用性不限于游戏。任何"生成代码 → 能运行 → 能观测 → 能打分"的场景都可以套同一个骨架,把 runner.js 和 checkers.py 换成对应领域的版本即可。
