简介:这是一套基于WebSocket的在线游戏开发Demo,面向初、中级Web开发者与游戏开发爱好者,帮助理解实时双向通信在游戏中的应用。压缩包共488个文件,容量仅2.96MB,包括391个JavaScript脚本、5个Go服务端源码、HTML页面及图标、图片等素材,核心文件如服务器入口、消息处理等均已包含。Demo覆盖WebSocket从握手、帧结构到数据交换的完整链路,并展示了Node.js、Go等语言搭建服务端、客户端用JavaScript实现连接与消息同步的常见方式,涉及多用户位置同步、服务器主动推送、断线重连与WSS安全连接等关键问题。目录结构清晰,可直接运行体验,也可作为二次开发的基础框架。目前已吸引93人学习,适合作为入门在线游戏通信技术、完成课程设计或快速搭建实时交互原型的参考。
1. 在线游戏 Demo 到底解决什么:WebSocket 不是唯一答案,但是最顺手的那个
你写过一个在线协作白板,或者一个五人联机的小游戏吗?如果用过 HTTP 轮询,大概率遇到过这种场面:客户端每隔 200ms 请求一次坐标,服务端压力飙升,玩家移动还是卡出残影。换用 WebSocket 之后,同一个游戏延迟从秒级降到几十毫秒,服务端负载反而降下来了。基于 WebSocket 的在线游戏开发 Demo,就是为了把这条从轮询到长连接的必经之路,浓缩成一份可以一天跑通的最小工程。它适合两类人:想零起点入坑实时游戏的前端或后端开发者,以及想评估 WebSocket 技术方案是否值得投入的团队负责人。这篇笔记会顺着“协议选型→最小实现→心跳保活→踩坑排查→上线扩展”一路展开,代码都是可以直接抄走的。
2. WebSocket 协议与游戏场景选型:为什么轮询撑不住,而全双工刚刚好
写代码之前,得先想清楚一个基础问题:在线游戏需要的是什么样的通信能力。很多人一上来就写 WebSocket,却说不清它比 HTTP 好在哪里。这一章把协议层面的取舍讲透,后面调参数、排查问题时才不会两眼一抹黑。
2.1 HTTP 轮询与 SSE 的瓶颈:在线游戏为什么需要全双工
最早的实时方案是 HTTP 轮询。客户端用setInterval定时请求服务端“有更新吗”,有就拉回来。这种做法在聊天室时代勉强能用,但放到在线游戏里就是灾难:一次移动操作从客户端发出,到服务端确认,再到其他玩家看到,中间隔着多个 HTTP 请求响应。轮询间隔设得太短,服务端 CPU 被空转请求吃满;设得太长,客户端操作延迟高到没法玩。更尴尬的是,HTTP 请求是无状态的,服务端无法主动给客户端“推”数据,只能等客户端来问。
长轮询(Long Polling)改进成“请求挂住,有数据才返回”,但连接仍然要频繁建立,服务器线程被长时间占着,并发一高就撑不住。Server-Sent Events(SSE)解决了服务端单向推送,但它是半双工的——客户端只能通过另一个 HTTP 请求回传数据,两个连接来回切换,复杂度和延迟反而更高。
WebSocket 的核心价值在于全双工:握手通过 HTTP 升级后,连接变成一条双向长连接。客户端发消息不需要重建连接,服务端推消息也不需要客户端请求。对在线游戏来说,这意味着每次移动操作只需要发送一个几十字节的数据帧,没有头部开销、没有请求排队,延迟稳定在亚秒级。这也是为什么现代网页游戏基本都选它当传输通道。
2.2 帧格式与二进制/文本的选择:自定义协议的起点
WebSocket 的数据帧分为文本帧(text frame)和二进制帧(binary frame)。文本帧就是 UTF-8 编码的字符串,适合 JSON;二进制帧适合字节流,比如压缩后的坐标数据、增量快照。刚上手做 Demo,直接用 JSON 文本帧最省事——调试时能直接在浏览器 Network 面板看到消息内容,不用写解析器。但你要清楚它的代价:同样的坐标要占用 20 到 30 倍于二进制帧的字节数。到了需要优化带宽的阶段,就该考虑二进制协议。
另一个要提前定的是消息封装格式。在线游戏里消息类型很多:加入、离开、移动、攻击、聊天、心跳。常见做法是统一用一个 JSON 对象,type字段表示消息类型,后面跟业务数据,例如:
{"type":"move","x":100,"y":200,"id":7}这个格式后面所有客户端和服务端共用,别为了省几个字节把字段名都缩写掉,否则测试时没人能看懂。我习惯在前端维护一个protocol.js,把每条消息的构造和解析封装成函数,和渲染逻辑隔离。这样协议变更时只需改一个文件,而不是在游戏代码里到处找JSON.parse。
2.3 常见服务端方案对比:ws 库、Spring Boot WebSocket、Netty
选型决定了这个 Demo 写起来顺不顺手。下表是几个常见方案在“开发一个最小 Demo”场景下的直观对比:
| 方案 | 上手成本 | 性能 | 生态与周边 | 适合场景 |
|---|---|---|---|---|
| Node.js + ws 库 | 低 | 中高 | 前端同语言,JSON 无缝 | 快速原型、中小型网页游戏 |
| Spring Boot WebSocket | 中 | 中 | Java 体系,分布式方案多 | 后端是 Java 的团队 |
| Netty | 高 | 高 | 定制性强,踩坑多 | 大型 MMO、框架底层 |
| Python websockets | 低 | 中 | AI 结合方便 | 教学、协议验证 |
这个 Demo 我选了 Node.js + ws 库。理由主要有三个:一是前端也是 JavaScript,前后端共用一套对象字面量协议,不用做对象到 JSON 的手工映射;二是 ws 库本身极轻,没有任何框架层概念,暴露的就是WebSocket.Server和ws.on('message'),适合把协议和游戏逻辑讲清楚;三是修改后直接node重启就能验证,效率比编译型语言高很多。你完全可以用 Spring Boot 或 Netty 复刻同样的协议,核心思路不变。
3. 把 Demo 跑起来:基于 ws 库的最小前后端代码与三条启动命令
这章直接进入实操。我会把整个项目拆成一个server.js和一个index.html,实现一个最简单的“多人在线移动方块”游戏:每个玩家在浏览器里移动鼠标,屏幕上自己的方块和别人的方块都会实时移动。没有花哨的玩法,但它完整覆盖了连接建立、消息广播、断线通知这三个 WebSocket 游戏最基本的环节。
3.1 项目初始化:三条命令先让服务器跑起来
首先创建一个空目录,然后在里面执行:
npm init -y npm install ws node server.js说明:
npm init -y生成一个package.json,接受所有默认配置,只用于管理依赖。npm install ws安装 ws 库,这是本项目唯一的第三方依赖。node server.js启动游戏服务器,默认监听 3001 端口。
我习惯把端口写成一个常量,例如const PORT = 3001;,后面改端口只需要动这一处。开发阶段建议不要用 80 或 8080,避免和本地其他服务冲突。
3.2 服务端代码:连接管理、消息分发与广播
新建server.js,内容如下:
// 基于 ws 库的 WebSocket 游戏服务器 const WebSocket = require('ws'); const PORT = 3001; const wss = new WebSocket.Server({ port: PORT, maxPayload: 64 * 1024 }); // 用 Map 保存在线客户端,key 是客户端 id,value 是 WebSocket 实例 const clients = new Map(); let nextId = 1; wss.on('connection', (ws) => { // 给新连接分配一个自增 id const id = nextId++; ws.playerId = id; clients.set(id, ws); // 给新玩家发欢迎消息,里面带自己的 id ws.send(JSON.stringify({ type: 'welcome', id })); // 通知其他玩家:有人加入了 broadcast({ type: 'join', id }); ws.on('message', (data) => { try { const msg = JSON.parse(data); // 只处理 move 类型的消息,避免未知消息导致服务器崩溃 if (msg.type === 'move') { msg.id = id; // 服务端信任自己分配的 id,忽略客户端传入的 id broadcast(msg); } } catch (e) { console.error('消息解析失败: %s', data.toString()); } }); ws.on('close', () => { clients.delete(id); broadcast({ type: 'leave', id }); }); }); // 向所有在线客户端发送消息 function broadcast(msg) { const raw = JSON.stringify(msg); for (const ws of clients.values()) { if (ws.readyState === WebSocket.OPEN) { ws.send(raw); } } } wss.on('listening', () => { console.log('游戏服务器已启动,端口 %d', PORT); });逻辑说明:
clients用 Map 而不是数组,因为频繁增删连接时,Map 按 key 删除和读取都是常数时间。maxPayload限制单条消息最大 64KB,防止有人往服务器灌大包。- 每个连接第一次进入时服务端分配
playerId,这个 id 是整个 Demo 里标识玩家的唯一方式。 - 收到消息后
JSON.parse放在 try/catch 里,挂一个未知格式的消息不会让整个服务器崩溃。 - 广播前检查
readyState === WebSocket.OPEN,避免把消息发给已经断开但还没从 Map 里删掉的连接。
参数说明:
PORT是监听端口,前端连接时必须保持一致。maxPayload的单位是字节,64 * 1024是 64KB,对于移动消息绰绰有余;如果后面要传地图快照可以调大到 256KB,但要防止滥用。
3.3 前端代码:连接、发送坐标与渲染所有玩家
新建index.html,内容如下:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>WebSocket 在线游戏 Demo</title> <style> canvas { border: 1px solid #333; cursor: crosshair; } </style> </head> <body> <canvas id="game" width="800" height="600"></canvas> <script> const canvas = document.getElementById('game'); const ctx = canvas.getContext('2d'); const ws = new WebSocket('ws://' + location.hostname + ':3001'); const players = new Map(); let myId = null; ws.onopen = () => { console.log('已连接服务器,等待 id 分配'); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'welcome') { myId = msg.id; players.set(myId, { x: 400, y: 300 }); } else if (msg.type === 'join') { players.set(msg.id, { x: 400, y: 300 }); } else if (msg.type === 'leave') { players.delete(msg.id); } else if (msg.type === 'move') { const p = players.get(msg.id); if (p) { p.x = msg.x; p.y = msg.y; } } }; canvas.addEventListener('mousemove', (e) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'move', x: e.offsetX, y: e.offsetY })); } }); function render() { ctx.clearRect(0, 0, 800, 600); for (const [id, p] of players) { ctx.fillStyle = id === myId ? '#e74c3c' : '#3498db'; ctx.fillRect(p.x - 10, p.y - 10, 20, 20); ctx.fillText(id, p.x - 5, p.y - 15); } requestAnimationFrame(render); } render(); </script> </body> </html>逻辑说明:
ws.readyState === WebSocket.OPEN保证了只有在连接可用时才发送消息,避免鼠标移动时向关闭状态的连接发送导致报错。- 收到
welcome消息后,把当前玩家的 id 存起来,并给自己一个初始位置;其他玩家的位置在join时统一给一个屏幕中央,等对方的move消息来了再更新真实坐标。 - 渲染循环里用
playersMap 遍历所有玩家,自己显示红色,别人显示蓝色,每个方块头顶显示玩家 id,方便调试。
参数说明:
location.hostname会自动读取当前页面域名,这样本地打开index.html时也能连到localhost:3001,不用写死 IP。- 鼠标事件的
e.offsetX/e.offsetY是相对于 canvas 的坐标,不需要额外计算页面偏移。
保存server.js和index.html,先运行node server.js,再用浏览器打开index.html,多开几个浏览器窗口就能看到多个方块互相跟随了。
4. 心跳、断线重连与在线状态管理:让 Demo 在弱网下不翻车
第 3 章的 Demo 可以跑,但真实网络环境比本地恶劣得多。手机切网、Wi-Fi 信号波动、代理超时,都会让连接断掉而双方还以为对方在线。这一章解决的是两个问题:怎么发现连接已经死了,以及怎么优雅地恢复。
4.1 心跳机制:协议层 ping/pong 与应用层业务心跳
WebSocket 协议自带控制帧,ping和pong。浏览器端的 WebSocket 收到服务器的ping后会自动回pong,不需要你写代码。服务器端要做的是定时发ping,并检查有没有收到pong。如果在超时时间内没收到,就判定连接已死,主动终止。
在server.js中增加心跳逻辑:
// 心跳定时器:每 30 秒扫描一遍所有连接 const HEARTBEAT_INTERVAL = 30000; const HEARTBEAT_TIMEOUT = 10000; // 为每个连接增加 isAlive 标记 wss.on('connection', (ws) => { ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); // ... 原有代码 }); const heartbeatTimer = setInterval(() => { for (const [id, ws] of clients) { if (ws.isAlive === false) { ws.terminate(); clients.delete(id); broadcast({ type: 'leave', id }); continue; } ws.isAlive = false; ws.ping(); } }, HEARTBEAT_INTERVAL); wss.on('close', () => { clearInterval(heartbeatTimer); });逻辑说明:
- 每次 ping 之前,先把
isAlive置为false。如果在下一轮心跳之前收到了pong,isAlive会被改回true。这样就能判断哪些连接“一个心跳周期内没回话”。 ws.terminate()会强制关闭底层 TCP 连接,比ws.close()更果断,适合判定死亡的场景。- 关闭服务器时要
clearInterval(heartbeatTimer),否则 Node.js 进程不会退出。
参数说明:
HEARTBEAT_INTERVAL是心跳间隔,30 秒是相对保守的值;局域网开发可以缩到 10 秒,公网游戏建议 15~30 秒,太频繁会浪费带宽。HEARTBEAT_TIMEOUT用“两轮间隔内没回 pong”作为判定逻辑,所以它不直接出现在代码里,但你要理解:如果 30 秒 ping 一次,那么一个连接最多会在 60 秒内被判定为死。
协议层 ping/pong 以外,在线游戏通常还有一层“业务心跳”。游戏服务器需要知道玩家是否在操作,如果长时间没有移动、攻击等数据,就认为玩家挂机,可以踢下线或者标记为“离开”。业务心跳可以由客户端定时发送{"type":"heartbeat","timestamp":...},服务端收到后更新该连接的最后活跃时间。这和协议层 ping/pong 不冲突:ping/pong 保障 TCP 连接层面活着,业务心跳保障玩家状态层面活着。
4.2 断线重连:指数退避与消息补发
网络抖动经常导致连接瞬间断开,但游戏进程还在。浏览器端的 WebSocketonclose事件触发后,自动重连是必须的。常见的做法是“指数退避”:第一次重连等 1 秒,第二次等 2 秒,第四次等 4 秒,最多等 10 秒,避免服务器刚重启时所有客户端同时撞上来重连。
前端代码里增加一个connectWithRetry函数:
let ws = null; let retryDelay = 1000; let reconnectTimer = null; function connect() { ws = new WebSocket('ws://' + location.hostname + ':3001'); ws.onopen = () => { retryDelay = 1000; // 连接成功,重置重连等待时间 console.log('连接成功'); }; ws.onclose = () => { // 清理旧连接,避免重复 send clearTimeout(reconnectTimer); reconnectTimer = setTimeout(() => { connect(); }, retryDelay); retryDelay = Math.min(retryDelay * 2, 10000); }; ws.onerror = (err) => { console.warn('WebSocket 错误', err); }; } connect();此外,重连成功之后,服务器不知道玩家的位置。最简单的方案是客户端在onopen里重新发送一次自己的最新坐标,或者服务器在welcome消息里带上当前所有在线玩家的列表。如果做的是移动类游戏,建议在服务端保存每个玩家的位置快照,新连接时一口气推给新玩家,否则别人会看到一个“裸号”在页面上乱跑。
4.3 在线状态管理:join、leave 消息的时序问题
心跳和重连解决了“连接断了没被发现”,另一个隐藏问题是“连接还活着,但玩家已经不在游戏”的假在线状态。Demo 第 3 章的join/leave消息只在连接建立和关闭时广播,但如果你引入了业务心跳,就必须让服务端定时清理超过 N 秒没发业务心跳的客户端。
我常用的方案是维护一个lastActive字段:
ws.lastActive = Date.now(); // 在 message 处理里更新 ws.on('message', (data) => { const msg = JSON.parse(data); ws.lastActive = Date.now(); // ... 处理业务消息 }); // 在服务器定时器里检查 const USER_TIMEOUT = 60000; const statusTimer = setInterval(() => { for (const [id, ws] of clients) { if (Date.now() - ws.lastActive > USER_TIMEOUT) { ws.terminate(); clients.delete(id); broadcast({ type: 'leave', id }); } } }, 10000);参数说明:
USER_TIMEOUT设为 60 秒,意味着 60 秒内没有任何业务消息就被踢掉。如果是挂机休闲游戏,可以调大到 5 分钟。- 检查定时器 10 秒跑一次,精度足够,不增加太多负载。
这样做的意义是:即使客户端已经崩溃、没有触发close事件,服务器也会在最多 60 秒内把它清理出去,避免房间越积越满。
5. 在线游戏开发 Demo 常见问题排查:5 个高频报错的现象、原因与解法
这一章是血泪经验。前面四章把正常路径走通了,但实际开发中 80% 的时间都花在排错上。我把常见的问题整理成“现象→原因→解决”三条一组,你遇到类似问题时可以直接对照查。
5.1 连接建立后立刻被断开,浏览器报 1006 错误
现象:浏览器 Network 面板里 WebSocket 连接显示已连接,但 1 秒内就断开,状态码 1006(非正常关闭)。
原因:最常见的有三种。一是服务端在同一端口上启动失败,比如 3001 被占用;二是前端用ws://请求,但服务端实际上是 HTTPS/WSS,导致握手协议不匹配;三是 Nginx 或代理服务器配置了proxy_read_timeout,默认 60 秒,连接空闲超过 60 秒就会被代理断开。
解决:先用lsof -i :3001确认端口没冲突;再确认前端 URL 协议是ws://(HTTP 环境)还是wss://(HTTPS 环境)。如果走 Nginx,要在location里设置proxy_read_timeout 300s;和proxy_send_timeout 300s;,并开启Upgrade请求头转发:
location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 300s; }解决后建议在旁边写个connection.onclose的日志,把关闭码和原因打出来,不要只靠浏览器默认提示。
5.2 消息乱序:一个玩家先发的消息反而后到
现象:同一个玩家快速移动鼠标时,另一个玩家看到的位置忽前忽后,甚至倒退。
原因:WebSocket 底层是 TCP,数据帧到达顺序是保序的,但你的服务端可能对不同连接的消息做异步处理。比如我见过有人把每个message事件丢进setImmediate或异步队列处理,不同消息的执行顺序就无法保证。另外,如果客户端在收到上一次消息后立刻发送下一次消息,网络拥塞时也可能出现帧延迟叠加。
解决:服务端处理消息时不要跨连接做异步调度,单连接上的ws.on('message')回调天然是顺序的,直接在回调里完成业务逻辑和广播。如果确实需要异步处理,给每条消息加一个单调递增的seq字段,接收方检测到seq小于上一条则丢弃或重新排序。
5.3 广播风暴:人数到 50 之后服务器 CPU 飙升
现象:房间里只有几十个方块,服务器 CPU 却超过 100%,网络流量巨大。
原因:第 3 章的broadcast会把所有消息发给所有在线客户端。假设 A、B、C 三个人,A 移动一次发两条消息(A 到 B、A 到 C),B 移动又发两条,消息量是 O(n²) 的。50 人在线时,每秒可能有上千条帧。
解决:按房间隔离,只把消息发给在同一个房间内的玩家。最简实现是让每个连接带一个roomId,广播时只遍历同房间的客户端。第 6 章会详细给出房间方案。如果现阶段仍需要全局广播,就压缩消息体积:坐标用整数、去掉type字符串改用数字码,都能降低流量。
5.4 服务器重启后,客户端一直显示“连接中”
现象:服务器Ctrl+C再node server.js启动,浏览器页面里所有玩家还在,但新连接不进来,旧连接也没人走。
原因:客户端没有onclose重连逻辑,或者重连成功后再加入的玩家没有收到已经存在玩家的位置快照。服务器重启后,客户端的心跳断了,但浏览器只在onclose触发时才知道,如果没有监听onclose,页面会以为连接还在。
解决:在客户端加上第 4 章的断线重连,并在onopen后重新获取全量玩家状态。服务器端在welcome消息里除了发自己的 id,还要把clients里其他人的位置一并带上,这样新加入的玩家不需要等待别人下一次广播。
5.5 二进制帧与文本帧混用导致解析崩溃
现象:服务器用JSON.parse解析客户端数据,有时能通,有时直接抛异常;或者客户端收到数据后event.data是 Blob,JSON.parse失败。
原因:浏览器 WebSocket 默认接收二进制消息时,event.data类型是 Blob。服务器如果发的是文本字符串,浏览器端是字符串没问题;但一旦有人调用了ws.send(Buffer.from(...)),浏览器收到的就是 Blob。在游戏协议早期,可能有人为了传图片把二进制数据塞进来,导致客户端收到两种类型。
解决:统一协议:要么全部用文本 JSON,要么全部用二进制。如果必须混用,就在消息头里加一个字节标识类型。前端可以设置ws.binaryType = 'arraybuffer',然后用 DataView 解析头部。Demo 阶段建议只走文本 JSON,保持最简单。如果你要优化带宽,再单独写一个二进制协议适配层,不要在一个消息里混类型。
6. 从 Demo 到可上线:房间隔离、多实例广播与压测验证
前面几章把单机版 WebSocket 游戏打通了,但真实业务不可能只有一个房间、一台服务器。这个 Demo 的下一步,我建议按“房间隔离→多实例同步→压测验证”这三个阶梯走。
6.1 房间隔离:给 Demo 加一个简单的房间路由
最简单的房间方案就是给connection加一个roomId字段,客户端握手时通过查询参数带上房间号:
// 服务器端握手时读取查询参数 wss.on('connection', (ws, req) => { const params = new URLSearchParams(req.url.split('?')[1]); ws.roomId = params.get('room') || 'lobby'; }); // 广播改成按房间过滤 function broadcastToRoom(roomId, msg) { for (const ws of clients.values()) { if (ws.roomId === roomId && ws.readyState === WebSocket.OPEN) { ws.send(raw); } } }前端连接时:new WebSocket('ws://localhost:3001?room=room1')。这样多个房间互不干扰,消息量从 O(n²) 变成 O(n × 房间人数)。
6.2 多实例广播:用 Redis pub/sub 替代全量广播
等用户量上来,单台 Node.js 进程不够时,通常会用 PM2 或 Docker 起多个实例。但 WebSocket 连接是“粘在”某台实例上的——A 实例上的用户发消息,B 实例上的用户怎么知道?常见做法是引入 Redispublish/subscribe:
const redis = require('redis'); const pub = redis.createClient(); const sub = pub.duplicate(); // 收到消息后,除了广播给本实例的连接,还要发布到 Redis sub.subscribe('game_channel', (message) => { // 其他实例发来的消息,再广播给本实例的连接 const msg = JSON.parse(message); broadcastToRoom(msg.roomId, msg); }); ws.on('message', (data) => { const msg = JSON.parse(data); pub.publish('game_channel', JSON.stringify({ ...msg, roomId: ws.roomId })); });注意要避免“回声”:本实例发布到 Redis,订阅端又收到后广播回来,导致重复消息。通常用一个实例标识字段,让本实例收到 Redis 消息时跳过已经广播过的连接。
6.3 压测与日志验证:用脚本确认丢包率和延迟
上线前至少要做一个压测脚本,验证单台实例能扛住多少并发。Node.js 环境下可以用ws库自己写脚本模拟 100 个客户端同时连接和收发消息,统计平均延迟和丢包率。我习惯在服务端加一个DEBUG日志开关,每收到 1000 条消息打印一次当前延迟中位数,用console.time粗测足够定位问题。
我自己的习惯是:每次改完协议,先用两个浏览器窗口最多验证三个人;然后跑压测到 50 个连接;最后用 Wireshark 抓包看帧间隔是否均匀。上线后如果用户报告“画面抖动”,第一反应是看心跳超时日志里有没有大量terminate,而不是猜前端渲染问题。这行日志就是你的后悔药。希望这些经验让你的 Demo 少走一点弯路,帮到你。
本文还有配套的精品资源,点击获取