零依赖+WebRTC P2P:网页小游戏实时联机的工程化实践

发布时间:2026/10/8 4:35:48
零依赖+WebRTC P2P:网页小游戏实时联机的工程化实践 你有没有遇到过这样的情况朋友往群里甩了个游戏链接你点开转圈三秒白屏再刷新资源又一次从 CDN 加载。于是你想一个网页小游戏而已凭什么要这么重这个痛点我太熟悉了。做 OmniGame 的初衷就是想把“网页小游戏”这件事从头到尾重新捋一遍它到底能不能做到零依赖、秒开、断网能玩、复制一个 HTML 文件就能跑等我把这条路走到尽头发现真正的天花板根本不在加载速度上而在实时互动上——当你想让两个人、甚至八个人在一个网页里同屏对战靠传统服务器转发那一套成本和复杂度会迅速失控。这才是 OmniGame 技术白皮书真正想回答的问题如何用零依赖的极致轻量加上 WebRTC P2P 的原生点对点能力把网页小游戏的工程上限拉到真正能打的高度。这篇文章不是产品宣传软文而是我实际踩坑、重构、验证后的技术复盘。所有思路都围绕一个核心网页小游戏的工程化能不能抛弃 Node 依赖、抛弃服务器中转、抛弃安装流程依然做到好玩、能联机、可维护。适合独立开发者、小游戏团队以及所有对 WebRTC 实时架构感兴趣的 Web 工程师参考。1. 从零依赖到 WebRTC P2P为什么要把网页游戏的工程上限顶上去1.1 零依赖不是偷懒而是对“分发链路”重新做减法很多人一听“零依赖”第一反应是“不就是图省事吗”。真不是。零依赖是一种非常刻意的工程约束它逼你把所有资源都变成代码的一部分逼你直面一个灵魂拷问你的游戏到底有没有资格留在用户设备里。我最初做单文件小游戏时给自己的硬性要求是一个 .html 文件浏览器打开就能玩。这意味着图片素材不能外链、音频不能外链、字体不能外链、框架不能外链。所有美术资源要么程序化生成要么用极小的 base64 内联逻辑全部用原生 JavaScript 手写。这个约束带来的好处是立竿见影的加载只需要解析一个文件离线双击就能玩丢到微信、飞书、Discord 的聊天窗口里也永远不会出现“CDN 过期导致白屏”这种问题。后来我把这套思路命名为“零依赖分发链路”核心是把“网络依赖”从分发的每一个环节里剔除。普通网页游戏的分发链路是用户点击链接 → DNS 解析 → 服务器响应 → 加载 HTML → 再加载 JS/CSS/图片 → 初始化框架 → 启动游戏。OmniGame 的分发链路是用户拿到文件 → 浏览器打开 → 启动游戏。加载时间和失败率都降到了几乎为零这种东西用在实际社交裂变场景里效果比任何性能优化都猛。当然零依赖也有代价你不能用现成的游戏引擎不能用 React/Vue 的响应式帮助管理状态更不能指望 webpack 帮你处理模块依赖。所以 OmniGame 在工程上做了一个关键决策开发时用现代工具链提升效率发布时把产物压缩、内联成一个单文件。模块化是给开发者服务的单文件是给用户服务的两者并不冲突关键在于构建脚本要做一次彻底的“资源收编”。1.2 为什么做完零依赖之后还要折腾 WebRTC P2P单文件游戏做到极致之后我很快就触碰到了一个新的工程上限单人游戏。独乐乐可以没有服务器但众乐乐怎么办最初我想的是传统方案WebSocket 连接服务器服务器做房间管理、消息转发、状态同步。这套方案成熟但有一个绕不开的问题带宽成本和服务器压力。网页小游戏的实时性要求非常高比如射击游戏每秒要同步几十次位置和朝向一个房间 8 个人每个人每秒钟都要把自己的状态发给服务器再由服务器广播给其他所有人。服务器转发带来的额外一跳延迟在本地同城对战也许感觉不明显一旦跨地域那 50 到 100 毫秒的超额延迟会让操作明显变“肉”。WebRTC P2P 的出发点恰恰是消灭中间人。浏览器原生支持 WebRTC两个客户端可以直接建立 UDP 数据通道没有服务器中转延迟更低带宽成本直接降为零除了打洞失败时的 TURN 兜底流量。这和网页游戏的结合有一个天然优势WebRTC 是内置于 Chrome、Firefox、Safari 的标准能力不需要用户安装任何软件不需要下载插件这和“零依赖”本质上是一回事——依赖浏览器原生能力而不是依赖额外安装的软件。我把 OmniGame 的联网设计成“零依赖优先P2P 升级”单人模式不联网多人模式走 WebRTC 数据通道信令阶段才短暂依赖一个轻量服务器。这样单人场景保持了原来的零依赖优势多人场景则获得了超低延迟和极低的服务器开销两种模式在同一个代码库里共生而不是互相妥协。1.3 对比传统方案为什么说 P2P 是网页小游戏的最优解之一先别急着下结论我换个视角对比一下四种可选方案这样你能更清楚地理解 OmniGame 的技术选型逻辑。方案延迟服务器成本实现复杂度浏览器兼容离线可用纯本地单机无无低最好最好WebSocket 服务器中转中高随人数线性增长中很好差WebRTC 数据通道P2P很低仅信令高很好中WebRTC TURN 兜底低仅兜底流量高很好中这里多说一句 TURN。WebRTC 的 P2P 并不是百分百总能直连成功当双方网络环境特别复杂比如企业 NAT 双向对称时STUN 打洞可能会失败。此时 TURN 服务器作为中继流量经过服务器转发是保底方案。但好消息是大多数家宽和 4G/5G 场景下P2P 直连成功率都在八成以上TURN 只是用来兜最后那 20% 的。至于为什么不用成熟的游戏引擎自带的联机方案——那些方案很多依赖引擎的后端服务或商业 SDK与 OmniGame“不引入外部依赖”的宗旨相悖。自己做一套基于 WebRTC 的薄封装虽然初期成本高一点但长期来看代码在自己手里行为可控不欠技术债。2. 零依赖单文件游戏的核心实现怎么从零开始撑起一个完整小游戏2.1 Canvas 渲染与主循环动起来的第一步单文件游戏的技术底座是 Canvas 2D 和 requestAnimationFrame。我见过很多初学者把主循环写在 setTimeout 里那其实是一个常见的误区。requestAnimationFrame 是浏览器专门为动画设计的 API它会在每次屏幕刷新前回调保证帧率与显示器刷新率同步避免撕裂与不必要的重复绘制。而在后台标签页里requestAnimationFrame 会自动暂停这恰恰是游戏“后台挂机不跑资源”的最优解。OmniGame 的主循环被我拆成了三段式输入采集 → 状态更新 → 渲染绘制。每一帧开始时统一读取键盘和鼠标状态更新游戏世界中的所有实体玩家的位置、子弹的飞行、敌人的 AI、碰撞检测最后一口气把 Canvas 清空并绘制所有元素。这三步顺序固定防止输入和渲染互相插队出现“明明按键了但它不响应”的诡异问题。这里有个提高画面流畅度的细节Canvas 的尺寸要和 DPRdevicePixelRatio设备像素比对齐。如果你直接把 Canvas 设置成 CSS 像素 800x600在 Retina 屏幕上会明显发糊。正确做法是先把 Canvas 内部宽高乘以 window.devicePixelRatio再用 CSS 把它设置为原来的逻辑尺寸。我最初没注意这个细节游戏在自己电脑上还行一到同事的 MacBook 上就全是马赛克还以为是代码渲染逻辑写错了。我用一个约 50 行的最小示例来抽象说明核心循环的结构你直接套到自己的游戏里也能跑const canvas document.getElementById(game); const ctx canvas.getContext(2d); const DPR window.devicePixelRatio || 1; canvas.width 800 * DPR; canvas.height 600 * DPR; canvas.style.width 800px; canvas.style.height 600px; ctx.scale(DPR, DPR); const state { keys: new Set(), bullets: [], enemies: [], player: { x: 400, y: 300, hp: 100 }, lastTime: performance.now() }; function update(dt) { if (state.keys.has(ArrowLeft)) state.player.x - 200 * dt; if (state.keys.has(ArrowRight)) state.player.x 200 * dt; // 子弹移动、碰撞检测、敌人生成都放这里 } function render() { ctx.clearRect(0, 0, 800, 600); ctx.fillStyle #333; ctx.fillRect(state.player.x - 20, state.player.y - 20, 40, 40); // 绘制所有子弹、敌人、HUD } function loop(t) { const dt Math.min((t - state.lastTime) / 1000, 0.05); state.lastTime t; update(dt); render(); requestAnimationFrame(loop); } requestAnimationFrame(loop);注意 dt 被钳制在 0.05 秒以内这是为了防“切后台回来之后瞬间跳一大步”的问题。浏览器切后台再切回来时requestAnimationFrame 的回调时间差可能达到几秒钟如果不做钳制游戏里的物体会直接瞬移出地图。这个细节虽然小但没有它你的游戏就会在最不该出错的时刻崩坏体验。2.2 程序化资源生成美术和音频怎么做到“零外部文件”做零依赖游戏最大的拦路虎不是逻辑而是资源。你要画面好看、音效带感但又不能把 png 和 mp3 塞进 HTML虽然 base64 可以但会让文件膨胀得非常难看。这里我推荐的路线是走程序化资源用极简几何体 颜色渐变来做视觉用 Web Audio API 实时合成音效。视觉方面用 Canvas 绘制圆形、矩形、多边形再叠加阴影和渐变就能做出相当不错的像素风或扁平风效果。比如一个小飞机完全可以由三个几何体组成机身是一个圆角矩形机翼是两个三角形发动机尾焰是一个带透明度的渐变椭圆。配合一点点的粒子系统每一个粒子就是一个带速度的圆形视觉张力立刻就有了。这套思路尤其适合射击游戏、躲避游戏、音乐节奏游戏。音频方面Web Audio API 可以让你完全通过代码合成音效。射击音效本质上是一段极短的噪音或方波衰减爆炸音效是低频正弦波的下滑得分音效是几个不同频率的短音按顺序播放。我把这些封装成几个函数比如 shoot()、explode()内部创建 AudioContext 的 OscillatorNode 和 GainNode再包一层不复杂的包络线控制音量衰减。这套做法做出来的音效风格统一、体积为零还顺便帮你省掉了给音频配 licensing 的麻烦。程序化资源还有一个隐藏优势它让你天然获得了“动态生成”能力。敌人颜色可以随关卡动态改武器音效可以随角色状态变化所有东西都是参数驱动的改一改数值游戏风格就完全不同。对一个想要快速迭代原型的人来说这比切图快得多。2.3 空间管理与对象池性能稳定性的两个隐藏功臣零依赖游戏很容易陷入一个性能误区“反正一个页面也就几百个元素直接每帧全量遍历不就好了”。对小规模场景下这样写没问题但一旦子弹数量上来比如弹幕类游戏每帧遍历所有对象做碰撞检测就会很快出现卡顿。原因是两个循环嵌套的全量碰撞检测复杂度是 O(n²)。轻量级的解决方式有两个对象池和空间网格。对象池的意思是子弹和敌人的对象不随地创建销毁而是维护一个空闲列表。射击时从空闲列表里取一个对象激活它子弹飞出边界后不是把对象从数组里删除而是把它标记为“空闲”放回池子。这样做能大幅减少 JavaScript 的 GC垃圾回收压力尤其适合子弹高频生成与销毁的射击类游戏。GC 停顿在 PC 上不明显但在中低端移动设备上就是帧率杀手。空间网格则是一个朴素而高效的空间索引方案。把整个游戏区域划分成固定大小的小格子每个对象在移动时把自己的 ID 加入对应格子的对象列表里。碰撞检测时只检查同一个格子里的对象对而不是全图两两匹配。200 个对象全量检测是 19900 对用空间网格后通常每帧只需要检测几十到几百对性能和规模基本脱钩。2.4 状态机与回调解耦零依赖不等于零架构零依赖最容易产生的负面倾向是所有代码堆在一个 HTML 里全局变量满天飞最后变成一团乱麻。OmniGame 做了两个非常轻量但有效的架构约束UI 状态机和逻辑事件总线。状态机用来处理游戏流程。菜单、游戏中、暂停、结算、等待联机这五种状态互斥所有游戏循环只在“游戏中”状态更新实体“暂停”状态只渲染不更新“结算”状态只响应“再来一局”的输入。这样做可以把每个状态的复杂度隔离不至于在菜单状态下误触发游戏逻辑。事件总线则用来解耦逻辑模块。比如音效模块监听“SHOOT”事件UI 模块监听“HP_CHANGE”事件游戏逻辑本身不直接调用音效函数。这样做的好处是当你后续引入 WebRTC 联机、要同步战斗事件时只需要从事件总线里“偷听”并转发而不需要改动原有逻辑代码。事件总线在零依赖场景下可以是一个极简的发布订阅器十几行代码就够用。3. WebRTC P2P 联机方案落地从信令服务器到数据通道3.1 信令服务器唯一一点“依赖性”怎么做到极简WebRTC 建立连接之前双方需要交换 SDP会话描述协议信息和 ICE 候选地址。这些交换内容本身是纯文本但交换动作需要一个双方都能访问的通道这就是信令服务器。OmniGame 的信令服务器设计原则是只做一件事房间号到连接状态的映射不做业务逻辑不转发游戏数据不保存对局记录。我用的是一个不到 200 行的 WebSocket 信令服务。客户端连接后发送 join 消息附带一个房间号服务器把同一个房间里的其他客户端描述返回给对方之后双方各自创建 RTCPeerConnection通过信令通道交换 SDP 和 ICE 候选。等 DataChannel 一打开信令服务器就可以退居二线了后续所有游戏数据不再经过它。这里有一个容易被忽略的设计点信令服务断开后不应影响已经建立的对等连接。也就是说P2P 通道一旦打通即使在游戏中途信令服务器挂掉对局双方仍然可以通过已有的 DataChannel 继续通信。我专门做过这个测试游戏进行到一半时直接 kill 掉信令服务两台客户端毫无知觉地打完了整局。对于需要快速验证的开发者我会推荐先写一个极简的公共信令服务比如跑在 Vercel 上的 Serverless WebSocket或者直接用 webRTC 官方示例里的信令格式先把连通性跑通。信令本身不要做得太重后续完全可以替换成自己部署的服务确保核心逻辑不受影响。3.2 RTCPeerConnection 与 DataChannel 配置的真实用法建立一条 P2P 连接核心 API 不多RTCPeerConnection 负责管理连接createDataChannel 创建数据通道onicecandidate 处理 ICE 候选oniceconnectionstatechange 监听连接状态ondatachannel 接收远程创建的数据通道。OmniGame 用的 DataChannel 配置有讲究ordered: falsemaxRetransmits: 0。这意味着数据不保证顺序且不重传。为什么这么配因为游戏状态同步最在意的不是“每条消息都到达”而是“最新的位置是不是最新”如果用可靠且有序的模式默认一旦中间丢包发送端会等待重传后续数据全部排队延迟急剧升高。关掉可靠性和顺序就相当于用 UDP 的行为跑数据丢包时立刻发下一个状态包玩家感受到的是微妙的位置抖动而不是整体卡顿。我给出一个建立连接的最小结构信令消息用 JSON 传输// A 端创建连接 const pc new RTCPeerConnection({ iceServers: [{ urls: stun:stun.l.google.com:19302 }] }); const dc pc.createDataChannel(game, { ordered: false, maxRetransmits: 0 }); dc.onopen () console.log(DataChannel open); dc.onmessage (e) handleRemoteMessage(JSON.parse(e.data)); pc.onicecandidate (e) { if (e.candidate) signal.send({ type: candidate, candidate: e.candidate }); }; pc.createOffer().then((offer) pc.setLocalDescription(offer)) .then(() signal.send({ type: offer, sdp: pc.localDescription })); // B 端接收 offer 后 await pc.setRemoteDescription(offer); const answer await pc.createAnswer(); await pc.setLocalDescription(answer); signal.send({ type: answer, sdp: pc.localDescription }); pc.ondatachannel (e) { const dc e.channel; dc.onmessage (msg) handleRemoteMessage(JSON.parse(msg.data)); };这不是完整的轮子但骨架是对的。在实际项目里你还需要处理 ICE 候选的批量转发、连接状态的超时重试、多房间人数限制等逻辑。记住一个原则信令阶段可以慢几十毫秒的差距不重要DataChannel 阶段必须快每一毫秒的延迟都直接影响手感。3.3 主机迁移与角色分工谁来当权威P2P 连接建立后最大的架构挑战不是“能不能连”而是“谁说了算”。两个人对战一个人的画面说“我打中你了”另一个的画面说“我躲开了”到底信谁的OmniGame 的做法是“主机权威 客户端预测”。每个房间在首名玩家加入时被确定为“主机Host”其他玩家为“客户端Client”。主机拥有所有实体状态的最终决定权所有客户端把输入上报给主机主机在每个状态帧周期内运行完整的游戏模拟然后把权威状态广播给各客户端。客户端本地预测自己的输入结果以降低延迟同时根据主机的权威状态做校正。这里涉及到帧同步和状态同步的经典争论。帧同步要求所有客户端跑同一个逻辑帧输入完全一致这样计算出来的状态也一致它带宽效率高但是对网络抖动非常敏感一个包丢了所有客户端必须等待或者回滚。状态同步则是直接同步结果位置、血量、朝向对丢包容忍度高实现也简单更适合小团队快速迭代。OmniGame 选了状态同步因为零依赖的单文件游戏没有能力也没有必要去实现复杂的帧同步回滚机制。客户端预测和校正的实现要点是客户端记录自己发出的输入序号和对应预测位置主机广播状态时附带最后处理的输入序号当客户端的预测和权威状态偏差超过阈值时立刻用权威状态覆盖本地位置并做一次简短的插值过渡避免画面瞬移。这套机制在射击游戏里尤其重要你开枪的瞬间如果还要等服务器点头才显示枪口火光体验就全毁了。4. 联机实时对战中的几个硬骨头排查思路与实操避坑4.1 ICE 连接失败为什么有人能连上有人永远连不上做 P2P 联机最常撞的墙就是自己两台设备测试一切正常室友一加入就失败客户在另一个城市也连不上。这不一定是代码 bug更多是 NAT 打洞失败的产物。ICE 打洞的成功率取决于双方 NAT 类型。如果双方都在普通家宽路由器后面锥形 NATSTUN 打洞大概率成功如果其中一方在企业防火墙后面、或者运营商网络做了对称 NAT直连就会失败这时候必须由 TURN 服务器中继。OmniGame 的处理方式是配置多个 STUN/TURN 服务ICE 候选会自然包含 host、srflx、relay 三层浏览器会自己尝试所有组合最终选一条可用路径。排查 ICE 问题时我几乎会在控制台打印每一个 onicecandidate 的类型和地址然后用下面的列表快速定位现象可能原因排查方向只有 host 候选没有配置 STUN检查 iceServers 配置host srflx连不上NAT 类型对称或限制需要配置 TURN 服务relay 候选出现但慢直连失败走了中继检查两端网络策略限制oniceconnectionstatechange 一直 checking信令转发不完整打印 ICE 候选交换日志这里有一个容易踩的坑ICE 候选的交换时机。有些实现会在所有候选收集完毕后再一次性发送如果收集过程持续几秒钟双方可能早就超时了。正确做法是 onicecandidate 一回调就立刻通过信令通道转发给对方让连接尽早“试跑”。候选可以后到连接可以先成。4.2 DataChannel 卡顿为什么带宽够游戏还是“一顿一顿”有人会误以为 WebRTC 的传输质量一定比服务器转发好真不一定。DataChannel 用的同样是网络链路丢包、乱序、带宽饱和都会带来问题。尤其是 ordered: false 的模式下如果你每一帧都发送全量状态位置、朝向、血量、子弹列表当玩家数量增加、实体数变多后带宽会瞬间爆掉。OmniGame 的压缩策略是“差分同步”只发送变化的部分。玩家位置如果和上一帧没差太多就把它压缩成相对偏移用 16 位整数存 dx/dy血量没变化就不发子弹只在生成和销毁时各发一条元数据而不是每一帧都全量遍历。这样一来一个 8 人对局的带宽占用可以从每秒几百 KB 降到几十 KB。另一个关键点是“状态同步频率要和渲染帧率解耦”。你的游戏可能是 60 FPS 渲染但状态同步不需要 60 次每秒。通常 10 到 20 Hz 的状态同步已经完全够用剩下的帧间变化用本地插值补足。想想看服务器或者主机的逻辑帧率是 20 Hz广播间隔 50 毫秒一次客户端在两次广播之间根据上一帧状态和最新状态做线性插值人眼几乎感知不到区别。DataChannel 的背压backpressure也值得提一下。如果你用 dc.send() 发送数据的速度太快而接收端处理不过来内部缓冲会持续增长延迟随之拉高。OmniGame 里做了一个简单的背压控制检查 dc.bufferedAmount超过阈值后暂缓生成下一帧状态优先把旧的、已经落后的状态清掉。4.3 实时同步中的抖动与延迟补偿代码层面的三个实用招即使有了 P2P 低延迟网络抖动依然存在。我分享一下 OmniGame 里实际用到的三个延迟补偿招数它们都属于“只要几十行代码就能见效”的类型。第一招是时间戳插值。每个状态包带上主机端的逻辑时间戳客户端根据当前时间与包时间戳的差值选择最近的快照进行插值渲染。前提是客户端和主机的时间基准要尽量接近最简单的做法是对局开始时做一次 ping 测量计算单程延迟然后用“主机时间戳 单程延迟”作为本地期望时间。第二招是延迟自适应。客户端保留最近 20 个状态包的到达间隔实时估算当前网络抖动动态调整本地缓冲区的等待时间。如果网络突然变卡就多等一下再插值避免画面疯狂回退。第三招是预测与回滚。客户端的本地预测如果持续被权威状态纠正说明预测误差偏大这时可以给预测算法增加一个“惯性因子”预测位移偏向上一帧而不是完全跟随最新输入。这会降低操作灵敏度但换来明显更少的回弹感。对动作游戏来说玩家的感受是“操作偏顺滑”而不是“位置总被拉回去”。5. OmniGame 的工程化闭环构建、部署与我的实测心得5.1 构建流程开发时模块化发布时单文件两手都硬零依赖是发布时的形态不等于开发时也要把所有代码塞进一个 HTML。OmniGame 的源码是一个标准的前端工程用 ES Modules 组织目录大致是core游戏循环、事件总线、renderCanvas 绘制、粒子系统、audioWeb Audio 合成、netWebRTC封装、信令客户端、games具体游戏逻辑。构建脚本做三件事打包成一个 JS 文件、把所有代码内联进 HTML 模板、压缩混淆。构建时最需要注意的是别让 tree-shaking 把你的“副作用模块”当垃圾摇掉。Web Audio 合成模块和 canvas 渲染模块经常被静态分析误判为“未使用”因为它们只向事件总线注册回调、没有显式导出被引用的函数。我的解决方案是在构建配置里把 net、audio、render 模块声明为 sideEffects 保留或者统一走一个 plugin 入口确保每个模块都被引入。构建产物还应该做“作品指纹”把游戏版本号、构建时间、Git 短哈希写进一个全局变量。这在联机调试时极其有用——如果主机是 1.2.0、客户端是 1.2.1很多时候不是代码逻辑 bug而是版本不一致导致的状态结构错位。我在写了版本校验后的第一周就靠这个字段定位了三个原本以为是“玄学”的断连问题。5.2 部署与分发从单文件到 P2P 对局的三种形态OmniGame 的部署形态有三种对应不同使用场景。第一种是离线单文件直接把 HTML 分发到任意环境适合个人电脑存储、U 盘拷贝、局域网共享。这种形态继承零依赖的全部优点断网也能玩也是你手里的“最后一版保底包”。第二种是静态网页托管把单文件放到任意对象存储或者静态托管平台上GitHub Pages、Cloudflare Pages 都可以用户通过 URL 访问加载一次之后天然走浏览器缓存。这种形态适合线上分享和社交裂变。第三种是持续在线联机版静态页面 一个极轻量的信令服务。信令服务可以用平台的 Serverless Functions 承载也可以在小型虚拟机上跑。这个形态下页面本身仍然是零依赖的用户感知不到任何安装、登录、插件流程。值得一提是我不会把信令服务做成高可用集群。P2P 对局的健壮性来自对等连接信令服务只是一个“握手引导”它的可用性要求远低于传统游戏服务器。即使某个时段信令服务不可用已有连接的对局也不会受影响只是不能再开新房间而已。5.3 实测经验零依赖与 P2P 结合后我踩过的最后三个坑做完整套工程我想把三个真实踩过的坑记录下来它们都不是“看文档就能发现”的问题。第一个坑是移动端浏览器的 WebRTC 兼容性差异。桌面 Chrome 和 Firefox 的 DataChannel 行为基本一致但 iOS Safari 在某些版本里对 DataChannel 的消息类型支持有限强制要求 ArrayBuffer不支持字符串。我在消息包装层加了一个自动转换发送时统一转成 ArrayBuffer接收时按字节流解析彻底绕开这个兼容地狱。第二个坑是 Canvas 在移动设备上的“省电模式”降频。部分安卓手机会在后台一段时间后自动降低屏幕刷新率导致 requestAnimationFrame 变成 30 FPS游戏整体变慢。这不是网络问题而是设备问题不能通过代码强制改变我只能选择尽量让游戏在 30 FPS 下也能可玩所有动画都要做 time-based基于时间差而非 frame-based基于帧计数。第三个坑是“P2P 成功 ≠ 延迟低”。WebRTC 会优先选择 host 候选即局域网或本机回环但当你和朋友跨地域联机时iceCandidatePair 选择的可能是一个延迟 100ms 的 relay 路径即使直连候选也能用。后来我增加了对 ICE candidate pair 的延迟探测选路完成后发一个 RTT 探测包如果延迟超过阈值且存在更优候选就尝试切换。这个逻辑在真实网络里不一定总能成功但至少让问题可视化。我的体会是做 OmniGame 最大的收获不是“我用了 WebRTC”或者“我是零依赖”而是建立了一套完全可复用的网页游戏工程范式。它让我明白了工程上限从来不是一个单点指标而是分发、性能、实时性、可维护性四个维度的合力。如果你也想做类似的事我建议从最小闭环开始先做一个单文件对战游戏再加 P2P 联机最后再考虑信令和压缩。每一层都稳扎稳打做过一遍你对“网页游戏到底能有多强”的感知才会真实。