二进制协议设计
基于 rts-server-golang wire/ 模块解析 关键词: 二进制协议, 消息类型, 手写codec, 小端序, Protobuf对比
概述
游戏消息协议设计在 UDP 可靠传输层之上(L4),是客户端与服务端通信的语言。
本项目不使用 Protobuf,而是手写二进制编解码,原因是:
- 无需外部依赖
- 完全掌控每个字节
- 适合游戏这种固定格式、高频小消息的场景
- 学习目的:理解协议本质
消息类型总览
| ID | 消息 | 方向 | 用途 |
|---|---|---|---|
| 1 | Hello | C→S | 协议版本协商 |
| 2 | HelloAck | S→C | 版本确认 |
| 3 | JoinRoom | C→S | 加入房间 |
| 4 | JoinAck | S→C | 加入结果 |
| 10 | Cmd | C→S | 玩家命令 |
| 11 | FrameBundle | S→C | 封帧广播 |
| 12 | HashAck | C→S | Hash 上报 |
| 13 | RTTReport | C→S | RTT 上报 |
| 14 | NPub | S→C | N 变更通知 |
| 15 | FeedbackHint | S→C | 即时反馈(非帧同步) |
| 20 | Resume | C→S | 断线重连 |
| 21 | Resync | S→C | 重连同步数据 |
| 30 | Bye | 双向 | 断开连接 |
核心消息详解
Hello / HelloAck — 握手
go
type Hello struct {
ProtocolVersion uint16
PlayerName string // max 32 bytes
}
type HelloAck struct {
ProtocolVersion uint16
ServerTickRate uint16
Accepted bool
}为什么需要版本协商?
客户端和服务端的 wire 协议版本必须匹配,否则编解码会错乱。Hello 带客户端版本,HelloAck 告知是否接受:
go
ack.Accepted = hello.ProtocolVersion == wire.ProtocolVersionJoinRoom / JoinAck — 加入房间
go
type JoinRoom struct {
RoomID string // 空=创建新房
}
type JoinAck struct {
RoomID string
PlayerID uint8 // 服务器分配
Seed uint64 // 世界 seed(关键)
MapW int32
MapH int32
Accepted bool
}Seed 的作用:服务端通过 JoinAck.Seed 告知客户端世界 seed,客户端据此初始化确定性 PRNG。
Cmd — 玩家命令
go
type Cmd struct {
Tick uint32 // 在哪一帧执行
Player uint8 // 谁发的
Op uint8 // Move=1 / Attack=2 / Stop=3
UnitID uint32 // 操作哪个单位
TargetX int32 // 目标位置(原始 fixed 值)
TargetY int32
TargetID uint32 // 攻击目标
}定长 22 字节(不含 type byte):
type(1) + tick(4) + player(1) + op(1) + unitid(4) + tx(4) + ty(4) + targetid(4) = 23FrameBundle — 封帧广播
服务端将同一帧所有玩家的命令打包广播:
go
type FrameBundle struct {
Tick uint32 // 帧号
NCurrent uint8 // 当前 N 值
Cmds []Cmd // 该帧所有命令
}客户端收到后按 tick 缓存,本地执行。
HashAck — Desync 校验
go
type HashAck struct {
Tick uint32
Hash uint64 // 执行完该帧后的 world hash
}每帧执行完客户端上报 hash,服务端 HashAgg 对比。
RTTReport — RTT 采样
go
type RTTReport struct {
Samples [3]uint16 // 最近 3 个 RTT 样本(毫秒)
}客户端主动上报 RTT,供 AdaptiveN 计算最大 RTT。
NPub — N 变更通知
go
type NPub struct {
EffectiveFromTick uint32 // 从哪帧开始生效
N uint8 // 新 N 值
}服务端通知客户端 N 变了,客户端据此调整输入延迟。
Resume / Resync — 断线重连
go
type Resume struct {
ConnID uint16
LastExecutedTick uint32
Token [16]byte // 重连令牌
}
type Resync struct {
HasSnapshot bool
Snapshot []byte // 全量快照(落后太多时)
Frames []FrameBundle // 增量帧(追赶时)
}编码设计
固定格式 + 小端序
go
// 小端序:符合大多数 CPU 内存布局,编解码简单
binary.LittleEndian.PutUint32(buf[0:4], value)
value = binary.LittleEndian.Uint32(buf[0:4])变长字符串处理
字符串用 长度前缀:
go
// 写入
nameLen := byte(min(len(name), 32))
buf := make([]byte, 1+nameLen)
buf[0] = nameLen
copy(buf[1:], name)
// 读取
nameLen := int(data[0])
name := string(data[1:1+nameLen])空值处理
go
// Accepted: bool → 0=false, 1=true
if m.Accepted {
buf[5] = 1
} else {
buf[5] = 0
}
// 读: data[4] != 0 即为 true手写编解码的权衡
优点
- 无外部依赖
- 每个字节完全可控
- 高频小消息性能好
- 学习协议设计本质
缺点
- 字段增减要改两边代码
- 无 schema 校验,容易写错
- 多语言 SDK 要各自实现
实际项目建议
对于生产游戏服务端:
- 小消息用 手写二进制(游戏协议,字段少)
- 大消息用 Protobuf(复杂结构,跨语言)
- 本项目的做法是教学目的,生产中可以用 hybrid 方案
与 Protobuf 对比
| 维度 | 手写二进制 | Protobuf |
|---|---|---|
| 字段增减 | 需改编解码 | 改 schema 即可 |
| 跨语言 | 各语言重写 | 自动生成 |
| 压缩率 | 更高(无 tag) | 略低(有 tag) |
| 校验 | 无 | schema 校验 |
| 复杂度 | 低(无依赖) | 高(需 proto 工具链) |
相关
- 项目源码: rts-server-golang
/internal/wire/ - 上篇: 04_录制与回放
- 下篇: 06_服务端架构