
搞网页小游戏最让我头疼的从来不是玩法而是怎么让人打开就玩。做过联机游戏的都知道拉上朋友一起玩的第一步往往是下载、注册、开房间、进服务器光这套流程就能劝退一大半人。所以当我开始折腾 OmniGame 这个项目时给自己定了一条硬规矩零依赖复制链接、浏览器打开就能玩。而更贪心的是我还想让它支持多人实时联机。这不是个容易的目标。传统网页联机方案离不开服务器中转一来要买服务器、写后端、维护房间状态二来延迟也不可控。到了 OmniGame 做到后半程我干脆把联机方案整个推倒换成了WebRTC P2P玩家之间直接点对点通信服务器只负责介绍认识游戏数据包不再经过我自己的服务器中转。这套架构跑下来我才真正感受到网页小游戏的工程上限被抬到了什么位置——不需要安装任何软件不需要后端长连接两个浏览器之间就能低延迟跑实时对战。这篇博文就做一份技术白皮书式的复盘从零依赖的单文件网页资产结构到 WebRTC 的 P2P 通道搭建、游戏状态同步设计、主机迁移与断线重连再到我在实测中踩过的一堆坑。内容偏工程向但我会把每个决策背后的为什么也讲清楚适合正在做网页游戏联机、或者想理解浏览器 P2P 到底能干什么的开发者参考。1. 零依赖的工程哲学为什么一个游戏要从零依赖开始1.1 零依赖不是啥都不管而是把所有资产压缩成单一页面很多人一听零依赖第一反应是那不就是写个简单 HTML 吗。真不是。OmniGame 的零依赖指的是玩家侧没有任何安装成本项目侧也没有构建、打包、第三方库维护的负担。整个游戏就是一个可以被看作单一交付物的网页资产——HTML、CSS、JavaScript、美术资源全部内联在一个文件里或者最小化地拆成几个静态文件放在同源目录下。我在设计时坚持了单页面即产品的原则。一个典型的 OmniGame 游戏页面结构大概长这样!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titleOmniGame 示例夜间飞行/title style /* 全部布局、动效、Canvas 样式内联 */ /style /head body canvas idgame/canvas script // 游戏逻辑、音频合成、网络模块全部内联 /script /body /html你把这个文件交给任何一个人他用浏览器打开文件游戏就能跑。放到任何静态托管上别人拿到链接就能玩。这种单个文件即完整应用的形态就是把交付成本压到了理论最低点。为什么我坚持零依赖因为我观察到网页小游戏的隐形流失率极高。一个稍微像样的游戏如果玩家需要经过下载 App - 注册 - 解压 - 安装 - 打开 - 更新这条链路每一步都在流失用户。而浏览器直接打开链接意味着零门槛分享一篇文章、一个二维码游戏就出去了。这种分发效率是 App 形态永远比不了的。1.2 零依赖带来的工程红利没有 node_modules也没有环境地狱做前端项目的人都有过这种经历一个新项目 clone 下来npm install 装了半天跑起来发现 node 版本不对、某个依赖有 breaking change、构建产物出了问题。这些问题在 OmniGame 的体系里从根源上不存在——没有依赖也就没有依赖升级带来的连锁反应。整个项目我刻意绕开了所有运行时依赖和构建工具。开发时直接改 HTML 里的代码刷新浏览器就是最新的。发布时原样扔到任意静态服务器甚至连打包这一步都不需要。这对小游戏这种玩法迭代快、上线周期短的品类来说价值巨大我想改一个参数改完保存玩家那边刷新就能体验到想修一个物理碰撞的 bug直接改源码不用等 CI 构建产物。那美术素材怎么办传统游戏一定要大量图片资源。OmniGame 的解法是能生成的绝不外挂文件简单的粒子、星光、渐变背景用 Canvas 实时绘制音效用 Web Audio API 程序化合成角色动画用代码驱动 Canvas 路径变化。只有少量真正复杂的纹理才考虑内联为 base64。这不是为了炫技而是零依赖本身就要求资源尽可能内联把外部请求数降到最低。我实测过一个包含完整玩法和游戏音效的页面总体积控制在 100KB300KB 是很轻松的这在移动网络下基本秒开。1.3 把浏览器当免费依赖WebRTC 恰好是现代浏览器内置的能力严格来说OmniGame 也不是真的零依赖——它依赖的是浏览器本身已经提供的能力。现代浏览器本身就是一套巨大的运行时内置了 Canvas、Web Audio、WebSocket、IndexedDB还有我们最关心的 WebRTC。对我这种做网页小游戏的人而言WebRTC 就像是浏览器这个平台上白送的 P2P 联机能力我不需要引入任何第三方 SDK只需要调用原生接口就能在两个浏览器之间建立实时数据通道。所以零依赖和 WebRTC 并不矛盾。我的做法是把零依赖当作工程原则把 WebRTC 当作原则之下的能力基础。在 OmniGame 的技术栈里没有一行代码依赖于外部的联机服务商玩家之间一旦建立起 P2P 连接所有游戏数据都是直连传输。这一下就把联机游戏的两个大问题同时解决了服务器成本和连接延迟。2. 为什么最终的联机方案选了 WebRTC P2P2.1 网页联机的三条路线我为什么没选 A 和 B做网页游戏联机业内有几条常见路线我逐一对比过最后才锁定 WebRTC。先说第一条传统中心服务器方案也就是用一个后端Node、Go、Java 都行维护房间玩家通过 WebSocket 连上来所有消息由服务器转发。这方案的优点是开发简单、逻辑可控缺点也很致命在线人数上来之后带宽成本和服务器压力呈线性增长而且每一条消息都要先飞到服务器再飞到对面物理距离直接决定了延迟上限。对实时对战类小游戏来说转一圈服务器的延迟是不可接受的。第二条路线是Serverless 房间方案用云函数和托管数据库做房间管理玩家之间通过云轮询同步状态。这种方案的好处是不用长期占用服务器资源按调用次数计费坏处是它的同步机制天然不适合高频状态更新。云函数那点毫秒级冷启动还有数据库读写开销叠加在游戏循环里只适合棋牌类这种低频操作完全不支持弹幕射击、格斗、赛车这种每帧都要同步状态的玩法。第三条就是 OmniGame 最终走的WebRTC P2P 方案。它的核心特点是浏览器之间直接建立连接游戏数据不经任何服务器中转。服务器准确地说叫信令服务器只在建立连接前干一件事——帮双方交换建立连接所需的握手信息之后就可以退居幕后。游戏过程中哪怕信令服务器宕机了已经在玩的人也不受影响。这种架构我做过一个非常直白的类比传统方案是所有人去邮局取信P2P 方案是互相交换电话号码之后直接打电话。三条路线的核心对比我整理成了表格做技术选型时可以拉到一起看方案玩家间延迟服务器成本开发难度适用场景中心服务器转发高受服务器位置影响在线人数越高成本越高中等房间制、低并发或不在意延迟Serverless 轮询较高有函数调用开销按调用付费空闲不花钱低低频状态同步、棋牌WebRTC P2P低直连不中转信令服务器极低负载较高实时对战、点对点互动2.2 一次 P2P 连接的完整生命周期WebRTC 对很多前端开发者来说是个黑盒子我在这里用它的时候也花了不少时间才把流程理清。如果要用最简单的话解释WebRTC 就是让两个浏览器互相打电话的技术而打电话之前需要互相知道对方的号码和位置这个交换过程叫信令。完整建立一条连接的过程是这样的发起方先调用createOffer生成一个 offer包含自己支持的编码、传输方式等信息格式是一段叫 SDP 的文本然后通过信令服务器把它发给接收方。接收方拿到 offer调用createAnswer生成应答 SDP再传回发起方。双方拿到对方的 SDP 后就要开始探测怎么直连到对方——这个阶段浏览器会尝试各种路径包括局域网直连、通过 STUN 服务器获取自己的公网映射地址、以及最不得已时通过 TURN 服务器中继转发。实际编码出来就是下面这样一套核心流程我用极简的 JavaScript 表示const peer new RTCPeerConnection({ iceServers: [ { urls: stun:stun.example.com:3478 }, // TURN 服务器只在 NAT 环境极为复杂时兜底 ] }); // 1. 创建数据通道用于传输游戏状态 const channel peer.createDataChannel(game, { ordered: false, maxRetransmits: 0 }); // 2. 发起方生成 offer const offer await peer.createOffer(); await peer.setLocalDescription(offer); sendSignal({ type: offer, sdp: offer }); // 通过信令服务器发给对方 // 3. 接收方收到 offer 后生成 answer await peer.setRemoteDescription(offer); const answer await peer.createAnswer(); await peer.setLocalDescription(answer); sendSignal({ type: answer, sdp: answer }); // 4. 双方通过事件监听交换 ICE 候选地址 peer.addEventListener(icecandidate, ({ candidate }) { if (candidate) sendSignal({ type: ice, candidate }); });这段代码省略了信令传输本身我用的是极简 WebSocket 服务但它涵盖了建立连接的全部关键环节。stun:开头的服务器用来帮浏览器发现自己公网映射地址iceServers里的配置并不是所有情况下都必须但公共 STUN 能显著提高直连成功率。我用 omniGame 项目里的房间场景测试过同一局域网内直连通常能在 300ms 内建立连接公网环境下 80% 以上的连接可以在 1 秒左右完成握手。2.3 NAT 穿透的真相为什么有时候直连就是失败做 WebRTC 联机绕不开 NAT 穿透这个话题。大多数家用设备都在路由器后面路由器会给每台设备分配一个内网 IP对外通信则借用路由器的公网 IP 加一个端口映射。两个都在 NAT 后面的浏览器要想直连就得靠ICE 框架浏览器先枚举自己的本机地址host 候选再用 STUN 服务器获取NAT 映射后的公网地址server-reflexive 候选两边拿到候选后进行连通性检查找到能通的那一对。但现实比理想残酷。NAT 的类型决定了很多情况下打洞成功与否。我实测下来最常见的是锥形 NATCone NAT这种场景下只要双方都用 STUN 获取了公网映射地址大概率能直连但如果遇到对称型 NATSymmetric NAT设备每次连接同一个目标都映射不同的端口STUN 学到的地址对实际连接没有意义这时候基本只能靠 TURN 服务器中继。于是我们前端玩家很容易碰到一个现象两个人在同一个 WiFi 下联机非常顺滑但一个在移动网络、一个在公司网络时偶尔就会卡在正在连接界面。这不是游戏 bug而是 NAT 穿透失败。OmniGame 的应对是把谁是发起方、是否启用中继这些逻辑做成可配置的并且在 UI 上实时显示连接状态——玩家至少能知道到底是网络问题还是游戏问题而不是卡在一个黑屏上。2.4 拓扑结构为什么小游戏用 Mesh P2P 就够了WebRTC 的组网方式有很多种OmniGame 默认用的是Mesh 拓扑每个玩家和房间里的其他玩家各建一条 P2P 连接形成全网状网络。这个方案在大型视频会议里会被嫌弃因为 N 路视频流会让每个节点的带宽暴涨但放到小游戏场景里反而恰到好处。我做了一个简单的数学测算3 人联机需要 3 条连接4 人需要 6 条5 人需要 10 条以此类推连接数是n*(n-1)/2。在 OmniGame 的定位26 人休闲对战内每条数据通道承载的只是游戏状态值、输入指令这些几字节到几百字节的数据包带宽压力几乎可以忽略不计。更重要的是Mesh 不需要任何服务器参与数据转发也就没有单点瓶颈哪怕有一个玩家网络很差只影响他自己那条边不会拖垮整个房间。如果将来要支持几十人同场竞技我会考虑换 SFU选择性转发单元方案需要一个轻量中继服务器转发每个玩家的状态但至少对目前 OmniGame 的游玩场景来说Mesh 是工程实现最少、延迟最低、成本最省的选择。从实际体验反馈看给我留下最深刻印象的是一次 5 人联机的测试全程没有任何一条连接出现明显卡顿而这个过程中根本没有一台游戏服务器在转发数据。3. 核心实现拆解把 P2P 缝进游戏循环3.1 网络层封装数据通道不是 WebSocket使用方式完全不同拿到一条 WebRTC 数据通道很容易但真正把它用好需要先理解它和 WebSocket 的本质区别。WebSocket 是客户端到服务器的长连接所有消息天然有序、可靠WebRTC 的RTCDataChannel则允许我们在可靠/不可靠、有序/无序之间自由组合。这一下就打开了巨大的优化空间。对实时的游戏状态同步来说我根本不需要迟到的旧数据——如果这一帧的坐标已经过期了那么等它重传到也没有意义。所以在 OmniGame 里创建数据通道时我特意设置成了不可靠、无序模式const channel peer.createDataChannel(state, { ordered: false, maxRetransmits: 0 });maxRetransmits: 0表示每条消息只发送一次不重传。这样配合的是游戏里的状态覆盖逻辑同一玩家同一时刻只保留最新一条完整状态如果网络上丢了旧包新包到了直接覆盖。这比 TCP 式的可靠传输更适合实时游戏因为它不会有Head-of-line blocking——网络层不会因为一个丢包而把后面的所有包堵在队列里等待重传。还有一个 WebSocket 时代少有人关注的坑消息大小。RTCDataChannel 的单条消息大小在实践中不要超过 64KB这主要和底层 SCTP 协议的分片机制有关。我在项目里要求所有同步协议使用紧凑的二进制编码用 DataView 手动写定型数组一个玩家的完整状态——位置、朝向、速度、动画状态、生命值全部塞进不超过 128 字节的二进制包里。消息越小越不容易触发分包和拥塞控制实时性越好。3.2 主机发现与房间协议信令只做介绍人WebRTC 建立 P2P 连接之前必须有一方知道另一方存在这一步绕不开一个轻量级信令服务。OmniGame 的信令服务我做得很克制一个 WebSocket 服务器只实现三个动作——创建房间、加入房间、转发信令消息。它不存储任何游戏状态只把 offer、answer、ICE 候选从 A 传到 B。房间协议的设计参照了很经典的邀请码制创建房间的玩家生成一个短码比如OMG-7F3K朋友加入时输入这个码信令服务器把同一个房间内各玩家的 WebSocket 连接关联起来然后为他们转发建立 WebRTC 连接所需的握手数据。一旦数据通道打开信令服务器就完成了使命——之后的游戏数据完全走 P2P不再经过它。一个实际的考量是房间创建者也是主机。因为信令服务器不保持游戏状态所以必须有一个玩家承担权威服务器的角色负责统一下发全局状态、判定胜负、处理游戏事件。OmniGame 采用的就是主机权威模式创建房间的玩家跑一个游戏权威逻辑副本所有其他玩家把输入指令发给主机主机计算出结果后把权威状态广播给所有人。这能很大程度上杜绝不同玩家之间状态不一致的问题是联机小游戏里最容易被忽略的稳定性设计。3.3 实时同步的取舍状态型同步与输入型同步游戏状态同步有两种主流路线我在 OmniGame 里都用过最后按游戏类型做了分化。第一种是输入型同步也叫锁步同步所有玩家把操作指令发给主机主机在同一帧执行所有玩家的指令再把执行结果广播。这种方案的优点是网络包极小一个操作只有几个字节、所有玩家看到的世界完全一致适合 RTS、格斗这种结果可确定性很强的游戏缺点是对延迟极度敏感一个玩家卡顿全房间都要等在公网环境下体验容易崩。第二种是状态型同步主机计算完整个游戏世界把所有实体的位置、状态打包广播给客户端客户端负责插值渲染。这个方案的优点是能容忍一定的网络抖动玩家延迟稍高也只会表现为对方瞬移而不会让全房间卡住缺点是数据包稍微大一点每个玩家都有 200ms 的本地延迟补偿问题。做 OmniGame 的时候我给不同类型的游戏定了不同的默认策略动作类、休闲对战类默认状态型同步 客户端插值同步要求苛刻的小众玩法比如需要精确判定先后手的小游戏可以切换为输入型同步。做技术选型时一定要想清楚你的玩法对所有玩家看到同一画面的要求有多高而不是无脑选择某个模式。我最初在第一个 Demo 里用了输入型同步结果公网测试时一有玩家卡顿整个房间体验直接崩盘后来切到状态型同步 主机权威同样网络条件下流畅度提升非常明显。3.4 主机迁移与断线重连把掉线从事故变成体验P2P 联机最尴尬的一点是主机玩家掉线整局游戏就崩了。传统中心服务器方案不存在这个问题因为服务器永远在线但 P2P 里主机说走就走游戏状态就没了。OmniGame 针对这个问题做了一套分级降级机制。第一步客户端本地缓存。每个普通玩家在自己本地也维护一个影子状态——它和主机权威状态不完全一样毕竟可能有错误预测但关键时刻可以用来恢复场景。第二步主机心跳与超时检测。每个客户端定期收到主机的状态同步包如果超过 1500ms 没收到就进入疑似主机掉线状态UI 提示等待主机恢复。第三步如果主机确实失联房间内剩余的玩家会选举一个临时主机新主机用自己本地缓存的影子状态作为恢复起点广播一个新的权威状态让剩余玩家继续游戏。这套机制不是完美的因为影子状态可能有偏差主机迁移后难免会出现一次全体同步跳变所有玩家回到新主机的版本。但相比直接全员回到大厅这已经是从事故变成体验的进步。我认为任何做 P2P 联机项目的人都应该在第一天就考虑主机迁移而不是等到玩家投诉了再补——因为补丁式的迁移实现往往要重构状态管理成本极高。4. 数据通道的限制、性能优化与调试手段4.1 数据通道的隐形限制RTCDataChannel 虽然强但它毕竟运行在浏览器沙箱里有几条隐藏在文档之外的限制我在 OmniGame 的实际开发里一一踩过。第一条是背压backpressure处理。数据通道底层有流控如果发送端不管接受端的接收速度疯狂发数据缓冲会无限增长导致延迟飙升、内存暴涨。WebRTC 提供了bufferedAmount属性来查看待发送的数据量我的做法是设置一个阈值通常 1MB 左右一旦超过就暂停模拟逻辑等bufferedamountlow事件触发后再继续。第二条限制是并行的数据通道数量。每个RTCPeerConnection可以创建多条逻辑通道各自独立配置可靠性但我在实际测试中发现通道数量太多会带来额外的调度开销。OmniGame 的做法是精简到一条或两条一条用不可靠模式做高频状态同步另一条用可靠模式做突发事件比如游戏开始、道具拾取、结算信息。可靠通道负责必须到达的消息不可靠通道负责丢了就丢了的实时状态各司其职。第三是消息大小和频率的权衡。我测试过数据通道的单条消息在 1KB 以下时非常稳定发到几十 KB 就可能遇到分片和丢包率上升。所以游戏里的大对象比如整局回放、文件级数据我永远不会直接通过数据通道发而是拆分成小块用可靠通道分批发。频率上状态同步我控制在 2530 包/秒也就是每 3340ms 发一次完整状态输入命令可以更激进但仍然要照顾到上面说的背压问题。4.2 实测数据P2P 到底比服务器快多少为了验证P2P 更快这个结论到底是不是玄学我在 OmniGame 里做了一组对照测试同样的两个玩家一个走我的公网信令服务器 WebSocket 转发游戏状态模拟中心服务器方案另一个走 WebRTC P2P 直连。测试环境一边是北京家庭宽带一边是上海移动网络测得的结果很有参考价值指标WebSocket 经服务器转发WebRTC P2P 直连单次状态包延迟RTT3560ms1530ms抖动jitter±12ms±5ms传输路径经服务器节点多次路由尽量直连或经过最少中继服务器带宽消耗每玩家 50KB/s 级信令阶段后约为 0掉线恢复延迟重连需重新握手同设备换网络时仍需重建表格里的数据说明一个很关键的事情P2P 不是让延迟消失而是减少一段不必要的路程。物理距离还在但从客户端到服务器再到客户端的三角形路径被尽量压缩成了直连线。特别是在两个玩家地理上很近时同一个城市、同一个网络P2P 的优势格外明显。对 OmniGame 这类休闲竞技游戏来说延迟从 50ms 降到 20ms体感差异是质变级的一个是微微有延迟但能玩一个是基本感觉不到延迟。4.3 调试工具与排障步骤别再把 WebRTC 当黑盒WebRTC 调试有一个利器很多人并不知道Chrome 内置的chrome://webrtc-internals。打开这个页面再启动游戏它会以时间线方式记录下每一次 ICE 候选、信令交换、数据通道状态变化、连接质量统计延迟、丢包、往返时间。我在 OmniGame 开发阶段几乎每天都会开着它排查问题。实际排障时我一般按四步走。第一步看ICE Connection State是否稳定在connected如果它反复在checking和disconnected之间切换说明候选路径不稳定可能触发了 ICE 重启。第二步看inbound-rtp和outbound-rtp里那个currentRoundTripTime字段这个值就是毫秒级的 RTT延迟是几毫秒还是几百毫秒一目了然。第三步看>