蘑菇战争 2 在六个平台上用同一套多人模拟跑了约十年,而这套模拟跑在普通浮点数上,里面没有任何定点数数学。这句话通常会引发争论,所以游戏的首席技术官 Alex Silaev 把联机代码到底怎么工作、现实中到底是什么破坏了确定性、以及让团队收获最大的那个 bug,原原本本写了下来。
游戏:蘑菇战争 2,一款同屏数千单位的即时战略游戏,已上线约十年。
平台:Switch、PlayStation、Xbox、PC、iOS 与 Android,某些时期全部在同一批服务器上跨平台对战。
联机方案:确定性锁步加回滚,用普通浮点数运算,没有定点数数学。
逻辑帧:33 毫秒,每秒 30 tick;只有逻辑绑定 tick,渲染按设备帧率走。
安全网:客户端用 FNV-1a 对已确认 tick 做状态哈希,失配率是一项常驻指标。
教训:十年里浮点数从不是主要问题,手写拷贝代码才是。
一句话认识这款游戏
蘑菇战争 2 是一款同屏数千单位的即时战略游戏,已上线约十年,覆盖 Switch、PlayStation、Xbox、PC、iOS 与 Android,某些时期这些平台全部在同一批服务器上跨平台对战。它的联机方案是确定性锁步加回滚,模拟跑在普通浮点数上。作者 Alex Silaev 是开发商 Zillion Whales 的首席技术官,做 Unity 游戏已有 15 年以上,本篇案例出自他那本讲 Unity 性能、内存与架构的书中的联机章节。
本篇拆解的两篇一手来源都发表于 2026 年 10 月 31 日的 dev.to:主篇是《十年跨平台 RTS 的回滚联机:不用定点数》,姊妹篇是《百分之一百个字段里忘了一个:追踪蘑菇战争 2 的掉同步》,后者对这次找不同步的过程讲得更深。下文每一条事实都能追溯到这两篇文章,凡是作者自述或个人观点的地方都会明确标注。
薄服务器,胖客户端
多人架构有几种经典做法:服务器模拟、客户端只渲染;某个客户端当主机;或者每个客户端都模拟同一份内容。即时战略和格斗游戏用的是最后一种:每个客户端都跑完整模拟,网络上只传玩家输入。服务器完全不跑游戏逻辑,只转发数据包,再顺手做几件小事,比如判定胜负。
这让服务器便宜得离谱。按 Silaev 的说法,这套架构下一台 50 美元的服务器能撑住约 10000 名同时在线玩家。流量也不随单位数量增长,这正是同屏数千单位得以成立的唯一原因。逻辑以 33 毫秒的固定 tick(逻辑帧)推进,即每秒 30 tick;经典 PC 即时战略的网络回合要长得多,《帝国时代》大约是 200 毫秒。渲染和动画按设备能跑到的帧率走,只有逻辑绑定 tick。
为什么要回滚,而星际争霸不需要
纯锁步是等人的:客户端要拿到所有玩家的第 N tick 输入后,才会计算第 N tick。在 PC 上这没问题,PC 即时战略玩家掉线时游戏就显示等待玩家,模拟永远只向前走。但在手机上行不通:延迟又高又跳,一局只有三到五分钟,没人愿意盯着等待画面。所以蘑菇战争 2 采用了格斗游戏的做法。
客户端立刻执行自己的指令,所以玩起来感觉像单机。数据包发给服务器,再由服务器转发给其他所有人。当另一个客户端已经跑到第 5 tick,才收到玩家 1 在第 1 tick 做了 X,它就恢复第 1 tick 之前的存档快照,应用这条输入,再把第 1 到第 5 tick 重算一遍。画面跳变用插值抹平,或者如果单位慢到没人注意,就干脆不抹平。快照每 10 到 20 tick 存一次;格斗游戏每 tick 都存,因为它们一直在回滚两三个 tick。蘑菇战争 2 回滚得更远,但没那么频繁。
骨架:快照、排序与一次内存拷贝
Silaev 公布的骨架是设计示意,不是游戏的实装代码,应该当作设计来读,而不是当作文件来抄。世界状态是一组扁平的结构体数组,整体拷贝。快照保留 600 tick,按每秒 30 tick 算就是 20 秒,比这更老的输入到货即视为已经掉同步。输入到达后按所属 tick 归档,同一 tick 内的输入按 playerId 再按序号排序。如果输入属于过去的 tick,模拟就把该 tick 标为脏,从不晚于它的最近快照恢复,再一路重算回来。
其中有两个细节比看上去重要。第一是 tick 内的排序:如果两个玩家对同一 tick 发了指令,每个客户端必须以相同顺序应用它们,而数据包到达顺序在每个客户端都不同,所以不能用它。第二是拷贝的代价:在一帧里重算 10 到 20 tick 是家常便饭,所以拷贝整个世界必须接近一次内存拷贝。如果一次快照意味着遍历上千个对象还要分配内存,回滚会拖垮任何手机。在支持的最弱设备上,一个逻辑 tick 大约占 33 毫秒预算里的 6 毫秒,尖峰过后每帧还能追回两三个 tick。附赠的好处是录像不花钱,因为一场比赛就是它全部的输入。
两个玩家抢同一个位置时谁赢
假设只剩一个空位可造建筑,两个玩家相隔 33 毫秒点中了它。每个人先看到自己的建筑立起来。接着玩家 2 收到玩家 1 的数据包,回滚到第 1 tick,应用玩家 1 在此建造,再走到自己第 2 tick 的指令,发现它已经无效了。这条指令留在历史记录里,只是不再起作用。玩家 1 则是镜像:回滚、保住自己的建筑、跳过玩家 2 的指令。数据包到达顺序无关紧要,重要的是包里写明的 tick。一旦所有数据包到齐,每个客户端的世界就完全一致。
浮点数是确定性的,大体上
接下来的部分是人们最爱反驳的。IEEE 754 规定了基本运算的舍入方式,所以同样的加减乘除序列在合规硬件上会产生同样的比特位。如果 10 除以 100 得到的是 0.0999999 而不是 0.1,那也没关系,关键是每个客户端得到的都是同一个 0.0999999。按 Silaev 的说法,现实中的分歧来自三个地方。第一是编译器:同一工具链、同一配置编译出的客户端彼此一致,但换一种构建方式就可能分歧。融合乘加是最经典的元凶,因为 a 乘 b 加 c 只舍入一次和舍入两次,低位结果会不一样。
第二是数学库:sin、cos、pow 这类函数在不同平台上的实现不一样,所以模拟代码里不要调用引擎或操作系统的超越函数,要用基本运算自己写。第三是一小撮奇怪的硬件:团队在少数厂商的部分安卓设备上看到了分歧,大约占这类设备的 1%。市面上有数万种设备型号,不可能全部修好,但可以把这些玩家分到同一个房间里对战。
Silaev 写道,真正现代的问题是向量化编译器。比如 Unity 的 Burst 可能会把运算融合成 FMA、在快速数学模式下重排运算,或者按目标 CPU 使用 rsqrt 这类近似指令。关于 Burst 的确定性浮点模式,Silaev 只说了这么一句,而且是按他所知:它曾经宣布过,但据他所知实际上从未真正发布,所以请把它当作作者的自述,而不是已证实的结论。他写道,如果用 Burst 跑模拟,很可能最终还是需要浮点数以外的东西。在最近一个项目里,团队走了教科书路线,做了定点数数学以便全部走 Burst,后来又把它拆掉了,性能反而提升了约 40%。这个约 40% 是作者对其自己项目的自述,不是独立基准测试。定点数并不免费:每次乘法都要扩宽再移位,而 Sqrt、Sin、Atan2 会变成手写查表或迭代,恰好把你想省的时间吃掉。他今天推荐的既不是通用定点数也不是浮点数,而是按物理量自选单位的普通整数。他的例子是:如果速度从不超过每秒 100 米、一个 tick 从不超过一秒,毫米和毫秒就够用了,毫米为单位的位置加上毫米每秒的速度乘以毫秒数再除以 1000,仍然落在 32 位整数范围内,因为 100000 乘以 1000 是 10 的 8 次方。除法会截断,对非常慢的物体会损失精度,但它在所有地方都以同样方式截断,所以确定性是安全的。
真正导致掉同步的是什么
逻辑里用了 System.Random 或 UnityEngine.Random。随机性必须成为世界状态的一部分,要有种子,并且随状态一起回滚。
遍历 Dictionary 或 HashSet。顺序没有保证。
对字符串和对象用 GetHashCode。在 .NET Core 里它是按进程随机化的。
引擎物理。PhysX 在不同机器之间不是确定性的。
键相等时不稳定的排序。一定要加第二键,比如实体 id。
任何碰到 deltaTime 的东西。逻辑必须跑在固定 tick 上,而不是帧时间上。
还有一类是任何规则都抓不住的:手写快照里有人忘拷的一个字段。
被遗忘的那个字段
过去快照是靠每个状态类手写的 CopyFrom 方法来拍的,一个单位大约有一百个字段。有人加了一个新字段,却忘了把它写进 CopyFrom。什么也没坏。没有回滚时,这个字段过着正常的生活,游戏也是对的。只有当回滚恰好落在该字段起作用的那个 tick 上时才会出事:一个客户端恢复快照后保留了被丢弃预测里的旧值,另一个客户端拿到的却是正确值。在团队遇到的那次里,同一栋建筑在一个客户端上被占领了,在另一个客户端上却还是中立。想靠手工复现几乎不可能,因为回滚必须恰好发生在那个被遗忘字段变化的 tick 上。
团队是靠哈希抓住它的。客户端定期对已经确认的 tick(即所有输入都已收到)做世界状态哈希,发给服务器。哈希失配的对局占比是一项常驻指标。上面说的硬件长尾总会带来一些背景噪声,但有一天这个比率上升了,这意味着出现了新的分歧源,就在某处触碰模拟状态的代码里。修复分两步。第一步是反射测试:找出所有带 CopyFrom 的类型,用随机值填满一个实例,拷贝一次,再逐个字段比对,包括基类里的私有字段。Silaev 在姊妹篇里写道,这个测试是他请一个 AI 模型写的,因为这是机械性工作,肉眼很容易核对。第二步是把单位的全部可直接拷贝字段收进一个结构体,拷贝变成一次赋值,新字段再也不可能被遗忘,因为编译器会替你拷贝。已发表的结构体示例里的字段名只是示意,少数仍需手写拷贝的引用字段则由测试守着。
哈希、作弊者与一对一难题
同一套哈希还能处理作弊。如果三个客户端报哈希 A、一个报哈希 B,少数那个会被要求再往回滚、重新模拟。一次性的小故障自己就好了;改过的客户端会一直报 B,最终会被移出房间,其他人什么都察觉不到。一对一的对局里,两票无法判定谁对谁错。这时服务器可以跑一个无头客户端,把整场比赛的输入都喂给它。它什么都不渲染,几乎瞬间就能把整场重算一遍;它认同谁,谁就赢。这也是团队手里最好的真实掉同步调试工具。
关于哈希本身还有两个实用提醒。不要用 GetHashCode。要按固定顺序遍历状态的原始字节,用 FNV-1a 这类简单的算法,并且在创建结构体时清零,因为填充字节里可能有垃圾数据,会让相同数据的哈希对不上。还有,只对输入已全部确认的 tick 做哈希,否则你会去追那些下一次回滚后就自行消失的掉同步。
一个值得偷走的自检
团队手里最便宜的确定性测试不需要联网。在编辑器里开一个调试模式,每 tick 回滚一 tick、重算、比对哈希,任何快照没有完整捕获的状态、任何对时间或顺序的隐藏依赖,都会立刻现形,远早于真实对局。这是 Silaev 最建议先偷走的做法:如果你的快照在编辑器里连每 tick 回滚一次都扛不住,在真实对局里也扛不住。
要点:该偷什么,不该抄什么
回滚让锁步游戏在糟糕的移动网络上也能像单机一样顺滑,代价是重算 tick。快照要做到接近内存拷贝,否则就别做。
只要管住编译器、远离平台数学库和向量化快速数学,浮点数在主机、PC 和手机的生产环境里也可以是确定性的。
大多数掉同步来自随机性、遍历顺序、哈希和手写拷贝代码,而不是浮点数。
只对已确认的 tick 做哈希,把掉同步率当作指标来跟踪,并在服务器上备一个无头客户端来裁决争议。
把模拟状态收进可直接拷贝的结构体,并用机械化测试验证拷贝:一个被遗忘的字段是颗定时炸弹,玩法测试抓不到它。
不要把公布的骨架当作生产代码来抄。它是设计的示意,值得保留的是那些常量和排序规则。
不要把关于 Burst 确定性浮点模式的说法当作已证实结论。Silaev 只说据他所知如此,本文保留了这一归属。
不要把 40% 这个定点数数字当作基准测试。它是作者对其单个项目的自述。
不要对未确认的 tick 做哈希。你会去追那些下一次回滚后就自行消失的掉同步。
一款 2001 年的老游戏没有网战,社区是怎么给它装上回滚的
手电自己动了:Project Ghost 如何修好 Photon Fusion 多人手电同步
联机不卡却总被踢出重下地图?Factorio 掉同步真正的原因

