传输协议(自行实现客户端)
你用的引擎或语言还没有官方 SDK(如 Godot 的 GDScript、Unreal、原生 iOS / Android)时,可以按本页用 WebSocket 自己实现游戏客户端:先用 HTTPS 加入房间、拿到游戏节点的地址和连接票据,再用 WebSocket 连接节点收发二进制消息。有官方 SDK 的平台请直接使用 SDK:Web、Unity。
只公开 WebSocket 通道
游戏节点还有一条 UDP(KCP + 加密)通道,延迟更低,只由官方 SDK 实现,本页不描述。自己实现时用 WebSocket:加密由 TLS 负责,几乎所有引擎都自带 WebSocket,一般一两天能完成。
一次完整的连接:
1. HTTPS POST /client/v1/sessions 登录 IM,拿到 access_token(已登录的跳过)
2. HTTPS POST /client/v1/game/rooms/{room_id}/join 带上业务给的票据,拿到 connect.ws_url 和 connect.connect_ticket
3. WSS 连接 connect.ws_url(子协议 im.game.v1)
4. WSS → 第一条二进制消息:握手(HELLO + 连接票据)
5. WSS ← WELCOME:座位、成员、当前帧号、resume_token
6. WSS ⇄ INPUT / FRAME / CUSTOM / PING / PONG … 每条二进制消息就是一条协议消息| 要做什么 | 看哪里 |
|---|---|
| 加入、离开房间,找回当前房间 | HTTPS 接口 |
| 连接节点、握手 | 连接与握手 |
| 把消息编码成字节、从字节解出消息 | 消息格式 |
| 发送操作、按顺序处理帧、补帧 | 帧同步 |
| 心跳、断线后恢复 | 心跳与断线恢复 |
| 处理节点返回的错误 | 错误码 |
| 用测试数据核对自己的实现 | 测试数据 |
你需要实现的部分
| 部分 | 说明 | 工作量 |
|---|---|---|
| HTTPS 调用 | 登录、加入(C1)、离开(C2)、找回(C3),JSON | 小 |
| 消息编解码 | 18 种消息,大端整数、带长度前缀的字节串 | 小,几百行 |
| 收帧整理 | 按帧号排序、去重、缺帧时请求补帧 | 小 |
| 心跳和断线恢复 | 每秒 PING;断线后用 resume_token 恢复,失败时重新调 C1 | 中 |
HTTPS 接口
接口都在 $IM_API 下,带登录得到的 User Token:Authorization: Bearer <access_token>。登录、Token 续期见客户端登录。错误的格式同服务端错误码,原因在 details.reason。
加入房间(C1)
POST /client/v1/game/rooms/{room_id}/join
{"client_session_id": "join-6f2a01b7-468e", "ticket": "gtk_9f3KqZ1mXb2…", "transport": "ws"}| 字段 | 说明 |
|---|---|
client_session_id | 本次加入的 ID,8–64 位可见 ASCII(0x21–0x7E),由你生成。同一次加入流程中重试、断线后重新取连接票据时必须保持不变;App 重启后用新的 |
ticket | 你的服务端签发给这个玩家的加入票据。open 房间不传 |
transport | 传 ws |
返回 201(首次加入)或 200(同一登录会话重复调用,座位不变):
{
"room": {"room_id": "rank-8812", "status": "open", "sync_mode": "frame", "tick_rate": 30, "max_players": 4, "reconnect_window_seconds": 60},
"member": {"seat": 0, "username": "alice", "status": "joined"},
"connect": {
"node_id": "game-e1",
"ws_url": "wss://game-e1.game.example.com:7001/game/v1/ws",
"connect_ticket": "AQ3k…",
"protocol_version": 1,
"expires_at": "2026-11-10T08:01:00.000Z"
}
}| 字段 | 说明 |
|---|---|
connect.ws_url | 游戏节点的 WebSocket 地址 |
connect.connect_ticket | 连接票据,握手时原样发给节点。60 秒内有效、只能用一次;过期或用过后重新调 C1 取新的 |
connect.protocol_version | 协议版本,当前为 1 |
room.reconnect_window_seconds | 断线后保留座位的时间,决定你最多重连多久 |
响应中还有 connect.udp、connect.session_key、connect.key_id、connect.transport,只用于 UDP 通道,用 WebSocket 时忽略。session_key 是敏感数据,不要写进日志。
拿到座位后 60 秒内没有连上节点,座位会被释放(connect_timeout)。
| 返回 | 原因 | 处理 |
|---|---|---|
403 permission_denied(没有 reason) | 加入票据无效、过期或已用过 | 让你的服务端重新签发 |
403 permission_denied + game_disabled / plan_feature_disabled | 平台没有开放,或套餐不含游戏房间 | 提示暂时无法进入 |
404 not_found、409 conflict + room_not_open | 房间不存在或已结束 | 提示对局已结束 |
409 conflict + room_full / seat_taken | 房间已满、指定的座位被占 | 提示房间已满 |
409 conflict + joined_on_other_device | 同一账号已在其他设备加入 | 提示在其他设备进入了对局 |
503 unavailable + game_node_unavailable | 房间所在的游戏节点暂时不可用 | 稍后重试 |
401 unauthenticated | User Token 过期 | 续期后重试一次 |
离开房间(C2)
POST /client/v1/game/rooms/{room_id}/leave,没有请求体。可以重复调用;从没加入过返回 {"member": null}。离开时先在 WebSocket 上发 LEAVE,再调这个接口。
找回当前房间(C3)
GET /client/v1/game/current 返回本登录会话还没离开的房间 {"room": {"room_id", "status"}, "member": {...}},没有时都为 null。App 重启后用它找回对局,再用同一个 room_id 调 C1(不需要新票据,用新的 client_session_id)。
这要求沿用原来的登录会话:保存好登录得到的 token,重启后继续使用。重新登录会成为新的登录会话,C3 查不到原来的房间,C1 也会返回 joined_on_other_device。
连接与握手
- 用
connect.ws_url建立 WebSocket 连接,子协议传im.game.v1。 - 连上后发送第一条二进制消息(握手):
| 偏移 | 长度 | 内容 |
|---|---|---|
| 0 | 1 | kind:1 = HELLO(用连接票据加入),2 = RESUME(断线恢复,见断线恢复) |
| 1 | 2 | token 的字节数,大端 |
| 3 | n | token:HELLO 时为 connect_ticket 字符串的 UTF-8 字节;RESUME 时为 resume_token 原样的字节 |
| 3+n | … | 一条 HELLO 或 RESUME 消息(按消息格式编码) |
- 节点回复的第一条消息是
WELCOME(成功)或ERROR(失败,见错误码),之后节点不会再回复握手。
例:connect_ticket 为 "AQ3k",HELLO 的 client_nonce = 0x0123456789ABCDEF,sdk_version = "godot/0.1"
01 kind = HELLO
00 04 token 长度 = 4
41 51 33 6B "AQ3k"
01 消息类型 HELLO
01 23 45 67 89 AB CD EF client_nonce(u64,大端)
09 sdk_version 长度(uvarint)
67 6F 64 6F 74 2F 30 2E 31 "godot/0.1"- 节点在 10 秒内收不到握手就断开。
client_nonce填一个随机数;sdk_version填平台/版本,不超过 64 字节,用于排查问题。- 握手之后,每个二进制帧就是一条消息,节点忽略文本帧。不要把几条消息拼在一个帧里。
消息格式
每条消息的第一个字节是类型,后面的字段按表中的顺序紧挨着编码,没有分隔和填充:
| 类型 | 编码 |
|---|---|
u8、u16、u32、u64 | 无符号整数,大端,1、2、4、8 字节 |
i8 | 有符号 8 位整数(补码),只用于 snapshot_seat |
bool | 1 字节,0 或 1,其他值视为错误 |
uvarint | 无符号变长整数(每字节低 7 位,最高位为 1 表示后面还有,小端顺序,与 Protocol Buffers 的 varint 相同) |
bytes | uvarint 长度 + 字节 |
string | 同 bytes,内容为 UTF-8 |
frames | uvarint 帧数,每帧:u32 帧号 + u8 操作数 + 每个操作(u8 座位 + u8 长度 + 字节) |
解码时,越界、长度超过上限、bool 不是 0 或 1、末尾有多余字节,都应当作消息无效丢弃。节点连续收到 3 条无效消息会以 protocol_unsupported 断开。
消息一览
| 类型 | 名称 | 方向 | 字段(按顺序) |
|---|---|---|---|
0x01 | HELLO | 客户端 → 节点(只在握手中) | client_nonce: u64,sdk_version: string(≤ 64) |
0x02 | WELCOME | 节点 → 客户端 | conv: u32,seat: u8,sync_mode: u8,tick_rate: u8,room_state: u8,start_frame: u32,frame: u32,members: uvarint 个数 + 每个(seat: u8,username: string,online: bool),resume_token: bytes(≤ 256),server_time_ms: u64,snapshot_seat: i8,max_players: u8 |
0x03 | RESUME | 客户端 → 节点(只在握手中) | client_nonce: u64,last_frame: u32 |
0x10 | INPUT | 客户端 → 节点 | ack_frame: u32,data: bytes(≤ 128) |
0x11 | FRAME | 节点 → 客户端 | frames(≤ 3 帧:新帧和前两帧冗余) |
0x12 | FRAME_REQ | 客户端 → 节点 | from: u32,to: u32 |
0x13 | FRAME_BATCH | 节点 → 客户端 | frames(≤ 300 帧) |
0x20 | CUSTOM | 双向 | to: u16,from: u8,reliable: bool,data: bytes(≤ 1024) |
0x30 | READY | 客户端 → 节点 | 无 |
0x31 | START | 客户端 → 节点 | 无 |
0x32 | ROOM_STATE | 节点 → 客户端 | state: u8,start_frame: u32,reason: string,result: bytes |
0x33 | MEMBER | 节点 → 客户端 | seat: u8,username: string,kind: u8,reason: string |
0x40 | SNAPSHOT_PUT | 客户端 → 节点 | frame: u32,data: bytes(≤ 65536) |
0x41 | SNAPSHOT | 节点 → 客户端 | frame: u32,data: bytes(≤ 65536) |
0x50 | PING | 客户端 → 节点 | client_ms: u64,rtt_ms: u32 |
0x51 | PONG | 节点 → 客户端 | client_ms: u64,server_ms: u64 |
0x60 | LEAVE | 客户端 → 节点 | 无 |
0x7F | ERROR | 节点 → 客户端 | code: u8,message: string,reason: string,result: bytes |
字段的取值:
| 字段 | 取值 |
|---|---|
sync_mode | 1 帧同步(frame),2 即时转发(relay) |
room_state、ROOM_STATE.state | 1 等待开局,2 对局中,3 正在结束 |
MEMBER.kind | 1 上线,2 断线(reason 为 network 或 timeout,可能回来),3 离开(座位释放) |
WELCOME.snapshot_seat | 需要提供快照的座位;-1 表示房间没有开启快照 |
WELCOME.conv | 只用于 UDP,WebSocket 忽略 |
CUSTOM.to | 收件座位的位掩码(座位 n 对应第 n 位),0 表示除自己以外的所有人;from 由节点填写,发送时填 0 |
CUSTOM.reliable | WebSocket 上总是可靠送达,这个字段原样转给收件人 |
ROOM_STATE.result、ERROR.result | 结束房间时你的服务端给的结算结果,JSON 的 UTF-8 字节;没有时长度为 0 |
帧同步
开局:房间的 start_mode 为 all_ready 时,每个玩家发 READY,所有已连上的玩家都准备后开局;auto 在第一个玩家连上时开局;manual 由你的服务端调开局接口。客户端的 START 暂不开放。开局时节点广播 ROOM_STATE(state=2, start_frame),第一帧的帧号为 start_frame。WELCOME 的 room_state 已经是 2 时,说明你加入时对局已经开始。
发送操作:每帧发一条 INPUT,data 是你自己编码的本帧操作(≤ 128 字节),ack_frame 填你已经按顺序处理到的帧号。节点把一个帧周期内收到的最后一条 INPUT 放进下一帧。每个座位每秒不要超过帧率的 2 倍条,超出的被丢弃,连续 10 秒超出会被移出房间。
接收帧:节点每帧发一条 FRAME,里面最多 3 帧:最新的一帧加前两帧的冗余。按下面的规则整理后再交给游戏逻辑,保证不乱序、不重复、不跳号:
- 记下期望的下一帧
next,初始为 WELCOME 的frame + 1(frame为 0 时从 1 开始)。 - 收到
FRAME或FRAME_BATCH时,丢弃帧号小于next的,其余放进缓存。 - 只要缓存里有
next,就交给游戏逻辑并next + 1,重复。 - 缓存里最新的帧号大于等于
next + 3(冗余也补不上)时,发FRAME_REQ(from=next, to=最新帧号-1);同一个next只请求一次。节点用FRAME_BATCH回复(一次最多 300 帧)。
每一帧的 inputs 只包含这一帧有操作的座位。sync_mode 为 relay 的房间没有帧,只用 CUSTOM。
快照:WELCOME 的 snapshot_seat 等于你的座位时,你是快照提供者,请按房间设置的间隔发 SNAPSHOT_PUT(frame, 游戏状态)。断线超过 60 秒(节点只保留 60 秒的帧)后恢复时,节点会先发 SNAPSHOT,你用它恢复游戏状态,把 next 设为 SNAPSHOT.frame + 1,再接收之后的帧。
心跳与断线恢复
心跳:连上后每 1 秒发一条 PING(client_ms=本地毫秒时间, rtt_ms=最近的往返延迟,没有时为 0)。节点立即回 PONG,原样带回 client_ms 并附上 server_ms。往返延迟 = 收到 PONG 的时间 − client_ms,建议取最近 10 次的中位数;服务器时间 ≈ server_ms + 往返延迟 / 2。节点 10 秒没收到任何消息就断开这个连接,所以心跳不能停。
判定断线:5 秒没有收到任何消息,或 WebSocket 关闭,按断线处理。
断线恢复
- 立即重连:新建 WebSocket,握手的
kind = 2,token 为 WELCOME 里的resume_token,消息为RESUME(client_nonce=随机数, last_frame=已处理的最后一帧)。 - 成功时节点回
WELCOME,然后用FRAME_BATCH(必要时先发SNAPSHOT)补发last_frame之后的帧。用新 WELCOME 里的resume_token替换旧的。 - 节点回
ERROR(11 resume_rejected),或连接票据相关的错误时:用同一个client_session_id重新调 C1,拿到新的connect_ticket,再用kind = 1握手。这种情况下节点不会主动补帧,请按收到的 WELCOME 的frame发FRAME_REQ补齐。 - 连不上时按 0、1、2、4、4… 秒退避重试,直到超过
room.reconnect_window_seconds;超过后座位已被释放,结束对局。 - 收到
kicked、room_closed、node_draining、seat_taken时不要重连,见错误码。
离开:玩家主动退出时发 LEAVE,关闭 WebSocket,再调 C2。
对局结束:你的服务端结束房间时,节点先广播 ROOM_STATE(state=3, reason, result),再发 ERROR(9 room_closed, reason, result) 并在 1 秒后断开。reason 为结束原因(如 closed_by_server、expired),result 为结算结果。
错误码
节点用 ERROR 消息报告错误。握手阶段回 ERROR 后关闭连接;连上以后收到 ERROR,按表处理:
code | 名称 | 什么时候 | 处理 |
|---|---|---|---|
| 1 | ticket_invalid | 握手:连接票据无效 | 重新调 C1 取票后再握手一次;仍失败让业务重新签发加入票据 |
| 2 | ticket_expired | 握手:连接票据过期或已用过 | 同上 |
| 3 | room_not_found | 握手:房间不在这个节点上或已结束 | 结束 |
| 4 | room_full | 握手:座位不可用 | 结束,提示房间已满 |
| 5 | seat_taken | 握手:座位已有更新的加入;连上后:同一座位在别处连上(reason 为 replaced) | 结束,提示在其他设备进入了对局 |
| 6 | protocol_unsupported | 握手格式不对、连续发送无效消息 | 检查实现,结束 |
| 7 | rate_limited | 连上后:连续 10 秒超出发送频率 | 按断线恢复处理;这时座位通常已被释放,恢复和重新加入都会失败,结束对局 |
| 8 | kicked | 连上后:被你的服务端移出 | 结束,不要重连 |
| 9 | room_closed | 对局结束(reason、result 见上文) | 结束,显示结算 |
| 10 | node_draining | 游戏节点下线维护 | 结束 |
| 11 | resume_rejected | 握手:resume_token 无效或座位已不存在 | 用同一 client_session_id 重新调 C1,再用 HELLO 握手 |
测试数据
官方 SDK 用同一份测试数据保证实现一致,你可以用它核对自己的编解码和收帧整理:下载测试数据(协议版本 1) · SHA256 校验文件。
| 文件 | 内容 | 怎么用 |
|---|---|---|
messages.json | 全部 18 种消息的字段和对应的十六进制编码,以及必须拒绝的输入 | valid 的每一项:按 fields 编码应等于 hex,解码 hex 应等于 fields;invalid 的每一项都应解码失败 |
ws_handshake.json | 握手(HELLO、RESUME)的完整字节 | 用 kind、token_hex、message_hex 拼出的结果应等于 hex |
frames.json | 收帧整理的场景 | 按 steps 依次送入帧号,每步交付的帧和补帧请求应与 deliver、request 相同 |
behavior.json | 超时、心跳、重连退避等常量,加入接口和节点错误码的处理 | 对照本页的规则 |
测试数据中 64 位整数写成十进制字符串,字节串写成十六进制。
规则与细节
- 一个座位同一时刻只有一个连接。同一账号用同一座位再握手,旧连接会被节点断开。
- 帧号是
u32,从开局帧开始每帧加一;对局时长受房间的最长时长限制,不会溢出。 - 节点不保证
PONG、FRAME一定送到你这里(连接断开时会丢);靠帧号和补帧保证完整。 - 协议的新版本会改变 C1 返回的
protocol_version;收到不认识的版本号时请提示用户升级。解码时遇到不认识的消息类型直接忽略,以便节点以后增加新消息。不要发送本页没有列出的消息。 - 本页描述的是协议版本 1。限制和计费见控制台、计费与限制。
