每次看到“网页小游戏”这个词,我脑子里首先蹦出来的不是玩法策划,而是那个让人头大的联机问题:到底要不要上服务器?要上多大服务器?流量费谁出?聊着聊着就发现,大多数小游戏压根不需要一个常驻后端,它们需要的只是“让两个浏览器之间能直接传话”。OmniGame 就是沿着这个思路做出来的一个零依赖网页小游戏工程,核心技术栈只有三个关键词:零依赖、WebRTC、P2P。没有框架、没有构建步骤、没有 npm 安装,打开浏览器就能跑,玩家之间通过 WebRTC 的 P2P 通道直接交换数据,而不是再绕一段服务器中转。
这篇文章我会把 OmniGame 从零到一过程中踩过的坑、拆过的轮子、以及我自己对技术选型的思考完整写出来。它不是什么高深莫测的架构设计,但如果你也想做“网页小游戏多人联机”却不想一上来就引入一整套重后端,或者想搞明白 WebRTC 这条链路到底是怎么回事,那我踩过的这些坑应该能帮你省下不少时间。
1. 为什么是零依赖:网页小游戏的工程边界其实很窄
做 OmniGame 之前,我先想清楚了一个问题:网页小游戏这个品类的工程约束到底是什么?答案特别直白,就是“分享即玩”。一个访客点开链接,三五秒内必须能上手,最好连加载进度条都别太长。这意味着游戏资源要轻、启动要快、运行环境要足够通用。如果这时候再引入 npm 依赖、打包工具、环境变量、构建产物,玩的人还没开始,项目先把自己卷死了。
所以我把“零依赖”当成工程红线:浏览器原生能做的事,绝不用第三方库去包一层。游戏逻辑用原生 JavaScript 写,UI 用 CSS Grid 和 Canvas 解决,网络通信直接用 WebRTC 和 WebSocket 的标准 API。这里说的零依赖,并不仅仅指前端代码不引库,还包括整个工程没有构建步骤、没有模块加载器、没有 npm install。代码写完了,直接在浏览器里打开 HTML 文件就能跑,需要多人联机时再配一个几十行的轻量信令服务。
有人会问:不用构建工具,代码组织和模块化怎么做?答案是现代浏览器原生已经支持 ES Module,<script type="module">就是标准的模块系统。服务端那边我也刻意只用了 Node 内置的http模块,配合 SSE 实现了一个极简信令服务,整个过程没有跑过一次npm install。这种“扣扣搜搜”的做法看似限制很多,实际上反而让我把注意力全放在浏览器提供的能力上,不花时间调试“某个库的版本为什么不兼容”。
零依赖还带来一个额外的工程红利:没有中间层,就没有中间层引入的不可控因素。凡是最终会跑在玩家浏览器上的代码,全是我自己写的,几百行而已,出现任何问题都能直接定位;凡是部署在服务端的代码,只有信令服务那 30 行,逻辑上唯一要做的事情就是把一个人发来的信令消息原样转给另一个人。这就让整条调试链路变得非常舒服,浏览器报错、服务端日志、网络面板三者对应起来,不需要猜“这个异常是不是某个库抛出来的”。
2. 方案选型:C/S、WebSocket 中转,还是 WebRTC P2P?
聊完零依赖,接下来要回答的是 OmniGame 的核心问题:两个浏览器之间,到底用什么方式通信。我在最开始列了三套方案,分别对应三种完全不同的工程口味。
第一套是传统的客户端-服务器模式,游戏逻辑全在服务器上跑,浏览器只负责渲染操作结果。这套方案的优点是好控制、好反作弊,逻辑统一在服务端,但缺点也致命:需要一个体量不小的后端,需要运维,需要处理并发扩容,而且每一帧的输入输出都要走一次网络往返,延迟高了一个数量级。对一个小游戏来说,为它配一个可扩展的分布式后端,属于典型的杀鸡用牛刀。
第二套是 WebSocket 服务器中转模式,浏览器只做客户端,所有玩家消息都发到一台 WebSocket 服务器上,服务器再把消息广播给其他人。这个方案工程上最容易实现,稳定性也好,但它有一个难以回避的问题:无论玩家之间物理距离多近,数据都必须绕到服务器上走一圈,RTT 天然多了一段。对需要精确定位的动作类小游戏来说,这段绕路带来的延迟抖动直接影响手感。
第三套就是 OmniGame 最终采用的 WebRTC P2P 模式。浏览器之间直接建立点对点连接,数据包不再经过服务器中转,游戏指令的发送路径就是“玩家A -> 玩家B”的直线路径。时延理论上是局域网内的网络时延,跨公网时也远比中转方案低。更重要的是,当一场游戏只有两到四名玩家时,每条连接都是独立的,不存在“服务器负载”的概念,带宽成本被分摊到了每个玩家自己的上行链路上,对开发者来说几乎是零成本运营。
当然 P2P 不是什么银弹,它有一个天然的工程上限:网状拓扑下每增加一个玩家,每个终端就要多维护一条 DataChannel。五个人游戏时,每个客户端要维持四条上行通道和四条下行通道,跑一些精细动作同步时带宽压力很大。所以 OmniGame 的定位很明确,就是两到四人规模的小游戏联机,人数一旦超过八人,我就会建议你老老实实回到 C/S 或 WebSocket 中转模型。选型这件事,永远不是选“最好的”,而是选“最匹配场景的”。
3. 核心链路拆解:WebRTC 连接建立时,底层到底干了什么
很多人在网上搜 WebRTC 教程,一上来就是RTCPeerConnection和createOffer,抄完代码发现连不上,也不知道为什么。我自己在 OmniGame 里做联机时,也有一段“凭感觉调 API”的黑暗时期。后来把底层链路完整梳理了一遍,才明白连接建立不是一个 API 调用,而是一连串协商和试探的过程,整个过程可以拆成四步:信令交换、SDP 协商、ICE 收集与连通性检查、DataChannel 建立。
先说信令。WebRTC 本身只负责建立连接和传输数据,它并不负责“你怎么找到对方”。就像打网络游戏之前,你得先知道朋友在哪个服务器、房间号是什么。这个“找到对方”的动作就是信令(Signaling)。WebRTC 标准故意不规定信令的传输方式,留给开发者自己决定,你可以用 WebSocket、用 SSE、甚至用邮件手动复制粘贴消息都行。OmniGame 选的是多种方式里最轻的一种:HTTP + SSE,因为它只需要做一件事——把玩家 A 发出的 SDP/ICE 消息原样转发给玩家 B,完全不需要复杂的房间管理和持久化状态。
然后是 SDP 协商。当你调用createOffer()后,浏览器会生成一个 SDP(Session Description Protocol)字符串,里面描述了本端想用的媒体能力、编码格式、传输参数等。对方收到 Offer 后调用createAnswer()生成 Answer,双方交换完毕后,就都知道了对方“能吃什么、不能吃什么”,这是协商阶段。这里有个容易被忽略的细节:一个RTCPeerConnection每次会话只能完成一次稳定的 Offer/Answer 交换,如果双方同时发起 Offer,就产生了“ glare ”冲突。我在代码里对应处理的方法是:每一端固定判断自己是不是发起方,不是发起方的一方收到 Offer 后立刻用“ polite peer ”模式响应,永远不主动抢发 Offer,这样能从协议层规避大部分冲突。
消息协商完之后进入 ICE 阶段,这是 WebRTC 最核心也最容易被误解的部分。ICE(Interactive Connectivity Establishment)做的事情,是帮两端找出“当前网络条件下,哪条路径能真正把数据送过去”。浏览器会通过 STUN 服务器查询自己的公网地址映射,同时收集本机的内网 IP、端口组合作为候选地址(candidate),然后通过信令把所有候选发给对方。双方拿到候选列表后,就开始依次尝试配对连通性检查。你可以把候选理解成“两个人在不同小区,互相喊话:我这里有门牌号,你先来敲敲看哪扇门能打开”。NAT 类型决定打洞成功的概率:如果双方都在宽松型 NAT 下,很容易打通;一旦遇到对称型 NAT 或企业级防火墙,UDP 打洞几乎必然失败,这时候就需要 TURN 服务器作为最后的中转兜底。
最后是 DataChannel。createDataChannel本身不产生数据流,它是在 ICE 连接建立后,基于 SCTP over DTLS 封装出的一条数据通道。DataChannel 有两种模式:可靠有序(ordered, reliable)和部分可靠无序(unordered, partial reliable)。在 OmniGame 里,我做了区分:玩家的操作指令走unordered + maxRetransmits: 0的通道,因为操作消息丢一两条可以接受,但延迟必须最低;而房间状态、玩家加入离开等关键控制消息走可靠有序通道,保证不丢不乱。这两种通道在代码里唯一的区别就是几个初始化参数,但使用效果的天差地别,这个我后面详细展开。
4. 实操:OmniGame 的零依赖工程是怎么一步步搭起来的
接下来是硬菜环节。我把 OmniGame 的核心实现拆开给你看,它是怎么做到“零依赖”却能跑起多人 P2P 联机的。
4.1 极简信令服务:只用 Node 原生模块就够了
信令服务的代码量非常少,总共不到 40 行。我用的不是 WebSocket,而是 HTTP + SSE。为什么不用 WebSocket?因为零依赖的红线要求我不能引入ws这个 npm 包,而 Node 原生并没有 WebSocket 服务端实现;但 Node 原生http模块是内置的,SSE 也只是 HTTP 的一个流式响应特性,完全不需要额外依赖。实际体验下来,SSE 做这种“单向推送、客户端用 GET 请求主动上报”的信令场景特别合适。
const http = require('http'); const crypto = require('crypto'); // 房间表: roomId -> { peers: Map<peerId, res> } const rooms = new Map(); http.createServer((req, res) => { const url = new URL(req.url, 'http://localhost'); const roomId = url.searchParams.get('room'); const peerId = url.searchParams.get('peer'); if (url.pathname === '/connect') { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }); res.write(': connected\n\n'); if (!rooms.has(roomId)) rooms.set(roomId, new Map()); rooms.get(roomId).set(peerId, res); req.on('close', () => { rooms.get(roomId)?.delete(peerId); }); return; } if (url.pathname === '/send') { const targetPeer = url.searchParams.get('to'); const message = url.searchParams.get('msg'); const target = rooms.get(roomId)?.get(targetPeer); if (target) target.write(`data: ${encodeURIComponent(message)}\n\n`); res.end('ok'); return; } res.end('not found'); }).listen(8787);这段代码的流程很直接:每个玩家通过/connect建立一个 SSE 长连接,服务器把每个连接的res对象存进房间表;当玩家 A 要发送信令给玩家 B 时,A 向/send发一个请求,服务器找到 B 的res,把消息以 SSE 事件形式推给 B。消息体的编码我用encodeURIComponent处理,避免换行和特殊字符破坏 SSE 协议。这样哪怕 SDP 里塞了各种网络参数,信令层也不会解析和关心内容,它就是一条透明管道。
实际用了之后,我发现 SSE 信令的唯一缺点是连接保持依赖 HTTP 长连接,如果中间有任何代理强行断链,客户端不会立刻感知,需要靠心跳来保活。不过在局域网和普通公网环境下,跑起来还是很稳的。
4.2 浏览器端 P2P 封装:一个 Peer 类搞定连接和消息
浏览器端我没有用任何 WebRTC 封装库,而是直接写了一个OmniPeer类,把RTCPeerConnection的生命周期封装起来。核心方法就这么几个:createOffer、handleAnswer、handleCandidate、send、close。
class OmniPeer { constructor({ polite = false, onMessage, onOpen, onClose } = {}) { this.pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, ], }); this.polite = polite; this.channels = { reliable: null, unreliable: null, }; this.pc.onicecandidate = (e) => { if (e.candidate) this.sendSignal('candidate', e.candidate); }; this.pc.ondatachannel = (e) => { this.setupChannel(e.channel); }; this.onMessage = onMessage; this.onOpen = onOpen; this.onClose = onClose; } setupChannel(channel) { if (channel.label === 'game-ctrl') { channel.onmessage = (e) => this.onMessage(JSON.parse(e.data)); } if (channel.label === 'game-action') { channel.onmessage = (e) => this.onMessage(JSON.parse(e.data)); } channel.onopen = () => { if (this.channels.reliable && this.channels.unreliable) this.onOpen(); }; } createDataChannels() { const reliable = this.pc.createDataChannel('game-ctrl', { ordered: true }); const unreliable = this.pc.createDataChannel('game-action', { ordered: false, maxRetransmits: 0, }); this.setupChannel(reliable); this.setupChannel(unreliable); } async createOffer() { this.createDataChannels(); const offer = await this.pc.createOffer(); await this.pc.setLocalDescription(offer); return offer; } async handleAnswer(answer) { await this.pc.setRemoteDescription(answer); } async handleRemoteOffer(offer) { this.createDataChannels(); await this.pc.setRemoteDescription(offer); const answer = await this.pc.createAnswer(); await this.pc.setLocalDescription(answer); return answer; } async handleCandidate(candidate) { try { await this.pc.addIceCandidate(candidate); } catch (e) { // 有时远程候选在 setRemoteDescription 之前到达,需缓存处理 } } send(type, data, reliable = true) { const channel = reliable ? this.channels.reliable : this.channels.unreliable; if (channel && channel.readyState === 'open') { channel.send(JSON.stringify({ type, data })); } } }这里有一点需要仔细处理:一端的ondatachannel回调触发时间,取决于通道是在哪一端创建的。创建方调用createDataChannel,接收方则通过ondatachannel事件拿到通道对象。我在createOffer和handleRemoteOffer里都调用了createDataChannels(),就是为了保证通道创建逻辑对两端一致。另一个小坑是:icecandidate事件的触发时机往往早于远端setRemoteDescription,所以addIceCandidate可能会报错。解决方案是在连接状态还处于have-local-offer之前,把收到的 candidate 缓存起来,等远端描述设置完再批量加入,否则个别候选丢失会影响 NAT 穿越成功率。
4.3 初始连接的生命周期:一整套标准流程
把信令服务和 OmniPeer 组装起来后,完整连接流程就是七步走:
- 玩家 A 打开页面,输入房间号点击“创建房间”,此时 A 被指定为发起方;
- A 调用
createOffer()拿到 offer,通过信令服务发给 B; - 玩家 B 在同一个房间号页面点击“加入”,收到 A 的 offer,调用
handleRemoteOffer()生成 answer 返回给 A; - A 收到 answer 后调用
handleAnswer(),两端都完成 SDP 描述设置; - 两端在 ICE 阶段持续交换 candidate,直到连接状态从
new变成connected; - DataChannel 触发
onopen,双方可以开始互发消息; - 任意一方断开,
onclose触发,对方显示“玩家掉线”。
实际操作中最容易出问题的是第二步和第三步的顺序。我在前期版本里让双方都尝试创建 offer 并互发,结果经常出现两边都处于等待对方的死锁状态。后来改成严格区分“发起方”和“加入方”:只有房间创建者主动发 offer,加入方永远被动响应。这样虽然少了一点对称美,但流程上简单且可靠。
4.4 游戏同步策略:没有服务器,谁说了算?
P2P 模式下的游戏同步,和 C/S 模式有一个本质差异:没有一个双方公认的权威节点。这也意味着必须自己定义游戏状态的“最终解释权”。OmniGame 里我选择了“主机权威 + 增量状态校准”的模式。
具体来说,创建房间的玩家被选为“主机”(Host),主机负责维护一份权威游戏状态:每个 NPC 的位置、血量、弹幕、道具刷新等。其他玩家(客户端)只发送操作指令(比如“我按下了方向键右”“我发射了一颗子弹”),主机收到指令后更新权威状态,再定期把状态增量广播给所有客户端。客户端在这个模型里做状态插值和本地预测,减少因网络延迟带来的顿挫感。
这个模式本质上还是把逻辑集中到了单个节点上,但它与“服务器权威”不一样:主机本身就是游戏参与者,而非额外部署的服务器,所以没有额外的机器成本。缺点也很清楚——主机的网络带宽成了瓶颈,它既要上行接收所有人的操作,又要下行广播所有状态。所以我把操作消息放到不可靠通道(丢消息可接受),把主机权威状态快照放到可靠通道(必须完整到达),让两条通道各司其职。实测下来,四人游戏、每条消息平均 60 字节、每秒 20 次状态广播的情况下,主机的上行带宽大概在 3-4MB/s 左右,家用宽带的典型上行(10-20Mbps)完全扛得住。
4.5 断线与重连:不写重连的联机游戏是不完整的
网络断开在 P2P 模式里比 C/S 模式更隐蔽,因为一端崩溃时,另一端的RTCPeerConnection可能过很久才触发connectionStateChange。我在 OmniGame 里专门设计了心跳机制:两端每 500ms 通过不可靠通道发送一个空信息,如果连续 5 次没收到对方的心跳,就判定对端掉线。这个心跳本身还兼了“链路质量探测”的功能,让客户端能动态调整状态插值的平滑参数,网络抖的时候就多用缓冲,网络稳的时候就降低预测延迟。
重连方面我做了一个比较务实的方案:如果需要重连,直接重建一个OmniPeer,走一遍完整的信令流程。看似粗暴,但对小游戏来说反而比“断点续传”要可靠得多——游戏状态每帧都在变,与其尝试恢复一个可能已经过期的连接,还不如重新同步一份最新状态。房间内其他玩家会在重连过程中保持当前画面状态,加上一个半透明的“对手重连中”遮罩,等新连接建立后接受一次全量状态快照,游戏继续。
5. 常见问题与排查实录:那些 WebRTC 教程没告诉我的坑
这一段我把实际开发中反复踩过的坑整理成了速查表,基本覆盖了能想到的所有 P2P 联机问题。
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
ICE 状态一直停留在checking | STUN 服务器不可达、NAT 打洞失败 | 先确认两端是否都能通公网,用curl stun.l.google.com:19302测试;如果 NAT 是对称型,配置 TURN 服务器兜底 |
SDP 交换后 dataChannel 不触发onopen | 两端都调用了createOffer,导致 glare 冲突 | 指定唯一发起方,另一方始终走 polite 响应逻辑,等待 Offer 而不是主动创建 |
| 偶发收到 candidate 时报错 | candidate 先于 remoteDescription 到达 | 缓存 candidate 到队列,在setRemoteDescription完成后再批量addIceCandidate |
| 消息发送成功但对方收不到 | 发送时 dataChannel 为connecting状态 | 所有send前检查readyState === 'open',并只在onopen后启用游戏逻辑 |
| 一段时间后连接静默断开 | 没做链路保活,NAT 映射老化 | 加心跳消息,500ms 一次;连续超时后主动重建连接 |
| 多人游戏时延迟越来越差 | 网状拓扑带宽叠加,上行带宽被打满 | 降低状态广播频率、减少无关消息、把操作指令切到不可靠通道;严格控制在线人数 |
下面挑三个问题展开说一下,因为它们不是单纯“查个文档”能解决的,需要从根上理解。
第一个是 WebRTC 隐私泄露问题。这个词经常有人在网上搜,搜多了你就知道它指的是什么:浏览器在正常使用 WebRTC 的过程中,出于 NAT 穿越的目的,会向 STUN 服务器发送请求,从而暴露自己的公网 IP;同时收集本地候选时,还会把内网 IP、网卡地址信息暴露给网页应用。对 OmniGame 这种游戏应用来说,如果玩家通讯录里隐私观念比较强,这就成了一个正经的合规问题。我在实现时做了三层防护:第一,默认不预建 PeerConnection,只有玩家明确点击“加入游戏”后才开始连接;第二,现代浏览器默认启用 mDNS 候选,会用*.local这种伪主机名替代真实内网 IP,这个特性一定不要关;第三,如果游戏面对的是特别注重隐私的场景,可以在浏览器层面限制 WebRTC 的 IP 处理策略,或者直接禁用对应能力。从“需要远距离联机”的角度看,关掉 WebRTC 等于关掉 P2P 功能,但作为一种用户选择,确实是存在的防护手段。我自己的态度是:应用层不要偷偷摸摸调 STUN,把“哪些信息会被访问”写清楚,比什么都强。
第二个是 ICE 打洞失败时的体验问题。很多公网环境好的开发者本地测试时一切正常,一部署到真实用户那边就发现 40% 的用户连接超时。原因往往是:企业防火墙挡了非 53 端口出站的 UDP 包,或用户路由器是对称型 NAT。这时候唯一的解药就是 TURN 服务器。WebRTC 的iceServers配置里,stun和turn可以同时出现:ICE 会优先尝试所有候选路径,只有在 UDP 打洞失败后才走 TURN 中转。只要配置正确,数据流量会自动选择最合适的传输路径,体验不会“直接变差”,而是“能连上,但延迟偏高”。如果做的是面向公众的小游戏,TURN 服务器是不能省的,哪怕用一台小带宽的云主机只做兜底,也比让玩家一直卡在连接页面强。
第三个是“为什么我关了某些浏览器功能后,P2P 游戏就玩不了了”。这其实是个认知问题:WebRTC 的 ICE 流程高度依赖浏览器的 UDP 能力,如果用户在浏览器设置里主动禁用了 WebRTC 或者 UDP,那么 P2P 模式理所当然无法工作。OmniGame 在遇到这种情况时要做好降级提示,比如检测到pc.iceConnectionState长时间处于failed后,弹出“当前网络环境无法直连”的说明,而不是让玩家干等。这一点我一开始没做,后来发现用户体验差别非常大。
做 OmniGame 这个项目的整个过程,最大的感受是:WebRTC 并不神秘,它的核心就是“在不确定的网络里尽力找到一条可用的路”。真正需要工程师花心思的,不是createOffer那几行 API 调用,而是信令链路的可靠性、同步协议的设计、断线重连的兜底,以及大量边缘情况的处理。如果你也要做类似的小游戏联机,我建议先从两三个人的局域网测试开始,把候选收集、ICE 状态流转这些细节用日志打出来看一遍,再上公网踩一次 NAT 打洞的坑,基本就能建立起对整套链路的直觉。最后送你一个小技巧:开发时在控制台把pc.iceConnectionState和channel.readyState的变化都打在日志里,你将会发现,之前那些莫名其妙的“连不上”,其实每一步都有迹可循。