简介这份资源是面向Web前端与游戏开发初学者的WebSocket在线游戏实战Demo聚焦实时双向通信在游戏场景中的落地帮助读者理解握手流程、数据帧结构、多用户状态同步与服务器主动推送等核心机制适合具备基础JavaScript能力、希望从零搭建实时对战环境的开发者练手。压缩包共488个文件以391个js脚本为主体承载服务端逻辑与客户端交互代码另含55张jpg与10张png界面素材、14个ico图标、5个go源文件及少量html页面与md说明整体约2.96MB体量轻便、便于本地运行调试。目前已有94人学习下载。Demo覆盖连接建立、消息广播、游戏逻辑同步与断线重连等环节读者可据此梳理服务端会话管理与客户端数据收发的完整链路并参考其中的数据交换格式与安全传输思路为后续开发实时在线游戏积累可复用的实现经验。1. 一个把“实时对战”拆开给你看的 WebSocket 游戏 Demo最近在整理一个联机小游戏的联调问题时我又把这份基于 WebSocket 的在线游戏开发 Demo 翻出来跑了一遍。它做的事情很聚焦用 WebSocket 把服务端和浏览器之间的双向通道打通让两个玩家能在同一个房间里实时同步位置、状态和操作而不是靠轮询去“猜”对方在哪。对想入门实时联机、又不想一上来就啃大型游戏引擎的开发者来说这个 Demo 的价值在于它把“连接建立、房间管理、消息广播、状态同步”这几件事拆得足够小小到你能在一个下午里把整条链路读明白。它适合两类人一类是刚接触 WebSocket、想找个能跑起来的完整例子另一类是想给现有网页项目加实时能力、需要一份可改造骨架的熟手。2. 技术栈与目录结构先看清这套骨架怎么搭的2.1 技术选型为什么是 WebSocket 而不是轮询在线游戏最怕的就是延迟和状态不一致。传统 HTTP 轮询要么间隔太长导致操作滞后要么间隔太短把服务端压垮而且每次请求都要重新带一遍头部信息。WebSocket 在握手之后保持一条长连接服务端可以主动把其他玩家的状态推过来这正是实时对战需要的模型。这个 Demo 的服务端用 Node.js 起一个 WebSocket 服务浏览器端用原生 WebSocket API 连接渲染层用 Canvas 画简单的游戏对象。整套东西没有引入重型框架目的是让协议层和游戏逻辑层保持清晰的分界。常见做法是服务端用ws这个库来管理连接它比原生http模块手写升级握手要省事得多同时又能拿到每个连接的readyState和事件回调。客户端这边连接、收发消息、断线重连都封装在一个通信模块里游戏逻辑只关心“收到什么消息、更新什么状态”不直接碰 socket 对象。这种分层在 Demo 阶段看起来有点多余但等你真要加房间、加匹配、加断线恢复时就会庆幸当初没把 socket 调用散落在渲染代码里。2.2 目录结构每个文件负责什么一份能跑起来的 Demo目录通常不会太深但职责要分得开。下面是我按这类项目常见组织方式还原的结构实际拿到压缩包后可以对照着看websocket-game-demo/ ├── server/ │ ├── index.js # 服务端入口创建 WebSocket 服务 │ ├── roomManager.js # 房间创建、加入、销毁 │ └── gameLoop.js # 服务端 tick广播状态 ├── client/ │ ├── index.html # 页面入口Canvas 容器 │ ├── net.js # WebSocket 连接与消息封装 │ ├── game.js # 游戏对象与渲染逻辑 │ └── input.js # 键盘/鼠标输入采集 ├── shared/ │ └── protocol.js # 消息类型常量前后端共用 └── package.jsonshared/protocol.js这个文件值得单独说一句。前后端如果各写一套消息类型字符串改一个字段名就要两边一起改漏一处就是线上事故。把消息类型、字段名、默认值集中到一个文件里服务端require、客户端通过构建或直接引用能省掉大量“对不上”的排查时间。Demo 阶段可以直接用 CommonJS 或 ES Module 共享正式项目里再考虑打包工具处理。2.3 启动流程三条命令把环境跑起来拿到压缩包后先确认本机 Node.js 版本。这类 Demo 一般对版本不敏感Node 16 以上都能跑。进入项目根目录后按下面顺序操作# 安装依赖主要是 ws 这个 WebSocket 库 npm install # 启动服务端默认监听 3000 端口 node server/index.js # 另开一个终端起一个静态文件服务给 client 用 npx serve client服务端起来后浏览器打开静态服务给出的地址比如http://localhost:5000。开两个标签页分别用方向键控制能看到两个页面里的方块各自移动、并且对方的位置也在同步变化就说明整条链路通了。如果只有一个页面能动、另一个没反应先看服务端控制台有没有打印连接建立和消息广播的日志再看浏览器开发者工具的 Network 面板里 WebSocket 帧有没有正常收发。提示静态服务和 WebSocket 服务端口不同浏览器会有跨域限制。Demo 里常见做法是服务端在握手阶段不校验 Origin或者静态服务加一个代理把/ws转发到 3000 端口。跑不通时优先查这一层而不是怀疑游戏逻辑。3. 核心模块拆解一条消息从输入到广播的完整链路3.1 服务端连接管理每个 socket 都是一个玩家服务端入口要做的事很明确创建 WebSocket 服务、给每个新连接分配身份、把消息路由到对应处理函数。下面这段是这类 Demo 里最典型的结构// server/index.js const WebSocket require(ws); const { MSG } require(../shared/protocol); const roomManager require(./roomManager); const wss new WebSocket.Server({ port: 3000 }); wss.on(connection, (ws) { // 给每个连接挂一个玩家上下文后续消息都从这里取 ws.player { id: null, roomId: null, x: 0, y: 0 }; ws.on(message, (raw) { let msg; try { msg JSON.parse(raw); } catch (e) { return ws.send(JSON.stringify({ type: MSG.ERROR, reason: bad_json })); } switch (msg.type) { case MSG.JOIN: roomManager.join(ws, msg.roomId); break; case MSG.INPUT: roomManager.handleInput(ws, msg.payload); break; case MSG.LEAVE: roomManager.leave(ws); break; default: ws.send(JSON.stringify({ type: MSG.ERROR, reason: unknown_type })); } }); ws.on(close, () roomManager.leave(ws)); });这段代码的关键点有三个。第一ws.player是挂在连接对象上的上下文玩家 id、房间 id、坐标都放这里避免用全局 Map 再维护一份映射减少状态不同步的可能。第二消息解析用try/catch包住坏消息直接回错误而不是让进程崩掉这是联机服务的基本素养。第三close事件里一定要调leave否则玩家断线后房间里的幽灵对象会一直存在别人看到的是一个不动的方块排查起来很费劲。参数上port改成你环境里空闲的端口即可MSG常量来自共享协议文件新增消息类型时两边同步加不要用魔法字符串。handleInput收到的是玩家输入服务端不应该直接信任客户端传来的坐标常见做法是只接收“方向 时间戳”由服务端根据速度和 tick 间隔算出新位置这样能挡住一部分改坐标的作弊。3.2 房间管理加入、广播与清理房间是这类游戏 Demo 的核心抽象。一个房间持有若干玩家连接负责把某个玩家的状态变化广播给同房间的其他人。下面是一个精简版实现// server/roomManager.js const { MSG } require(../shared/protocol); const rooms new Map(); // roomId - Setws function join(ws, roomId) { if (!rooms.has(roomId)) rooms.set(roomId, new Set()); const room rooms.get(roomId); ws.player.id ${roomId}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}; ws.player.roomId roomId; room.add(ws); // 告诉新玩家当前房间已有成员便于客户端初始化 ws.send(JSON.stringify({ type: MSG.JOINED, selfId: ws.player.id, peers: [...room].filter(c c ! ws).map(c c.player) })); broadcast(roomId, { type: MSG.PEER_JOIN, player: ws.player }, ws); } function broadcast(roomId, msg, except) { const room rooms.get(roomId); if (!room) return; const data JSON.stringify(msg); for (const client of room) { if (client ! except client.readyState 1) { client.send(data); } } } function leave(ws) { const roomId ws.player.roomId; if (!roomId || !rooms.has(roomId)) return; const room rooms.get(roomId); room.delete(ws); broadcast(roomId, { type: MSG.PEER_LEAVE, id: ws.player.id }); if (room.size 0) rooms.delete(roomId); // 空房间及时回收 } module.exports { join, leave, broadcast };rooms用Map而不是普通对象是因为房间 id 可能由客户端传入用对象会有原型链污染的风险Map的键更干净。broadcast里判断readyState 1对应 WebSocket 的 OPEN 状态避免往已关闭的连接写数据抛异常。leave里空房间删除这一步很多人会漏Demo 跑一会儿内存里堆一堆空 Set虽然量小不至于出事但养成习惯对后面做匹配系统有好处。JOINED消息里带peers是为了让新加入的客户端一次性拿到房间里其他人的初始状态否则要等下一次广播才能看到别人体验上会有一个短暂的空窗。PEER_JOIN则通知老玩家有新成员进来客户端据此创建对应的渲染对象。3.3 客户端通信封装连接、重连与消息分发客户端这边最容易写乱。把 socket 操作直接塞进游戏循环后面加断线重连会非常痛苦。比较稳妥的做法是单独一个net.js// client/net.js const { MSG } require(../shared/protocol); class Net { constructor(url) { this.url url; this.handlers new Map(); this.ws null; this.reconnectDelay 1000; } connect() { this.ws new WebSocket(this.url); this.ws.onopen () { this.reconnectDelay 1000; // 连上后重置退避 this.emit(open); }; this.ws.onmessage (evt) { const msg JSON.parse(evt.data); this.emit(msg.type, msg); }; this.ws.onclose () { this.emit(close); // 指数退避重连避免服务端刚重启就被打满 setTimeout(() this.connect(), this.reconnectDelay); this.reconnectDelay Math.min(this.reconnectDelay * 2, 10000); }; } on(type, fn) { if (!this.handlers.has(type)) this.handlers.set(type, []); this.handlers.get(type).push(fn); } emit(type, payload) { (this.handlers.get(type) || []).forEach(fn fn(payload)); } send(type, payload) { if (this.ws this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify({ type, payload })); } } } module.exports new Net(ws://localhost:3000);handlers用 Map 存事件回调游戏逻辑通过net.on(MSG.PEER_MOVE, fn)订阅通信层不关心谁在监听。重连用指数退避第一次 1 秒、第二次 2 秒封顶 10 秒这个策略在本地开发时可能感觉不到但部署到真实网络后能明显减少服务端抖动时的连接风暴。send前判断readyState避免连接还没建立就发消息导致静默丢失。注意重连成功后服务端会把它当成一个新连接原来的玩家 id 和房间信息都丢了。Demo 阶段可以接受重新加入房间正式项目里需要在JOIN消息里带一个客户端生成的会话标识服务端据此恢复状态。这个点后面改造章节会展开。3.4 游戏循环与状态同步tick 驱动而不是事件驱动实时游戏的状态推进通常靠固定间隔的 tick而不是每次输入就立刻广播。服务端按固定频率比如每秒 20 次推进世界状态然后把变化广播出去// server/gameLoop.js const { MSG } require(../shared/protocol); const roomManager require(./roomManager); const TICK_MS 50; // 20 次/秒 setInterval(() { for (const [roomId, room] of roomManager.rooms) { const snapshot []; for (const client of room) { // 根据输入方向推进位置服务端是唯一权威 client.player.x client.player.vx || 0; client.player.y client.player.vy || 0; snapshot.push({ id: client.player.id, x: client.player.x, y: client.player.y }); } roomManager.broadcast(roomId, { type: MSG.SNAPSHOT, players: snapshot }); } }, TICK_MS);TICK_MS决定同步频率50 毫秒对应 20Hz对这类方块移动的 Demo 足够流畅再高会明显增加带宽和 CPU。vx、vy是玩家输入时设置的速度分量服务端每 tick 累加到坐标上客户端只负责把收到的快照画出来。这种“服务端权威 客户端表现”的模型是联机游戏的标准做法客户端不做预测也能跑只是操作手感会有一点延迟后面可以加本地预测来优化。广播整份快照而不是增量在玩家数量少的时候最简单也最不容易出错。等房间人数上去、带宽成为瓶颈再考虑只广播变化的玩家或者做兴趣管理。Demo 阶段不要过早优化先把链路跑通。4. 避坑与排查联调时最容易翻车的几个点4.1 现象两个页面都连上了但互相看不到对方原因通常是房间 id 不一致。客户端加入时如果各自生成随机房间号两个人根本不在同一个房间自然收不到彼此的广播。解决方法是让房间 id 由 URL 参数或输入框统一指定比如?roomtest1两边用同一个值。排查时在服务端join里打印roomId和当前房间人数一眼就能看出来。4.2 现象消息发出去了服务端没反应先确认发的是不是合法 JSON。客户端如果直接send一个对象而没有JSON.stringify浏览器会把它转成[object Object]服务端JSON.parse直接抛错。另一个常见原因是消息类型字符串拼错比如服务端期望input、客户端发的是INPUT大小写不一致。把类型常量集中到shared/protocol.js就是为了消灭这类问题改的时候只改一处。4.3 现象玩家断线后房间里还留着一个不动的方块这是close事件里没做清理或者清理了但没广播PEER_LEAVE。检查ws.on(close)是否调用了leave以及leave里是否向剩余玩家广播了离开消息。还有一种隐蔽情况客户端刷新页面时旧连接可能还没触发close新连接已经进来了短时间内同一个玩家出现两个对象。可以在join时按会话标识去重或者接受短暂重复、等旧连接超时后自然清理。4.4 现象本地能跑换台机器就连不上多半是地址写死成了localhost或127.0.0.1。客户端new WebSocket(ws://localhost:3000)在别的机器上打开时localhost 指向的是那台机器自己当然连不上。改成服务端的局域网 IP或者用相对路径配合反向代理。另外检查防火墙有没有放行 3000 端口这个坑在跨机器联调时非常常见。4.5 现象玩一会儿浏览器标签页卡死通常是消息频率太高或者渲染层每帧都在重建对象。服务端 tick 设得太密、广播数据量又大客户端onmessage里做重活主线程被占满。先把TICK_MS调大观察是否缓解再把渲染改成复用对象、只更新坐标不要每次快照都new一堆东西。用浏览器 Performance 面板录一段看是网络回调耗时还是绘制耗时定位清楚再改。5. 二次开发从 Demo 到能用的联机骨架拿到这份 Demo 之后最值得先动的是断线恢复因为它直接决定这套骨架能不能往真实项目上靠。前面提过重连后服务端会把玩家当新人房间和状态全丢。改造思路是在客户端首次连接时生成一个sessionId存在sessionStorage里JOIN消息带上它服务端维护sessionId - player的映射重连时如果命中就把新连接挂回原来的玩家上下文并回发当前房间快照。这样刷新页面或短暂断网后玩家还能回到原来的位置而不是从零开始。// 服务端 join 里增加会话恢复逻辑示意 function join(ws, roomId, sessionId) { const existing sessionStore.get(sessionId); if (existing existing.roomId roomId) { // 旧连接如果还开着先踢掉避免双开 if (existing.ws.readyState 1) existing.ws.close(); existing.ws ws; ws.player existing; ws.send(JSON.stringify({ type: MSG.RESUMED, player: existing })); return; } // 走正常的新玩家流程并写入 sessionStore }第二个改造点是输入预测。现在客户端要等服务端快照回来才更新位置网络稍差就能感觉到操作“粘手”。常见做法是客户端收到输入后立刻本地移动同时记录一个待确认队列收到服务端快照时对比自己预测的位置和服务端位置偏差超过阈值就平滑纠正回来。这个机制不需要改服务端协议纯客户端就能加是性价比很高的一步。第三个方向是房间匹配。现在房间 id 靠手动指定可以加一个简单的匹配队列玩家点“开始”后进入等待池凑够两人就分配房间并通知双方。服务端用一个数组存等待中的连接每次有新人进来就检查长度够数就shift出两个建房间。逻辑不复杂但要注意等待中的连接断线时从池子里移除否则会匹配到一个已经关掉的 socket。改造点改动位置难度收益断线恢复服务端 join 客户端 sessionId中高决定能否用于真实场景输入预测仅客户端 game.js低中明显改善手感房间匹配服务端新增 matchmaker低中去掉手动指定房间状态压缩服务端广播格式中低人数多了才需要我自己的习惯是每加一个改造点先用两个标签页加一个手动断网开关验证一遍确认断线、重连、状态恢复三条路径都走通再往下做。有一次跳过这步直接加匹配结果等待池里混进了已断开的连接匹配成功后一方永远收不到开始消息查了半天才发现是清理逻辑漏了。从那以后我每次动连接生命周期相关的代码都强制把断线场景先跑一遍。希望这份拆解帮到你把 Demo 跑起来之后先从断线恢复下手这套骨架就能往能用的方向走一大步。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?