慢节奏游戏的联网更简单。
未必。
这是我看完 Old Light 那篇设计笔记后的第一反应。舰队跨星系要飞几个小时,玩家关掉标签页经济照样涨,一次点击多等 300 毫秒根本看不出来。既然看不出来,客户端预测、状态回滚、延迟补偿就都不需要。协议只要两个消息,一个 world.init,一个 world.delta。
快游戏那一堆麻烦,好像就这么绕过去了。
我现在觉得这话只对一半。绕过去的不是难题,是能自己暴露出来的那一半难题。
难度换了个位置。从性能挪到了簿记。而簿记这种难度有个要命的性质:没人在替你对账。
射击游戏里你算错一帧,屏幕当场告诉你错了。帧率是个裁判。慢游戏没有裁判。
作者自己举的例子就很好。服务器把资源余额推算到当前时刻,但数据库那行还带着上次实际保存的时间戳,可能已经是几个小时前的。把推算后的新余额配着旧锚点一起发出去,客户端拿到之后又推算一遍,那几个小时的收入就被算了两遍。
这个 bug 不报错。没有异常,没有堆栈,也不会有哪一帧崩掉。表现是客户端计数稍微领先真实值,然后在收到服务器真值时跳回来一下。作者说要跨几个小时手工核对才能发现。线上大概就是"我的资源好像突然多了一点"。而玩家自己也说不准,因为没人手算过一个帝国的收入。
值得想一下这个"说不准"。这不是工作量大,是反馈的因果链断掉了。玩家十小时后打开标签页,看到一个数,这个数不对,但要证明它不对,得把十小时里发生的每一笔重算一遍。绝大多数人不会干这个。所以大部分这类错误根本没被报告,只是被当成了"我记错了"。
修法是规定被推算值的锚点必须是推算发生的那一刻,不是数据库行的时间戳。这个修法没什么可争的。我想说的是它为什么只能靠一条回归测试钉住。因为除了那条测试,没有第二个东西会替你抓它。
同一类还有定时器。建造到期时服务器要在那一刻结算,玩家在不在线都一样。但内存里的定时器会随进程一起消失,所以重启的时候得把还挂着的重新设上,还要把停机期间已经到期的、按它们原本该完成的时刻补结算。这段逻辑写对了没人在意,写错了也没人在意,直到有人发现自己少收了几个小时。
再说客户端预测。
Old Light 干脆不做预测,客户端只是个查看器,所有变更都是服务器可拒绝的请求,玩家等往返。作者的理由是动作反馈周期以分钟或小时计,多等 300 毫秒不可见。
这个推理成立,我同意。
但它有个代价,作者没提。客户端预测在快游戏里被当成平滑手段,其实它同时在干第二件事:对账。预测和服务端结果不一致,客户端立刻就知道,而且知道到具体哪一步。它是个免费的、每帧都跑的校验器。
删掉它,等于把一个会自动报警的东西拆了。
换句话说,不做预测换来的不是省事,是把对账从每帧挪到了没人管的角落。这笔交易在延迟上赚的,在可观测性上全赔回去。
你可能会反对说,慢游戏本来就不需要那么高的可观测性,错了改就是了。
这句话我暂时没法反驳。但我注意到作者在别的地方很在意这件事。他做房间的时候,先检查房间里有没有人连接,没人就跳过构建私人视图的那部分计算。AI 帝国一直在造东西,从没有玩家连上去,不检查的话大量富化计算全浪费在空房间上。
这个优化是对的。可它也顺带说明另一件事:没人在看的时候,服务器确实省了算力。但数据库该写还是写,数值该长还是长。看的人不在,错照样在往里攒。等他回来,错已经错了一整晚。
评论区里 Valentyn Kit 提了个更狠的建议:别把推算后的值放到网线上,就发带真实锚点的存储余额,让客户端只推一次。他的理由是,就算你发新数值配新锚点,那还是同一个公式有两份实现。两份实现不会当场吵架,它们会先在舍入上慢慢漂移,漂到有人发现为止。
这条我觉得是整场讨论里最值得抄在本子上的。
两份实现是这类系统的暗债。它们永远不会在第一秒暴露分歧,因为一开始只差一个舍入。等差到人眼可见的量级,中间已经积累了不知道多少次没被记录的对账失败。而你想要的,是只有一个地方可能算错。这不是洁癖,是把排查面积缩小到一个点。
¹ 也许有例外。有些场景下两端各算一遍是有意义的,比如服务端要防作弊、客户端要瞬时反馈。但那通常意味着两份实现是被有意设计成可以对账的,而不是各自算各自的。
如果这个判断成立,接下来会看到的东西长得不太像事故报告。
没有堆栈,没有异常,没有哪一帧崩掉。只有一条客服工单,写着"我的资源好像多了一点",附一张截图,时间和数值都对不上任何人手算的结果。开发要去查,先得确认这个玩家到底是不是真记错了。这一步没法自动化。
对实时游戏来说,这类问题的验尸报告里最贵的一栏是修复时间。对慢游戏来说,最贵的一栏是这错了多久。第二栏通常没人填得出来。
² 我不是在反驳 Old Light 的作者。他说不需要延迟补偿、不需要插值缓冲,这个判断是对的,我也这么认为。我想说的是这个"对"有代价,代价不在他讨论的那一栏里。
³ 定时器重启重设那段我只从逻辑上推过,没实际运维过这类系统。真实实现里的边界大概比我说得脏。
