Build dossier · 20
Game RTC 会话管理:房间、信令与断线恢复
记录网页版 NES 双人联机的 RTC 会话层:临时房间、两条 DataChannel、TURN 凭据和断线恢复是怎么接起来的。
给网页版 NES 加双人联机时,我最先想到的是交换按键。真正开始做以后,先遇到的却不是输入同步,而是怎么把两个浏览器可靠地放进同一场会话。
房间要区分房主和客机,信令连接会断,RTCPeerConnection 也会失败。浏览器恢复后,旧的 SDP 和 ICE candidate 不能继续混进新连接;TURN 密钥又不能直接放在前端。当前项目最后把这部分单独做成了一个很小的会话服务。
这篇先记录房间、信令、两条 DataChannel 和断线恢复。游戏帧怎么回滚、状态怎么对齐,放在下一篇。
先分清 WebSocket、WebRTC 和 DataChannel
这几个名字放在一起很容易混。
WebSocket 是浏览器与服务器之间的一条长连接。这个项目用它交换“怎么建立点对点连接”的信息,也就是 SDP 和 ICE candidate。服务器看得到这些信令,但不转发实际游戏输入。
WebRTC 是浏览器之间建立实时连接的一组协议和 API。它会利用 ICE 尝试多种网络路径:能直连就点对点,直连失败时可以经过 TURN 中继。
DataChannel 则是 WebRTC 连接里的数据通道。摄像头和麦克风传音视频轨道,DataChannel 传应用自己的二进制或文本消息。NES 的手柄输入、状态 hash 和完整存档都走 DataChannel。
所以当前路径是:
浏览器 A ──WebSocket──> 信令服务 <──WebSocket── 浏览器 B
浏览器 A <========= WebRTC / DataChannel =========> 浏览器 B
前一条负责让双方找到彼此,后一条才真正传游戏数据。信令服务停一下,不代表已经建立的 DataChannel 立刻断开。
当前会话的职责
一场联机固定两个人:房主使用控制器 1,客机使用控制器 2。两边还必须使用同一个 ROM 和同一版模拟器核心。
服务端不运行 NES,也不保存游戏状态。它只负责:
- 创建临时房间;
- 分配 host 和 guest 槽位;
- 转发 SDP 与 ICE candidate;
- 给已加入房间的客户端签发短期 TURN 凭据;
- 在连接损坏时通知双方重建;
- 给短暂断线保留 30 秒重连窗口。
等待中的房间 10 分钟过期,两人到齐后延长到 6 小时。这些是当前项目的固定参数,不是 WebRTC 的要求。
创建房间时先绑定游戏版本
房主创建房间时提交 gameId、ROM SHA-256 和 coreBuildId。服务端生成 10 位房间码和一段随机 hostToken,房间里只保存 token 的 SHA-256。
const descriptor = {
roomId,
gameId: body.gameId,
romSha256: body.romSha256.toLowerCase(),
coreBuildId: body.coreBuildId,
createdAt: now,
expiresAt: now + WAITING_TTL_MS,
occupied: false,
};
这里绑定 ROM 和核心版本,只是提前确定房间身份。真正打开 DataChannel 后,双方还会再交换一次 hello,检查实际连接上的两端是否一致。单靠邀请链接里的参数不够,因为 URL 可以修改,页面也可能已经切到别的游戏。
房主凭 hostToken 认领 host 槽位。客机第一次加入时获得 sessionToken,之后如果 WebSocket 短暂断开,必须同时带回原来的 clientId 和 sessionToken 才能拿回 guest 槽位。另一个客户端不能只知道房间码就顶掉正在重连的人。
WebSocket 只转发信令
两个浏览器并不知道对方的网络地址,也不知道该用哪种传输配置。建立 WebRTC 前,它们要先交换描述连接能力的 SDP,以及 ICE 找到的候选网络地址。WebRTC 没有规定这些消息必须通过什么渠道发送,因此项目用现成的 WebSocket 转发。
客户端处理信令时使用串行 Promise 队列。原因很直接:setRemoteDescription() 还没完成时,后面的 candidate 可能已经到了;两端也可能同时触发 offer。把这些异步操作并行执行,偶发错误很难复现。
当前实现按 WebRTC 规范中的 Perfect Negotiation 方式处理 offer collision:房主是 impolite peer,客机是 polite peer。polite 一端遇到冲突时回滚自己的 offer,impolite 一端忽略冲突 offer。WebRTC 规范中的 Perfect Negotiation 示例
generation 隔离旧信令
第一次建连时 generation 是 0。任意一端发现 PeerConnection 进入失败状态,或 DataChannel 意外关闭,会通过信令服务请求恢复。
服务端对恢复请求做 2 秒限流,然后增加房间的 generation,清空双方的 ready 状态,再把新的代次广播给两边。客户端收到后关闭旧 PeerConnection,重新申请 TURN 凭据并建立新连接。
此后所有 rtc.signal 都携带 generation。服务端和客户端都会忽略旧代次消息。
这个字段解决的是一个实际竞态:旧连接已经判定失败,但它排队中的 candidate 或 offer 仍可能稍后到达。如果只看房间码,这些迟到消息会污染刚创建的新 PeerConnection。generation 相当于在消息上写明“这是第几次建连”,旧代次与新代次不会串线。
两端都 ready 后才开始协商
重建过程中,一端通常比另一端更早创建 PeerConnection。如果它立刻发 offer,对方可能还在关闭旧连接或申请 TURN 凭据。
因此每一代连接都维护 localReadyGeneration 和 remoteReadyGeneration。只有两边都对当前 generation 发送 rtc.ready,协商才真正开始。提前收到的 SDP 和 candidate 会暂存在队列里,等本地 PeerConnection 就绪后再按顺序处理。
这不是 WebRTC 协议新增的一层握手,只是项目自己的生命周期控制。它把“房间里有两个人”和“双方已经准备好处理本轮信令”分成了两个状态。
两条 DataChannel 的分工
两条 DataChannel 是当前实现里很关键的一处选择:
peer.createDataChannel("input", {
ordered: false,
maxRetransmits: 0,
});
peer.createDataChannel("control", {
ordered: true,
});
control 传的是不能丢、不能乱序的内容,例如:
- 双方身份和 ROM 版本握手;
- 初始状态与恢复状态;
- 状态 hash;
- 同步确认和恢复运行;
- 暂停与恢复。
这些消息晚一点仍然有价值。如果“恢复运行”跑到“状态载入”前面,双方会立刻再次跑偏,所以 control 保持可靠、有序。
input 传的是逐帧手柄状态。它的价值会迅速过期。假设帧 100 的包丢了,而帧 101、102 已经到达,如果坚持可靠有序,接收端可能要等帧 100 重传后才能看到后面的消息。这种现象通常称为 head-of-line blocking:队头的旧包挡住了后面已经到达的新包。
对实时输入来说,“尽快收到最新状态”通常比“每个旧包都补回来”重要,因此 input 使用无序和零次重传。RFC 8831 明确允许 DataChannel 使用有序或无序传输,并指出无序加零次重传可以提供类似 UDP 的发送特性。RFC 8831
这里说“类似 UDP”,不是说 DataChannel 直接使用裸 UDP。WebRTC DataChannel 实际建立在 SCTP、DTLS 和 ICE 之上,仍有加密、拥塞控制和消息边界。项目选择的是 SCTP 提供的部分可靠策略。
为了补偿不重传,input packet 会冗余最近几帧输入。某一包丢失后,下一包仍可能把缺失帧带回来。超过回滚窗口的旧输入即使补到,也不再值得阻塞最新输入。
分成两条还有一个好处:可靠控制消息的顺序约束不会直接套在高频输入上。它们仍共享一个 PeerConnection 和底层 SCTP association,并不是两条完全独立的物理网络;但在应用层语义上已经分开。
只有两条通道都进入 open,应用才回调 dataReady。随后双方先发送 hello 验证 ROM 和核心版本,再由房主发起初始状态同步。
TURN 凭据只发给房间成员
PeerConnection 创建前,客户端使用 X-Room-Session 请求 TURN 配置。服务端确认 session 属于当前房间后,生成 10 分钟有效的用户名:
过期时间戳:sessionToken 前 24 位
credential 是使用服务端共享 secret 对用户名计算 HMAC-SHA1,再编码为 Base64。coturn 文档描述的短期凭据机制也是这一形式:浏览器拿到的是限时用户名和密码,共享 secret 不离开服务端。coturn TURN REST API 说明
当前配置同时返回 UDP、TCP 和 TLS 三个 TURN URL。是否真正经过 TURN,由 ICE 选路决定;服务端签发凭据不代表这次连接一定使用中继。
WebSocket 断了不等于游戏通道立刻失效
信令 WebSocket 每 15 秒发送 heartbeat,30 秒没有 ack 就开始重连。重试间隔从 500 毫秒逐步增加,整个窗口限制为 30 秒。
WebSocket 恢复后,如果现有 PeerConnection 和两条 DataChannel 仍然健康,可以继续复用;如果已经损坏,再请求提升 generation 并重建 RTC。这样短暂的信令波动不会主动拆掉仍在工作的 P2P 数据通道。
另一边离线时,服务端保留原槽位 30 秒并通知在线端。超时后才真正移除 peer;房间已经没人则直接销毁。
当前还没解决完的部分
- 房间和 session 都只存在单个 Node.js 进程内,服务重启后全部丢失;
- 没有多实例房间路由,也没有共享状态;
- 当前只支持两个固定角色,不处理观战或房主迁移;
- generation 能隔离旧信令,但不能保证任何网络条件下都能恢复;
- 两条 DataChannel 仍共享底层 SCTP association,不等于传输资源完全隔离;
- 还缺覆盖高延迟、频繁断网、浏览器后台和 TURN-only 场景的完整测试记录。
目前这套会话层的目标很小:让两个浏览器知道自己是谁、正在处理哪一轮连接,以及不同类型的数据该采用什么传输语义。它不参与游戏模拟,但如果这层状态不清楚,后面的回滚和状态同步就没有稳定的传输基础。