返回全部文章

Agent Bridge:玩一款你看不见的游戏

发布于:
agent-runcandyrushwebgltoolingevidence

让 AI 智能体去试玩一款游戏,最直白的办法就是把游戏「给它看」:截下画布、 把图片交给视觉模型、问它看到了什么、决定一个动作、按键、再截图。这能跑通—— 在「demo 能跑通」的意义上。

而对一款强反应的幸存者类游戏来说,这几乎是能想到的最糟的设计。每一次决策都要 付一张图的代价。上下文不断堆积,直到会话质量崩塌。模型正在从渲染帧里费力还原 引擎本就精确知道的状态——敌人位置、生命值、冷却——而且还原得更差。更要命的是, 等到有什么值得注意的事发生时,它已经发生完了:在截图与按键之间,模拟照样在往前跑。

CandyRush 是一个确定性的固定步长模拟。这个事实让另一种设计成为可能:暂停模拟、 把状态读成 JSON、通过回放系统所用的同一个缓冲区注入输入、再精确推进若干 tick。 结构化文本进,确定性 tick 出。整个回路里没有一个像素。

这层面就是 agent bridge,而且它此刻正跑在生产环境上。

JS 入口点
20
window.__candy*
冷启动菜单 → 开跑
2.0s
零点击
步进 600 tick
150–310ms
此前 21,500ms
EditMode 测试
178
10 个 bridge 测试文件
浏览器实测轮次
3
25 项修复,F1–F16 + R3F1–R3F9
它归因的报错
277
每次场景加载,GH #610

它的形状

整个回路只有四步,而且没有一步需要「看」任何东西:

// 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:

URLBridge
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 都会重新瞄准玩家,所以拿原始意图外推, 外推出来的就是错的。
  • enemiesTotalincomingContactDps 是全群统计,而每实体列表只是最近 15 个的采样。 在场上有几百个敌人时用采样去算受到的伤害,一路读起来都很安全——直到你死掉。
  • runbossresult 在不适用时是被省略的,绝不置零。 置零的 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:NlevelReached:NsegmentChangedgateUnlockedbossSpawnedquotaMet,外加始终会停的 gameoverlevelup。按固定优先级,只有一个理由获胜——死亡同样会把 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 → 710 条新增

那是一次一次性的、初始化时刻的爆发,按场景加载确定性重复,既不是每次生成也不是每帧。 它还当场推翻了主流假说——模式检查从未失效,它确实正确地拒绝在 WebGL 上实例化 VFX 传送门。它只是从来没有发言权,因为有十个 VisualEffect 组件被烘进了一个模式检查 够不着的授权预制体里,随着每一个池化的门一起进入场景。三个门池、预热三个:三十个。

最难缠的细节,只有真实测量才找得到。这三十个本来就全是 inactive 的,Unity 照样报错 ——所以「把它们停用」这条路从一开始就走不通。它们只能被删掉。最终修复是一个预制体、 零 C# 代码、删掉 850 行死掉的 YAML。

这些没有一条是从截图里能看出来的,也没有一条是单元测试能够到的。走到那一步,要在主菜单 数一次控制台爆发、场景加载后再数一次、第二次场景加载后再数一次,然后走六百个 tick 去证明 这个数不会增长。那是一次测量会话——而这正是 bridge 真正的用途。

三轮在浏览器里犯错

这个故事诚实的部分是:bridge 刚发布时并不能用。

C# 那一侧是有单元测试的——十个测试文件里 178 个 EditMode 测试方法,逐字节钉住分发、 步进、作弊时序、回放截断,以及每一种应答的确切 JSON。但 .jslib 那一层,也就是真正的 JavaScript↔C# 边界,只在真实的 WebGL 开发构建中执行。它在结构上就是项目里任何自动化 测试都够不到的地方。

所以它被人工在浏览器里实测了三轮。

#403 立项Bridge 发布 (#425) + 第 1、2 轮实测第 3 轮实测运行遥测 + 回放查看器 (#467)回放平台 (#574)类型化 DTO (#608)
2026 年 7 月 10 日 → 8 月 1 日。除第一个外,每个标记都是一个已合并到 CandyRush dev 的 PR。

第一轮找出两个阻断级问题。多级连升会让 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合并交付内容差异文件
#4257月11日bridge 本体——遥测、控制、种子钉住、导出+7,845/−189101
#4297月11日第 2+3 轮:F1–F16、R3F1–R3F9,浏览器验证+5,414/−22450
#4677月13日运行/门/Boss 遥测、空间层、回放查看器+8,422/−126120
#5747月21日回放平台:生产环境捕获、从录像启动+4,899/−61778
#6088月1日类型化 DTO、统一序列化配置、golden 钉死的线格式+1,916/−54130
#611 + #6138月1日VFX 审计命令,随后 277 条报错被删除+95/−8515

它仍然做不到什么

把这些直说出来,比让人自己撞上去便宜:

  • 没有开火、冲刺或技能输入。 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 只是不再把这一点白白扔掉。