Agent Bridge:玩一款你看不见的游戏
让 AI 智能体去试玩一款游戏,最直白的办法就是把游戏「给它看」:截下画布、 把图片交给视觉模型、问它看到了什么、决定一个动作、按键、再截图。这能跑通—— 在「demo 能跑通」的意义上。
而对一款强反应的幸存者类游戏来说,这几乎是能想到的最糟的设计。每一次决策都要 付一张图的代价。上下文不断堆积,直到会话质量崩塌。模型正在从渲染帧里费力还原 引擎本就精确知道的状态——敌人位置、生命值、冷却——而且还原得更差。更要命的是, 等到有什么值得注意的事发生时,它已经发生完了:在截图与按键之间,模拟照样在往前跑。
CandyRush 是一个确定性的固定步长模拟。这个事实让另一种设计成为可能:暂停模拟、 把状态读成 JSON、通过回放系统所用的同一个缓冲区注入输入、再精确推进若干 tick。 结构化文本进,确定性 tick 出。整个回路里没有一个像素。
这层面就是 agent bridge,而且它此刻正跑在生产环境上。
它的形状
整个回路只有四步,而且没有一步需要「看」任何东西:
// 1. 读 —— 最新快照,已经解析好
const s = window.__candyState;
// 2. 决策 —— 对结构化数据做普通算术
const threat = s.enemies.filter(e => e.damageReadyInTicks < 10);
const dir = fleeFrom(threat, s.player);
// 3. 注入 —— 为模拟接下来要消费的那个 tick 提供确定性输入
window.__candyInput({ moveX: dir.x, moveY: dir.y });
// 4. 步进 —— 精确推进 N 个 tick,然后立刻发出新快照
window.__candyStep(6);
await window.__candyWait();
整层面在生产构建中被 #if UNITY_EDITOR || DEVELOPMENT_BUILD 完全编译掉。在开发版
WebGL 构建里,它还额外受页面 URL 的门控——正是这一点让同一份部署能服务两类受众。
今天就从浏览器驱动它
开发通道已经部署且公开。普通 URL 就是正常玩游戏。加一个查询参数就装上 bridge:
| URL | Bridge |
|---|---|
unity.irsik.software/games/candyrushdev/ | 关 —— 正常游玩 |
unity.irsik.software/games/candyrushdev/?agent=1 | 开 |
| 编辑器、桌面播放器 | 始终开 |
?agent=1、?agent=true、光秃秃的 ?agent,或者作为完整路径段的 agent-mode
都能装上它;?agent=0 是显式退出,并且优先级高于路径形式。
这道门存在的理由很朴素。在它之前,每一个加载开发通道来玩的真人,都在为一份完整
状态快照买单——序列化后作为一行 CANDY_STATE 打进 console.log,每六个 tick 一次,
大约每秒十次,产出没有任何人会去读的输出。
有必要把话说明白:这不是安全边界。 bridge 本就不存在于生产构建中,而任何能加载 开发通道的人都能自己把查询参数补上。它只是防止玩家意外获得智能体行为。仅此而已。
一次会话,从冷启动页面开始
写这篇文章时我对着线上部署真跑了一遍。下面每一个值都是实际返回的结果。
// 在世界存在之前就把它钉住。可跨页面重载存活(PlayerPrefs → IndexedDB)。
window.__candyPinSeed(777001);
await window.__candyWait();
// → {"ok":true,"cmd":"pinSeed","seed":777001,"seq":1}
// 从冷启动主菜单直接开跑。没有大厅,没有点击,没有 Play 按钮。
window.__candyStartRun({ characterId: 0 });
await window.__candyWait();
// → {"ok":true,"cmd":"startRun","characterId":0,"arcId":"","status":"loading","seq":2}
// 轮询直到场景落地。
while ((window.__candyState || {}).runState === "Menu") {
await new Promise(r => setTimeout(r, 250));
}
两秒之后,从一个冷菜单出发:
{ "runState": "Paused", "tick": 0, "seed": 777001, "pauseOwners": ["agent"] }
最后这一行值得细读,因为整个设计都浓缩在这一个对象里。这局冻结在第 0 个 tick, 由一个只属于智能体、不属于任何其他人的暂停令牌持有。零个不受控的实时 tick 跑过。 种子正是被要求的那个。到此为止,没有任何一件不是智能体造成的事情发生过。
这个性质并非免费——它是修复项 R3F1,下文关于浏览器实测的段落会讲清它的代价。
API 面
二十个入口点,全部装在 window 上。每一个都是排队命令,而非同步调用。
读状态
遥测| 调用 | 作用 |
|---|---|
__candyState | 是属性,不是函数。 最新快照,已解析。 |
__candyDump() | 请求一份新快照,并同步返回上一份。 |
__candySetMaxEntities(n) | 调整每实体列表的最近 N 上限。默认 15。 |
__candyVfxAudit() | 点名已加载场景中的每一个 VisualEffect。见下文的 bug 追猎。 |
行动
控制| 调用 | 作用 |
|---|---|
__candyInput(...) | 为模拟接下来消费的那个 tick 注入移动。移动是唯一的每 tick 输入——CandyRush 是自动攻击的幸存者游戏,没有开火、冲刺或技能键。 |
__candyLevelUp(action, index) | 解决升级选择面板:Pick、Skip、Reroll、Banish。会报告是否真的生效,所以被充能门控的空操作绝不会被误认为成功。 |
__candyStep(n) | 通过直接驱动 SimulateOneTick() 精确推进 n 个 tick。 |
__candyStepUntil(...) | 在预算内步进,一旦某个条件触发就立刻停下,并报告是哪一个。 |
__candyPause() / __candyResume() | 通过专属的持有者令牌暂停,因而能与游戏内暂停共存而非互相踩踏。 |
__candyStartRun(...) | 从任意阶段无头启动一局,冷启动主菜单也包括在内。 |
__candyCheat(name, ...) | 九个对模拟安全的作弊项,在下一个被模拟 tick 的开头生效。 |
__candyWait(timeoutMs) | 一个 Promise。 等待排队命令的受支持方式。 |
时间旅行
重建| 调用 | 作用 |
|---|---|
__candyPinSeed(seed) | 钉住下一局启动所用的内容种子。可跨页面重载存活。 |
__candyExportReplay() | 导出本局:种子、角色、arc,以及输入、升级和作弊三条流。 |
__candyRewind(k) | 确定性地把模拟恢复到 currentTick - k。 |
__candyLoadReplay(json, ...) | 载入一份导出,或按真实速度观看,或快进以校验。 |
每次发射还会写一行 CANDY_STATE <json> 控制台日志,所以一个只能读控制台、
完全接触不到 window 的智能体,信息依然是充足的。
快照究竟携带什么
这部分决定了整个想法成不成立,而诱惑在于:把引擎内部一股脑倒出来,让智能体自己 去理。bridge 刻意反其道而行。
{
"tick": 12456,
"runState": "InRun",
"pauseOwners": [],
"seed": 3921184017,
"player": { "x": 12.3, "y": -4.1, "hp": 84, "maxHp": 100, "level": 7,
"killsThisRun": 41, "damageDealtLastTick": 12.5,
"abilities": [ { "name": "WoodenWand", "cooldownRemainingTicks": 34, "ready": false } ] },
"enemiesTotal": 137,
"enemiesInContact": 2,
"incomingContactDps": 7.5,
"enemies": [ { "id": 91, "x": 15.0, "y": -2.0, "type": "gumdrop", "hp": 12,
"vx": -0.05, "vy": 0.02, "contactDamage": 5,
"damageReadyInTicks": 34, "marked": false, "behavior": "chaser" } ],
"run": { "segmentIndex": 3, "archetype": "Hunt", "quotaProgress": 2, "quotaTarget": 5,
"gates": [ { "id": 0, "x": 12.5, "y": -30.0, "lockState": "Locked", "leadsTo": "Boss" } ] }
}
这是一份契约,不是一次转储。 每个值都是派生好、可直接被智能体使用的,而不是照搬 引擎字段——因为原始字段会逼着智能体用它看不见的常数去重新推导,而那是一种隐藏耦合: 游戏一做数值调整,它就会无声地崩掉。
有几个取舍值得单独拎出来,因为每一个背后都是一个否则正在等着你的 bug:
enemies[].vx/vy是上一个 tick 上真实发生的位移,已经过流场导向、分离与障碍推挤, 而不是敌人的「意图」。追击者每个 tick 都会重新瞄准玩家,所以拿原始意图外推, 外推出来的就是错的。enemiesTotal与incomingContactDps是全群统计,而每实体列表只是最近 15 个的采样。 在场上有几百个敌人时用采样去算受到的伤害,一路读起来都很安全——直到你死掉。run、boss、result在不适用时是被省略的,绝不置零。 置零的run会被读成 「一局进行中的第 0 段」,置零的result会被读成「0 星」。pauseOwners点名是谁持有模拟——["agent"]还是["agent","levelup"]。这正是 「该调 resume」与「该调 levelUp」的分野,而光一个runState:"Paused"表达不了。- 所有速率都是「每 tick」,因此能与
__candyStep(n)直接复合。 - NaN 与 ±Infinity 发布为
0,并附带一个nonFinite键列出出问题的路径。原始NaN不是合法 JSON,过去会让智能体整份快照报废。而之所以否决改用null,理由更锋利: 像hp < 20这样的比较对null会读成真,于是一个坏掉的模拟变成一个自信的错误决策。
确定性才是真正的产品
任何调试面都能戳一戳运行中的游戏。让这一套成为测试仪器的,是会话可复现, 而这需要一组明确的决定:
- 输入以模拟下一个将要读取的 tick 为键,与硬件采样自身的键控方式一致。智能体注入的 一帧,被消费的方式与采样帧或回放帧完全相同。
- 注入会抑制硬件采样,页面上的误触按键无法争抢。智能体的输入是权威的且被记录的, 因此会话可以一模一样地回放。
- 步进从不触碰墙钟时间。 它会先取走智能体暂停令牌,这样帧驱动循环就无法在两次步进 之间偷跑 tick。
- 作弊在下一个被模拟 tick 的开头生效,绝不在命令回调里生效。 生成与击杀会消耗确定性 RNG;在帧时间施加的作弊会让 RNG 偏离 tick 前进,从而无声地毁掉每一次回放。每次施加都 连同其 tick 一起被记录并随导出一起发出。
它的证明毫不花哨,而形状恰到好处:两局都钉在种子 777001,在零输入下步进到同一个
tick 1800,逐字节比较。全部 25 个敌人位置精确一致。零差异。
停在有意思的地方
朴素的循环——步进六个 tick、读、决策、重复——每次决策都要付一整个往返。
__candyStepUntil 把它压缩成一次调用:
__candyStepUntil({ maxTicks: 600, stopOn: ["hpBelow:40", "levelReached:5"] });
const { result } = await __candyWait();
// { ok:true, cmd:"stepUntil", requested:600, stepped:214, tick:8931, stopped:"hpBelow" }
stopOn 是一个封闭集合,不是谓词语言:hpBelow:N、levelReached:N、
segmentChanged、gateUnlocked、bossSpawned、quotaMet,外加始终会停的
gameover 与 levelup。按固定优先级,只有一个理由获胜——死亡同样会把 HP 压到任何
阈值以下,而优先级正是用来阻止它去报告那个毫无用处的 hpBelow。
位置类谓词是被刻意省略的。智能体完全可以在两次调用之间在客户端算出来,而每往封闭集合 里加一个谓词,就是模拟此后每一个 tick 都要永远求值一次的谓词。
那个证明了这套东西价值的 bug
在开发版 WebGL 构建里,Unity 原生 VFX 运行时会在每次 Game 场景加载时打印:
Invalid VFX Particle System. It is skipped.
277 次,一次性爆发,什么都不点名。没有 GameObject,没有路径,没有资产,没有 GUID。
这个字符串来自 Unity 自己的 C++ 模块——它住在随包发布的 .wasm 里,项目代码无从改写。
它把屏幕上的 Development Console 淹到真实报错完全看不见,而这正是它的全部代价:
游戏里看不出任何异常。
当时的主流假说是某个传送门显示模式在 WebGL 上失效了。一个驱动 bridge 的智能体给出的 却是这张表:
| 观察 | 测量 |
|---|---|
| 在主菜单、任何场地加载之前 | 0 条报错 |
| Game 场景加载时 | 277 条,一次约 33 ms 的爆发,全部在 tick 0 |
| 第二次场景加载,不同 arc | 总数 277 → 554 —— 每次启动恰好 277 条 |
| 走 600+ tick 进入新流式加载的区块 | 0 条新增 |
| 一直玩到 tick ~1400,敌人 8 → 71 | 0 条新增 |
那是一次一次性的、初始化时刻的爆发,按场景加载确定性重复,既不是每次生成也不是每帧。
它还当场推翻了主流假说——模式检查从未失效,它确实正确地拒绝在 WebGL 上实例化 VFX
传送门。它只是从来没有发言权,因为有十个 VisualEffect 组件被烘进了一个模式检查
够不着的授权预制体里,随着每一个池化的门一起进入场景。三个门池、预热三个:三十个。
最难缠的细节,只有真实测量才找得到。这三十个本来就全是 inactive 的,Unity 照样报错 ——所以「把它们停用」这条路从一开始就走不通。它们只能被删掉。最终修复是一个预制体、 零 C# 代码、删掉 850 行死掉的 YAML。
这些没有一条是从截图里能看出来的,也没有一条是单元测试能够到的。走到那一步,要在主菜单 数一次控制台爆发、场景加载后再数一次、第二次场景加载后再数一次,然后走六百个 tick 去证明 这个数不会增长。那是一次测量会话——而这正是 bridge 真正的用途。
三轮在浏览器里犯错
这个故事诚实的部分是:bridge 刚发布时并不能用。
C# 那一侧是有单元测试的——十个测试文件里 178 个 EditMode 测试方法,逐字节钉住分发、
步进、作弊时序、回放截断,以及每一种应答的确切 JSON。但 .jslib 那一层,也就是真正的
JavaScript↔C# 边界,只在真实的 WebGL 开发构建中执行。它在结构上就是项目里任何自动化
测试都够不到的地方。
所以它被人工在浏览器里实测了三轮。
第一轮找出两个阻断级问题。多级连升会让 bridge 彻底软锁:第二个排队中的选择面板在
屏幕上明明开着,快照却报告一个选项都没有,而且只有真人用鼠标点一下才能清掉它。开局的
武器选择关卡既不可见也不可驱动,意味着智能体连一局都开不起来。它还发现文档里的等待
配方会死锁,原因很值得复述:Chrome 会在隐藏标签页上完全挂起 requestAnimationFrame,
而自动化智能体几乎总是在标签页隐藏的状态下驱动。Unity 自己的循环靠一个回退计时器勉强
继续跑,所以模拟仍在推进——但页面 JS 里任何基于 rAF 的等待根本不会被执行。
第二轮发现确定性叙事在地基上就是断的:钉住的种子扛不过页面重载,因为它住在 C# 内存里,
而 WASM 重载会摧毁那块内存。钉 424242,重载,跑起来的是 2328243829。第一轮报告把这一条
标为「未验证」。第二轮把它验证为已损坏。
第三轮量到了那个让一切都很痛苦的东西被修好之后的数字:step(600) 从 21,500 ms 降到
连续三次的 152 / 210 / 311 ms。十个模拟秒,用五分之一秒跑完,还是在标签页隐藏的情况下。
它还证明了一次完全自主的对局——武器选择、三次升级、直到死亡,零次 resume 调用、
Play 之后零点击。
随后的下半程复测又发现武器选择的上报发生了回归,以及可重入的 rewind 会把这局搁浅在 一个三百个 tick 之前就已经解决掉的开局关卡上。
那几轮总共产出二十五项编号修复——F1 到 F16,然后 R3F1 到 R3F9。它们呈现的规律一致而略微 令人谦卑:几乎每一项都是 C# 是对的、测试是绿的、而东西照样不能用——因为故障活在顺序里、 活在时序里,或者活在某个没有任何测试框架会去建模的浏览器行为里。
凭证
| PR | 合并 | 交付内容 | 差异 | 文件 |
|---|---|---|---|---|
| #425 | 7月11日 | bridge 本体——遥测、控制、种子钉住、导出 | +7,845/−189 | 101 |
| #429 | 7月11日 | 第 2+3 轮:F1–F16、R3F1–R3F9,浏览器验证 | +5,414/−224 | 50 |
| #467 | 7月13日 | 运行/门/Boss 遥测、空间层、回放查看器 | +8,422/−126 | 120 |
| #574 | 7月21日 | 回放平台:生产环境捕获、从录像启动 | +4,899/−617 | 78 |
| #608 | 8月1日 | 类型化 DTO、统一序列化配置、golden 钉死的线格式 | +1,916/−541 | 30 |
| #611 + #613 | 8月1日 | VFX 审计命令,随后 277 条报错被删除 | +95/−851 | 5 |
它仍然做不到什么
把这些直说出来,比让人自己撞上去便宜:
- 没有开火、冲刺或技能输入。 CandyRush 是自动攻击的幸存者游戏,每 tick 输入只有移动。 那些永远无效的输入字段被删掉了,而不是留着当装饰。
spawnEnemies只能生成当前段落里有活跃对象池的类型。 无论如何应答都是queued:true——请去看enemiesTotal。setHp最低钳到 1。 把血条清零会跳过死亡流程,让模拟卡在半死状态。要死,就得真的挨打。- rewind 扛不过页面重载——待处理的重建是进程状态。持久化的种子钉住加上手动导出可以覆盖这一场景。
.jslib边界依然不被测试覆盖,而且是永久性的、构造上的。这正是浏览器实测存在的全部理由。agent-mode/路径桩目前在已部署的开发通道上返回 404。 它存在于 WebGL 模板中,但不在 撰稿时线上的那个构建里。请用?agent=1,它是好用的。
还有一条与 bridge 本身无关的现实提醒:开发通道是一个开发版 WebGL 构建,而且很重。
冷加载一次拉取了 113.2 MB 资产数据和一个 21.9 MB 的 wasm 模块。Content-Type 被正确地
以 application/wasm 提供,所以流式实例化按预期工作——它单纯就是个大包。请按分钟而不是
按秒来预期,并做好标签页在 bridge 出现之前会在「still waiting on run dependencies」上坐很久
的准备。一旦它出现,__candyStartRun 会在两秒内把你送进一局真实对局。
之所以要造这个而不是接一个视觉模型,真正的理由并不是成本,尽管每次决策便宜大约一个数量级。 理由是:截图是对引擎本就精确知道的状态的一次有损渲染,而上面列出的每一个 bug——排队的选择 面板、被重载吃掉的种子、搁浅的武器关卡、277 条无从归因的报错——在截图里都是看不见的, 在快照里都是显而易见的。
这游戏本来就是确定性的。bridge 只是不再把这一点白白扔掉。