先说结论:OmniGame 这个项目,其实就是把一个网页小游戏从“依赖框架、依赖构建工具、依赖服务端”的常规套路里拽出来,回到浏览器最原始的形态——一个 HTML 文件、一个 JS 文件,再加上 WebRTC 原生的 P2P 能力,就能让两个玩家在浏览器里直接对战。没有 Node.js 服务端承载游戏逻辑,没有 WebSocket 中间层转发每一帧,更没有 npm install 之后长达几分钟的依赖安装提示。它避开的是“工程化堆砌”的惯性,用的却是足够硬核的 WebRTC 协商、ICE 穿透和 DataChannel 传输,最终把网页小游戏的工程上限拉到了一个新位置:单文件、零部署、点对点、可审计。
这篇文章适合三种人看:想搞懂 WebRTC 数据通道到底怎么落地到真实游戏的人;被“现代前端工程”折磨到想回归极简的人;以及正在做小游戏原型、又不想为了一局对战写一套后端的独立开发者。我会从零依赖的设计动机讲起,拆清楚 P2P 建连的完整链路,再落到可复制的代码实现和坑位清单。整篇都按我实际踩过的路子讲,有代码、有原因、有翻车现场。
1. 零依赖到底在解决什么问题
1.1 零依赖不是“没有依赖”,而是“依赖边界清晰”
很多人一听到零依赖就以为项目里不能出现任何第三方库,这其实是个误解。OmniGame 的零依赖,准确说是“零第三方运行时依赖”——不引入任何需要单独下载、安装、打包的 JS 库。但它依然依赖浏览器,依赖 WebRTC 标准,依赖 HTTP 协议。这里要做个区分:浏览器本身就是运行时,协议本身就是能力。WebRTC 不是库,而是浏览器内置的 API;信令交换用的 HTTP 也是浏览器自带的 fetch。所以零依赖的本质,是把依赖收敛到“浏览器标准能力”这一层,之后的一切都由自己控制。
这个选择带来的最直接好处是审计透明。一个项目里有什么代码,掰着手指头都能数清楚:信令服务是纯 Node 内置模块写的,客户端是一个原生 JS 文件。没有 node_modules,没有锁文件,没有“某个传递依赖存在漏洞所以升级了另一个包结果导致构建报错”的连锁反应。你拿到的就是你能看到的。对于小游戏这种追求轻快的场景,这种确定性值不少钱。
1.2 为什么小游戏适合走这条路
大游戏走零依赖是自找麻烦,但小游戏完全相反。网页小游戏的特点是:玩法规则简单、状态量小、同屏实体少、对帧率敏感但单帧数据量往往只有几百字节。这就决定了它天然适合走 P2P——不需要服务器托管全量状态,也不需要中心服务器为每帧转发广播。
另一个现实原因是部署形态。OmniGame 从设计之初就想清楚了一件事:这游戏应该能像一张图片一样被分享。静态托管也好、局域网里直接打开文件也好、拿 U 盘拷到另一台电脑也好,只要浏览器支持 WebRTC,游戏就能跑。如果走 npm 安装加打包上线,那它必然被绑死在某个域名上,失去这种“可携带性”。零依赖让这个游戏变成了一枚可移动的棋子,而不是一只拴在桩上的风筝。
1.3 零依赖能走多远:边界与代价
零依赖当然有代价。最明显的代价是信令服务仍然存在——P2P 的连接并不是凭空建立的,两个浏览器之间需要先通过某种方式交换各自的连接描述(SDP Offer/Answer)和网络候选(ICE Candidate)。这部分协议交换被 WebRTC 强制要求,绕不开。OmniGame 的处理方式是:把信令服务做成一个 150 行以内的极简 HTTP 服务,承担“房间号登记 + 消息转发”两个职责,连接建立之后它就可以退休了。之后所有游戏数据都走 DataChannel,信令服务器连接会保持但不产生游戏流量。
代价之二是不能什么都自己造。如果你想让游戏支持观战、录像回放、跨 N 个 NAT 的复杂网络,那确实需要一个更重的信令架构,甚至需要 TURN 中转服务器。但那是业务扩展的选项,不是工程基础的必要条件。OmniGame 的定位是“一局双人对战”,在明确这个边界之后,零依赖就是最优解——足够小,足够清晰,也足够展示 WebRTC 的全部关键路径。
提示:零依赖的真正价值不是“厉害”,而是可解释性和可移植性。做小游戏原型或教学演示时,它让“为什么这样做”这件事变得极其透明,新人也能在半小时内读完全部代码。
2. WebRTC P2P:把浏览器变成游戏服务器
2.1 完整链路:SDP、ICE、NAT、DataChannel
理解 WebRTC 的关键是忘掉“浏览器只能当客户端”这个印象。WebRTC 让两个浏览器之间能建立一条加密的、点对点的 UDP 通道,即使双方都在 NAT 后面。这里面有几个核心角色:
- SDP(Session Description Protocol):一种描述媒体和连接参数的文本协议。由发起方创建 Offer,应答方返回 Answer。它像是一封信,写清楚“我支持哪些编码、我的网络候选地址是什么”。
- ICE(Interactive Connectivity Establishment):用于在两个端点之间寻找可用传输路径的机制。它把本机所有候选地址(局域网 IP、公网映射地址、中继地址)收集起来,然后通过信令通道交换,再逐步测通。
- STUN:用来获取自己公网映射地址的辅助服务。它只解决“我对外看起来是什么地址”的问题,不转发任何游戏数据。
- NAT 穿透:两个设备在不同局域网内时,ICE 会尝试用 UDP 打洞方式建立直连。不是所有网络环境都能成功,成功率取决于双方 NAT 类型。
建连过程的真实流程是:浏览器 A 创建 RTCPeerConnection,生成 Offer,通过信令服务把它发给 B;B 收到 Offer 后生成 Answer 回传给 A。同时双方各自触发 onicecandidate 事件,把候选地址通过信令服务转发给对方。这个过程通常叫“信令交换”。一旦 ICE 找到可用路径,连接状态会从 connecting 变成 connected,此时 DataChannel 才可以正式收发数据。很多人第一次写 WebRTC 时等 DataChannel 的 open 事件立刻发数据,结果什么都发不出去,就是因为没等连接状态就绪。
2.2 为什么选 DataChannel 而不是 MediaStream
网页游戏同步数据的方式有三种:用 WebSocket 中转、用 WebRTC MediaStream 传音视频、用 WebRTC DataChannel 传任意数据。OmniGame 选第三种,理由很直接:游戏对战里 90% 的流量都是“操作描述”“位置状态”“碰撞事件”这类小包,它们需要的是一条低延迟的数据通道,而不是音视频流。DataChannel 是基于 SCTP 协议的,可以配置为可靠有序传输(类似 TCP 语义),也可以配置为不可靠无序传输(类似 UDP 语义)。对于小游戏,默认用可靠有序模式就够了,丢包少、实现简单、不会出现状态错乱。
MediaStream 的问题在于模型不匹配。它本质上是连续的媒体帧流,适合音频视频,但你如果要传一个“玩家按下左键”这样的事件,把它编码进媒体流里纯属自找麻烦。而 DataChannel 像一根自定义水管,你往里塞 JSON 字符串、二进制数组、甚至整个游戏快照都行。尤其是小游戏状态同步时,会频繁发送“蛇头坐标 + 方向 + 计分”,这类紧凑的结构化数据在 DataChannel 里发送和接收都非常自然。
2.3 主机权威:谁说了算
P2P 对战的常见设计问题是状态由谁决定。两个浏览器地位平等,如果各自跑一套模拟,那么双方看到的状态必然因网络延迟而发散。OmniGame 采用“主机权威”模式:创建房间的玩家是 Host,对局状态全部由 Host 的浏览器维护,加入房间的玩家是 Client,只发送操作指令(方向键、暂停键),并接收 Host 广播的状态快照。
这样做的好处是避免双写冲突。设想两个玩家都在自己本地移动蛇,然后通过 P2P 互发位置,那你必须处理“双方都认为自己在同一时刻吃到了同一个苹果”的矛盾。主机权威把这个矛盾消灭在源头:只有一台设备负责跑逻辑,所有判定都发生在同一处,其他玩家只是“遥控器”。代价是把 Host 浏览器的计算压力变成了性能瓶颈,但对贪吃蛇这种规模的小游戏来说,一帧里做几百次碰撞计算和若干次数组操作,性能开销完全可以忽略。
注意:主机权威模式要求 Host 的设备不能太差。遇到性能极低的老设备,要么降帧率,要么把逻辑搬到纯计算里优化(避免频繁 DOM 操作)。但不要为了“公平”改成双权威,那会引入回滚同步的复杂逻辑,对工程体量不划算。
3. 核心协议与同步机制:小游戏怎么保证流畅
3.1 消息协议:JSON 足够,二进制锦上添花
游戏消息分为三类:连接建立类的信令消息、游戏中的操作消息、以及状态广播消息。在 OmniGame 里,信令消息走 HTTP 长轮询(为了零依赖),而游戏消息在 DataChannel 里用 JSON 字符串发送。JSON 有三个优势:调试时能直接在控制台打印阅读、结构灵活、解析成本低。对每帧 1KB 以下的数据量来说,JSON 的序列化和解析时间远小于网络传输本身,所以不要过早优化成二进制协议。
但每个消息必须有清晰的类型字段,用type区分。例如:
{"type": "join", "room": "1234"}:客户端请求加入房间{"type": "input", "dir": "left", "seq": 42}:客户端操作指令{"type": "snapshot", "snake": [...], "score": 10, "tick": 1024}:主机广播的状态快照
关键的一点是用seq给每条操作编号,主机在状态快照里带回lastProcessedSeq来告诉客户端哪条操作已经生效。客户端可以根据这个编号判断自己发送的操作是否被主机接收,如果差距过大,就知道网络出现明显延迟。
3.2 操作采样与输入预测
客户端每帧都在监听键盘,但不会每帧都发送一条消息。因为键盘状态在游戏帧率 30Hz 下会产生 30 条/秒的输入消息,而实际有效的只是“按下方向键的瞬间”。OmniGame 的做法是做操作采样:只在方向变化时发送输入消息,方向不变就静默。这与传统游戏行业里“只在事件发生时同步”的思路一致,能极大降低频道压力。
输入预测(client-side prediction)则是为了缓解延迟体验。客户端在发送“左转”指令后,不等待主机回包,而是立即在本地将蛇头方向改掉。如果主机后续确认的快照和自己的预测一致,就继续运行;如果不一致,则以主机快照为准做一次修正。因为小游戏的操作窗口极短,这个修正往往只是几帧的微调,玩家几乎感知不到。但注意,预测只针对“本地玩家自己的蛇”,对对手的状态绝不预测,否则会出现对撞判断不公平的问题。
3.3 快照与增量同步的组合
主机权威模式下,主机需要定期把游戏状态推给客户端。OmniGame 用的是快照 + 增量混合策略:关键帧(每 10 个 tick)发送完整快照,中间的 tick 只发送变化量。完整快照包含蛇的全部坐标、苹果位置、分数;增量消息则只包含“蛇头坐标、方向、苹果是否被吃、分数变化”。这套组合的好处是:完整快照负责兜底(客户端掉帧或状态异常时可以强制对齐),增量消息负责日常的低延迟更新。
实现时用两个字段表达:snap表示是否为完整快照,tick表示当前逻辑帧序号。客户端收到完整快照时,直接覆盖本地状态;收到增量时,按 tick 序号补间更新。这里有个容易踩的坑:增量消息不可丢,一旦丢失,客户端会沿用旧状态,等收到下一帧增量时就会产生小跳变。所以 DataChannel 一定要用可靠有序模式(ordered: true),在这个场景下省掉乱序重排的心智负担,值得那一点点延迟。
3.4 断线与重连怎么处理
P2P 的断线检测比中心服务器难做——没有服务端心跳,你无法立刻区分“对方是被墙了”还是“只是失去焦点暂时停止响应”。OmniGame 的方案是游戏层心跳加超时判定:双方每秒互发一次心跳消息,附带本端逻辑帧号。如果 3 秒没有收到对端心跳,就把连接状态标记为 lost,棋盘上显示“连接已断开”,同时尝试一次 ICE 重协商(重新生成 Offer/Answer)来拉回连接。
实践中,真正的断线重连成功率不高,因为底层网络变化(比如从 Wi-Fi 切到 4G)几乎无法用 JS 层逻辑恢复。所以 OmniGame 的目标不是“断线后一定要连回来”,而是“断线能及时发现、给出明确提示、允许快速重开一局”。小游戏要的是体验上的坦白,不是系统层面上的完美保活。把预期设置对,工程难度完全不同。
4. 实操实现:从零搭出一个可玩的 P2P 小游戏
4.1 目录结构与开发环境
OmniGame 的整个工程,静态文件只有两个:
index.html:页面结构、画布、UI 按钮game.js:全部游戏逻辑(P2P 建连、消息协议、渲染、输入处理)
信令服务是独立的server.js,用 Node.js 内置模块http实现。根目录下不需要 package.json,不需要任何依赖安装。开发时只需要node server.js,然后浏览器打开http://localhost:3000。
这里有一个很容易被忽略的前提:WebRTC 在非安全上下文中无法使用。访问地址必须是https://或者http://localhost。如果你部署在局域网里,要记得给服务器加上 HTTPS,或者使用内网 http 时确保浏览器允许http://下的 WebRTC(实际上 Chrome 对 localhost 有豁免,但局域网 IP 没有)。我最初在局域网用 http 测试时,RTCPeerConnection 直接创建失败,排查了很久才意识到是安全上下文问题。
4.2 信令服务器的实现
信令服务的核心职责是:房间管理、消息转发。代码量不大,但要把两个细节做对:一是 Room 的创建和加入用 HTTP POST,二是轮询拉取消息要用 GET 并支持长轮询(避免频繁发起请求)。下面是一个简化示例:
// server.js 核心逻辑(零依赖,仅使用 Node 内置模块) const http = require('http'); const rooms = new Map(); function createRoom() { const roomId = Math.random().toString(36).slice(2, 8); rooms.set(roomId, { host: null, client: null, messages: [] }); return roomId; } function pushMessage(roomId, from, data) { const room = rooms.get(roomId); if (!room) return; room.messages.push({ from, data }); // 唤醒等待长轮询的另一端 } const server = http.createServer((req, res) => { const url = new URL(req.url, 'http://localhost'); if (req.method === 'GET' && url.pathname === '/poll') { // 长轮询实现 } else if (req.method === 'POST' && url.pathname === '/signal') { let body = ''; req.on('data', chunk => body += chunk); req.on('end', () => { const msg = JSON.parse(body); pushMessage(msg.room, msg.from, msg.data); res.end('{}'); }); } }); server.listen(3000);长轮询的注意事项是:不要让客户端一直挂着一个请求。设置超时时间(比如 25 秒)后返回空响应,客户端立刻重新发起,这个模式叫“定长轮询”,实现简单且能忍受。真正的长轮询需要服务器端挂起请求,等消息到了再返回,代码稍微复杂一点,但对信令这种低频通道没必要。
提示:信令服务传递的是 Offer、Answer、ICE Candidate 这类连接建立数据。这些数据只是文本描述,不包含游戏流量。连接建立后,就算信令服务宕机,正在进行的游戏也不受影响——这点可以用来做“断服不断局”的演示。
4.3 客户端 P2P 建连模块
客户端用原生 WebRTC API。核心流程是:创建 RTCPeerConnection,设置 ICE 服务器(至少配置一个 STUN),然后分主机和客户端走两条分支——主机先创建 DataChannel 并等待客户端加入;客户端加入后主动发起 Offer。代码骨架如下:
const pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' } ] }); let channel = null; pc.onicecandidate = (e) => { // 把 e.candidate 通过信令服务发给对端 sendSignal({ kind: 'candidate', data: e.candidate }); }; if (isHost) { channel = pc.createDataChannel('game', { ordered: true }); channel.onopen = startGame; // 主机作为被叫方,等待对端 Offer 后 setRemoteDescription + createAnswer } else { pc.ondatachannel = (e) => { channel = e.channel; channel.onopen = startGame; }; // 客户端发起 Offer,发送给主机 const offer = await pc.createOffer(); await pc.setLocalDescription(offer); sendSignal({ kind: 'offer', data: pc.localDescription }); }有几个细节直接影响成功率:
- ICE 候选需要在 setLocalDescription 之后才会开始收集,所以先 setLocalDescription,再监听 onicecandidate,顺序不能反。
- 收到对端 ICE 候选时,要判断
pc.remoteDescription是否已设置。如果还没设置 Answer,调用addIceCandidate会报错。稳妥做法是先把候选暂存,等 remoteDescription 设置好后再统一添加。 iceConnectionState要监听:connected表示通道可用,failed表示无法打通,disconnected表示通道丢失。这三个状态是游戏 UI 判断网络情况的依据。
4.4 游戏状态同步落地的核心代码
游戏逻辑这里以双人贪吃蛇为例。主机维护一个简洁的状态对象:蛇的坐标数组、当前方向、苹果位置、分数、tick 计数。每个 tick(这里用 100ms 一次)更新一次蛇的位置,广播增量消息或完整快照。
// 主机侧:逻辑帧循环 function hostTick() { tick++; // 更新蛇位置 updateSnake(); // 检查碰撞与吃苹果 checkCollision(); if (tick % 10 === 0) { // 每 10 tick 发送完整快照 channel.send(JSON.stringify({ type: 'snapshot', snap: true, tick, snake: snake.getPositions(), apple: apple.position, score })); } else { // 中间帧只发增量 channel.send(JSON.stringify({ type: 'snapshot', snap: false, tick, head: snake.head(), dir: snake.direction, ate: ateThisTick, score })); } }客户端接收消息后,分两种情况处理:完整快照直接覆盖;增量消息检查 tick 是否连续,如果发现跳号,则请求一次完整快照(发送带request_snap字段的消息)。这个请求虽然增加了一次往返,但能避免长期状态错乱。
操作消息则更简单:
// 客户端侧:方向变化时发送 document.addEventListener('keydown', (e) => { const dir = getDirectionFromKey(e.key); if (dir && dir !== currentDir) { channel.send(JSON.stringify({ type: 'input', dir, seq: ++inputSeq })); } });主机收到输入消息后,做一个合法性校验,防止玩家直接把方向变成反向导致蛇穿自己身体:
function handleInput(msg) { const dir = msg.dir; const opposite = { up: 'down', down: 'up', left: 'right', right: 'left' }; if (dir === opposite[snake.direction]) return; // 非法输入,忽略 snake.setDirection(dir); }5. 常见问题排查与工程避坑
5.1 ICE 失败:NAT 类型与 TURN 的必要性
实践中,WebRTC 直连成功率并不是 100%。两个客户端都处在对称型 NAT 后面时,常规 STUN 打洞无法成功,这就需要 TURN 服务器作为中继转发。OmniGame 默认只配置了 STUN,因此会有少量网络环境下建连失败。
排查思路是看pc.iceConnectionState的状态变化日志:如果一直是 checking 然后变成 failed,基本可以判定为无法穿透。此时有两个选择:一是部署一个自建 TURN(coturn 是常用方案),在iceServers里配置 TURN 地址;二是提示用户检查路由器的 NAT 类型或网关设置。小游戏场景下,我还是建议直接加 TURN,成本不高,但对兼容性的提升立竿见影。
注意:ICE 候选里有三种类型——host(本机局域网地址)、srflx(STUN 映射地址)、relay(TURN 中继地址)。调试时如果发现只用 relay 成功,说明直连失败但你靠 TURN 兜住了。这是正常现象,不代表代码有问题。
5.2 DataChannel 背压与消息拥堵
DataChannel 有一个隐藏问题:发送端持续高速写入时,如果接收端处理不过来,内部缓冲区会积压,导致延迟和丢包。浏览器在中继模式下会触发流量控制,但 JS 侧不感知。常见表现是:帧率正常,但操作响应越来越迟钝。
解决方法是做发送侧背压感知。DataChannel 有bufferedAmount属性,表示当前积压了多少字节,配合bufferedamountlow事件可以在积压减少时继续发送。对小游戏来说更实用的做法是:状态广播只保留最新一帧,如果上一帧还没发送完成,直接丢弃这一帧。因为状态同步场景里“最新状态”优先于“全量历史状态”,丢一帧比延迟一帧要好得多。
// 状态广播丢旧保新示例 let sending = false; function broadcast(state) { if (sending) return; sending = true; channel.send(state); channel.bufferedamountlow = () => { sending = false; }; }5.3 调试技巧:日志埋点与同屏双开
P2P 程序最烦的是“两个端都在本地,但连不起来”,而浏览器控制台只能看到一端。我建议从一开始就把信令消息、ICE 状态、DataChannel 状态打成一个前缀清晰的日志,例如[P2P][offer]、[P2P][candidate]、[DATA][open]。这样做的好处是,一旦连接失败,你能立刻判断卡在哪一步:是信令没交换成功,还是 ICE 没有候选,还是 DataChannel 没有 open。
另一个实战技巧是“同屏双开”:在同一个浏览器开两个标签页,一个创建房间(Host),一个加入房间(Client)。虽然两个标签页共享同一个浏览器进程,但 WebRTC 的 P2P 通道依然会经过本地网络栈,行为和真实跨机器很接近。用这种方法调试游戏逻辑,比同时操作两台电脑快得多。我第一次测试时就是在左右两个标签页里分别用一个方向键控件,直接观察双方画布的状态是否一致,很方便。
5.4 上线前的检查清单
把 OmniGame 从本地跑通到对外可用的过程里,有几个坑几乎每次都会碰到,整理成一张表方便对照:
| 检查项 | 容易踩的坑 | 确认方式 |
|---|---|---|
| HTTPS / localhost 环境 | WebRTC 在非安全上下文不可用 | 访问地址必须是 https 或 localhost |
| STUN/TURN 配置 | 忘了 Blob 在 ICE 中的工作方式 | 控制台查看 ICE 候选类型 |
| ICE Candidate 暂存 | 在 remoteDescription 设置前 addIceCandidate 报错 | 日志中确认先 setRemoteDescription |
| DataChannel 有序性 | 乱序导致状态跳变 | 创建时使用 ordered: true |
| 消息类型字段 | 接收端无法区分信令和游戏消息 | 所有消息都带 type 字段 |
| 心跳与超时 | 断线后发现不及时 | 超过 3 秒无消息标记 lost |
还有一个容易忽略的细节:页面失焦。玩家切走浏览器标签页时,游戏循环会被浏览器节流,导致主机停止广播快照,客户端就会开始累积延迟。解决方法是监听visibilitychange事件,在页面隐藏时主动暂停游戏并向对端发送暂停消息,避免因为定时器被节流而出现“两个人看到不同状态”的问题。
5.5 写在最后的工程心得
把 OmniGame 从“用 WebSocket + Node 服务器做联机”改造成“纯 P2P 对战”之后,我最大的感受是:工程上限的提升不是来自更复杂的架构,而是来自对浏览器的重新理解。浏览器不再是只能请求别人服务的终端,它本身就是一个有能力参与对等网络通信的节点。DataChannel 提供的传输通道、RTCPeerConnection 提供的建连机制、信令服务提供的瞬间握手,三者组合起来就等于把一台服务器塞进了玩家的浏览器里。
我最想留给你的一句话是:零依赖不是终点,而是一个开始。当你不依赖任何框架时,反而会被迫理解底层机制——你会认识 SDP 是干什么的、ICE 到底在穿透什么、DataChannel 和 WebSocket 的本质差异在哪里。这些东西,用框架的时候永远学不到。下一次你再写小游戏,不妨先想想:这局对战,真的需要一台中心服务器吗?