这几年我经常看到一类帖子:求网页版小游戏合集、希望复制链接浏览器就能打开、强调不用下载App。老实说,这类需求在玩家眼里是“免安装”,在开发者眼里却是另一道坎——你不仅要让游戏跑得足够轻,还得在实时互动场景下把网络体验做到几乎无感知。市面上已经有那类点开链接即玩的音游和卡通休闲小游戏,但一旦想加入“两个人同时在线操作同一个画面”这种玩法,工程复杂度会立刻翻倍。
这次整理的 OmniGame 实践骨架,核心就两件事:零第三方依赖,外加 WebRTC P2P 联机。我先把话说清楚,这并不是什么新奇玩法的创新,而是把网页小游戏的工程组织方式重新梳理了一遍。如果你的目标是给一个小游戏加联机、被 npm 依赖树和服务器账单搞得头疼、或者想理解浏览器原生 P2P 能力到底能撑到什么程度,那这篇技术笔记应该能给你一个可以抄作业的起点。
1. 为什么小游戏的上限不在玩法,而在工程组织
1.1 用户搜的不是“小游戏”,而是“零等待的链接”
搜索热词有时候比产品需求文档更诚实。我观察到大量搜索集中在“网页版小游戏合集”“不用下载App”“复制链接浏览器打开”这几类关键词上。这些词背后的真实诉求不是“我需要一个功能复杂的3A大作”,而是“我现在就想玩,最好点一下链接就进游戏”。
这恰好暴露了网页小程序最容易被低估的地方:分发成本极低,但体验成本极高。打开就要能玩,意味着不能有漫长的加载、不能要求用户装任何运行环境、不能因为缺了一个 CDN 资源就白屏。传统的网页项目,哪怕只是一个贪吃蛇,只要你引入了构建工具、引用了第三方脚本、依赖了外部字体库,就已经存在三个潜在故障点。而玩家不会关心你的打包链条,他们只会在链接打不开时选择离开。
不要在“免安装”这件事上偷懒,这是 OmniGame 立项目标的原点。零依赖不是洁癖,是为了把“链接即游戏”这个承诺做实。
1.2 服务器中转是如何一步步吃掉实时性的
先来算一笔延迟账。假设玩家A在北京,玩家B在上海,物理上的网络往返大约是20毫秒到30毫秒。如果游戏逻辑走中心服务器中转,那么A的操作要先从A传到服务器,再从服务器传到B,反之亦然。一个完整的操作反馈,实际耗时就是“A-服务器”加“服务器-B”的两段路程,翻倍。
这还没算服务器本身的排队、跨运营商路由绕路、以及高峰期丢包。你在本地开发环境里做测试感觉不到差异,一旦真实用户分布在各种网络环境里,服务器中转的延迟和抖动问题会瞬间暴露。即时对战类小游戏对延迟尤其敏感,一个100毫秒的输入延迟,玩家在操作手感上就已经有明显“粘滞感”。
带宽成本也要单独算。服务器中转模式下,每个玩家的状态帧都要先上行到服务器,再由服务器分发给房间里的其他所有玩家。N个人在线,服务器就要处理N份上行加N×(N-1)份下行转发。房间人数一多,流量成本呈指数往上走。对小游戏团队来说,联机服务器本身不贵,贵的是流量和运维排障的人力。
1.3 关键判断:小房间、低延迟、无权威判定的场景才适合P2P
不是所有游戏都适合 P2P。但网页小游戏有一个共性:大多数玩法是4到8人的小房间,节奏快、状态简单、没有复杂的世界规则。这种场景下,中心服务器的核心价值——权威判定、全局广播、持久化——并不刚需。
我当时的判断很简单:如果游戏不需要服务器来决定“谁赢了”,只是需要把每个玩家的操作状态同步给其他人,那么为什么非要让所有数据都绕一圈服务器?让浏览器和浏览器直接对话,反而更符合小房间低延迟的诉求。
这个判断是 OmniGame 所有设计的起点。先承认自己不需要权威服务器,才能把 P2P 架构的优势真正用起来。
2. 零依赖的含义与 OmniGame 的工程组织
2.1 零依赖的边界不是“不用库”,而是“只用浏览器原生能力”
很多人一听到零依赖,第一反应是“这年头不用框架怎么写代码”。这里要澄清一下边界:OmniGame 说的零依赖,特指不依赖任何第三方 JavaScript 库、不依赖构建工具、不依赖外部 CDN 资源,整个项目只使用浏览器原生提供的 API。
现代浏览器实际上已经内置了相当完整的游戏开发能力:DOM 和 Canvas 负责渲染,requestAnimationFrame 负责游戏循环,WebSocket 负责网络通信,WebRTC 负责P2P连接,AudioContext 负责音频,甚至连压缩和二进制处理都有原生 API 支撑。我盘点了一圈,除了缺一个“物理引擎”和“资源加载器”,其他核心能力浏览器几乎都给了。
项目目录非常朴素,一个 HTML 入口文件加几个 ES Modules 文件,没有 package.json,没有 node_modules,没有 webpack config。这不是为了显得技术高超,而是为了确保一件事:无论这个项目在哪个环境里被打开,只要浏览器还活着,它就能跑。
project/ index.html js/ main.js network/ signaling.js peer.js datachannel.js sync/ serializer.js state.js game/ loop.js input.js render.js用原生 ES Modules 组织代码,现代浏览器原生支持 import/export,不需要任何编译步骤。开发的时候直接起一个静态服务器,生产的时候把所有 JS 文件内容合并压缩进一个 HTML,整个游戏的体积可以控制在 100KB 左右。
2.2 单文件分发带来的工程价值
零依赖带来的一个直接红利是分发形态变得极其自由。我倾向把每次构建产物打包成单个 HTML 文件,这个文件可以扔到任意一个静态托管平台上,也可以放在内网共享,甚至可以存进对象存储生成直链。玩家拿到一个链接,打开即玩,不存在图片路径失效、CSS 加载失败、JS 文件版本不匹配这一类问题。
产品形态的取舍也清晰很多。单文件版本适合线上分享;保留 Modules 结构的版本适合继续开发调试;还可以把游戏嵌入到其他页面的 iframe 里做活动入口。这三种分发方式共用同一套代码逻辑,只是打包策略不同。
依赖第三方库最大的隐成本是供应链风险。哪怕是一个很小的 npm 包,也可能在某天因为上游版本更新引入安全漏洞。零依赖意味着项目的攻击面被压缩到浏览器本身,这在做网页小游戏分发时能省掉非常多不必要的担忧。
2.3 零依赖模型下,必须自己补的功课
零依赖不是免费午餐,库和框架之所以存在,是因为它们帮你解决了通用问题。不用框架,就得自己补上这些能力。
在 OmniGame 里,我自己动手实现了几块关键基础设施:状态容器,管理所有游戏对象的同步状态;序列化器,把状态对象编码成二进制格式;增量同步模块,只发送两个状态帧之间的差异;房间管理模块,处理创建房间、加入房间、离开房间的完整流程;断线重连模块,应对主机掉线或者网络切换的情况。
这些模块在传统框架生态里都有现成实现,手写确实费时间,但带来一个很大的好处:每一行代码都完全可控。调试 WebRTC 连接问题时,你能准确知道问题出在自己代码还是外部依赖里,这种确定性的排查体验在联机功能里非常值钱。
3. 为什么是 P2P:WebRTC 帮网页小游戏解决了什么
3.1 延迟、带宽、部署成本:三个维度的硬对比
我把中心服务器模型和 P2P 模型放在同一张表格里做对比,结论一目了然:
| 对比维度 | 服务器中转 | WebRTC P2P 直连 |
|---|---|---|
| 端到端延迟 | A到服务器+服务器到B,至少翻倍 | A与B直接网络路径,近似物理延迟 |
| 服务器带宽 | 流量随人数平方级增长 | 服务器只做握手信令,带宽消耗可忽略 |
| 部署成本 | 需要公网服务器、扩容、监控告警 | 只需要一个极简信令服务 |
| 单点故障 | 服务器挂了全部掉线 | 没有中心节点,部分连接仍可用 |
| 内网场景 | 无法利用局域网低延迟优势 | 同一局域网可做到极低延迟 |
| 大房间支持 | 适合几十人以上 | 适合4到12人小房间 |
这个表格基于我自己实测和项目经验的估算,不同网络环境会有浮动,但趋势没有悬念。网页小游戏通常就是小规模、强实时、低成本运营,P2P 在这些指标上全面占优。
3.2 浏览器如何穿越 NAT:ICE 候选收集与连接协商
从架构设计落到浏览器实现,WebRTC 能直连的核心在于 ICE 框架。ICE 会同时尝试收集本机网卡地址、公网映射地址、以及中继地址这三类候选项,分别叫 host 候选、srflx 候选、relay 候选,然后通过连通性检查选出真正能通的那条路径。
可以打个比方:你在公司内网办公室,对方在家里的路由器后面。两个人要直接通话,得先知道对方的“地址”。host 候选是办公室分机号码,srflx 候选是前台帮你转接的外部号码,relay 候选则是实在无法直连时由总机转接的通路。ICE 的职责就是把所有可能的号码都收集起来,逐一打给通信检查,找到一条真正能接通的线路。
WebRTC 之所以适合网页小游戏,是因为这套连接协商机制完全由浏览器底层实现,不需要开发者处理 STUN 打洞细节。我们只需要做好信令转发,把 SDP 和 ICE candidate 信息交换给对端。
3.3 为什么传游戏状态用 DataChannel,而不是视频流
很多人以为 WebRTC 只能传音视频,忽略了一个更重要的组件:RTCDataChannel。它基于 SCTP 协议,专门用来在浏览器之间传输任意二进制数据,支持可靠有序、可靠无序、不可靠无序等多种可靠性模式。
游戏同步不需要传视频流,只需要传状态。每个玩家的位置、角度、分数、动画状态,序列化之后可能只有几十到几百字节。用 DataChannel 传这些状态帧,比传视频帧的带宽消耗低两三个数量级,而且延迟表现更稳定。
DataChannel 的不可靠模式特别适合高频状态帧:上一帧丢了还没什么,下一帧马上就能补上,不需要重传旧数据浪费带宽。而玩家的操作指令这种关键事件,则走可靠有序通道,确保不会因为乱序导致逻辑错乱。一开一收、一可靠一不可靠,两条通道就把游戏同步的两个核心诉求覆盖了。
4. 从信令到数据通道:OmniGame 联机实现逐层拆解
4.1 信令服务的最小可用设计
WebRTC 本身不解决“双方如何找到对方”的问题,这需要额外的信令机制。OmniGame 选择了一个最简单的方案:一个极薄的 WebSocket 信令服务,只干三件事——创建房间、交换 SDP 描述、转发 ICE 候选。
信令服务不参与任何游戏逻辑,也不转发任何游戏数据。它只存在于连接建立的握手阶段,一旦 RTCPeerConnection 成功打通,信令服务就可以闲置下来。这意味着信令服务器即使被流量打爆,已经建立好的 P2P 连接也不会中断。
信令消息格式尽量保持精简,一条 JSON 就够:
{ "type": "offer", "roomId": "room-001", "sdp": "v=0...", "sender": "player-a" }{ "type": "candidate", "roomId": "room-001", "candidate": {"candidate": "candidate:1 1 UDP...", "sdpMid": "0"}, "sender": "player-a" }我用浏览器原生 WebSocket API 实现信令客户端,代码量很少。真正要留意的是房间 ID 的生成策略和加入房间的权限控制,这个根据实际场景设计即可,核心原则是:信令服务越薄越好,永远不要把游戏帧数据放进信令消息里。
4.2 PeerConnection 生命周期:一次完整连接怎么建立
开发 OmniGame 联机模块时,我把 RTCPeerConnection 的建立流程收敛成一个清晰的步骤序列。下面这个骨架代码可以直接复用,它是整个 P2P 联机的地基。
const config = { iceServers: [ { urls: "stun:stun.l.google.com:19302" } ] }; const pc = new RTCPeerConnection(config); // 创建 DataChannel(发起方) const stateChannel = pc.createDataChannel("state", { ordered: false, maxRetransmits: 0 }); const eventChannel = pc.createDataChannel("event", { ordered: true }); stateChannel.onopen = () => console.log("state channel open"); stateChannel.onmessage = (e) => handleStateMessage(e.data); eventChannel.onmessage = (e) => handleEventMessage(e.data); // 收集 ICE 候选并交给信令服务 pc.onicecandidate = (e) => { if (e.candidate) { signaling.send({ type: "candidate", candidate: e.candidate }); } }; // 发起方:创建 offer 并发送 const offer = await pc.createOffer(); await pc.setLocalDescription(offer); signaling.send({ type: "offer", sdp: pc.localDescription }); // 接收方:收到 offer 后创建 answer async function handleOffer(message) { await pc.setRemoteDescription(message.sdp); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); signaling.send({ type: "answer", sdp: pc.localDescription }); } // 接收方:收到 answer 后建立完整链路 async function handleAnswer(message) { await pc.setRemoteDescription(message.sdp); }这里有一个非常容易踩的坑:在调用setLocalDescription(offer)之后,ICE 候选收集是异步进行的。如果代码立刻就把 SDP 发出去,可能会丢失后续收集到的候选。稳妥做法是拿到本地描述后先等待一小段时间,或者等icegatheringstate变为complete再发送 SDP,我推荐后者,兼容性更好。
4.3 DataChannel 的分包策略:把小游戏的“心跳”做好
连接建立后,真正决定体验的是 DataChannel 上的数据设计。OmniGame 在联机模块里定义了两条通道,用途完全不同。
事件通道走可靠有序模式,负责传输玩家的操作指令、开始/结束事件这类必须保证到达的数据。这类数据量小、频率低,即使重传也基本没有性能压力。
状态通道走不可靠且允许无序的模式,负责传输每帧的位置坐标、速度、动画状态等高频数据。允许丢帧反而更适合游戏实时性,因为旧状态帧延迟到达的意义也不大,尽快反映最新状态才是关键。
在通道消息格式上,我选择了二进制编码而非 JSON。同样的一个玩家位置数据,用 JSON 传输可能占用几十字节,用二进制编码配合定点数压缩后只有几个字节。对于高频状态通道,这个优化影响巨大。后面在实测部分会具体展开。
5. 实测数据、调优手段与常见翻车点
5.1 三个典型网络场景下的实测表现
没有实测数据的架构建议都是空谈。我搭了一个简单的双人控制场景,在一个 800×600 的 Canvas 里控制两个方块互相追逐,用 P2P 同步位置状态。测试分三个场景,数值取多次测试的中位数。
| 测试场景 | 平均端到端延迟 | 状态帧丢帧率 | 主观手感 |
|---|---|---|---|
| 同一局域网 | 2~5ms | 0% | 完全同步 |
| 同一运营商同城 | 20~40ms | 0.5%以下 | 基本无感 |
| 跨运营商公网直连 | 40~80ms | 1%~3% | 偶有轻微回跳 |
| 跨运营商+NAT穿透失败 | 无法直连 | 100%连接失败 | 不可用,需中继 |
这个结果完全符合预期:P2P 最怕的不是距离远,而是 NAT 穿透失败。穿透成功时,跨运营商的延迟也只略高于同城,完全在可玩范围内;穿透失败时,整个连接会卡在 connecting 状态。
测试方法也很简单:两个浏览器窗口分别打开游戏,用一个记录时间戳的脚本打点,对比发送与接收的时间差。建议你在自己的项目里也建立这么一个基础延迟观测点,后续做任何网络调优都需要它有数据支撑。
5.2 把状态帧压缩到“无感”:序列化与增量同步
我这里分享两个被验证非常有效的优化手段。第一是定点数压缩,把浮点坐标乘以100转成 int16 整数再传输,接收端除以100还原。浮点转整数后,字节占用从 4 字节降到 2 字节,损失精度在厘米级,对大多数小游戏完全够用。
第二是增量状态同步。不是每帧都发送完整状态,而是发送相对上一帧变化的部分。比如玩家没有移动,就只发送一个“位置不变”的标记;只有操作和动画切换时,才发送对应状态字段。实测下来,一个4人房间的状态通道数据量可以减少 60% 以上。
序列化格式我统一用 ArrayBuffer 和 DataView 处理,避免使用 TextEncoder 做字符串编解码带来的额外开销。整个序列化和反序列化的耗时控制在 0.5ms 以内,完全不会挤占主线程的游戏循环时间。
5.3 联机不稳定排查链路:从 ICE 候选到 TURN 中继兜底
WebRTC 联机出问题,90% 都出在 ICE 协商阶段。根据我的排障经验,排查顺序非常重要,不要一开始就怀疑自己代码逻辑。
第一步,打开两个浏览器控制台,确认信令消息是否完整交换了 offer、answer 和 candidate。如果 candidate 消息没出现,说明 ICE 候选收集没跑完,常见原因是 STUN 服务器不可达,或者icecandidate事件监听器绑定得太晚。
第二步,检查pc.iceConnectionState的状态变化。一直停在checking说明正在尝试连通但还没成功,此时可以尝试在 RTCPeerConnection 配置里增加更多的 STUN 服务器。如果状态直接变成failed,说明各方候选无法穿透现有 NAT 类型。
第三步,确认是否需要中继兜底。当玩家双方位于对称型 NAT 后面时,常规打洞基本无效,此时必须配置 TURN 中继服务器。我用标准 TURN 服务做兜底,但在代码设计上把它定位成“应急通道”而不是默认路径,只有iceTransportPolicy设置为"relay"时才强制走中继,平时仍优先直连。
最后提醒一个最隐蔽的坑:不要把host候选直接过滤掉。有人为了隐私把所有主机候选都禁用,结果导致同一局域网的两台设备也无法直连,被迫绕公网中继。工程上要平衡隐私和连接成功率,不是一刀切。
6. P2P 的边界与安全护栏:什么场景不要迷信直连
6.1 用户为什么想“关掉”WebRTC:ICE 信息暴露的工程取舍
“WebRTC 怎么关闭”这个搜索热词,在工程视角下其实是合理的:浏览器在收集 ICE 候选时,会把宿主机的内网 IP 也暴露给对端。对某些隐私敏感场景来说,这种本机网络信息泄露不值得承受,所以很多站点会主动限制 WebRTC 能力,或引导用户禁用。
但站在游戏开发者的立场,直接放弃 WebRTC 等于放弃了低延迟联机。OmniGame 的处理方式是对候选做收窄:配置iceCandidatePoolSize限制候选数量,或者通过RTCPeerConnection的iceTransportPolicy控制候选类型。在必须隐藏内网拓扑的场景,才考虑用 mDNS 混淆候选地址,而不是粗暴禁用所有候选。
这个问题的核心不是“要不要用 WebRTC”,而是“能不能在拿到连接能力的同时细粒度控制暴露哪些本地信息”。工程方案可以在这两者之间找到平衡,而不是非黑即白。
6.2 主机掉线、网络切换与状态接管
P2P 最让人头疼的问题之一就是主机容错。传统服务器模型里,服务器永远在线,玩家掉线重进就行;P2P 模型里,房间的创建者如果掉线,其他所有人的连接可能瞬间全部悬空。
我实测中遇到过好几次:创建房间的玩家因为切换 Wi-Fi,IP 变了,PeerConnection 断开,剩下三个玩家集体掉线。解决方案是主机迁移机制——每个客户端都持续保存最近一份完整状态快照,当检测到与主机的连接断开时,从其余客户端中选举一个作为新主机,用最近快照继续广播状态。
选举策略不需要复杂,房间里加入时间最早且当前在线即可。关键是所有客户端必须保持状态快照的持续性,这要求游戏状态容器设计成可整体序列化的结构。整个过程在两秒内完成,玩家只会感觉瞬卡一下,不会丢失整个房间。
6.3 什么时候老老实实回到服务器架构
P2P 不是银弹,我整理了一个简单的决策清单,帮助你判断当前的游戏适不适合纯 P2P。
| 需求条件 | P2P 是否合适 | 原因 |
|---|---|---|
| 4~8人小房间实时对局 | 合适 | 延迟低,带宽可控 |
| 需要玩家账号和进度持久化 | 不合适 | 没有可信中心存储 |
| 需要排行榜和跨房间匹配 | 不合适 | 必须有中心服务 |
| 需要反作弊和权威判定 | 不合适 | 客户端自报状态可被篡改 |
| 房间人数超过12人 | 谨慎 | 全互联连接数会膨胀 |
| 弱网环境比例高 | 谨慎 | NAT、运营商限制较多 |
如果在需求列表里命中了两条及以上“不合适”,建议不要强行 P2P。老老实实设计一个轻量权威服务器,把 P2P 当作局域网或者低延迟房间的增强模式,而不是唯一的联机方案。工程架构没有面子问题,只有适配问题。
我在实际项目里最大的体会是,P2P 模式把“游戏即链接”这个体验从概念变成了现实。做分发测试时,一个文件扔过去就能玩,不需要解释任何依赖安装问题;玩家之间的延迟低于走服务器的方案,手感明显更跟手。代价也是真实的,调试联机问题时要同时盯着多个浏览器的控制台,主机掉线恢复机制也得花好几轮迭代才能稳定。
后续我打算继续在这个骨架上扩展两个方向:一个是把 WASM 编解码加进状态序列化流程,进一步压榨带宽;另一个是接上 BroadcastChannel 的本地多开调试模式,让同一台机器上多个浏览器标签页可以互通,方便做真实场景测试。网页小游戏的工程天花板,其实比大多数人想象的高不少。