Protocol · 文档

联机协议参考

房间消息怎么从一台机器走到另一台。拓扑、分帧、中继与本地桥、会话生命周期。

约 8 分钟 · 最后更新 2026-10-25

01拓扑:服务器星型

StartRide 的联机是 服务器星型:所有客户端都只和服务器说话,彼此之间不发生任何直连。

        ┌──────────────┐
        │   中继服务器   │   ← 只转发,不判定
        └──┬───┬───┬───┘
     ┌─────┘   │   └─────┐
     ▼         ▼         ▼
  客户端A   客户端B   客户端C   ← 全部判定都在这里
角色是不是主机权限
服务器不是只做房间作用域内的消息转发
「房主」不是,只是第一个进房间的普通客户端没有权限去模拟别人的车
普通客户端不是自己的车自己算
为什么不做 P2P

星型让「谁卡」这件事变得可定位:房间是同一台服务器在转发,延迟异常能直接归因到某条链路,不用去猜是哪两个人的直连出了问题。同时也不需要每个玩家开放端口、处理 NAT。

02分帧与字节布局

本地桥(游戏内 Lua ↔ 启动器)用的是最简单的长度前缀分帧,因为两端都是我们自己的代码,没必要上更复杂的协议。

┌────────────────────┬─────────────────────────┐
│ 4 字节长度(小端)  │   JSON 负载(UTF-8)     │
└────────────────────┴─────────────────────────┘
         ↑ 只算负载长度,不含这 4 个字节本身
字段长度字节序说明
length4 字节小端(little-endian)JSON 负载的字节长度
payloadlength 字节—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
  1. 连接客户端与中继建立长连接,拿到会话标识。
  2. 入房会话被挂到某个房间作用域,开始收发该房间的消息。
  3. 广播同步消息在房间内转发;玩法消息在玩法模块之间流转。
  4. 结束主动退出、心跳超时、被踢、关房,四种路径都要走同一套清理。
会话消失必须复位阶段

输入限制只由会话阶段决定。如果退出会话时没有把阶段收回「大厅」,局内的传送、复位、换车会被永久锁死。玩家看到的现象是「按键没反应」,但真因在阶段没回来。

清理要放在一条路径上

四种结束原因走四段不同的代码、各自清理 —— 这是漏项的温床。正确做法是收敛到一个清理函数,所有路径都调它。

09同步模型

同步层只做一件事:让每个人的屏幕上,别人的车看起来在正确的位置。

同步什么怎么发注意
本机位姿按固定频率上行,或变化量超过阈值时上行只发变化量,别每帧全量发
输入 / 状态状态位变化时才发别把每帧的油门刹车位置都发出去
事件一次性事件(撞击、报废、换车)事件不要重复消费,要幂等
出生 / 销毁车辆出现与消失顺序反了会出现幽灵车

远程车怎么被驱动

  • 远程车不是「瞬移过去」的。直接设置整体位姿会让物理引擎和碰撞求解器打架,结果是车穿墙、抖动、互相弹开。
  • 正确做法是逐节点施加力去逼近目标状态,让物理引擎自己算——看起来才是连续、可碰撞的。
  • 节点级驱动必然有误差,所以才需要持续的修正节奏,而不是一次到位。
同步是「看起来对」,不是「数值上相等」

追求两端数值完全一致是没有意义的 —— 有延迟、有丢包、有插值。判定标准永远是玩家看起来连不连续。

10版本协商

客户端与服务器在握手阶段交换版本号。策略是宁可拒绝,不要勉强兼容。

情况服务器行为为什么
版本一致正常进入—
客户端较旧拒绝,提示更新旧客户端发出的字段可能缺失,报错位置会很离谱
客户端较新拒绝,提示服务器需更新避免新字段被静默丢弃,产生难查的半失效
房间内统一版本

同一个房间里的人尽量用同一个版本。版本不一致时,轻则车辆显示异常,重则根本进不去房间 —— 而且这种问题在日志里往往什么都不留。

11失败与降级

故障表现应对
中继连不上启动器显示未连接,进不了房间检查网络与代理;回环地址务必直连
桥连不上游戏进去了但看不见人查本地 4444 是否被占用、是否被代理劫持
心跳超时自己被踢回大厅先看本地网络是否抖动,再看服务器负载
中途丢包别人的车一顿一顿正常现象;持续严重才需要查链路
版本不匹配直接进不去两端升到同一版本
先分清「连不上」和「连上了但没用」

这两类故障的排查路径完全不同:前者查网络与端口,后者查派发与状态机。看一眼启动器顶部的连接状态,能直接砍掉一半排查范围。

12硬规则清单

  1. 玩法逻辑一律不下发到服务器,服务器不认识玩法。
  2. 消息派发必须显式处理每种已知类型,并且要有兜底出口。缺兜底出口的派发会把没识别的消息静默丢弃 —— 不报错、不日志。
  3. 玩家中途离开要有宽限期,别因为一次网络抖动就判掉线。
  4. 共享的玩家列表会被多个线程读写,改动它必须加锁或用快照。
  5. 新增一种消息,就要在派发里加一个分支,并且同步更新审计门禁的覆盖范围。
  6. 位姿广播只发变化量;高频消息要小而密,不要攒批。
  7. 远程车用逐节点施加力驱动,不直接写整体位姿。
  8. 会话结束的清理收敛到一个函数,所有结束路径都调它。