ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

零依赖WebRTC P2P网页小游戏:从信令到状态同步的完整实践

零依赖WebRTC P2P网页小游戏:从信令到状态同步的完整实践 小游戏这个品类听起来就像是“随便写个 canvas 就能跑”的东西。可一旦在标题里加上“多人联机”事情就完全不一样了状态怎么同步、消息怎么转发、NAT 怎么穿、断线怎么处理每一个都能把原本轻松的工程变成一场灾难。OmniGame 是我近期在折腾的一个网页小游戏技术原型核心目标很简单——用原生 JavaScript、不依赖任何第三方框架和构建工具从零开始搭出一个支持双人实时联机的 WebRTC P2P 网页小游戏同时把工程结构做到可以继续扩展成长线项目的程度。这篇笔记会从项目思路、WebRTC 原理、信令实现、状态同步到常见问题排查完整记录我踩过的坑和沉淀下来的方案适合正在做网页小游戏、对 P2P 联机感兴趣、又不想被工具链绑架的开发者。1. 项目概述与整体设计思路1.1 从“能跑”到“能联”OmniGame 想解决什么问题对于个人开发者或者小团队做网页小游戏最大的优势是分发成本低、打开即玩。但一旦需要多人联机常规思路是上云服务器部署一套 WebSocket 服务、搭建房间、做状态同步再考虑并发和成本。这套方案当然成熟但对一个 1MB 都不到的小游戏来说工程重量明显超标。OmniGame 尝试的另一条路是把联机层下沉到浏览器原生支持的 WebRTC让两个玩家的浏览器直接建立点对点数据通道服务器只负责最初的“握手引路”和信令转发。这样既保留了小游戏轻量的特性又把实时互动的上限拉高到了传统网页小游戏难以达到的位置。真正触发我做这个项目的场景是我发现很多网页小游戏项目里联机代码和渲染代码混在一起。输入事件直接在 Canvas 回调里改坐标网络消息到了以后又直接操作 DOM最后项目稍微长大一点就再也改不动。所以我从一开始就规定OmniGame 的核心不是某个具体玩法而是一套能在“零依赖”前提下跑通的多人联机工程骨架。玩法只是验证骨架的载体。1.2 零依赖的边界定义与优势“零依赖”并不是一个营销词汇它有一个非常具体的边界OmniGame 的游戏端代码不依赖任何一个包管理器安装的第三方库、不依赖现代前端框架、不需要构建步骤浏览器直接通过script typemodule加载原生 ES Module。信令端只用 Node.js 内置的http模块实现也没有安装任何第三方依赖。这样整个项目拉下来npm install这一步被彻底删掉了。没有锁文件、没有 node_modules也没有上游依赖带来的供应链风险。你可能觉得这不现代但它确实让项目变得极其容易理解和审计。在实际工程里“零依赖”应该被理解为可控依赖而不是“什么都不能用”。WebRTC、Canvas、ES Module、requestAnimationFrame 这些浏览器原生能力本质上也是依赖但它们由浏览器引擎提供版本和行为相对稳定。OmniGame 的策略是凡是浏览器原生稳定提供的能力一律直接使用凡是需要引入第三方库才能解决的问题先试着用几十行原生代码解决。这个策略在联机模块上尤其明显。WebRTC 的 API 稍微繁琐但也只是繁琐并没有复杂到需要封装库才能用。零依赖带来的第一个直接好处是冷启动快。用 ES Module 加载没有打包器代码在浏览器里一眼就能看到原始文件第二个好处是对新手友好你可以直接在本地开一个静态服务器打开index.html就能跑。第三个好处是更容易调试Chrome 的chrome://webrtc-internals可以直接跟踪原生的RTCPeerConnection对象不需要从框架层猜数据流。代价是代码要写得更“啰嗦”比如实现一个信令轮询可能要 100 行但换来的是对每个字节的控制权。1.3 整体架构浏览器端、信令端、游戏逻辑端整体分成三层表现层、逻辑层、网络层。表现层是纯 Canvas 渲染逻辑层维护游戏状态和输入网络层管理 WebRTC 连接与信令。为什么要把网络层和逻辑层分开因为联机逻辑很容易写成一团乱麻如果没有明确的接口边界状态同步的每一行代码都在跟 UI 耦合。我定义的接口只有三个PeerLink.onMessage、PeerLink.send、PeerLink.close。游戏逻辑只认这三个方法完全不关心底层是 WebRTC 还是 WebSocket。这样后续就算换信令方案也只改网络层。信令端是整条链路里唯一需要服务器的部分。它不承载游戏数据只负责在 WebRTC 连接建立前后把offer、answer、candidate以及“谁加入了房间”这类临时消息转交给对方。因为信令消息频率低、体积小、实时性要求也不高我用 Node.js 的http模块加一个简易队列就能实现。项目里真正的游戏服务器是不存在的两个玩家建连之后数据流就不再经过信令服务。2. WebRTC P2P 核心原理拆解2.1 WebRTC 在游戏里的真实定位很多人第一反应是 WebRTC 不是用来做视频通话的吗其实 WebRTC 是一套为实时通信设计的浏览器原生能力集合视频、音频只是其中一层它同样提供RTCDataChannel这是一个基于 SCTP 的数据通道专门用来传输任意二进制或文本数据。对游戏来说这意味着两个浏览器之间可以建立一个低延迟、加密、有序或无序的传输管道无需经过中转服务器。和 WebSocket 相比WebRTC 最大的优势是数据路径是点对点的。在 WebSocket 模式下所有消息都会先到服务器再由服务器转发给另一端服务器离哪一端更近、带宽是否充足直接影响体验。而 WebRTC 建立成功后数据直接在两台机器之间流动只要两个玩家之间的网络路径质量足够好延迟通常比通过中心服务器更稳定。当然这个“直接”是有条件的后面我会专门讲 NAT 穿透的问题。另一个常被忽视的点是加密。WebRTC 的传输层强制使用 DTLS 加密数据通道里的内容对中间网络不可见这比裸 WebSocket 天然多了一层安全属性。对于小游戏来说这至少省去了自己设计应用层加密的麻烦。2.2 信令P2P 之前的那台“虚拟交换机”这里我想先纠正一个误区WebRTC 建立连接时一定需要一个信令通道因为双方需要交换 SDP 和 ICE 候选而这些元数据无法通过尚未建立的 P2P 通道传输。信令通道用什么实现都可以WebSocket、HTTP 轮询、甚至邮件只要能双向送达消息就行。OmniGame 使用 HTTP 长轮询纯粹是想保持零依赖的同时又不引入太多服务端代码。长轮询的思路很简单每个客户端有一个对应的消息队列消息通过POST写入目标队列客户端用GET从自己的队列中读取。没有消息时服务器可以挂起请求直到有新消息到达或者简单点每 300ms 轮询一次。后者虽然算不上优雅但信令不是高频行为实际压力很小。这个队列模型天然有序比直接让两个客户端互相发 UDP 包要可控得多。信令服务还可以顺手做房间注册和成员发现客户端第一次进入时向服务器登记就能拿到当前房间里的其他玩家 ID。2.3 SDP 与 ICE协商过程就是交换“联系方式”SDP 是一个描述媒体和数据通道参数的文本包含编解码信息、传输地址、扩展能力等。在游戏场景里你可以把它理解成一份“简历”双方先交换简历确认对方的通信参数再动态交换 ICE 候选。ICE 候选是浏览器探测到的可用网络路径常见的有本机地址、经过 STUN 映射后的公网地址以及 TURN 服务器中转地址。实际的协商流程是发起方创建RTCPeerConnection接着调用createDataChannel准备游戏通道然后createOffer生成 SDP再setLocalDescription最后把 SDP 通过信令发送给对端。对端收到后调用setRemoteDescription生成answer回传。两个人在互相交换候选时onicecandidate事件会不断触发每个候选都应当通过信令发出去。这里有一个很重要的细节不要在onicecandidate里简单打印日志而是要用一个回调把候选转交给信令层同时远端 SDP 设置完成之前收到的候选最好先暂存等setRemoteDescription后再addIceCandidate。2.4 DataChannel选对传输策略才能不卡顿createDataChannel创建通道时可以配置ordered、maxRetransmits等参数。ordered: true表示数据通道保证消息按发送顺序到达适合传输指令和房间事件如果配置ordered: false, maxRetransmits: 0则消息不重传、也无序适合高频发送的实时状态快照因为一旦丢包旧的状态本来就会被新状态取代重传旧数据反而浪费时间。但实际开发中我通常先把双通道方案跑通再针对真实网络调整。另一个容易忽略的是消息大小。DataChannel 的最大消息大小受浏览器底层 SCTP 缓冲区限制不同平台差异很大一般在 16KB 到 64KB 之间。如果你要同步完整的游戏状态一定要控制单个消息的体量。OmniGame 里我用了一个简单的二进制编码把快照中的玩家坐标用 16 位有符号整数表示一个 4 人状态的快照大约只有 30 字节而不是几百字节的 JSON。联机小游戏一旦玩家数量变多JSON 虽然能跑但 GC 压力和带宽压力会先于业务逻辑成为瓶颈。3. 从零实现一个可复现的 P2P 小游戏3.1 目录结构与零依赖基础项目结构非常朴素没有build没有dist没有public里堆一堆 hash 文件。我把所有前端代码放在static目录下启动时只需用任意静态服务器指向它。完整目录如下omni-game/ ├── static/ │ ├── index.html │ ├── style.css │ ├── js/ │ │ ├── main.js │ │ ├── game/ │ │ │ ├── engine.js │ │ │ └── snapshot.js │ │ └── net/ │ │ ├── signaling.js │ │ └── rtc.js └── server/ └── signaling-server.jsindex.html里的核心就是一句script typemodule src./js/main.js。因为用了 ES Module浏览器会按依赖图自动加载所有模块不需要打包器。main.js只负责把输入事件转成游戏逻辑能识别的动作并把网络层的消息路由到逻辑层。engine.js维护游戏状态和步进逻辑snapshot.js负责把状态编码成网络消息和从网络消息解码状态signaling.js是信令客户端rtc.js是 WebRTC 封装。3.2 最小信令服务器的实现信令服务我用 Node 原生http模块实现。核心数据结构是一个房间对象每个房间里有成员列表和每个成员对应的消息队列。客户端注册时拿到的peers就是除自己以外的房间成员。服务端代码去掉注释不到 60 行const http require(http); const rooms new Map(); function getRoom(room) { if (!rooms.has(room)) { rooms.set(room, { peers: [], queues: new Map() }); } return rooms.get(room); } http.createServer((req, res) { const url new URL(req.url, http://localhost); const room url.searchParams.get(room) || default; const clientId url.searchParams.get(clientId) || ; const action url.searchParams.get(action) || ; const r getRoom(room); res.setHeader(Content-Type, application/json); if (action register req.method GET) { if (!r.peers.includes(clientId)) { r.peers.push(clientId); r.queues.set(clientId, []); } res.end(JSON.stringify({ peers: r.peers.filter(id id ! clientId) })); return; } if (action fetch req.method GET) { const q r.queues.get(clientId) || []; res.end(JSON.stringify(q.shift() || null)); return; } if (req.method POST) { let body ; req.on(data, chunk body chunk); req.on(end, () { const msg JSON.parse(body); const q r.queues.get(msg.to); if (q) q.push(msg); res.end({ok:true}); }); return; } res.writeHead(404); res.end(); }).listen(8787, () console.log(signaling server: http://localhost:8787));这段代码的假设是房间很小最多几个人所以直接用一个数组保存成员列表用Set或Map做去重也可以。消息转发不支持“向房间所有人广播”只支持指定目标to因为 WebRTC 协商消息本来就是点对点的。客户端通过actionregister注册通过actionfetch拉取自己的消息通过POST发送消息。没有消息时fetch返回null客户端等 300ms 再轮询。3.3 客户端连接与房间配对信令客户端的实现要解决三件事注册、发送、轮询。我用一个类把这三件事收拢核心代码如下export class SignalingClient { constructor(room, clientId, endpoint) { this.room room; this.clientId clientId; this.endpoint endpoint; this.timer null; this.onMessage null; } async register() { const url ${this.endpoint}?actionregisterroom${this.room}clientId${this.clientId}; const res await fetch(url); const data await res.json(); return data.peers; } send(msg) { msg.from this.clientId; fetch(this.endpoint, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(msg) }); } startPolling() { const poll async () { try { const url ${this.endpoint}?actionfetchroom${this.room}clientId${this.clientId}; const res await fetch(url); const msg await res.json(); if (msg this.onMessage) this.onMessage(msg); } catch (e) { console.warn(signaling poll error, e); } this.timer setTimeout(poll, 300); }; this.timer setTimeout(poll, 300); } stop() { clearTimeout(this.timer); } }房间配对逻辑也很直接。客户端根据 URL 参数room进入指定房间注册后看返回的peers如果peers为空说明我是第一个进房间的人角色是“房主”主动创建 DataChannel然后发起offer。如果peers长度为 1说明房间里有房主角色是“客户端”等待接收offer。如果超过 1直接提示房间已满。WebRTC 封装里最关键的是PeerLink类。它的职责是持有一个RTCPeerConnection监听 ICE 候选和远端数据通道对外只暴露消息回调。房主和客户端都使用同一个类只是初始化方式不同。下面是核心逻辑export class PeerLink { constructor({ onSignal, onOpen, onMessage }) { this.pc new RTCPeerConnection({ iceServers: [ { urls: stun:stun.l.google.com:19302 } ] }); this.dc null; this.pendingCandidates []; this.onSignal onSignal; this.onOpen onOpen; this.onMessage onMessage; this.pc.onicecandidate e { if (e.candidate) { this.onSignal({ type: candidate, candidate: e.candidate.toJSON() }); } }; this.pc.ondatachannel e { this.setupChannel(e.channel); }; } hostChannel(label) { const dc this.pc.createDataChannel(label, { ordered: true }); this.setupChannel(dc); } setupChannel(dc) { if (this.dc) return; this.dc dc; dc.onopen () this.onOpen this.onOpen(); dc.onmessage e this.onMessage this.onMessage(e.data); } async createOffer() { const offer await this.pc.createOffer(); await this.pc.setLocalDescription(offer); this.onSignal({ type: offer, sdp: offer }); } async handleMessage(msg) { if (msg.type offer) { await this.pc.setRemoteDescription(new RTCSessionDescription(msg.sdp)); const answer await this.pc.createAnswer(); await this.pc.setLocalDescription(answer); this.onSignal({ type: answer, sdp: answer }); this.flushCandidates(); } else if (msg.type answer) { await this.pc.setRemoteDescription(new RTCSessionDescription(msg.sdp)); this.flushCandidates(); } else if (msg.type candidate) { if (this.pc.remoteDescription) { await this.pc.addIceCandidate(msg.candidate); } else { this.pendingCandidates.push(msg.candidate); } } } flushCandidates() { while (this.pendingCandidates.length) { this.pc.addIceCandidate(this.pendingCandidates.shift()); } } send(data) { if (this.dc this.dc.readyState open) { this.dc.send(data); } } }这段代码有一个细节只有房主调用hostChannel创建通道客户端绝不能也调用createDataChannel。如果双方都创建通道会出现两条通道消息就会串到不同通道上。客户端的 DataChannel 通过ondatachannel事件接收这个事件是在收到对方的offer后自动触发的所以客户端的初始化流程是先创建PeerLink再启动信令轮询最后等待handleMessage被调用。3.4 状态同步三种方案怎么选联机游戏最核心的问题是“谁的状态是权威的”。我做了一个小表格方便对照选型方案适合场景优点缺点客户端各算各的不纠正演示、休闲到极致实现最简单延迟最低状态分叉几乎无法做对抗主机权威 快照同步动作类、射击类、大多数双人小游戏只有主机跑完整逻辑一致性可控远端有一点延迟需要快照协议帧同步 / 锁步策略类、格斗类、强公平对局逻辑确定性强带宽低断线恢复难实现复杂度高OmniGame 第一版选择的是“主机权威 快照同步”。原因很现实双人小游戏的逻辑量级很小主机算一份状态绰绰有余。客户端不需要拥有完整的模拟逻辑只需要把输入发给主机然后渲染主机广播的快照。这样即使两个客户端代码版本不一致只要快照协议不变也能运行。主机端的帧循环大概是这样的function hostTick() { const inputs inputQueue.splice(0); state step(state, inputs); const payload encodeSnapshot(state); link.send(payload); } setInterval(hostTick, 50);客户端端的处理则更轻link.onMessage payload { const snapshot decodeSnapshot(payload); applySnapshot(snapshot); }; document.addEventListener(keydown, e { const input encodeInput(e.code); link.send(input); });这里的encodeSnapshot和decodeSnapshot并不是 JSON 序列化而是二进制编码。一个DataView即可完成玩家 ID 用 8 位整数坐标用 16 位整数血量用 8 位整数。这样一条快照消息稳定在几十字节以内消息体越小丢包重传压力越小WebRTC 弱网下的表现就越好。对新手来说第一版用 JSON 没问题只要在项目真正上线前把快照协议改成二进制。3.5 断线重连与清理流程P2P 连接的双方是平等的任何一方浏览器刷新连接都会断开。OmniGame 的策略是如果在iceconnectionstatechange里检测到disconnected或failed提示玩家回到房间如果信令服务还活着重新进入房间后让存活的一方重新作为房主刷新端作为客户端。这里有一个比 WebRTC API 本身更值得重视的问题旧连接没有完全关闭时新连接很容易被浏览器底层资源的残留状态干扰。所以断线重连的前提是先把旧的RTCPeerConnection关干净。另一个容易踩的坑是新连接创建后存活方必须重新生成一次offer而不是复用之前的 SDP。旧 SDP 里的 ICE 候选可能已经失效直接复用会让新连接卡在checking阶段。对休闲小游戏来说最稳妥的方案是“房间重建 主机全量状态快照”新连接建立后主机把完整状态当作一次普通快照发给客户端客户端直接覆盖本地状态而不是费劲做增量同步。4. 常见问题与排查技巧实录4.1 连不上信令问题还是 ICE 问题浏览器内置的chrome://webrtc-internals是个好东西。出现连不上时先用它截图两边的Signaling state和ICE connection state。如果信令消息在服务器端已经确认送出但Signaling state卡在have-local-offer或have-remote-offer多半是 SDP 没配对成功如果ICE connection state长时间停在checking或不断新增 candidate说明 NAT 穿透没成功。一个最快的自测方法是两台设备连同一个路由器的 Wi-Fi先排除 NAT 问题这种情况下大多数能直接通如果局域网也连不通问题大概率在 SDP 或信令。另一个很容易犯的错是把stun:stun.l.google.com:19302当成万能药。实际上STUN 服务器只负责告诉客户端“你的公网地址看起来是什么”它不保证一定打得通。如果局域网内能通、公网场景通不了不要怀疑代码先去查网络环境和 NAT 类型。还要注意页面的安全上下文。WebRTC 只在localhost和 HTTPS 环境下完整可用。如果你用 IP 地址加非 HTTPS 端口部署部分浏览器会拒绝RTCPeerConnection正常工作。开发时可以用http://localhost上线必须 HTTPS。4.2 WebRTC 怎么关闭释放连接的完整姿势关闭 WebRTC 连接是很多新手会忽略的操作。直接把pc变量置为null并不能让底层连接立即释放浏览器仍然会维护 ICE 状态和 SCTP 关联直到垃圾回收发生。正确的顺序是先关闭 DataChannel再关闭RTCPeerConnection同时把事件句柄全部解绑。下面这段是我项目里PeerLink.close()的标准写法close() { if (this.dc) { this.dc.onopen null; this.dc.onmessage null; this.dc.onclose null; this.dc.close(); this.dc null; } if (this.pc) { this.pc.onicecandidate null; this.pc.ondatachannel null; this.pc.close(); this.pc null; } }还有一个容易被忽略的清理点轮询定时器。长轮询的信令客户端如果还挂着setTimeout循环页面关闭后也会继续请求。在 close 里要调用signal.stop()清除回调。一个好的经验是进入空闲状态或者visibilitychange事件触发时主动调用一次 close避免用户在后台停留时连接一直占着资源。尤其在做“再来一局”功能时旧连接不关新连接很容易遇到奇怪的addIceCandidate失败。4.3 NAT 穿透盲区STUN 不是银弹很多团队以为加了 STUN 服务器就能穿透事实远没那么简单。STUN 只能让客户端发现自己的公网 IP 和端口能不能打通取决于 NAT 类型。对称型 NAT、企业网络、移动网络都可能让两个客户端找不到直达路径。这个时候唯一可靠的兜底方案是 TURN 服务器。TURN 本质上是一台中转服务器它不改变 P2P 的目标而是当 P2P 失败时把数据通过服务器中继。这意味着你仍然需要一台服务器但只在少数情况下产生流量。OmniGame 的零依赖演示版本没有内置 TURN所以它保证了代码零依赖却没有保证全网可达。这是我反复跟人强调的边界零依赖是工程层面的选择不代表网络环境永远友好。如果你要做一个上线产品建议部署开源的 coturn并把 TURN URL 和临时凭据下发给客户端。注意 TURN 凭据最好是短暂有效的不要写死在客户端代码里。4.4 性能与内存小游戏也要防泄漏P2P 联机游戏因为多了网络层泄漏点比单机游戏多很多。常见三类快照解码时创建DataView或对象但没有复用setInterval和requestAnimationFrame混用后没有在断线时清除多次重连时重复创建RTCPeerConnection旧连接没有 close。解决思路就一句话把网络层和游戏循环的生命周期绑在一起。一个实践技巧是给快照解码做对象池。每帧收到的 snapshot 先写入一个预先分配的 ArrayBuffer 和 DataView避免反复分配。比如const view new DataView(buffer); const x view.getInt16(offset, true);JSON 虽方便但在高频快照下的 GC 压力会直接带来卡顿用二进制编码后即使每秒收发 500 条消息也没有压力。另外一个细节是不要把Math.random生成的玩家 ID 当字符串反复拼接进消息协议二进制协议里最好用整数 ID。字符串比较、字符串拼接在小游戏里看似不起眼高频执行时一样会产生不确定性。5. 重新定义工程上限OmniGame 后续能做什么5.1 从双人对战到房间管理现在 OmniGame 只是一个双人 P2P 房间原型但架构可以往上长。房间层只要在信令服务里增加房间列表、密码、最大人数即可WebRTC 的 P2P 模型可以分成两种完全网状Mesh和星形主机转发。三五个玩家可以直接用 Mesh每个客户端都与其他所有人建立 DataChannel人再多客户端带宽和浏览器连接数会告急就需要选一个房主做转发或者回到中心服务器。了解这个边界很重要因为它决定了游戏类型的上限。如果你要做的是 4 人合作生存类小游戏Mesh 完全够用。如果要做 10 人以上的实时竞技我建议直接放弃纯 P2P改成一台轻量转发服务器或者把房主升级为“服务端”让房主拥有更强的上行带宽要求。P2P 不等于没有服务器它只是把服务器压力从中心节点转移到了参与者节点。认清这一点项目才不会在半路崩掉。5.2 零依赖的边界和“准零依赖”的现实选择如果游戏逻辑越来越复杂完全零依赖会变成一种执念。我个人在务实项目里的做法是核心游戏机制和网络协议保持零依赖这是最容易被复用和测试的部分而 UI、粒子效果、音频工具等外围能力按需引入成熟库。准零依赖不是坏词它意味着你把外部依赖控制在一个可审计、可替换的范围内并清楚知道每一段代码为什么存在。这个理念比“不装任何包”本身更有价值。OmniGame 的后续方向有很多把信令轮询换成真正的长连接加入自动配对把快照协议抽象成插件加上回放录制。这些都可以在现有骨架上做不需要推翻重写。重要的是先把“最简闭环”跑通然后在每一次重连、每一次状态同步中积累对 WebRTC 的直觉。回到开头那个问题网页小游戏的工程上限到底在哪做完 OmniGame 之后我的答案变了。过去觉得小游戏能跑起来就行现在觉得小游戏也可以拥有多人在线实时互动、可扩展的工程结构、清晰的网络边界。WebRTC P2P 给了一个相当低的起跑线不需要一开始就买服务器不需要接入复杂 SDK只要愿意搞懂信令、ICE 和状态同步原生浏览器能力就能撑住一个相当复杂的联机小游戏。最后分享一个我自己的习惯每次改完网络层我都会把PeerLink.close()写在代码最显眼的位置确保所有分支都能走到。联机项目烂尾的第一大原因不是功能做不完而是连接关不干净。
返回列表