1. 先说说我为什么要做这个“零依赖”的 WebRTC 小游戏引擎
1.1 从项目背景到核心目标
我一直有个执念:网页小游戏的工程天花板不该被“用框架”这件事锁死。做了几年 H5 游戏和互动营销页,我越来越觉得 Phaser、PixiJS 这类库很好用,但它们把你绑在了一套“别人定义好的运行时”里。团队换人、版本升级、包体膨胀,每一件事都在消耗本应用来打磨玩法的精力。所以当我在想下一款多人网页游戏怎么做时,我给自己立了一个规矩:除了浏览器自带的 API,什么第三方运行时都不依赖。于是就有了 OmniGame 这个项目——一个零依赖的网页小游戏运行时,底层直接使用浏览器原生能力,用 WebRTC 数据通道做 P2P 联机。
零依赖不是装酷,它解决的是非常现实的问题。首先,包体可以压缩到极致,整个框架加示例游戏做到 30KB gzip 以内没有任何问题,首屏加载速度比任何带引擎的都快。其次,你完全掌控内存对象、渲染队列和网络逻辑,不会被框架内部的“魔法反射”影响性能分析。最后,零依赖意味着它可以在任何支持标准 Web API 的环境下运行,包括一些审核严格、不允许加载第三方脚本的场景。
OmniGame 的目标很明确:用 canvas 2D 加 requestAnimationFrame 做渲染,用原生 WebSocket 做信令,用 RTCPeerConnection 加 RTCDataChannel 做游戏实体数据的 P2P 传输,彻底绕开“每人一台多人游戏服务器”的运维成本。适合谁看?如果你正在做派对类小游戏、双人对抗游戏、或者想给网页小游戏加一个无需后端的多人对战层,这篇文章能帮你少走大半年弯路。
1.2 为什么选 WebRTC P2P 而不是传统服务器中转
很多做网页多人游戏的团队,第一反应都是“客户端连服务器,服务器广播”。这套中心化模型很成熟,但它在小游戏场景里有两个硬伤。一是服务器成本太高,一个 8 人房间每秒要转发十几条状态消息,月活起来之后流量费用相当可观。二是服务器中转必然引入额外的延迟,哪怕你的服务器放在 BGP 机房,玩家与玩家之间的物理距离问题也没法消除。而 WebRTC P2P 的天然优势在于,数据直接在两个浏览器之间传输,不绕远路。
你可能会问:“P2P 不是还要先经过信令服务器吗?”对,但信令服务器只在建连阶段交换 SDP 和 ICE 候选,连接建立之后,游戏数据的传输就再也不经过它了。也就是说,信令服务器的带宽成本几乎可以忽略不计。当然,WebRTC 也不是银弹,极端 NAT 环境下会走 TURN 中继,这部分流量仍然有成本,但实际项目中 TURN 兜底的使用比例通常低于 5%。把 P2P 作为主路径、中继作为逃生通道,工程上完全可行,这也是 OmniGame 网络层的核心设计原则。
2. 整体架构:三个工程层次
2.1 游戏循环:从 requestAnimationFrame 出发
零依赖不代表从零发明轮子,而是把轮子做薄。OmniGame 的运行时核心只有三层,第一层是游戏循环层。你不用引入任何引擎,浏览器的 requestAnimationFrame 本身就是一个完美的游戏主循环,它会在每次屏幕刷新前回调你,让你更新游戏状态并绘制画面。我把它封装成一个最小调度器,支持帧率控制、暂停恢复、按优先级注册 update 与 render 回调。
主循环最容易被忽略的点是“更新”和“渲染”必须分离。很多新手喜欢在 update 里直接改 canvas 像素,这会导致逻辑计算和绘制相互干扰,帧率一旦波动整个逻辑时间轴都是乱的。我在 OmniGame 里把 update 固定为一个 accumulator 模式,用 deltaTime 累加,每 16.66 毫秒执行一次物理更新,渲染则跟随 display 刷新率。这样一来,即使某台电脑掉到 30 帧,游戏内的时间步长也是稳定的,不会出现“跳帧导致穿墙”的低级 bug。
渲染层面我直接用 Canvas 2D API。对一个小游戏来说,Canvas 2D 的性能完全够用,哪怕是 60 个带阴影的 sprite 同时运动,现代浏览器也能轻松扛住。我做了个非常轻量的渲染对象池,所有精灵对象创建时从池里取,销毁时还回去,避免频繁 GC 导致帧率抖动。这一层代码加起来不到 200 行,但替换掉一个完整渲染引擎的体量。
// OmniGame 主循环的最小实现 class GameLoop { private accumulator = 0; private lastTime = 0; private rafId = 0; constructor( private fixedStep = 16.666, // 60Hz 逻辑步长 private update: (dt: number) => void, private render: () => void ) {} start() { this.lastTime = performance.now(); this.loop(this.lastTime); } private loop = (now: number) => { const delta = now - this.lastTime; this.lastTime = now; this.accumulator += Math.min(delta, 100); while (this.accumulator >= this.fixedStep) { this.update(this.fixedStep / 1000); this.accumulator -= this.fixedStep; } this.render(); this.rafId = requestAnimationFrame(this.loop); }; }2.2 P2P 网络层:PeerConnection 的封装思路
第二层是网络层,这是 OmniGame 的灵魂。我把它设计成一个独立的 NetworkPeer 类,内部维护一个 RTCPeerConnection 实例和若干 RTCDataChannel。对外只暴露 connect()、send(type, payload)、on(type, handler) 三个接口,上层游戏代码根本不需要知道 WebRTC 的具体细节。为了做到零依赖,我没有使用任何现成的 WebRTC 封装库,而是直接调用浏览器原生接口。
PeerConnection 的创建有几个关键参数必须调对。iceTransportPolicy 设为 all 而不是 relay,保证在能直连的情况下绝不主动走中继。iceCandidatePoolSize 设成 10,可以减少 ICE 候选收集过程中的等待时间。bundlePolicy 用 max-bundle,把所有媒体传输合并到一个传输通道上,降低握手复杂度。另外一个经常踩坑的点是,如果你只需要数据通道而不传音视频,记得使用 RTCPeerConnection 构造函数的第二个参数,不要把音频和视频的 transceiver 初始化出来,否则浏览器会请求麦克风和摄像头权限,直接吓跑玩家。
数据通道方面,我在 OmniGame 里默认创建两条通道。一条用 reliable 模式传送关键状态消息,比如加入房间、玩家离开、游戏开始这些事件,保证不丢不重。另一条用 unreliable 且 unordered 模式传高频移动数据,牺牲可靠性换取低延迟。后面我会展开讲这两个通道如何配合使用。
// 创建 PeerConnection 的骨架 const pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, ], iceCandidatePoolSize: 10, bundlePolicy: 'max-bundle', }); // 正式游戏数据通道 const reliableChat = pc.createDataChannel('events', { ordered: true, }); const fastChannel = pc.createDataChannel('motion', { ordered: false, maxRetransmits: 0, });2.3 信令:再小的 P2P 也绕不开的“中间人”
第三层是信令层。你必须清醒地认识到一个事实:P2P 不是一个自举的网络协议,两个浏览器之前没有任何“直接发现对方”的机制。它们需要一个中间人互传 SDP 提案、SDP 应答和 ICE 候选。这个中间人就是信令服务器。OmniGame 的信令服务我用 Node.js 原生模块实现,没有引入 Socket.IO 或 ws 库,只用了 Node 自带的 http 模块加手动处理 WebSocket 握手。
为什么不用现成的 Socket.IO?因为零依赖原则要求我把每一行代码都握在自己手里。Node 原生 WebSocket 握手不算复杂,只要正确计算 Sec-WebSocket-Accept 的 SHA-1 哈希,再按帧格式解析文本消息即可。这个信令服务器的核心逻辑其实只有两件事:维护房间成员表、把消息路由给房间内指定用户。整个实现大约 300 行,部署时一个 node server.js 就能跑起来。
信令消息我用 JSON 格式,定义了 join、offer、answer、ice、leave 五种类型。join 时客户端携带房间号,服务端返回房间内已有用户的 id 列表;offer 方创建 PeerConnection 后把自己的 SDP 发给目标用户;answer 方收到后回传自己的 SDP;ICE 候选则是在整个建连过程中双向实时转发。这个流程很像两个人在互相递纸条,纸条上写的是“我能听到你,你能听到我吗?”。
3. 核心实现:信令、数据通道与同步策略
3.1 信令流程代码实现
我先把信令服务的核心代码放出来,你可以直接用 Node.js 跑。这个实现省略了房间清理和断线重连等边界逻辑,但核心流程是完整可运行的。
// 一个极简的 P2P 信令服务器(Node.js 原生实现) const http = require('http'); const crypto = require('crypto'); const clients = new Map(); // userId -> { socket, roomId } function wsAccept(key) { const hash = crypto.createHash('sha1') .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11') .digest('base64'); return `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${hash}\r\n\r\n`; } function decodeWsFrame(buf) { const lenByte = buf[1] & 0x7f; let offset = 2; if (lenByte === 126) offset += 2; else if (lenByte === 127) offset += 8; const mask = buf.slice(offset, offset + 4); offset += 4; const payload = Buffer.alloc(buf.length - offset); for (let i = 0; i < payload.length; i++) { payload[i] = buf[offset + i] ^ mask[i % 4]; } return payload.toString(); } const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('OmniGame signaling server is running'); }); server.on('upgrade', (req, socket) => { const key = req.headers['sec-websocket-key']; socket.write(wsAccept(key)); let userId = null; socket.on('data', (buf) => { const msg = JSON.parse(decodeWsFrame(buf)); if (msg.type === 'join') { userId = crypto.randomUUID(); clients.set(userId, { socket, roomId: msg.roomId }); const roomMembers = [...clients.entries()] .filter(([, v]) => v.roomId === msg.roomId) .map(([id]) => id) .filter(id => id !== userId); send(socket, { type: 'joined', userId, members: roomMembers }); roomMembers.forEach(memberId => { send(clients.get(memberId).socket, { type: 'newcomer', userId, }); }); } else if (msg.type === 'offer' || msg.type === 'answer' || msg.type === 'ice') { // 路由给指定用户 const target = clients.get(msg.to); if (target) send(target.socket, { ...msg, from: userId }); } else if (msg.type === 'leave') { clients.delete(userId); } }); socket.on('close', () => { if (userId) clients.delete(userId); }); }); function send(socket, obj) { const payload = Buffer.from(JSON.stringify(obj)); const header = Buffer.alloc(2 + (payload.length > 125 ? 2 : 0)); header[0] = 0x81; // FIN + text frame if (payload.length <= 125) { header[1] = payload.length; socket.write(Buffer.concat([header, payload])); } else { header[1] = 126; header.writeUInt16BE(payload.length, 2); socket.write(Buffer.concat([header, payload])); } } server.listen(8080, () => console.log('Signaling on :8080'));这段代码最核心的是帧编解码,WebSocket 的文本帧有一个 4 字节掩码,客户端发送的数据必须用掩码解码,服务端回包则不需要掩码。如果你用现成库做信令,完全不用关心这些细节,但自己实现一遍之后,你才真正理解 WebSocket 协议并不是什么黑魔法,它只是在 TCP 上套了一层轻量分帧。连这一步都敢自己造,后面的零依赖游戏引擎就不再有心理障碍了。
客户端侧的信令封装也不复杂。join 之后,如果是第一个进房间的人,就等待 newcomer;如果是第二个,就向 newcomer 发起 offer。谁先邀请谁没有绝对规定,双方都尝试主动连接就可能导致重复,所以我在实现里固定为“房间内先到者等待,后到者发起”。这个规则简单、无歧义,也不需要额外的状态机。
3.2 数据通道的两种模式选择
WebRTC 数据通道的可靠性配置,很多人一上来就懵。你要理解它本质上是 SCTP over DTLS,与 TCP 和 UDP 都不完全一样。RTCDataChannel 提供了几种可靠性模式,关键是 ordered、maxRetransmits、maxPacketLifeTime 三个参数。
ordered 为 true 表示消息要按发送顺序到达接收端,牺牲延迟保顺序,适合事件类消息。ordered 为 false 配合 maxRetransmits 设为 0,就是纯 fire-and-forget 模式,消息丢了就丢了,不重传也不等顺序,这是实时候移动数据的理想选择。还有一种中间形态是把 maxRetransmits 设成 2 或 3,允许轻量重传但不保证有序,适合既希望减少丢包、又不想被重传阻塞后续数据的场景。
我实测下来,对 60Hz 的玩家操作数据流,maxRetransmits: 0 的效果最稳。因为位置数据每帧都会刷新,丢了一帧下一帧马上覆盖,重传旧数据反而造成“瞬移回退”。事件通道用 ordered: true 保底,虽然可能偶尔延迟几十毫秒,但玩家加入、确认开局这类消息绝不能错序。两条通道并行时,实际各走各的,互不阻塞,这是 WebRTC 多数据通道设计里最令人舒心的一点。
3.3 帧同步与状态同步如何取舍
做 P2P 多人游戏,你必须先回答一个问题:同步什么?两种主流方案是帧同步和状态同步。帧同步是所有客户端接收同样的输入序列,各自运行同样的逻辑,结果自然一致。状态同步是一个客户端(通常是房主)计算最终状态,把状态广播给其他人。
帧同步的思路很诱人,因为它网络开销极小,每个人只发自己的操作指令。当年很多老平台对战游戏都是这么做的。但帧同步有一个前提条件:所有客户端的逻辑必须完全确定。浮点数在不同浏览器里可能因为 JIT 优化产生细微差异,Math.random 也不能用,所有随机都要用可复现的伪随机序列。这个条件在小游戏里其实很难保证,尤其当你用到一些原生 API 时,行为并不跨浏览器统一。
我最终在 OmniGame 里选用的是“房主权威 + 客户端预测”的混合状态同步方案。房主作为逻辑服务器,每 100ms 广播一次所有实体的权威位置与状态,客户端在两次广播之间本地预测输入效果,收到权威帧时做误差修正。这个方案的网络模型仍然是 P2P 的——房主不过是一台普通浏览器,只是逻辑上承担了权威职责。对 2 到 8 人的小游戏来说,状态同步的带宽增长是 O(n),在 n 很小时完全可接受,而且你能得到一个巨大的好处:任意客户端都可以成为房主,谁开房谁当,服务器不参与任何游戏逻辑。
4. 性能优化:把工程上限往上推的关键
4.1 网络包设计:从 JSON 到二进制
信令阶段用 JSON 没问题,但游戏数据通道继续用 JSON 就浪费了。我做过一个很直接的对比,一个玩家位置消息用 JSON 表示是{"x":123.45,"y":67.89,"vx":1.2,"vy":0.3,"act":1},大约 45 字节;用二进制协议只需要 9 字节。在 8 人房间每 1/30 秒同步一次的场景下,JSON 方案每秒产生约 10KB 流量,二进制方案只有 2KB。
我在 OmniGame 里定义了一套极简二进制协议。前 4 字节是消息头,包括 8 位消息类型、8 位玩家 id、16 位序列号。后面按类型跟不同字段。移动数据包用 16 位半精度浮点存 x 和 y,速度和动作标志位进一步压缩。半精度浮点用Math.fround转成 32 位浮点,再手动缩放到 16 位范围。这里的精度虽然不如单精度,但在屏幕坐标系下误差小于 0.01 像素,肉眼完全看不出来。
// 玩家移动包的编码与解码示意 function encodeMove(playerId, seq, x, y, vx, vy, act) { const buf = new DataView(new ArrayBuffer(11)); buf.setUint8(0, 0x01); // type: MOVE buf.setUint8(1, playerId); buf.setUint16(2, seq); // 序列号 buf.setInt16(4, Math.round(x * 32), true); // 精度 1/32 像素 buf.setInt16(6, Math.round(y * 32), true); buf.setInt8(8, clamp(vx, -127, 127)); buf.setInt8(9, clamp(vy, -127, 127)); buf.setUint8(10, act); return buf; }如果你不想处理二进制协议,也可以退一步用“按索引拼字符串”,比如01|3|1024|768|12|34|1,再配合字符串压缩。性能比 JSON 好,调试起来也更方便。但从工程上限的角度看,二进制是最终形态,而且它带来的带宽节省是实实在在的,尤其是在手机上跑时,弱网场景多省一字节就多一分流畅。
4.2 渲染层优化
Canvas 2D 的性能优化有几个老生常谈但极其重要的点。首先是避免在渲染循环里创建对象,每次都新建一个路径对象或渐变对象,会导致 GC 频繁触发,表现为帧率周期性卡顿。我在 OmniGame 里把所有样式对象在初始化阶段创建好,循环里只改变已有对象的属性。
其次是减少状态切换。canvas 上下文是一个大状态机,fillStyle、strokeStyle、shadow 等每次改变都会清空内部缓存。我按“先绘制所有同类型实体,再切换状态”的顺序组织渲染批次,而不是每画一个实体就设置一次样式。对于粒子、子弹这类大量小物体,这个方法能把 draw call 状态切换成本降一半以上。
第三是合理使用离屏 canvas。如果需要频繁绘制相同的纹理,先把纹理画到一个离屏 canvas 上,再通过 drawImage 快速贴图。爆炸特效、弹痕、拖尾光效这些用离屏预渲染能大幅减轻主线程负担。我甚至把一个半径为 80 的圆形阴影预渲染成了离屏纹理,实际绘制时只做一次 drawImage,替代了 20 次渐变填充计算。
4.3 游戏体量控制
追求零依赖必然带来一个附带收益:体积控制。OmniGame 的完整运行时加示例游戏,最终打包后 gzip 在 28KB 左右。这里没有魔法,就是把模块拆细,按需加载,每个模块本身够薄,而且坚决不引入 polyfill。现代浏览器统一支持 WebRTC、Canvas、WebSocket,就没有为老浏览器兜底的负担。
控制体积的过程实际上是一个持续的纪律训练。每次想引入一个小工具函数时,先问自己“这个函数真的要 30 行吗?”,大多数时候答案是不用。零依赖的框架要求你亲手写每一个函数,你会格外珍惜代码行数,而这种珍惜最终让整个项目保持在一个非常健康的状态:没有死代码,没有隐藏依赖,任何时候打开项目都能完全看明白它在干什么。
5. 真实环境中的问题排查实录
5.1 房间连不上的常见原因
做 WebRTC 最容易遇到的坑,不是代码本身,而是“为什么我就是连不上”。我总结出三类高频原因。第一类是 STUN 服务不可达。国内环境访问某些公共 STUN 服务器经常超时,表现在 ICE 候选收集阶段卡很久,最后连不上。我的处理方式是在配置里同时放三个 STUN 地址,包括 Google 公共 STUN 和两个自建 STUN,并把超时时间设置到 3 秒以上。
第二类是信令消息顺序问题。offer 比 join 先到、answer 比 offer 先到,这些时序问题在开发环境很难复现,因为本地网络延迟低,消息到达顺序基本稳定。但到真实网络,消息可能乱序。我的方案是在对端加入房间后再允许发 offer,并且客户端收到 offer 时检查本地是否已有 peer,如果没有就先创建再处理 SDP,确保状态机始终健壮。
第三类是 TURN 缺失导致对称 NAT 下无法直连。公共 STUN 只能帮你在大部分 NAT 下发现公网映射,遇到对称 NAT 时直连必然失败。OmniGame 的兜底方案是提供一个可选的自建 TURN 服务,配置为 coturn,建连失败时自动切换到 TURN。这里要再次强调,TURN 只兜底,正常情况流量根本不会经过它,所以即使你的服务器带宽只有 1Mbps,也足够服务几十个同时在线房间的极端兜底场景。
5.2 低带宽下的抖动与卡顿
即便连接建立成功,真实网络的抖动也会让你怀疑人生。移动端用户在 WiFi 和蜂窝网络之间切换时,会出现几十毫秒到几百毫秒的突发延迟。这种场景下,状态同步方案的客户端预测机制就是救命的。我在每个客户端维护了一个 200ms 的输入缓冲,发送操作指令时同时写入本地预测队列,房主广播权威帧后,客户端根据权威帧修正预测误差。
另一个关键调整是“可变同步频率”。房主在广播时动态评估最近 2 秒的平均 RTT 和丢包率,如果网络质量好就按 30Hz 广播,变差则降到 15Hz,同时提高客户端预测权重。这个自适应机制虽然只有几十行代码,但它在弱网环境下的流畅度提升非常明显。固定同步率在弱网下要么频繁超时,要么缓冲区见底,自适应方案总能找到当前网络能承受的极限频率。
还有一个小技巧:不要在 RTCDataChannel 上用超高频发送“我是谁我在哪”这种冗余信息。把多个玩家的移动包攒起来批量发,每包带一个目标玩家位图,空位不发。房主广播时合并 8 个玩家的状态到一个包,消息总量从 8 个小包变成 1 个稍大的包,净吞吐量不到原来的一半,网络空包数量大幅下降,对 Wi-Fi 休眠唤醒也更友好。
5.3 我的几招调试技巧
WebRTC 调试最实用的工具是 chrome://webrtc-internals。这个页面会记录你所有 PeerConnection 的完整状态,包括 ICE 状态机变化、候选对列表、数据通道的字节收发量。当玩家报告连不上时,我第一步永远是让他打开这个页面,截图发回来。通过查看候选对和连接状态,三分钟内就能定位是 STUN 失败、候选收集慢还是数据通道没打开。
另一个技巧是在数据通道上做“心跳加 RTT 探测”。每隔 500ms 发一条携带本地时间戳的可靠通道消息,对端收到后立即回一条同样带时间戳的消息。这样维护一个滑动窗口的 RTT 估值,不仅用于自适应同步频率,也能在连接中断前提前预警。心跳连续丢失 5 次,就判定连接失效,触发自动重连流程。
最后,强烈建议在开发阶段把 ICE 候选日志打开,并且用一个“手动信令模式”方便调试。在手动模式下,两个浏览器的 SDP 和 ICE 候选会打印到控制台,你可以复制粘贴互传,完全不依赖信令服务器。这个模式让你能把问题隔离在“P2P 建连失败”和“信令服务器故障”两个层面,避免网络问题和 WebRTC 问题搅在一起,排查效率能翻倍。
6. 最后想说的
从零开始造一个带 WebRTC P2P 的小游戏引擎,技术上并不算难,但它实实在在重构了我对“网页游戏工程上限”的认知。之前我总觉得多人游戏必须有后端,必须有专门的同步服务,必须用大而全的引擎才能撑起体面。做完 OmniGame 之后我才意识到,对 2 到 8 人的小规模对抗和合作游戏来说,一个 30KB 的零依赖运行时加一个不到 300 行的信令服务,完完全全够用,而且运营成本几乎为零。
我个人在实际操作中体会最深的一条原则是:工程上限不是由你用了多少技术堆出来的,而是由你敢于砍掉多少不必要的依赖决定的。每一次无谓的依赖增加,都让代码的可理解性、可部署性和长期可维护性下降一截。是做加法容易的,但持续做减法,才能真的把一个项目推到它原本摸不到的高度。OmniGame 目前还在持续扩展,后续我计划加进 WebTransport 的备选传输层、支持房间迁移的跨信令服务切换,以及更完整的断线重连状态机。如果你也在做类似的东西,欢迎沿着零依赖的思路走下去,你会发现浏览器的原生能力门槛低得惊人,而天花板高得离谱。