实时多人对战服务端:把规则层和网络层彻底拆开
这是我自己写完并部署上线的一个四人实时对局平台:真人房、AI 补位、房间邀请,H5 和 Android 包都出了,跑在自己的服务器上。
选这个题目练手是有原因的——它同时具备强规则、多人实时、状态必须绝对一致三个特征。任何一处判断写歪,四个客户端立刻就会吵起来。做完一遍,实时服务端该踩的坑基本踩了一轮。
一条核心设计决定
整个项目只有一个决定是真正重要的:规则引擎和网络层彻底分开。
server/ src/ core/ # 纯函数式的规则与对局状态机,不认识 socket server/ # HTTP + Socket.IO,只负责收发和广播 test/ # 直接对 core 做单元测试,不起服务core 里没有任何网络代码,它就是一个 (状态, 动作) => 新状态 的状态机。带来三个直接好处:
测规则不用起服务器。 单元测试几秒跑完,不需要开四个浏览器手动打一局。
AI 和真人走完全同一条代码路径。 AI 就是一个「读状态、返回动作」的函数,不是特殊分支。这意味着 AI 能触发的 bug 真人也能触发,反之亦然——不会出现”只有 AI 在的时候才出问题”这种最难查的情况。
换传输层不用动业务。 以后要换成原生 WebSocket 或者 KCP,core 一行都不用改。
服务端权威,客户端只负责画
客户端不重写任何规则。玩家操作发上去,所有判定以服务端为准,客户端只做渲染和输入。
这条在多人对战里没有商量余地。一旦让客户端参与判定,就要面对两个问题:客户端之间算出不同结果,以及有人改客户端。
代价是每个动作都要一个来回。所以协议设计上把「立刻能确定的反馈」(比如按钮按下的视觉响应)和「需要服务端确认的结果」分开处理,不让网络延迟表现成卡顿。
动作优先级仲裁
实时对局里最容易写错的地方,是同一个事件多个玩家同时有权响应。
同一个动作,可能 A 玩家要执行操作甲、B 玩家要执行操作乙,而乙的优先级更高。这时不能谁的包先到就听谁的——那样结果取决于网络抖动,同一局重放两次会不一样。
正确做法是开一个短暂的响应窗口,收齐(或超时)之后按固定优先级仲裁,再广播结果。还要处理同一优先级多人命中的情况。
这类逻辑全部在 core 里,所以能用单元测试穷举各种组合,而不是靠线上撞。
玩家中途离场
这个平台的规则里,有玩家达成目标后不是整局结束,而是该玩家离场、其余人继续。
看起来是个小规则,实际上它否决了一个很自然的建模方式:不能假定「一局 = 固定四个人从头打到尾」。对局状态里必须显式维护「还在场的玩家」这个集合,所有轮转、判定、结算都基于它。
一开始没意识到这点,是后来重构掉的。能不能在建模阶段就识别出这种”看起来是规则、实际是结构”的需求,很影响后面返工的量。
一次系统性缺陷排查
上线跑了一段时间后我做过一轮完整的缺陷审计,整理出 20 条问题,修掉 17 条,剩下 3 条需要改数据结构,排进了后续迭代。
最大的收获不是修了多少 bug,而是印证了一件事:
凡是能在
core层用单元测试复现的问题,修起来都很快;凡是只能靠”开四个浏览器手动打一局”复现的,都很慢。
后来我把所有能下沉的判断全部往 core 挪了。这条经验在别的项目上一样成立——可测试性不是测试的事,是架构的事。
客户端与打包
客户端用 Cocos Creator + TypeScript,一套工程同时出 H5 和 Android。协议完全复用服务端已有的定义。
Android 出包这一步坑最集中,主要是 Gradle 与 NDK 的版本匹配,以及资源压缩后的加载路径问题。
部署
服务端直接推到自己的服务器,不走 CI。原因很实际:部署频率不高,而且有几个目录(玩家数据、房间快照)绝对不能被覆盖,走自动化脚本反而容易手滑。涉及数据的变更必须先停服。
这算是本职工作带过来的习惯——部署方案要匹配变更频率和出错代价,不是越自动化越好。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!


