
1. 从零依赖到 P2P 联机OmniGame 到底想解决什么问题第一次看到 OmniGame 这个项目标题的时候我脑子里冒出来的第一个念头是又一个网页小游戏框架但仔细看完它的技术定位之后我发现这个东西的野心其实不在做游戏本身而是在重新划定网页小游戏的工程边界。它想做的事情用一句话概括就是——让一个网页小游戏从单机玩具变成可以联机对战的产品而且整个过程不依赖任何后端服务器、不依赖任何第三方联机平台、不依赖任何游戏引擎。这个定位听起来有点狂但拆开来看其实非常务实。网页小游戏这个领域长期存在一个尴尬的断层前端开发者用 Canvas 或者 DOM 就能写出一个能玩的贪吃蛇、五子棋、你画我猜但一旦涉及到两个人一起玩立刻就卡住了。传统的做法是搭一个 WebSocket 服务器做消息中转或者接入某个实时通信云服务。前者意味着你要维护服务器、处理并发、考虑部署和成本后者意味着你要绑定某个平台的 SDK免费额度用完就得掏钱而且数据要经过第三方。OmniGame 选择的路子是 WebRTC 的 P2P 数据通道。两个玩家打开同一个网页通过一次握手建立点对点连接之后所有的游戏状态同步、操作指令传输全部走这条直连通道中间不经过任何服务器。这意味着什么意味着你部署一个纯静态的网页托管在任意静态资源服务上就能支撑起一个完整的双人联机游戏。没有后端、没有数据库、没有运维成本。但这里有一个关键问题需要说清楚WebRTC 的 P2P 连接建立过程本身是需要一个信令环节的也就是双方要交换网络信息才能找到彼此。OmniGame 在这块的取舍很有意思——它没有完全去掉服务器而是把服务器的职责压缩到了极致只做一次性的信令交换连接建立之后服务器就彻底退场。这个设计思路在工程上非常聪明因为它把必须要有服务器这个约束从全程依赖降级成了握手瞬间依赖。适合读这篇内容的人大概分三类。第一类是前端开发者想给自己的小游戏加上联机功能但不想碰后端第二类是对 WebRTC 感兴趣但一直没找到合适练手场景的人第三类是做独立小游戏、想用最低成本验证联机玩法可行性的开发者。不管你属于哪一类接下来的内容会从架构设计、核心技术点、实操步骤到踩坑经验完整地拆一遍。2. 整体架构设计为什么是 Shadow DOM 加 Next.js 加 WebRTC 这个组合2.1 三个技术选型背后的真实考量OmniGame 的技术栈组合乍一看有点混搭Next.js 做应用框架、Shadow DOM 做游戏隔离、WebRTC 做联机通信。但如果理解了这个项目要解决的问题就会发现这三个选择其实是被需求倒逼出来的不是拍脑袋决定的。先说 Next.js。很多人会问一个网页小游戏框架为什么要用 Next.js用 Vite 加 React 不是更轻吗这里的关键在于 OmniGame 的定位不是一个游戏而是一个能承载多个游戏的平台。它需要路由、需要页面级的代码分割、需要静态导出能力。Next.js 的 App Router 配合静态导出output: export可以生成纯静态的 HTML 加 JS 产物直接扔到任意静态托管上就能跑。而且它的动态导入能力让每个游戏的代码只在进入对应路由时才加载不会让首屏背上一堆用不到的代码。如果用 Vite 加 React Router 当然也能做但 Next.js 在路由约定、构建优化、静态导出这块的成熟度确实省事。再说 Shadow DOM。这是整个架构里最容易被低估的一环。网页小游戏平台有一个天然的难题多个游戏可能来自不同的开发者用了不同的 CSS 框架、不同的全局样式、不同的 DOM 操作习惯。如果所有游戏都挂在同一个 document 下样式冲突几乎是必然的。A 游戏的* { box-sizing: border-box }可能把 B 游戏的布局搞崩C 游戏引入的某个 UI 库可能污染全局变量。Shadow DOM 的作用就是给每个游戏创建一个封闭的 DOM 子树外部的样式进不来内部的样式出不去。这就像给每个游戏发了一个独立的房间你在自己房间里怎么折腾都不会影响到隔壁。最后是 WebRTC。这个选择的核心动机是去中心化联机。传统的 WebSocket 方案需要服务器持续在线做消息中转而 WebRTC 的 DataChannel 建立之后数据直接在两个浏览器之间传输。延迟更低少了一跳服务器转发、成本更低不需要为每个房间维持服务器连接、隐私更好游戏数据不经过第三方。代价是连接建立的复杂度更高需要处理信令交换、NAT 穿透、连接状态管理这些问题。OmniGame 的价值就在于把这些复杂度封装掉了。2.2 分层架构与数据流向把这三个技术放在一起OmniGame 的整体架构可以分成四层来理解。最底层是通信层由 WebRTC 的 RTCPeerConnection 和 RTCDataChannel 构成。这一层负责建立 P2P 连接、维护连接状态、提供可靠或不可靠的数据传输通道。游戏的所有实时数据——玩家位置、操作指令、状态同步包——都走这一层。往上一层是信令层。这一层负责在连接建立之前帮两个玩家交换网络信息SDP offer/answer 和 ICE candidate。OmniGame 在这一层的设计是最小化服务器依赖可以用一个极简的信令服务甚至可以用手动复制粘贴的方式完成握手适合测试场景。信令层在连接建立之后就完全退场不参与后续任何数据流转。再往上是游戏运行时层。这一层负责游戏的加载、生命周期管理、输入处理、渲染循环。每个游戏被封装成一个独立的模块运行在自己的 Shadow DOM 容器里。运行时层还负责把游戏产生的网络事件比如我出牌了翻译成 DataChannel 消息以及把收到的消息还原成游戏事件。最顶层是应用框架层由 Next.js 负责。这一层管路由、管页面布局、管游戏列表、管用户进入某个游戏时的加载流程。它不关心游戏内部怎么运行只负责把正确的游戏模块加载到正确的容器里。数据流向是这样的玩家 A 在游戏里做了一个操作游戏运行时把这个操作序列化成消息通过 DataChannel 发给玩家 B。玩家 B 的运行时收到消息反序列化成游戏事件应用到本地游戏状态触发渲染更新。整个过程没有服务器参与延迟取决于两个玩家之间的网络距离。2.3 为什么不用现成的联机方案市面上做网页联机的方案不少比如各种实时通信云服务、开源的房间服务器、甚至一些游戏引擎自带的联机模块。OmniGame 选择自己基于 WebRTC 造轮子理由有三点。第一是成本结构。云服务方案通常按连接数或消息量计费小规模玩没问题一旦用户量上来成本就不可控。P2P 方案的成本几乎为零因为服务器只在信令阶段短暂参与不承担数据中转。第二是数据主权。游戏数据直接在玩家之间传输不经过第三方服务器这对一些对隐私敏感的场景比如带聊天功能的游戏很重要。第三是工程可控性。用现成方案意味着你要接受它的 API 设计、它的限制、它的 bug。自己基于 WebRTC 做虽然前期投入大但每一层都可以按需定制遇到问题也能深入排查。当然P2P 方案也有它的局限。最明显的是它不适合大规模多人游戏——WebRTC 的 P2P 连接是网状结构N 个玩家需要 N×(N-1)/2 条连接人数一多就撑不住。所以 OmniGame 的定位很明确双人对战、小规模协作这类场景而不是 MMO。这个边界划得很清楚也是它工程上务实的地方。3. 核心技术点拆解WebRTC 数据通道与 Shadow DOM 隔离3.1 WebRTC DataChannel 的建立过程与关键参数WebRTC 的 P2P 连接建立是整个项目里技术含量最高的部分也是最容易出问题的地方。我把这个过程拆成几个关键步骤每一步都说明它在干什么、为什么需要、以及容易踩什么坑。第一步是创建 RTCPeerConnection 实例。这个对象是整个连接的核心它管理着 ICE 候选收集、DTLS 加密、SCTP 传输等底层细节。创建的时候可以传入 ICE 服务器配置通常是 STUN 服务器。STUN 的作用是帮客户端发现自己公网地址因为大多数设备都在 NAT 后面不知道自己对外暴露的地址是什么。这里有个经验公共 STUN 服务器在测试阶段够用但生产环境最好自己搭一个或者用可靠的商业 STUN因为公共 STUN 的可用性和延迟都不稳定。第二步是创建 DataChannel。DataChannel 是建立在 PeerConnection 之上的数据传输通道可以理解为一条虚拟网线。创建的时候有几个关键参数需要关注ordered是否保证消息顺序。对于游戏状态同步通常需要保证顺序所以设为 true。但如果是一些可以容忍丢包的实时数据比如位置更新设为 false 可以降低延迟。maxRetransmits或maxPacketLifeTime控制丢包重传策略。设为 0 表示不重传适合实时性优先的场景不设则表示可靠传输。protocol自定义协议标识可以用来区分不同类型的通道。我的建议是给游戏开两条通道一条可靠通道走关键指令比如我认输了游戏结束一条不可靠通道走高频状态同步比如鼠标位置。这样既能保证关键逻辑不出错又能让实时数据保持低延迟。第三步是信令交换。这一步是 P2P 连接建立过程中唯一需要服务器参与的地方。流程是这样的发起方创建 offer通过信令服务器发给接收方接收方收到 offer 后创建 answer再通过信令服务器发回给发起方。同时双方都在收集 ICE candidate每收集到一个就通过信令服务器发给对方。这个过程叫 ICE 候选交换目的是让双方找到一条能通的网络路径。这里有个常见的误解很多人以为信令交换完连接就建立了。实际上 ICE 候选交换是持续进行的双方会不断尝试各种路径组合直到找到一条能通的。这个过程可能需要几秒钟取决于网络环境。在代码里要监听iceconnectionstatechange事件根据状态变化更新 UI让玩家知道正在连接中还是连接成功还是连接失败。第四步是连接建立后的状态管理。DataChannel 的onopen事件触发才意味着真正可以发数据了。在这之前发的消息会失败。所以游戏逻辑要等onopen之后再启动联机部分。另外要监听onclose和onerror处理断线情况。断线之后 WebRTC 不会自动重连需要应用层自己实现重连逻辑——通常是重新走一遍信令交换流程。3.2 Shadow DOM 隔离的实操细节Shadow DOM 的隔离能力很强但用起来有几个细节不注意就会踩坑。第一个细节是样式注入方式。Shadow DOM 内部的样式不会自动继承外部的样式所以你不能指望全局的 CSS reset 或者字体设置能作用到 Shadow DOM 里面。解决办法是在 Shadow Root 内部手动插入一个style标签把游戏需要的所有样式都写进去。如果游戏用了 CSS 框架需要把框架的样式也一起注入。这里有个技巧可以用adoptedStyleSheets来共享样式表比每次插入style标签更高效尤其是多个游戏共用同一套基础样式的时候。第二个细节是事件穿透。Shadow DOM 内部的事件默认不会冒泡到外部但有些事件比如 click、keydown会以composed: true的方式穿透边界。这带来一个好处你可以在外部统一监听键盘事件然后转发给当前活跃的游戏。但也要注意如果游戏内部也监听了同样的键盘事件可能会出现重复响应。解决办法是在游戏内部处理事件时调用stopPropagation或者在外部监听时判断事件来源。第三个细节是焦点管理。Shadow DOM 内部的元素获取焦点时外部的document.activeElement会指向 Shadow Host 而不是内部的实际元素。如果游戏需要处理键盘输入要确保焦点正确落在游戏容器上。我的做法是在游戏容器上设置tabindex0进入游戏时主动调用focus()并在容器内部监听键盘事件。第四个细节是性能考量。Shadow DOM 本身对性能影响很小但如果每个游戏都创建大量 DOM 节点加上 Shadow Root 的封装内存占用会比普通 DOM 高一些。对于小游戏来说这不是问题但如果游戏里有大量动态元素建议用 Canvas 渲染而不是 DOM 操作。3.3 游戏运行时的生命周期设计每个游戏在 OmniGame 里都是一个独立的模块有明确的生命周期。这个设计让游戏之间互不干扰也让资源的加载和释放变得可控。生命周期大致分四个阶段加载、初始化、运行、销毁。加载阶段负责动态导入游戏模块和它的资源文件初始化阶段创建 Shadow DOM 容器、注入样式、实例化游戏对象运行阶段启动渲染循环和事件监听销毁阶段清理定时器、断开网络连接、移除 DOM 节点。这里的关键是销毁阶段要做干净。我见过太多项目因为没清理干净导致内存泄漏——游戏切换几次之后页面就卡了。具体要清理的东西包括requestAnimationFrame的循环、setInterval和setTimeout、事件监听器、WebRTC 连接、以及任何持有外部引用的对象。建议给每个游戏模块定义一个destroy()方法在里面统一做清理框架在切换游戏时调用它。另一个设计点是游戏与框架的通信接口。游戏不应该直接操作框架的 DOM 或者全局状态而是通过一套定义好的接口来交互。比如游戏需要显示一个对手已断开的提示它应该调用框架提供的showToast()方法而不是自己去创建 DOM。这样框架可以统一控制 UI 风格游戏开发者也不用关心框架的实现细节。4. 实操过程从零搭建一个可联机的网页小游戏4.1 项目初始化与依赖配置先把项目骨架搭起来。用 Next.js 的 App Router 模式配置静态导出。npx create-next-applatest omnigame --typescript --app --no-tailwind --no-eslint cd omnigame然后在next.config.js里开启静态导出/** type {import(next).NextConfig} */ const nextConfig { output: export, images: { unoptimized: true }, }; module.exports nextConfig;这里解释一下为什么用静态导出。OmniGame 的联机能力不依赖服务器所以整个应用可以编译成纯静态文件。静态导出之后out目录里的内容可以直接扔到任意静态托管上不需要 Node.js 运行环境。这大大降低了部署门槛和成本。接下来安装 WebRTC 相关的依赖。实际上浏览器原生就支持 WebRTC不需要额外的库。但为了简化信令交换和连接管理可以用一个轻量的封装库。我选的是peerjs它把信令服务器和连接管理都封装好了适合快速验证。如果你想要完全自主可控也可以自己实现信令逻辑后面我会讲怎么做。npm install peerjs4.2 信令交换的最小实现信令交换是 P2P 连接建立的关键环节。我用两种方式来实现一种是基于 PeerJS 的托管信令适合快速验证另一种是自建极简信令服务适合生产环境。先说 PeerJS 的方式。PeerJS 提供了一个免费的云信令服务你只需要创建一个 Peer 实例它会自动分配一个 ID然后通过这个 ID 就能建立连接。import Peer from peerjs; // 发起方 const peer new Peer(); peer.on(open, (id) { console.log(我的 ID 是, id); // 把这个 ID 通过某种方式告诉对方 }); // 接收方用发起方的 ID 建立连接 const conn peer.connect(remoteId); conn.on(open, () { console.log(连接建立成功); conn.send({ type: hello }); });这种方式的好处是简单几行代码就能跑通。坏处是依赖 PeerJS 的云服务如果它挂了或者限流了你的游戏就连不上了。所以生产环境建议自建信令。自建信令服务的核心逻辑其实很简单维护一个房间号到连接的映射当一个客户端发送 offer 时服务器把它转发给同房间的另一个客户端answer 和 ICE candidate 同理。用 Node.js 加ws库几十行代码就能实现。import { WebSocketServer } from ws; const rooms new Map(); const wss new WebSocketServer({ port: 8080 }); wss.on(connection, (ws) { ws.on(message, (data) { const msg JSON.parse(data); const room rooms.get(msg.roomId) || []; if (msg.type join) { room.push(ws); rooms.set(msg.roomId, room); if (room.length 2) { room.forEach((client, i) { client.send(JSON.stringify({ type: ready, initiator: i 0 })); }); } } else { // 转发 offer/answer/candidate 给房间里的另一个客户端 room.forEach((client) { if (client ! ws client.readyState 1) { client.send(data); } }); } }); });这个信令服务的职责非常单一配对和转发。它不存储任何游戏数据不参与连接建立之后的任何通信。连接建立后即使信令服务挂掉已经建立的 P2P 连接也不受影响。4.3 游戏状态同步的策略选择联机游戏最核心的技术问题就是状态同步。两个玩家各自运行一份游戏逻辑怎么保证他们看到的状态是一致的这里有几种策略各有适用场景。策略一主机权威模式。一个玩家作为主机负责运行完整的游戏逻辑另一个玩家作为客户端只发送操作指令和接收状态更新。主机收到客户端的操作后更新游戏状态然后把新状态广播给客户端。这种模式逻辑简单不容易出现状态不一致但主机玩家有天然优势延迟低而且主机断线游戏就结束了。策略二锁步模式。双方都运行完整的游戏逻辑只同步操作指令不同步状态。只要双方的逻辑是确定性的输入相同输出就相同。这种模式适合回合制游戏比如棋类因为操作频率低同步压力小。缺点是任何一方的逻辑出现偏差都会导致状态分叉而且需要等待双方的操作都到达才能推进。策略三状态广播模式。双方各自运行逻辑定期广播自己的状态收到对方状态后做插值或校正。这种模式适合实时性要求高的游戏比如动作游戏但实现复杂度最高需要处理冲突和延迟补偿。对于 OmniGame 的定位双人小游戏我推荐主机权威模式。它的实现难度和可靠性之间平衡得最好。具体做法是进入游戏时通过某种方式比如比较 ID 大小决定谁是主机主机运行完整逻辑客户端只负责发送输入和渲染主机发来的状态主机每帧或每隔几帧发送一次状态快照。状态快照的序列化也有讲究。JSON 最简单但体积大对于高频同步不合适。可以用ArrayBuffer加自定义二进制格式或者用MessagePack这类紧凑的序列化方案。对于小游戏来说JSON 通常够用但如果同步频率超过每秒 20 次建议换二进制格式。4.4 把游戏装进 Shadow DOM 的完整流程现在把游戏模块挂载到 Shadow DOM 里。整个流程分五步。第一步在页面里创建一个宿主元素div idgame-host/div第二步在 JavaScript 里创建 Shadow Rootconst host document.getElementById(game-host); const shadowRoot host.attachShadow({ mode: open });mode: open表示外部可以通过host.shadowRoot访问内部方便调试。如果设为closed外部就完全访问不到隔离更彻底但调试困难。开发阶段建议用open。第三步注入样式。把游戏需要的 CSS 作为字符串插入const style document.createElement(style); style.textContent :host { display: block; width: 100%; height: 100%; } .game-container { position: relative; } canvas { display: block; } ; shadowRoot.appendChild(style);注意:host选择器它指向 Shadow Host 本身用来设置容器的尺寸和布局。Shadow DOM 内部的样式不会影响外部所以可以放心用通用的类名。第四步创建游戏容器和 Canvasconst container document.createElement(div); container.className game-container; container.tabIndex 0; shadowRoot.appendChild(container); const canvas document.createElement(canvas); canvas.width 800; canvas.height 600; container.appendChild(canvas);第五步实例化游戏并启动const game new MyGame(canvas, { onNetworkMessage: (msg) conn.send(msg), }); conn.on(data, (data) game.handleNetworkMessage(data)); game.start();这里的关键是把网络通信和游戏逻辑解耦。游戏不直接操作 WebRTC 连接而是通过回调函数发送消息、通过方法接收消息。这样游戏模块可以独立测试也方便替换通信层。5. 常见问题与排查技巧实录5.1 连接建立失败的排查思路WebRTC 连接建立失败是最常见的问题而且原因往往不明显。我整理了一个排查顺序按这个顺序走基本能定位到问题。先看信令是否成功。打开浏览器控制台看 offer 和 answer 是否成功交换。如果信令阶段就失败了那问题在信令服务或者房间配对逻辑上跟 WebRTC 本身无关。常见原因是房间号不匹配、信令服务没启动、或者 WebSocket 连接被拦截。再看ICE 候选是否收集到。在onicecandidate事件里打印候选信息如果一直是 null 或者收集不到候选说明 STUN 服务器不可用。换一个 STUN 服务器试试或者检查网络环境是否限制了 UDP 流量。然后看ICE 连接状态。监听iceconnectionstatechange状态会经历new、checking、connected、completed几个阶段。如果卡在checking很久然后变成failed说明双方找不到可通的网络路径。这种情况通常发生在对称 NAT 环境下需要 TURN 服务器做中继。TURN 服务器会转发数据不再是纯 P2P但至少能连上。最后看DataChannel 是否打开。即使 ICE 状态是connectedDataChannel 也可能因为 DTLS 握手失败而没打开。监听onopen和onerror如果一直没触发onopen检查一下是不是浏览器版本太旧或者有安全策略限制。5.2 状态不同步的典型场景状态不同步是联机游戏里最让人头疼的问题因为它的表现往往是偶尔发生很难稳定复现。我遇到过几种典型场景分享一下排查方法。场景一操作丢失。玩家 A 说我出牌了但玩家 B 那边没反应。原因通常是消息在传输过程中丢了而 DataChannel 配置的是不可靠模式。解决办法是关键操作走可靠通道或者给每个操作加序号接收方发现序号不连续就请求重发。场景二状态分叉。双方看到的状态不一致比如 A 看到自己赢了B 看到自己赢了。这通常是因为双方都运行了完整逻辑但输入到达的顺序不同导致计算结果不同。解决办法是改用主机权威模式只让主机运行逻辑客户端只渲染。场景三延迟导致的视觉不一致。玩家 A 看到自己的角色已经移动到位置 X但玩家 B 看到的还在位置 Y。这是网络延迟的正常表现不一定是 bug。解决办法是用插值和平滑处理让远程玩家的位置变化看起来更自然而不是瞬间跳变。5.3 性能与内存问题的处理网页小游戏跑久了变卡通常不是游戏逻辑的问题而是资源没释放干净。我总结了几个检查点。检查 requestAnimationFrame 是否在销毁时取消。每个游戏实例应该保存自己的 rAF ID在destroy()里调用cancelAnimationFrame。如果忘了这一步切换游戏后旧的渲染循环还在跑CPU 占用会越来越高。检查事件监听器是否移除。addEventListener添加的监听器如果不移除即使 DOM 节点被删了监听器还可能持有引用导致内存泄漏。建议用AbortController统一管理监听器销毁时调用abort()一次性清理。检查 WebRTC 连接是否关闭。离开游戏时要调用peerConnection.close()否则连接会一直保持占用网络资源。同时要清理 DataChannel 的引用避免它持有游戏对象的引用导致无法回收。检查定时器是否清理。setInterval和setTimeout返回的 ID 要保存下来销毁时用clearInterval和clearTimeout清理。特别是那些周期性发送状态同步的定时器不清理的话会一直往一个已经关闭的连接发数据。5.4 常见问题速查表问题现象可能原因排查方法解决方案连接一直卡在 checkingNAT 穿透失败查看 ICE 候选类型配置 TURN 服务器DataChannel 不打开DTLS 握手失败查看控制台错误检查浏览器版本和证书消息发送失败连接未建立或已断开检查 readyState等 onopen 后再发送状态不同步逻辑非确定性或丢包对比双方日志改主机权威模式游戏切换后卡顿资源未释放检查内存占用完善 destroy 逻辑移动端无法连接权限或网络限制检查控制台确保 HTTPS 环境提示WebRTC 在非安全上下文HTTP下无法使用开发和测试时也要确保是 HTTPS 或者 localhost。6. 工程化扩展与个人经验总结6.1 从双人扩展到多人的思路OmniGame 目前的定位是双人联机但如果想扩展到三到四人的小规模多人架构上需要做一些调整。最直接的方式是网状连接每个玩家和其他所有玩家都建立一条 P2P 连接。四个人就是六条连接每个人维护三条。这个规模下网状结构还能撑住但人数再多就不行了。另一种方式是星型结构选一个玩家作为主机其他玩家都只和主机连接。主机负责转发消息。这种方式连接数少N-1 条但主机负担重而且主机断线整个房间就散了。适合对等性要求不高的场景。如果要支持更多人就得回到服务器中转的方案或者用 WebRTC 的 SFU选择性转发单元架构。但那已经超出了 OmniGame 的定位属于另一个量级的工程了。我的建议是先把双人场景做扎实多人作为后续扩展方向。6.2 部署与分发的注意事项静态导出之后部署非常简单把out目录扔到任意静态托管就行。但有几个细节要注意。HTTPS 是必须的。WebRTC 的 API 在非安全上下文下不可用所以你的托管服务必须支持 HTTPS。现在大多数静态托管都默认支持但如果你自己搭服务器记得配证书。信令服务的部署。如果用了自建信令它需要是一个常驻的 WebSocket 服务。可以部署在同一个域名下用路径区分比如/signal走 WebSocket其他走静态资源。这样只需要一个域名配置也简单。跨域问题。如果信令服务和静态资源不在同一个域名下需要配置 CORS。WebSocket 不受同源策略限制但 HTTP 请求比如获取房间列表会受限制。建议尽量同域部署省去跨域配置的麻烦。6.3 我在实际项目中的几点体会做这类 P2P 联机项目最大的体会是不要过早优化。一开始就想着支持多少人、做多复杂的同步策略往往会导致架构过度设计。先把双人、主机权威、JSON 同步这套最简单的方案跑通验证玩法可行之后再根据实际瓶颈去优化。我见过太多项目死在想太多上。另一个体会是日志和调试工具要早做。P2P 连接的问题很难复现没有详细的日志根本没法排查。建议在开发阶段就把 ICE 状态变化、DataChannel 开关、消息收发都打上日志并且提供一个调试面板可以实时查看连接状态。这个投入在后期排查问题时能省下大量时间。还有一点是要给玩家明确的连接状态反馈。P2P 连接建立需要时间如果玩家点了开始游戏之后界面没反应他会以为卡了然后刷新页面。所以要设计清晰的连接状态 UI正在连接、连接成功、连接失败、对手已断开。这些状态的变化要实时反映到界面上让玩家知道发生了什么。最后分享一个小技巧在测试 P2P 连接的时候用两个不同的浏览器比如 Chrome 和 Firefox或者一个正常窗口加一个隐私窗口比开两个标签页更接近真实场景。因为同一浏览器的两个标签页可能共享一些网络状态测试结果不够准确。另外用手机热点和 WiFi 分别连两个设备可以模拟真实的跨网络场景更容易发现 NAT 穿透的问题。这个项目后续还可以往几个方向扩展加一个游戏大厅让玩家能浏览和选择游戏加房间列表支持创建和加入不同房间加观战模式让第三方通过信令服务器接收状态广播但不参与操作。每一个扩展都会带来新的工程挑战但核心的 P2P 通信层和 Shadow DOM 隔离层不需要大改这也是这个架构设计得比较好的地方。