如果你以为网页小游戏的巅峰就是随手写一个 Canvas 弹球、拖几个精灵就算完工那 OmniGame 这个项目可能会让你改观。它是我想把网页小游戏的工程上限拉到新的位置做的一次完整尝试零依赖——不引任何前端框架、不加任何构建工具、不跑 Node.js只用浏览器原生能力同时把 WebRTC P2P 塞进游戏里让两个隔着网络的玩家不经过任何中转服务器直接对战。我从零开始搭这套东西中间踩了不少坑也把很多看起来简单、做起来要命的细节理清了。这篇文章就是做这个项目的完整记录适合想探索网页原生能力边界、想给小游戏加联机功能又不想买服务器的人也适合想彻底搞懂 WebRTC DataChannel 在实际项目中怎么落地的开发者。1. 为什么坚持零依赖不是怀旧是工程约束1.1 零依赖到底意味着什么很多人一听零依赖第一反应是都 2025 年了还用原生 JS。这个反应我能理解但我坚持这么做的原因不是情怀而是工程上的确定性。零依赖意味着你的项目只有一个要求浏览器能打开 HTML 文件。不需要 npm install不需要 Webpack/Vite 做打包不需要处理 node_modules 目录不需要担心某个依赖被下架之后整个项目跟着瘫痪。你要做的事情就是打开文件夹、双击 index.html游戏就能跑。这种直接可用的状态对于网页小游戏这种偏分享、偏传播的类型来说非常关键——你发给朋友一个文件他就能玩而不是发给朋友一个安装依赖教程。而且零依赖还有一个容易被忽略的好处代码可审计。整个项目就只有你写的那些 JS 文件每一行都是自己写的出了问题可以一路追踪到底不会有这个 bug 其实出在某个第三方库内部的甩不掉的锅。在 P2P 通讯这种本身就带状态和异步陷阱的场景里少一层中间黑盒排查问题就少一层地狱。1.2 给网页小游戏加上 P2P 要付什么代价传统的网页小游戏联机最常规的路线是 WebSocket 一台中转服务器。浏览器把操作发给服务器服务器转发给其他人。这个路线很成熟但代价也很明确服务器要钱、要维护、要考虑并发上限、要写鉴权。如果你做的只是一个朋友之间聚会玩的小游戏为了它租一台云服务器实在划不来。WebRTC 解决的正是这个问题。它让浏览器之间能直接建立点对点连接数据不经过中间服务器。声音、视频、任意二进制数据都能通过 DataChannel 在浏览器之间传。它把这些能力做成浏览器原生 API不依赖任何第三方库。这等于把联机能力直接送给你了。但代价也随之而来你不能像 WebSocket 那样连上就能发消息。WebRTC 建立连接之前需要双方先交换一些握手信息这称为信令Signaling。信令是 WebRTC 主动留给开发者的部分——浏览器不管你怎么把双方的握手消息互换你可以用 WebSocket、可以用 HTTP、甚至可以人工复制粘贴。所以我说从零依赖到 WebRTC P2P这个组合关键路径其实是游戏本身零依赖能跑P2P 的握手信息也想办法在不依赖服务器的情况下互传。我在 OmniGame 里用了最朴素的方案创建房间的人复制一串房间号握手数据加入房间的人粘贴进去双方就完成了信令交换。这个方案体验不炫酷但它把必要条件砍到了极限只需要一个静态托管甚至本地打开都行。2. 可运行工程的目录结构与模块切分2.1 一个白皮书工程怎么组织直接用 ES Modules不需要构建工具不等于把所有代码塞进一个 JS 文件里。现代浏览器原生支持 ES Modules直接在 HTML 里写script typemodule然后在模块内部用import/export组织代码。这样做的最大好处是文件可以按照职责拆分但运行时依然零依赖。OmniGame 的目录结构大概是这样的omnigame/ ├── index.html ├── room.html ├── css/ │ └── main.css └── js/ ├── main.js // 入口游戏循环启动 ├── game/ │ ├── loop.js // requestAnimationFrame 游戏循环 │ ├── input.js // 键盘输入捕获与映射 │ ├── world.js // 游戏世界状态、实体管理 │ ├── renderer.js // Canvas 2D 渲染 │ └── bulletPool.js // 子弹对象池 └── net/ ├── signaling.js // 信令交换创建房间/加入房间 ├── peer.js // RTCPeerConnection 封装 ├── datachannel.js // DataChannel 收发与协议解析 └── sync.js // 游戏状态同步逻辑入口 HTML 里只需要一行模块引用script typemodule src./js/main.js/script浏览器加载它的时候会自动解析main.js里的所有import语句按需加载依赖文件。没有打包器、没有额外网络请求但代码的组织方式和大型项目没有本质区别。2.2 游戏循环与渲染层的零依赖实现网页小游戏的核心驱动力是游戏循环。我倾向于使用requestAnimationFrame它本身是浏览器原生 API而且能根据屏幕刷新率自动调节帧率比setInterval(fn, 1000/60)靠谱得多——后者在标签页被切到后台时会节流甚至暂停前者则会被浏览器自动暂停以节省资源这对小游戏来说反而正确。游戏循环我分了两个部分逻辑更新按固定时间步长进行。我设置了 50ms 一个逻辑 tick也就是 20Hz 的逻辑频率。为什么不用 60Hz因为在 P2P 联机场景下同步成本直接跟逻辑帧率挂钩20Hz 是延迟和带宽的折中。后面讲同步时你会看到它的意义。渲染更新每一帧都跑用当前时间推断逻辑状态之间的插值位置让画面看起来丝滑。渲染层用的是 Canvas 2D API零依赖前提下这是最稳妥的选择。射击小游戏的弹道、爆炸粒子、玩家飞船用fillRect、arc、drawImage都能画不需要 WebGL。考虑到 P2P 场景网络才是最大瓶颈渲染只要保证不卡顿就够。还要提一个明显的优化点子弹对象池。射击类游戏最典型的问题就是大量对象频繁创建销毁造成的 GC 抖动表现为玩着玩着突然卡一下。我的做法是维护一个BulletPool初始化时创建 200 个子弹对象射击时从池里取回收时放回池里。这样游戏运行期间几乎不产生新的对象分配。// js/game/bulletPool.js export class BulletPool { constructor(size 200) { this.pool []; for (let i 0; i size; i) { this.pool.push({ active: false, x: 0, y: 0, vx: 0, vy: 0, owner: null }); } } spawn(x, y, vx, vy, owner) { const b this.pool.find((item) !item.active); if (b) { b.active true; b.x x; b.y y; b.vx vx; b.vy vy; b.owner owner; } return b; } release(b) { b.active false; } }这个池子不好的一点是find遍历 200 次但 200 长度本身很小实测下来开销可以忽略。如果你追求极致可以把pool换成链表结构但在网页小游戏这个尺度下没必要。3. WebRTC DataChannel从连接协商到消息通道3.1 信令交换的极简路径手工复制粘贴WebRTC 建立连接前必须先做信令。我在这里选择的是最原始也最可靠的方式创房者复制一长串文本加入者粘贴回来双方手动完成握手。具体流程是这样的创建房间页面上点击创建房间生成一个RTCPeerConnection创建 DataChannel调用createOffer()生成 SDP并调用setLocalDescription()把它变成本地描述。把 SDP 和 ICE 候选信息一起打包成 JSON 字符串显示在页面上。玩家把它复制发给朋友。加入房间另一个玩家把这段 JSON 粘贴到页面上程序解析后创建一个新的RTCPeerConnection调用setRemoteDescription()设置对方的 SDP然后createAnswer()生成自己的 SDP。加入者把自己的 SDP 打包复制回去创房者粘贴后setRemoteDescription()连接完成。这里面的关键是 ICE 候选ICE candidates。WebRTC 在尝试建立连接时会不断发现可能的网络路径——可能是本机内网地址可能是路由器映射后的公网地址也可能是通过 STUN 服务器发现的地址。每个路径都是一个候选。双方都收集到一组候选后会尝试用同一对候选建立通路。OmniGame 的手工信令实现我简化成收集所有 ICE 候选用 Promise 打包成 JSON避免用户来回复制多次。大概长这样async function createOfferWithCandidates() { const pc new RTCPeerConnection({ iceServers: [{ urls: stun:stun.l.google.com:19302 }] }); const dc pc.createDataChannel(game, { ordered: true }); const candidates []; pc.onicecandidate (e) { if (e.candidate) candidates.push(e.candidate); }; const offer await pc.createOffer(); await pc.setLocalDescription(offer); // 等 500ms 收集 ICE 候选 await new Promise((r) setTimeout(r, 500)); return JSON.stringify({ type: offer, sdp: pc.localDescription.sdp, candidates, }); }等 500ms 是个很粗放的做法你网络状况好可能瞬间就收集完了网络不好可能还没收集完。更严谨的做法是等onicegatheringstatechange变成complete事件。但我特意在这里用等待时间是为了让手工流程简单可读——反正 ICE 收集完成之后所有候选都会通过onicecandidate回调触发等 500ms 基本能覆盖绝大多数家用宽带场景。3.2 为什么 P2P 连接打不穿 NATSTUN 的作用边界这一步也是新手最容易卡住的地方。很多人以为建了连接、发了 offer 和 answerP2P 就通了实际上双方可能处于完全不同的内网。家用路由器负责把内网设备映射到公网这个过程叫 NAT。WebRTC 要打穿 NAT需要双方互相认识到自己的公网地址。STUN 服务器的作用就是这个它告诉浏览器从外界看你的公网 IP 和端口是 X.X.X.X:端口。浏览器拿到这个地址后把它作为一个 ICE 候选发给对方。对方尝试连接这个公网地址如果路由器允许端口映射通道就建立了。但边界在这里暴露得很清楚STUN 能发现地址不代表连接一定通。有些路由器做了严格的 NAT 类型限制比如对称型 NAT单纯靠 STUN 根本无法建立直达连接。这种情况要打通必须使用 TURN 服务器——TURN 是一种中继模式流量最终还是经过一个服务器。零依赖的前提决定了 OmniGame 不会自建 TURN所以我在架构里明确做了取舍默认支持 NAT 穿透成功率较高的家庭宽带场景不承诺穿透所有复杂 NAT。这套取舍在实现上怎么落地ICE 候选会在candidate.candidate字符串中带上类型如host、srflx、relay。srflx就是通过 STUN 发现的外部地址如果连接日志里大量尝试了srflx仍然失败就大概率是 NAT 太严需要换网络环境或走中继方案。3.3 DataChannel 通信协议设计连接建立之后真正的游戏数据通过 DataChannel 传输。DataChannel 默认是可靠有序模式的类似于 TCP 的语义游戏操作消息丢一条都会导致状态错乱用这种模式是对的。但要注意DataChannel 的高可靠模式不是零延迟它的机制是重传网络抖动时延迟会飙升。所以协议设计上我给消息做了简单的 3 字节头部字段长度说明type1 字节消息类型0输入1快照2心跳3加入/离开seq2 字节消息序号用于检测连续性body变长消息体优先用紧凑二进制格式为什么不直接用 JSON 字符串一把梭JSON 在调试阶段很直观但它的问题是体积大——每个字段名都要重复传输解析也要花时间。在 P2P 小游戏场景下消息量不大而且频次高我用的是紧凑的二进制格式用DataViewArrayBuffer把操作类型、坐标、时间戳直接编码进去。例如输入消息的 body 结构是byte 0: inputType byte 1: pressed 状态位 float32: x float32: y uint32: clientTime这样一个输入消息只有 4441114 字节比 JSON 动辄 100 字节的版本要省太多。接收方能直接通过DataView.getFloat32读取不需要 JSON.parse。代价是消息可读性差、调试麻烦但为了在一堆消息里卡出低延迟值。发送和接收的核心逻辑我封装在datachannel.js里export function encodeInput(inputType, pressed, x, y, clientTime) { const buf new ArrayBuffer(14); const dv new DataView(buf); dv.setUint8(0, 0); // type: input dv.setUint8(1, (inputType 1) | (pressed ? 1 : 0)); dv.setFloat32(2, x); dv.setFloat32(6, y); dv.setUint32(10, clientTime); return buf; } export function decodeInput(buf) { const dv new DataView(buf); return { inputType: (dv.getUint8(1) 1) 0x7, pressed: (dv.getUint8(1) 0x1) 1, x: dv.getFloat32(2), y: dv.getFloat32(6), clientTime: dv.getUint32(10), }; }客户端把这种消息发送到 DataChannel 的send()里接收方监听onmessage取出event.data是一个 ArrayBuffer再用对应解析函数还原成结构体。整个流程在浏览器里是原生的没有编解码库参与非常干净。4. P2P 联机玩法主机/客户端模型与帧同步取舍4.1 帧同步还是状态同步网页小游戏怎么选这是做 P2P 联机游戏不可回避的问题。帧同步Lockstep的思路是所有客户端都在同一个逻辑帧率下运行每帧把玩家的输入打包发给其他人大家各自跑同一个确定性逻辑最后状态一致。好处是带宽极低坏处是每次逻辑必须绝对确定——任何一处浮点运算在不同 CPU 上有细微差别、任何一次Math.random()没有统一种子都会导致游戏世界分裂。这放在网页环境里尤其痛苦因为 JS 的浮点和随机数在不同浏览器实现上可能有细节差异你根本控制不了。状态同步State Sync的思路是有一个权威端定期广播整个游戏世界的状态其他客户端只做渲染不参与逻辑运算。它的坏处是带宽消耗大好处是简单、健壮而且天然能应对某些玩家延迟高的场景。我在 OmniGame 里选了状态同步而且形态做成了主机权威创建房间的玩家同时运行完整游戏逻辑加入的玩家只是显示器 操作器。主机以 20Hz 的频率生成快照把每个实体的位置、血量、子弹状态发给客户端。客户端在渲染帧里做插值减缓因延迟带来的跳变。为什么不在主机和客户端之间共享逻辑因为共享逻辑要求每个参与者的计算必须同步、输入必须严格排序——这在 P2P 的手工信令模式下风险太大。一局游戏如果出现两个人看到的世界不一样体验会瞬间崩塌。主机权威把冲突的根源直接砍掉了世界只有一份就在主机上。4.2 房间规模与带宽估算状态同步最大的问题是带宽所以我做了一张估算表来决定参数。假设一局游戏最多 5 个人1 主机 4 客户端地图上同时存在的实体玩家、子弹、爆炸粒子约为 50 个。每个实体我用 16 字节打包4 字节实体 ID、4 字节 x、4 字节 y、1 字节血量、2 字节状态位、1 字节类型标识。50 个实体 × 16 字节 800 字节/快照。快照 20Hz 就是 800 × 20 16000 字节/秒约 15.6 KB/s按比特算约 125 Kbps。这个数值对国内家庭宽带的 10M 到 100M 上行来说是完全可以接受的。加上协议头的开销和其他消息整体控制在一路视频通话流量的十分之一以下。为了进一步缩我做的最有效优化是只发变化实体如果某个实体位置没变、血量没变、状态没变这帧就不发它。子弹这种轨迹是匀速直线运动的实体甚至可以不在每个快照里重复发位置只发生成位置方向速度客户端本地推算出中间位置。这个优化做完快照体积直接降到 300 字节左右同房人数就能适当放宽。4.3 输入延迟的处理为什么要在消息里放客户端时间戳P2P 对战的另一个核心问题是输入延迟。客户端点击键盘按键消息发到主机主机处理后再把快照发回来——这个过程至少需要一轮延迟 RTT。如果主机在收到输入后才改变游戏世界客户端会明显感到操作滞后。我在输入消息里编码了客户端时间戳这样主机可以准确知道这个输入是客户端在第几毫秒发出的在物理模拟时按时间戳顺序处理输入而不是按到达顺序处理。到达顺序本身受网络抖动影响乱序处理输入会让玩家感觉操作被吞掉。这种时间戳方案也叫输入缓冲效果在 20~80ms 延迟下都相当明显。注意这里不需要太精确的时钟同步。因为主机权威模式下世界以主机时钟为准客户端时间戳只用来排序输入和计算 RTT不需要对齐不同设备的时间基准。5. 兼容性摸底与真实踩坑记录5.1 Safari 对 ICE 候选与众不同的处理整个项目开发完我第一个真实踩到的大坑是同样的代码在 Chrome 上联机稳定换成 Safari 上其中一个玩家连接成功率就暴跌。排查过程很费劲最终在系统日志里发现Safari 对trickle ICE的支持时序和 Chrome 不同。Chrome 倾向于把 ICE 候选尽早发出Safari 则可能在你已经完成setRemoteDescription之后才触发onicecandidate。如果我的信令流程里把候选列表加入JSON.stringify的时间点放在了setRemoteDescription之前Safari 这边就会丢失部分候选。解决办法是不要在createOffer后立刻收集候选而是监听icegatheringstatechange到complete状态再导出候选。其次公共 STUN 服务器不要只配一个我最终配了两个const pc new RTCPeerConnection({ iceServers: [ { urls: stun:stun.l.google.com:19302 }, { urls: stun:stun.cloudflare.com:3478 }, ], });多一个 STUN 备选在部分网络运营商封锁个别 STUN 域名的情况下能提高容错。这个配置是我实测下来可靠性最好的组合。5.2 无服务器托管 鉴权的边界P2P 零依赖带来的另一个被很多人忽视的问题是DataChannel 上的消息没有鉴权。WebRTC 建立连接时双方只能通过信令阶段确认对方身份。而这个身份关联的只是一个复制粘贴的字符串——任何人拿到你的房间号都能加入进来并发送命令。我在设计游戏时把这一点明确定位为信任模型游戏即只适合熟人聚会场景不适合对外开放的公服。为了尽可能提高门槛我做了两件事房间号本身采用高随机性字符串而不是1234这种易猜的数字加入者必须在信令阶段提供房间密钥密钥由创房者手工告知。但我要明确告诉读者这个方案的安全性远不如真正的服务端鉴权。如果你要做开放匹配的联机游戏必须引入签名或加密握手逻辑。零依赖解决的是有没有服务器的问题不解决该不该有服务器的问题。5.3 偶发的双方都显示失败但实际成功问题开发中还有一个特别反直觉的 bug连接状态机显示failed但 DataChannel 却能正常收发消息。一度让我怀疑自己写的状态机有问题后来查明是 ICE 重新协商导致的。当网络路径发生变化时比如 WiFi 切换到蜂窝网ICE 会重新选择候选对连接状态会短暂进入disconnected或failed然后自动恢复。我的状态机最初直接根据连接状态做 UI 提示结果造成连接失败的误报。最终处理方式是只把closed视为真正的终态其余disconnected、failed都视为可能恢复同时用 DataChannel 的心跳消息作为活体探测只要心跳能来回UI 就保持已连接。6. 从 P2P 到混合架构这套路线的真实天花板写到这里我必须如实说纯 P2P 不是万能钥匙。它在几个人熟人互相玩的场景下完美适配但一旦你希望做公开房间、大量玩家在线匹配它的天花板就会显现。连接成功率纯 STUN 方案放在复杂 NAT 环境里成功率大概在 80%~90% 这个区间剩下的人会连不上。做公开产品必须部署 TURN 兜底这就回到了需要一台服务器的情况。房间规模状态同步模式下主机带宽随人数线性增长。5 人没问题10 人压力不小50 人基本不可能。离线支持零依赖意味着逻辑全在浏览器里没有服务端做存档和反作弊。想长期运营、积累用户数据必须有服务端。所以我给后续演进留的思路是保留 P2P 作为核心玩法引擎但外部包装一个极简的静态信令服务器甚至可以用纯静态 JSON WebSocket来做房间列表。这样既能保留游戏本身零依赖可单机玩的特点又能在需要开放匹配时补上最后一环。我已经把这套方案写进了 OmniGame 的后续计划。目前项目的全部代码仍然是一堆原生 HTMLJS 文件双击就能跑随便找个静态托管扔上去就能联机。每次朋友问你这个游戏能让我和我朋友一起玩吗我就把那两个 HTML 文件发过去复制、粘贴、开打。这就是零依赖 P2P 带给我的最大红利分享成本低到压缩包就是一个完整的联机游戏。