联机协议参考
房间消息怎么从一台机器走到另一台。拓扑、分帧、中继与本地桥、会话生命周期。
01拓扑:服务器星型
StartRide 的联机是 服务器星型:所有客户端都只和服务器说话,彼此之间不发生任何直连。
┌──────────────┐
│ 中继服务器 │ ← 只转发,不判定
└──┬───┬───┬───┘
┌─────┘ │ └─────┐
▼ ▼ ▼
客户端A 客户端B 客户端C ← 全部判定都在这里
| 角色 | 是不是主机 | 权限 |
|---|---|---|
| 服务器 | 不是 | 只做房间作用域内的消息转发 |
| 「房主」 | 不是,只是第一个进房间的普通客户端 | 没有权限去模拟别人的车 |
| 普通客户端 | 不是 | 自己的车自己算 |
星型让「谁卡」这件事变得可定位:房间是同一台服务器在转发,延迟异常能直接归因到某条链路,不用去猜是哪两个人的直连出了问题。同时也不需要每个玩家开放端口、处理 NAT。
02分帧与字节布局
本地桥(游戏内 Lua ↔ 启动器)用的是最简单的长度前缀分帧,因为两端都是我们自己的代码,没必要上更复杂的协议。
┌────────────────────┬─────────────────────────┐
│ 4 字节长度(小端) │ JSON 负载(UTF-8) │
└────────────────────┴─────────────────────────┘
↑ 只算负载长度,不含这 4 个字节本身
| 字段 | 长度 | 字节序 | 说明 |
|---|---|---|---|
| length | 4 字节 | 小端(little-endian) | JSON 负载的字节长度 |
| payload | length 字节 | — | UTF-8 编码的 JSON 对象 |
- 长度只算负载,不包括长度字段自己 —— 这里差 4 个字节,表现是解析出一堆乱码或者永远等不到完整包。
- 一次读取不保证拿到完整包。必须先读满 4 字节头,再按长度读满负载,中间要处理粘包与拆包。
- 单条消息不要做得特别大。位姿这类高频消息应该小而密,别攒一大批再发。
基于流的传输没有「消息边界」这个概念。把所有逻辑都建立在「读到的就是一条完整消息」这个假设上,是新手写网络代码最常见的错误。
03消息封套
每条消息都是一个 JSON 对象。传输层不校验字段,字段的含义由收发两端约定。
{
"type": "pose", // 消息类型,派发靠它分流
"room": "4821", // 房间号(房间作用域内的消息才有)
"from": "player-7f3a", // 发送者标识
"ts": 1730000000000, // 发送时间戳(毫秒)
"data": { "...": "..." } // 与 type 对应的负载
}
| 字段 | 是否必填 | 作用 |
|---|---|---|
type | 必填 | 决定交给哪个处理分支 |
from | 必填 | 发送者;服务端用它判断是否需要回发给本人 |
room | 房间内消息必填 | 房间作用域;服务器按它决定转发给谁 |
ts | 选填但建议带上 | 用于超时判断和乱序丢弃 |
data | 视类型而定 | 负载;字段由类型定义 |
封套这几个字段一旦定了就不要动 —— 它们是所有消息的共同语言。要扩展就扩展 data,并且保证旧客户端收到不认识的 data 字段时能安全忽略。
04本地桥
本地桥是游戏内 Lua 和启动器之间的一条本机通道,监听 127.0.0.1:4444。
| 属性 | 值 |
|---|---|
| 监听地址 | 127.0.0.1:4444(只监听回环) |
| 分帧 | 4 字节小端长度 + JSON |
| 谁连谁 | 游戏侧作为客户端连上来,启动器作为服务端 |
| 生命周期 | 游戏进程在,桥就在;游戏退出,桥关闭 |
为什么要有它
- 游戏内的 Lua 不自己开 socket:让 Lua 处理网络连接、重连、心跳,是给自己找麻烦。
- 所有网络细节(重连、心跳、认证)都留在启动器这一侧,Lua 只做「发一条 JSON、收一条 JSON」。
- 出问题时排查面很小:桥是通的,问题就在上层;桥不通,问题就在启动器或游戏进程。
它绑的是回环地址,所以别人连不上你的 4444,也别指望靠它直连。如果本机装了代理软件,记得把回环地址加进直连例外 —— 代理劫持本地回环会导致桥连不上,现象是「游戏进去了但一个人都看不见」。
05中继入口
中继服务器同时暴露两条入口,承载的内容完全一致,客户端按运行环境选一条:
| 入口 | 地址 | 面向谁 |
|---|---|---|
| TCP | 服务器 7777 | 桌面启动器(长连接,二进制长度前缀分帧) |
| WebSocket | /relay/(走 80 / 443) | 浏览器类客户端、以及被防火墙拦掉 7777 的场景 |
- 两条入口在服务器内部汇合到同一个会话管理器,所以一个走 TCP 的客户端和一个走 WS 的客户端可以在同一个房间里互相看见。
- 走 WS 时的分帧由 WebSocket 协议自己负责,消息体是同样的 JSON。
- 房间作用域在服务器侧维护:消息进来先看
room,再决定转发给哪些连接。
别把 TCP 和 WS 理解成「两套系统」。它们只是同一份消息的两个入口,任何一端的改动都会立刻影响另一端 —— 改协议时两条路都要验证。
06房间与会话
| 动作 | 谁发起 | 服务器做什么 |
|---|---|---|
| 建房 | 任意客户端 | 分配房间号,把发起者挂进去 |
| 加房 | 任意客户端 | 按房间号挂进对应作用域,超时未到则拒绝 |
| 离开 | 客户端 | 从作用域摘除,通知同房间其它人 |
| 心跳超时 | 服务器判定 | 视为掉线摘除,并广播给同房间 |
| 踢人 / 关房 | 房主或管理端 | 摘除指定会话 / 解散整个房间 |
| 黑名单 | 管理侧 | 在准入阶段就拒绝 |
会话标识
- 每条连接在建立时拿到一个唯一的会话标识,它跟着连接走,不跟着账户走。
- 同一个人重连会拿到新的会话标识,所以「按会话标识去重」比「按账户去重」更可靠。
- 房间号是字符串,不要假设它是数字 —— 别做数学运算。
网络抖一下就把人判成掉线,体验非常差。正确做法是加一个短宽限期(几秒级):期间保留该玩家的位置与状态,宽限期内回来就当作没离开。
07心跳与超时
长连接必须有心跳,否则「线断了但没人知道」这件事会一直挂着,房间里就会出现永远不动也不消失的幽灵玩家。
| 机制 | 做法 |
|---|---|
| 心跳 | 客户端定时发一条轻量消息,服务器刷新它的活跃时间 |
| 超时判定 | 服务器超过阈值没收到心跳,判定掉线并摘除会话 |
| 宽限期 | 掉线后短暂保留状态,允许瞬时重连恢复 |
| 退避 | 重连失败时按指数退避重试,别死循环猛冲 |
心跳消息里不要夹带状态数据。一旦夹带,它就从「证明我活着」变成了「一条必须正确的业务消息」,出错面立刻扩大。
08会话生命周期
connect
│
▼
[ 已连接 ]
│ join(room)
▼
[ 在房间内 ] ── 收发同步与玩法消息
│
┌──────────┼───────────┐
│ │ │
主动离开 心跳超时 被踢 / 关房
│ │ │
└──────────┴───────────┘
│
▼
[ 会话结束 ] → ★ 本机必须复位到 lobby
- 连接客户端与中继建立长连接,拿到会话标识。
- 入房会话被挂到某个房间作用域,开始收发该房间的消息。
- 广播同步消息在房间内转发;玩法消息在玩法模块之间流转。
- 结束主动退出、心跳超时、被踢、关房,四种路径都要走同一套清理。
输入限制只由会话阶段决定。如果退出会话时没有把阶段收回「大厅」,局内的传送、复位、换车会被永久锁死。玩家看到的现象是「按键没反应」,但真因在阶段没回来。
四种结束原因走四段不同的代码、各自清理 —— 这是漏项的温床。正确做法是收敛到一个清理函数,所有路径都调它。
09同步模型
同步层只做一件事:让每个人的屏幕上,别人的车看起来在正确的位置。
| 同步什么 | 怎么发 | 注意 |
|---|---|---|
| 本机位姿 | 按固定频率上行,或变化量超过阈值时上行 | 只发变化量,别每帧全量发 |
| 输入 / 状态 | 状态位变化时才发 | 别把每帧的油门刹车位置都发出去 |
| 事件 | 一次性事件(撞击、报废、换车) | 事件不要重复消费,要幂等 |
| 出生 / 销毁 | 车辆出现与消失 | 顺序反了会出现幽灵车 |
远程车怎么被驱动
- 远程车不是「瞬移过去」的。直接设置整体位姿会让物理引擎和碰撞求解器打架,结果是车穿墙、抖动、互相弹开。
- 正确做法是逐节点施加力去逼近目标状态,让物理引擎自己算——看起来才是连续、可碰撞的。
- 节点级驱动必然有误差,所以才需要持续的修正节奏,而不是一次到位。
追求两端数值完全一致是没有意义的 —— 有延迟、有丢包、有插值。判定标准永远是玩家看起来连不连续。
10版本协商
客户端与服务器在握手阶段交换版本号。策略是宁可拒绝,不要勉强兼容。
| 情况 | 服务器行为 | 为什么 |
|---|---|---|
| 版本一致 | 正常进入 | — |
| 客户端较旧 | 拒绝,提示更新 | 旧客户端发出的字段可能缺失,报错位置会很离谱 |
| 客户端较新 | 拒绝,提示服务器需更新 | 避免新字段被静默丢弃,产生难查的半失效 |
同一个房间里的人尽量用同一个版本。版本不一致时,轻则车辆显示异常,重则根本进不去房间 —— 而且这种问题在日志里往往什么都不留。
11失败与降级
| 故障 | 表现 | 应对 |
|---|---|---|
| 中继连不上 | 启动器显示未连接,进不了房间 | 检查网络与代理;回环地址务必直连 |
| 桥连不上 | 游戏进去了但看不见人 | 查本地 4444 是否被占用、是否被代理劫持 |
| 心跳超时 | 自己被踢回大厅 | 先看本地网络是否抖动,再看服务器负载 |
| 中途丢包 | 别人的车一顿一顿 | 正常现象;持续严重才需要查链路 |
| 版本不匹配 | 直接进不去 | 两端升到同一版本 |
这两类故障的排查路径完全不同:前者查网络与端口,后者查派发与状态机。看一眼启动器顶部的连接状态,能直接砍掉一半排查范围。
12硬规则清单
- 玩法逻辑一律不下发到服务器,服务器不认识玩法。
- 消息派发必须显式处理每种已知类型,并且要有兜底出口。缺兜底出口的派发会把没识别的消息静默丢弃 —— 不报错、不日志。
- 玩家中途离开要有宽限期,别因为一次网络抖动就判掉线。
- 共享的玩家列表会被多个线程读写,改动它必须加锁或用快照。
- 新增一种消息,就要在派发里加一个分支,并且同步更新审计门禁的覆盖范围。
- 位姿广播只发变化量;高频消息要小而密,不要攒批。
- 远程车用逐节点施加力驱动,不直接写整体位姿。
- 会话结束的清理收敛到一个函数,所有结束路径都调它。