简介这是一份基于WebSocket的在线游戏开发入门Demo面向Web前端与Node.js/Go后端开发者旨在拆解实时游戏通信的关键链路。压缩包共488个文件、2.96MB以391个js脚本为核心客户端逻辑辅以go服务端源码、html页面入口及jpg/png/ico等静态资源目录结构紧凑适合快速阅读与二次修改。已有93人学习浏览。Demo覆盖WebSocket握手建连、数据帧解析、JSON数据交换等基础机制并实现多玩家位置同步、服务器主动推送等典型玩法场景服务端包含main.go、msg.go等模块前端通过index.html与login.html接入便于对照理解双向通信与状态管理。同时针对连接断开、WSS加密、负载均衡和数据压缩等生产问题给出优化思路能帮助开发者少走弯路快速搭建自己的实时交互原型。1. 基于 WebSocket 的在线游戏开发 Demo它解决的正是实时对战最麻烦的连接管理问题我拆这份“基于 WebSocket 的在线游戏开发 Demo.zip”时原以为里面又是一个把聊天室改成“伪实时对战”的玩具结果发现它把实时对战里最容易翻车的一段——连接管理、房间交互、状态广播——做成了一套可以直接跑起来的最小骨架。服务端有一组能挂住玩家、转发操作的 WebSocket 处理逻辑客户端有一套包含连接、断线重连和操作发送的 JS 封装解压后改改房间号就能看到两个浏览器里的角色同步移动。它不是一个完整游戏但适合刚入行没多久的前端或后端开发者也适合想快速验证“多人实时交互”思路的同学先跑通闭环再往里填玩法。整份资源的价值不是代码量而是把在线游戏里绕不开的三件事拆开了连接怎么保活、消息怎么定格式、房间状态怎么广播。下面从选型、实现到踩坑按我拆 Demo 的顺序展开。2. 连接层设计与最小实现弄清楚 WebSocket 在在线游戏里到底扮演什么角色2.1 为什么在线游戏必须用 WebSocket而不是轮询或 SSE在线游戏和普通 Web 应用最大的差异是“状态要双向流动”。玩家按一下方向键客户端要立刻把操作送去服务端服务端算出新位置要马上把坐标推给所有同房间玩家。这里面的延迟要求通常在一两百毫秒内而且要支持服务端主动向下推消息。HTTP 轮询的问题是每次请求都要带完整的头部即使只传一个方向键也要走完“请求-响应”整个链路服务器无法主动向客户端推送服务端有状态更新时只能等下一次请求。SSEServer-Sent Events解决了服务端推送但它仍然是单向通道客户端只能通过额外的 HTTP 请求往上送操作。所以轮询和 SSE 都适合“半个实时场景”比如通知列表、文件进度但不适合玩家操作和游戏状态双向交换。WebSocket 在 TCP 之上建立了一条长连接双方可以随时发消息消息头只有几字节并且浏览器原生支持 WebSocket 客户端服务端用 Node.js 的ws库或浏览器端的 WebSocket API 就能搭起来。在线游戏 Demo 选 WebSocket看中的就是这种“双向、低延迟、长连接”的特性。需要注意WebSocket 只解决传输问题不解决“消息格式怎么定”“状态怎么同步”的问题所以它才是骨架不是全部。2.2 在 Demo 里搭最小连接层服务端 ws 与浏览器客户端这份 Demo 里服务端最核心的代码是用 Node.js 的ws库创建 WebSocketServer。下面是去掉业务逻辑后的连接骨架// server.js const { WebSocketServer } require(ws); const wss new WebSocketServer({ port: 8080 }); wss.on(connection, (socket, req) { // socket 是当前玩家的连接对象req 可以拿到来源 IP 等信息 socket.isAlive true; // 服务端定期 ping客户端回 pong用来验证连接是否还活着 socket.on(pong, () { socket.isAlive true; }); // 收到消息后进入统一的消息分发函数 socket.on(message, (rawData) { console.log([收到] ${rawData}); // 这里会按 type 分发后面第 3 章讲 }); socket.on(close, () { // 清理房间中的玩家引用 console.log([断开] 连接关闭); }); });参数说明port: 8080是监听端口浏览器端连接时也会写这个端口socket.isAlive是一个自定义属性用来配合心跳检测判断连接是否失效rawData是默认收到的二进制或字符串。实际运行时ws库已经把 WebSocket 协议细节封装好我们只需要关心连接事件和消息事件。浏览器客户端就简单得多浏览器原生提供了WebSocket构造函数// client.js function connect() { const ws new WebSocket(ws://localhost:8080); ws.onopen () { console.log(连接成功); }; ws.onmessage (event) { // event.data 是服务端推过来的字符串 const message JSON.parse(event.data); console.log(message); }; ws.onclose () { // 断线重连可以在 3 秒后重新调用 connect setTimeout(connect, 3000); }; } connect();这段代码里唯一要重点理解的是JSON.parse(event.data)。写在线游戏时我习惯让所有消息都走 JSON 字符串这样浏览器端和服务端都能通过type字段做分发。如果直接传二进制虽然带宽小但调试和排错会麻烦不少。Demo 里用字符串 JSON也是为了让初学者能一眼看懂消息流向。2.3 连接状态管理用一个心跳机制判断“假在线”实际开发里最常见的坑是客户端网络闪断浏览器没有立刻触发close服务端也不知道连接已经失效。如果这时候服务端还往这个 socket 上广播消息会积压或引发异常。Demo 里采用的标准做法是“心跳检测 超时清理”。服务端每隔 30 秒 ping 一次客户端只要还在就会回 pong。如果某个 socket 超过 60 秒没有更新isAlive服务端就主动 terminate。这段逻辑可以写在 setInterval 里// server.js 心跳检查 const interval setInterval(() { wss.clients.forEach((socket) { if (!socket.isAlive) { socket.terminate(); return; } socket.isAlive false; socket.ping(); }); }, 30000); wss.on(close, () { clearInterval(interval); });这里有一个小设计每次 ping 之前把isAlive置为 false如果 pong 回来会重新置 true。这样就能区分“已断开”和“暂时没回包”。心跳间隔和超时时间要根据业务场景调整对局类游戏希望快速发现掉线可以把间隔缩到 10 秒如果是棋牌类等待场景30 秒就足够。这份 Demo 的好处是心跳逻辑已经写好修改参数就能套用到不同类型的实时游戏。3. 玩家、房间与状态同步把消息广播升级成游戏事件分发3.1 消息协议先定 type、再定 payload在线游戏里最容易出问题的是消息格式不统一。服务端往不同客户端发消息时如果有的发字符串、有的发对象客户端解析就会出现一堆兼容代码。我拆这份 Demo 时看到作者第一件事就是定义了一套基础消息协议每条消息都是一个 JSON 对象包含type和payload两个字段。{ type: join_room, payload: { roomId: room-a, playerId: player-1, name: p1 } }type用来表示消息类型payload里放具体数据。这套协议把消息分成三类玩家操作类、房间管理类、状态同步类。在 Demo 中用的几个 type 大致如下type方向作用join_room客户端 - 服务端玩家加入指定房间leave_room客户端 - 服务端玩家主动离开房间player_move客户端 - 服务端上报玩家当前操作或坐标game_state服务端 - 客户端广播给房间内所有玩家最新状态参数设计上要注意playerId最好由服务端生成不要信任客户端传来的 playerId。原因后面避坑章节会提。roomId由客户端携带没有大问题但服务端要校验这个房间存在且未满员。这套协议看起来简单但它保证了后续所有功能都只依赖type分支而不是去解析一段意义不明的字符串。3.2 房间管理用 Map 维护连接与房间的关系连接层做好之后房间管理就顺理成章。Demo 里服务端用一个Map来保存房间到玩家集合的关系const rooms new Map(); // roomId - Setsocket function joinRoom(socket, roomId) { if (!rooms.has(roomId)) { rooms.set(roomId, new Set()); } rooms.get(roomId).add(socket); socket.roomId roomId; broadcast(roomId, { type: game_state, payload: { info: 已加入房间 } }); } function leaveRoom(socket) { const roomId socket.roomId; if (!roomId || !rooms.has(roomId)) return; rooms.get(roomId).delete(socket); if (rooms.get(roomId).size 0) { rooms.delete(roomId); } }这段代码的核心思路是把连接对象自身挂到房间里而不是把玩家 ID 挂到房间里。好处是广播时直接遍历 socket 集合就能发消息省掉一次 ID 到 socket 的映射。socket.roomId是自定义字段用来记录当前所在房间方便离开时做清理。需要注意Set的delete在元素不存在时不会报错所以清理逻辑写得很宽容。当房间人数有上限时要在roomSizeLimit判断。常见做法是const ROOM_LIMIT 4; function joinRoom(socket, roomId) { const room rooms.get(roomId); if (room room.size ROOM_LIMIT) { socket.send(JSON.stringify({ type: error, payload: { message: 房间已满 } })); return; } // ... 正常加入 }这里的ROOM_LIMIT要根据游戏类型调整大逃杀类 100 人都算少棋牌类 4 人就是上限。Demo 里写的 4 人适合测试真实项目里建议在服务器配置文件里读取而不是写死在代码里。3.3 状态同步把每个玩家的最新状态推给全房间多人对抗游戏里服务端需要保存一份“权威状态”比如玩家位置、得分、血量然后定时广播这份状态。Demo 里最简单的做法是让服务端维护一个gameState对象每收到一次玩家操作就更新对应坐标再广播给房间内所有人。const stateMap new Map(); // roomId - { players: MapplayerId, { x, y } } function applyPlayerMove(socket, action) { const roomId socket.roomId; const player stateMap.get(roomId).players.get(socket.playerId); if (!player) return; player.x action.dx; player.y action.dy; broadcast(roomId, { type: game_state, payload: { roomId, players: Array.from(stateMap.get(roomId).players.values()) } }); }广播函数需要一个遍历房间内所有玩家的方法function broadcast(roomId, message) { const room rooms.get(roomId); if (!room) return; const data JSON.stringify(message); room.forEach((socket) { if (socket.readyState 1) { socket.send(data); } }); }这里有一个值得注意的点socket.readyState 1表示连接处于 OPEN 状态。因为心跳检测只能保证连接没有被清掉但消息发送瞬间可能刚好碰到 socket 正在关闭所以广播时要检查 readyState。参数dx、dy是这次移动的增量不要直接传绝对坐标否则一旦客户端和服务端时钟或逻辑不一致就会出现“角色瞬移后又弹回原地”。3.4 服务端权威与客户端预测的取舍Demo 里这段同步写法属于“服务端权威”的最小实现所有玩家的状态都以服务端计算为准客户端只负责把操作发上来然后等待服务端广播。这种模型的优点是反作弊容易做缺点是延迟高一些因为玩家按键盘到屏幕看到角色移动中间至少要经过一次往返。如果想让手感更跟手常见做法是“客户端预测 服务端校正”客户端按本地操作立即更新位置同时把操作发到服务端等服务端广播回来后对比结果不一致再纠偏。这个技巧对新手来说有点复杂我建议先把 Demo 里服务端权威的运行逻辑跑通再往里加预测否则排查问题时分不清到底是网络层还是逻辑层出错。这份 Demo 的价值恰恰在于它把“状态同步”这件事拆成了最清晰的一条链路。4. 避坑/常见问题连接闪断、心跳失效、重复入房与集合安全4.1 反向代理 60 秒断连不是我代码错了是 Nginx 配置没到位现象Demo 在本机跑得好好的部署到服务器上后玩家进入房间 1 分钟左右连接就断开而且断的时间点非常稳定。一开始我以为是心跳间隔太长把心跳改成 5 秒仍然没用。后来看 Nginx 日志才发现问题出在反向代理默认超时时间。原因Nginx 做 WebSocket 反向代理时需要显式开启 Upgrade 协议头并且proxy_read_timeout默认值只有 60 秒。如果超过 60 秒没有任何数据从后端返回Nginx 就会主动断开这个连接。WebSocket 长连接如果只靠 ping/pong 保活而代理层没有设置足够长的 read timeout照样会被掐断。解决location /ws { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }这里的proxy_read_timeout代表反向代理等待后端返回数据的最大时长在线游戏对局通常超过 1 小时设成 3600 秒只是一个保险值。如果服务器上还有防火墙或云安全组也要检查 80/443 之外的端口是否放行。这类问题最坑的地方是代码完全正常但部署环境多了一道代理排查时要先看响应头里有没有Upgrade字段。4.2 消息被我拆成了两段WebSocket 的粘包困惑要分清分层现象客户端收到服务端广播时偶尔会出现JSON.parse(event.data)抛错报错内容是把两条消息拼在了一起看起来像网络粘包。我一开始以为是 WebSocket 协议本身会粘包然后在客户端做了一堆拆包逻辑越写越复杂。原因WebSocket 协议在 OSI 第七层已经定义了消息帧每一帧都有明确的消息边界底层 TCP 即使把数据拆成多个包到应用层时ws库也会把完整的 WebSocket 消息还原好。所谓“粘包”通常发生在直接用裸 TCP Socket 实现消息边界时而不是用 WebSocket 库时会出现的问题。如果确实出现了两条消息合并成一条更可能是服务端连续调用多次socket.send而客户端监听事件时把多次触发的事件合并处理了。解决回到协议上来给每一条消息都确定唯一的分隔边界。使用 WebSocket 时不需要自己做长度前缀只要保证客户端每次收到消息都按完整 JSON 解析就行。如果确实要在裸 TCP 上改做法是发送消息前先写入一个 4 字节的头部记录消息体长度const header Buffer.alloc(4); header.writeUInt32BE(Buffer.byteLength(payload), 0); socket.write(Buffer.concat([header, payload]));但这份 Demo 用的是ws就不要在业务代码里裁切消息。遇到JSON.parse报错时先打印event.data看是不是服务端广播了空字符串或异常数据再来判断是不是消息合并。4.3 断线重连后房间里出现了两个相同玩家现象客户端做了断线重连后原房间里残留了旧连接新连接又加入同一房间导致其他玩家看到同一个用户出现两次而且旧连接发送的操作已经收不到。更麻烦的是旧连接在几分钟后才触发 close 事件房间里的玩家集合被白白占着。原因WebSocket 连接断开时服务端不一定能第一时间感知。如果客户端在旧连接还没被 server 端确认断开时就去重连服务端收到的是两个不同的 socket 对象而这两个 socket 带的是同一个 playerId于是同一房间出现两个 entry。解决在客户端重连后先发送一条签到消息服务端收到新连接后根据 playerId 主动把旧连接踢下线再执行加入房间逻辑function handlePlayerLogin(socket, playerId) { if (playerSockets.has(playerId)) { const oldSocket playerSockets.get(playerId); oldSocket.terminate(); } playerSockets.set(playerId, socket); }这里用terminate()而不是close()是因为要立刻切断底层连接而不是先做握手关闭流程。旧的 close 事件后来触发时清理逻辑里要判断当前 socket 是否还是自己如果是被替代的旧连接就不要删掉新连接。这也是在线游戏里比较隐蔽的一种竞态处理的关键是“以 playerId 为准而不是以 socket 为准”。4.4 广播集合被并发修改不只是“边遍历边删”的问题现象服务器日志偶尔会抛出Cannot read properties of undefined (reading send)或Set changed during iteration的错误。最开始是偶发玩家多了之后出现越来越频繁最后在广播时直接崩了。原因广播函数在forEach房间内所有 socket 时有连接刚好断开触发 close 事件对同一个房间的集合执行delete。在 JavaScript 的Set里迭代过程中如果元素被删除虽然不会立即崩溃但会导致广播漏发或索引错乱。更麻烦的是如果资源里用数组保存玩家边遍历边 delete 会出现“越界”或“空位”。解决广播时先拷贝一份集合再执行遍历。拷贝要拷贝 socket 列表而不是拷贝socket对象本身function broadcast(roomId, message) { const room rooms.get(roomId); if (!room) return; const data JSON.stringify(message); const clients Array.from(room); // 拷贝一份避免和 close 清理冲突 clients.forEach((socket) { if (socket.readyState 1) { socket.send(data); } }); }这样即使广播过程中有 socket 被从原集合删除也不会影响当前遍历。如果你用的是 TypeScript可以把房间玩家列表设计成Setnumber存 ID再通过 ID 从全局的playerSockets表里取 socket这能进一步避免“集合和连接状态不一致”的问题。这个坑说明在线游戏的连接层不是写完就不管了连接生命周期和房间状态的耦合往往是 bug 高发区。5. 验证与进阶用 50 个模拟客户端把你的 WebSocket 骨架压实这份 Demo 可以继续往上加玩法但我更建议先做一次“压力体检”。只连两个浏览器看不出问题一旦到了 50 个连接消息丢失、延迟和内存泄漏都会冒出来。你可以用 Node.js 写一个模拟客户端脚本同时建立 50 个 WebSocket 连接每 500 毫秒发送一次随机移动操作const WebSocket require(ws); for (let i 0; i 50; i) { const ws new WebSocket(ws://localhost:8080); ws.on(open, () { ws.send(JSON.stringify({ type: join_room, payload: { roomId: stress, playerId: p${i} } })); setInterval(() { ws.send(JSON.stringify({ type: player_move, payload: { dx: Math.random() * 2 - 1, dy: Math.random() * 2 - 1 } })); }, 500); }); }跑起来后观察服务端进程的内存占用和消息广播耗时。如果发现内存持续增长多半是rooms和stateMap里没有在 close 时清理干净如果广播耗时上涨考虑把 JSON 序列化结果缓存起来而不是每个玩家都单独JSON.stringify。这个小脚本也是我后来做任何 WebSocket 项目都要保留的工具改一下玩家数量和发送频率就能模拟很多场景。再往深走Demo 的单机房间模型要变成多进程部署就得把“房间”从进程内存搬到 Redis 或消息队列里。常见做法是每个进程仍然持有 WebSocket 连接但房间状态发布到 Redis 频道其他进程订阅频道后再广播给本进程内的玩家。这一步不要急着做先把单机版本的状态同步跑稳再让自己理解“连接层”和“状态层”应该拆开才是这份 Demo 留给你的进阶路径。我现在的习惯是每次拿到这类实时通信 Demo第一件事不是看功能而是先找它的断线清理逻辑和消息协议定义再把模拟客户端脚本跑一遍确认没有明显的并发问题。资源里那套“心跳 房间集合 状态广播”的思路已经成了我做在线功能的基础模板。希望这篇拆解能让你少走几个本可以绕开的弯路也把这份 Demo 真正吃透。本文还有配套的精品资源点击获取