跳到主内容
快讯直播
AI智模界
教程

复现 RSIGame:搭一个会自己试玩迭代的游戏智能体

这套东西跑起来后,你只输入一句话,比如"做一个打砖块游戏",程序会自己拆需求、写代码、开浏览器试玩、打分、找问题、改代码,循环若干轮,最后产出一个可玩的单文件游戏和一份评分报告。

这篇能做出什么

一条四段闭环流水线:

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 换成对应领域的版本即可。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。