简介:这是一份基于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 轮询的问题是每次请求都要带完整的头部,即使只传一个方向键,也要走完“请求-响应”整个链路,服务器无法主动向客户端推送,服务端有状态更新时只能等下一次请求。SSE(Server-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 -> Set<socket> 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: Map<playerId, { 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,可以把房间玩家列表设计成Set<number>存 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 真正吃透。
本文还有配套的精品资源,点击获取