BetterCrewLink 是怎么连起来的:从「设置服务器地址」到「语音响起」的完整连接流程
本文基于 BetterCrewLink nightly 客户端源码与官方 TS 服务器源码梳理, 所有事件名、参数、代码片段均对应真实实现,适合想了解 BCL 联机语音原理、 或打算自建信令服务器的开发者阅读。
0. 全局:一个房间其实是三张网
信令服务器是"红娘":只在认识阶段牵线;语音开始后它只负责 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); // 入房
服务器随后做两件事:
给新玩家回
setClients(现有成员表socketId → Client)和setHost(房主 id);给房间里的每个老成员推
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 完再补交
候选有三类,来源和意义各不同:
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. 阶段五:语音就位
DataChannel('data')打开 →connect事件 →(房主延时 1 秒)把房间设置 JSON 经 P2P 控制通道发给对方;pc.ontrack收到远端 MediaStream → 交给空间音频引擎(距离衰减、方向、混响);说话状态上行:本机 VAD 检测 →
socket.emit('VAD', talking)→ 服务器广播VAD { activity, client, socketId }→ 对端显示"说话中"图标;成员进出、房主变更(
setHost/setClient/setClients)、大厅设置变更持续走信令网。
6. 断线与重试(很值得"抄"的工程细节)
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. 故障速查表(流程各阶段对应的失败症状)
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,做成员表清理
协议层注意两点:
兼容 Engine.IO v3(socket.io 2.x 老客户端)与 v4(4.x 新客户端) 双代际;
服务器本身不需要理解任何 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(事件分发)。