
每次看到“网页小游戏”这个词我脑子里首先蹦出来的不是玩法策划而是那个让人头大的联机问题到底要不要上服务器要上多大服务器流量费谁出聊着聊着就发现大多数小游戏压根不需要一个常驻后端它们需要的只是“让两个浏览器之间能直接传话”。OmniGame 就是沿着这个思路做出来的一个零依赖网页小游戏工程核心技术栈只有三个关键词零依赖、WebRTC、P2P。没有框架、没有构建步骤、没有 npm 安装打开浏览器就能跑玩家之间通过 WebRTC 的 P2P 通道直接交换数据而不是再绕一段服务器中转。这篇文章我会把 OmniGame 从零到一过程中踩过的坑、拆过的轮子、以及我自己对技术选型的思考完整写出来。它不是什么高深莫测的架构设计但如果你也想做“网页小游戏多人联机”却不想一上来就引入一整套重后端或者想搞明白 WebRTC 这条链路到底是怎么回事那我踩过的这些坑应该能帮你省下不少时间。1. 为什么是零依赖网页小游戏的工程边界其实很窄做 OmniGame 之前我先想清楚了一个问题网页小游戏这个品类的工程约束到底是什么答案特别直白就是“分享即玩”。一个访客点开链接三五秒内必须能上手最好连加载进度条都别太长。这意味着游戏资源要轻、启动要快、运行环境要足够通用。如果这时候再引入 npm 依赖、打包工具、环境变量、构建产物玩的人还没开始项目先把自己卷死了。所以我把“零依赖”当成工程红线浏览器原生能做的事绝不用第三方库去包一层。游戏逻辑用原生 JavaScript 写UI 用 CSS Grid 和 Canvas 解决网络通信直接用 WebRTC 和 WebSocket 的标准 API。这里说的零依赖并不仅仅指前端代码不引库还包括整个工程没有构建步骤、没有模块加载器、没有 npm install。代码写完了直接在浏览器里打开 HTML 文件就能跑需要多人联机时再配一个几十行的轻量信令服务。有人会问不用构建工具代码组织和模块化怎么做答案是现代浏览器原生已经支持 ES Modulescript typemodule就是标准的模块系统。服务端那边我也刻意只用了 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()后浏览器会生成一个 SDPSession Description Protocol字符串里面描述了本端想用的媒体能力、编码格式、传输参数等。对方收到 Offer 后调用createAnswer()生成 Answer双方交换完毕后就都知道了对方“能吃什么、不能吃什么”这是协商阶段。这里有个容易被忽略的细节一个RTCPeerConnection每次会话只能完成一次稳定的 Offer/Answer 交换如果双方同时发起 Offer就产生了“ glare ”冲突。我在代码里对应处理的方法是每一端固定判断自己是不是发起方不是发起方的一方收到 Offer 后立刻用“ polite peer ”模式响应永远不主动抢发 Offer这样能从协议层规避大部分冲突。消息协商完之后进入 ICE 阶段这是 WebRTC 最核心也最容易被误解的部分。ICEInteractive 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: MappeerId, 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 返回给 AA 收到 answer 后调用handleAnswer()两端都完成 SDP 描述设置两端在 ICE 阶段持续交换 candidate直到连接状态从new变成connectedDataChannel 触发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 状态一直停留在checkingSTUN 服务器不可达、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的变化都打在日志里你将会发现之前那些莫名其妙的“连不上”其实每一步都有迹可循。