ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

在线游戏开发Demo实战:WebSocket协议、心跳与断线重连全解析

在线游戏开发Demo实战:WebSocket协议、心跳与断线重连全解析 简介这是一套基于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 EventsSSE解决了服务端单向推送但它是半双工的——客户端只能通过另一个 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 是客户端 idvalue 是 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 langzh-CN head meta charsetutf-8 titleWebSocket 在线游戏 Demo/title style canvas { border: 1px solid #333; cursor: crosshair; } /style /head body canvas idgame width800 height600/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。如果在下一轮心跳之前收到了pongisAlive会被改回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 到 CB 移动又发两条消息量是 O(n²) 的。50 人在线时每秒可能有上千条帧。解决按房间隔离只把消息发给在同一个房间内的玩家。最简实现是让每个连接带一个roomId广播时只遍历同房间的客户端。第 6 章会详细给出房间方案。如果现阶段仍需要全局广播就压缩消息体积坐标用整数、去掉type字符串改用数字码都能降低流量。5.4 服务器重启后客户端一直显示“连接中”现象服务器CtrlC再node server.js启动浏览器页面里所有玩家还在但新连接不进来旧连接也没人走。原因客户端没有onclose重连逻辑或者重连成功后再加入的玩家没有收到已经存在玩家的位置快照。服务器重启后客户端的心跳断了但浏览器只在onclose触发时才知道如果没有监听onclose页面会以为连接还在。解决在客户端加上第 4 章的断线重连并在onopen后重新获取全量玩家状态。服务器端在welcome消息里除了发自己的 id还要把clients里其他人的位置一并带上这样新加入的玩家不需要等待别人下一次广播。5.5 二进制帧与文本帧混用导致解析崩溃现象服务器用JSON.parse解析客户端数据有时能通有时直接抛异常或者客户端收到数据后event.data是 BlobJSON.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?roomroom1)。这样多个房间互不干扰消息量从 O(n²) 变成 O(n × 房间人数)。6.2 多实例广播用 Redis pub/sub 替代全量广播等用户量上来单台 Node.js 进程不够时通常会用 PM2 或 Docker 起多个实例。但 WebSocket 连接是“粘在”某台实例上的——A 实例上的用户发消息B 实例上的用户怎么知道常见做法是引入 Redispublish/subscribeconst 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 少走一点弯路帮到你。本文还有配套的精品资源点击获取
返回列表