BetterCrewLink 是怎么连起来的:从「设置服务器地址」到「语音响起」的完整连接流程

本文基于 BetterCrewLink nightly 客户端源码与官方 TS 服务器源码梳理, 所有事件名、参数、代码片段均对应真实实现,适合想了解 BCL 联机语音原理、 或打算自建信令服务器的开发者阅读。


0. 全局:一个房间其实是三张网

网络

协议/端口

作用

流量大小

信令网

Socket.IO(WebSocket, TCP 9736)

所有玩家连同一台服务器:下发配置、成员列表、转发 SDP/ICE、说话状态

极小,全程存在

探测网

STUN/TURN(UDP 3478 + 中继端口段)

发现公网地址、NAT 打洞、打洞失败时提供中继

仅建连时

媒体网

WebRTC P2P Mesh

玩家两两直连传音频,N 人房 = N×(N-1)/2 条连接

大,但不经服务器

信令服务器是"红娘":只在认识阶段牵线;语音开始后它只负责 VAD、成员进出、房主变更这类小消息。 这也是为什么信令服务器的带宽要求极低(1 核 512M 足够),而 TURN 中继才是带宽大户。


1. 阶段一:建立信令连接,领"装备"

游戏运行且语音开启时,VoiceController 调用 connection.start(serverURL, stream):

// ConnectionController.ts
const socket = io(serverUrl, { transports: ['websocket'] });   // 纯 WebSocket,不用 polling
  • serverURL 来自设置(默认官方源,可改成自建的 域名:端口)。

  • Engine.IO 握手时客户端会在查询串里带协议代际:新版客户端(socket.io-client 4.8.3)发 EIO=4; 官方 TS 服务器(socket.io 2.4.1)只认 EIO=3——新旧代际不匹配就连不上,这是自建时最常见的坑 (Go 重写版因此做成 EIO=3/4 双栈,两代客户端都能连)。

连上的瞬间,服务器主动下发"装备":

// 事件:clientPeerConfig
{
  "forceRelayOnly": false,
  "iceServers": [
    { "urls": "stun:stun.miwifi.com:3478", "region": "cn" },
    { "urls": "turn:your.domain:3478", "username": "...", "credential": "...", "region": "cn" }
  ]
}

客户端用 AJV 校验(validateClientPeerConfig),通过后缓存为 this.iceConfig, 之后每一条 P2P 连接都用这份配置;配置畸形则弹窗提示并回退默认配置。

两个关键字段:

  • forceRelayOnly: true → 之后所有连接 iceTransportPolicy: 'relay'(全员走中继,隐藏玩家 IP);

  • region:官方协议本没有的扩展字段,服务器原样透传,客户端可凭它做"按地区选中继节点"。


2. 阶段二:进入房间(由游戏状态驱动)

玩家在游戏里进入某个 lobby,客户端依次发三个事件:

// ConnectionController.joinLobby()
socket.emit('leave');                                                        // 先退旧房(幂等)
socket.emit('id', playerId, clientId, friendCode, playerUid, playerIdentifier); // 身份登记
socket.emit('join', lobbyCode, playerId, clientId, isHost);                   // 入房

服务器随后做两件事:

  1. 给新玩家回 setClients(现有成员表 socketId → Client)和 setHost(房主 id);

  2. 给房间里的每个老成员推 join(新玩家的 socketId, client)。

一个反直觉的设计:发出 offer 的是老成员,不是新加入者。 收到 join 事件的每个老成员都会创建一条 initiator 连接主动开价, 新玩家只负责应答——N 个老成员会同时向新玩家发 offer(去重逻辑见第 6 节)。


3. 阶段三:SDP 协商(全部经服务器中转)

老成员(initiator)创建连接:

// PeerConnection.ts(initiator 分支)
const channel = this.pc.createDataChannel('data');   // 控制通道,同时兼作"连通探针"
const offer  = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
// 打包发给服务器,由它按 to 转发
socket.emit('signal', { data: { type: 'offer', sdp, connectionId }, to: peer });
  • connectionId = crypto.randomUUID():发起方生成,此后这条连接的每一个 signal 消息都带上它, 用来判断消息属于"第几代连接"——重试风暴时直接丢弃过期代的消息。

  • 服务器收到 signal 只做转发,附上 from 和对方的 client 信息: → { data, from, client }。

  • 新玩家收到 offer → 创建非 initiator 连接 → setRemoteDescription → createAnswer → 原路回程。


4. 阶段四:ICE 候选收集与 NAT 打洞(精华部分)

new RTCPeerConnection(config) 用的就是阶段一缓存的 iceConfig。每发现一个候选:

// 先 SDP 后 candidate 的顺序保证(自研信令没有标准 trickle 语义,全靠这个)
this.pc.onicecandidate = (event) => {
  if (this.localDescriptionEmitted) this.emit('signal', { type: 'candidate', candidate });
  else this.pendingLocalCandidates.push(candidate);        // SDP 还没发出去,先攒着
};
// 接收端对称:远端描述 set 之前,candidate 进 pendingCandidates 队列,set 完再补交

候选有三类,来源和意义各不同:

候选类型

来源

含义

host

本机网卡

内网直连素材

srflx

STUN Binding 应答

我的公网映射地址,打洞的原材料

relay

TURN Allocate 应答

中继地址,打洞失败的兜底

WebRTC 引擎(libwebrtc)的两个重要行为,决定了你怎么配服务器列表:

  • STUN 查询是并行的、顺序无意义:引擎用 std::set 存 STUN 服务器、同时发起查询、按反射地址去重。 但列表里千万别放探测不通的"死 STUN"——收集阶段要等它失败才结束,会把每次建连接拖慢到十秒级。

  • TURN 列表顺序 = relay 候选优先级:basic_port_allocator.cc 对列表逐项做 relative_priority--, 第一个 TURN 的中继候选优先级最高。没有延迟选路能力——想让玩家用最近的中继,要么把节点排前面, 要么自己在客户端做探测排序(自建生态的 region 选路就是这么来的)。

候选通过 signal 事件 trickle 给对端后,双方开始连通性检查: 互发 STUN Binding Request 探测对方的公网地址——穿过 NAT 软状态建起通路,这就是"打洞"。

结果分两种:

  • host↔host / host↔srflx 成功 → 真 P2P,音频直传,服务器零带宽;

  • 双方都是对称 NAT → 只能 relay↔relay,媒体走 TURN 服务器中继(能用,但吃服务器带宽)。


5. 阶段五:语音就位

  1. DataChannel('data') 打开 → connect 事件 →(房主延时 1 秒)把房间设置 JSON 经 P2P 控制通道发给对方;

  2. pc.ontrack 收到远端 MediaStream → 交给空间音频引擎(距离衰减、方向、混响);

  3. 说话状态上行:本机 VAD 检测 → socket.emit('VAD', talking) → 服务器广播 VAD { activity, client, socketId } → 对端显示"说话中"图标;

  4. 成员进出、房主变更(setHost / setClient / setClients)、大厅设置变更持续走信令网。


6. 断线与重试(很值得"抄"的工程细节)

机制

实现

建连超时

每条连接挂定时器,到期 retryPeer

ICE 异常

failed/closed 立即重试;disconnected 挂观察计时器,持续断开才重试

指数退避

delay = base × 2^min(attempt, 4),封顶上限,避免疯狂重连

代次隔离

每次重试生成新 connectionId,旧代的 answer/candidate 全部丢弃

offer 竞争

两头同时重试时保留恰好一个 offer:socket.id < from 字典序决胜

重复抑制

相同 sdp + connectionId 的 offer 直接忽略;answer 只有 initiator 收

信令断开

disconnect → 销毁全部 peer、清空成员表;重连后由 setClients 重建

玩家刷新

刷新会换 socket.id,同 clientId 的旧条目被剔除(防止幽灵成员)


7. 旁路与开关

  • 大厅浏览器:独立的第二个 Socket.IO 连接,订阅公开房间列表(lobbybrowser 事件, 服务器持续推 lobby 更新);点击加入时发 join_lobby(ack 回调 (state, codeOrError))。

  • 移动端信标:定时向 lobbyCode + '_mobile' 目标发 signal,告诉手机版玩家"房主在这"; 收到带 mobilePlayerInfo 的 signal 则触发"检测到手游玩家"。

  • NAT 修复开关(注意它是把双刃剑):

const config = SettingsStore.store.natFix ? DEFAULT_ICE_CONFIG_TURN : this.iceConfig;
// DEFAULT_ICE_CONFIG_TURN = 硬编码官方德国 TURN turn.bettercrewl.ink:3478 + iceTransportPolicy:'relay'

它绕过服务器下发的配置、强制全员中继到德国官方节点(国内实测中继 436ms)。 自建场景必须改成复用服务器配置只加 relay 策略,否则开了"修复"反而爆炸。


8. 全流程时序图


9. 故障速查表(流程各阶段对应的失败症状)

阶段

症状

优先排查

① 信令

客户端连不上、一直转圈

地址必须是 域名:端口;服务器端口未对公网放行;EIO 代际不匹配(v3/v4)

② 入房

进房无反应

id/join 参数不全;服务器没回 setClients

③ 协商

长时间 connecting

WebSocket 中途被断(CDN/反代未开 WS 支持);answer 丢失触发重试

④ 收集

进房后半天才能说话

配置里有死 STUN 拖慢 gathering——逐个探测清掉

④ 打洞

chrome://webrtc-internals 全是 relay

双对称 NAT 属正常,需 TURN 兜底;或 natFix 开着指向了国外硬编码 TURN

⑤ 语音

能连通但断续、电流声

中继走了跨境 UDP(丢包)/ 服务器带宽被中继打满

全程

国内玩家延迟高

TURN/STUN 列表没有就近节点;把 region: cn 节点排在前面


10. 写给想自建服务器的人:服务端必须实现的事件契约

最小可用的信令服务器只需要做这几件事(Go 重写版 Interstellar-server 即按此 1:1 复刻原版语义):

连接建立      → 立刻下发 clientPeerConfig
收到 id       → 记录玩家身份(playerId/clientId/friendCode/uid)
收到 join     → 入房:回 setClients+setHost 给新人,广播 join 给老人
收到 leave    → 退房清理;必要时给余下成员重发 setClients/setHost
收到 signal   → 按 to 转发,附 from + client(纯透传,不解析 SDP)
收到 VAD      → 同房广播 {activity, client, socketId}
收到 lobby    → 转发给订阅了 lobbybrowser 的连接(大厅列表)
收到 join_lobby → 带 ack 返回 (state, codeOrError)(大厅浏览器用)
断开连接      → 等价 leave,做成员表清理

协议层注意两点:

  1. 兼容 Engine.IO v3(socket.io 2.x 老客户端)与 v4(4.x 新客户端) 双代际;

  2. 服务器本身不需要理解任何 SDP/ICE 内容——它只是个带房间概念的二进制消息转发器, 这也是 Go 重写能保持极低资源占用的原因。


参考源码位置:src/renderer/voice/ConnectionController.ts(信令与重试)、 src/renderer/lib/PeerConnection.ts(SDP/ICE 细节)、src/renderer/voice/types.ts(默认 ICE 配置)、 src/renderer/views/LobbyBrowser/LobbyBrowser.tsx(大厅浏览器)、 官方服务器 src/index.ts(事件分发)。

一个Among Us模组的汉化和开发者