最近在给自己的微信小游戏(网格摆块 + 兵线对冲的 1v1 对战玩法)加联机对战。踩完一整轮坑之后把方案和经验记下来,给同样要做小游戏实时对战的朋友一个参考。服务端是 C#(DotNetty),客户端 Cocos Creator,协议 WebSocket + protobuf。
为什么选帧同步
对战双方的所有操作只有"摆块、用道具、选技能"这几类离散指令,单位数值全部可以由逻辑模拟推出。帧同步(lockstep 思路)下服务端只转发指令帧,不算游戏逻辑:
- 带宽极小:指令帧只有几十字节;无指令的空帧干脆不发包(下文 L2 优化);
- 服务端轻量:单房间成本≈一个定时器,单机几百上千房间无压力;
- 防作弊天然成立:服务端不信用端任何状态,只定帧号;结果由双端各自模拟后上报交叉校验。
代价是客户端必须是确定性模拟:同种子 + 同指令序列 → 两端状态逐位一致。这件事说起来一句话,做起来全是坑。
确定性模拟三条军规
- 同一个战斗种子由服务端开局下发,一切随机都从种子派生;
- 固定步进(20Hz,逻辑与渲染帧率解耦),不同设备不同帧率产生完全相同的模拟;
- 随机流归属清晰:这是我们翻车最狠的地方。棋盘的补给随机流必须按座位号派生(
rngFor(seat)),而不是"我方吃主流、镜像吃支流"——否则同一座位棋盘的补给序列在拥有者端和镜像端根本是两个流,摆块指令按槽位解析出不同的形状,镜像端校验静默失败,对面看你永远在"消极比赛"。
镜像视角最容易踩的坑
帧同步双端各模拟两份棋盘:本地视角下 ally=自己、enemy=对手,两端视角互为镜像。这意味着所有按 ally/enemy 区分的参数和逻辑顺序,都是视角相对量:
- 我们的 PvE 关卡里敌方小兵是刻意削弱的(速度 6 vs 我方 88,基地血量也不同)。这套参数泄漏进 PvP 后,同一个逻辑小兵在两端速度不同——两端模拟的根本不是同一场战斗,各自都显示"我压着对面打";
- 暴击/眩晕这类概率判定,roll 点的消费顺序必须按座位序而不是"先 ally 后 enemy",否则两端随机流错位;
- 兵线 tick 要放在两个棋盘都推进完之后,且棋盘按座位序推进——否则对手出兵天然慢一帧,且战斗单位数组顺序两端不一致(影响溅射"最近目标"这类遍历)。
判据很简单:把双端模拟各跑 60 秒无头对比,任何一项状态(位置/数值/基地血)不一致就是分歧。我们最后就是用这个方法把分歧压到 0 的。
断线重连:比想象中深
断线保留座位 + 45 秒重连窗口 + resumeToken 凭证,这些是标配。真正坑在链路细节:
- 重连后新会话上什么绑定都没有——必须先重新登录(绑定 playerId)+ 重新进逻辑服(绑定消息路由),再发 Resume 请求,否则包在服务端路由层就被静默丢弃;
- 断流期间客户端要冻结自治推进——继续推进会把错过的指令帧甩在"过去帧",恢复时永远补不回来,两端就此分歧;
- 同账号在第二端登录的会话换绑要有守卫:房内 + 旧会话还活着就拒绝换绑,不然第二个页签会把房内那端的推送通道抢走,房主永远等不到开局广播(这个事故我们真实发生过)。
结算:不信任任何一端
对局结果由双端各自模拟得出并上报,服务端做一致性校验:一致则广播权威结算;不一致记日志(反作弊线索),按剩余血量兜底判胜。客户端任何"我赢了"的本地判断都只用于上报,不直接生效。
小结
帧同步的难点从来不在"转发指令",而在确定性:随机流归属、视角相对参数、事件顺序、时间源,任何一处视角相关或环境相关的差异都会 quietly 毁掉两端一致性——而且症状往往表现为"对手像个笨 AI"这种完全误导方向的表象。建议早写一个无头双端对比脚本,分歧一旦出现就能精确到帧定位。
后续有空再写服务端房间生命周期管理(结算释放、空房回收、会话归属守卫)的笔记。就酱。