
简介这是一份面向在线游戏开发入门者与WebSocket学习者的完整示例以Go语言搭建服务端、Web页面作为客户端演示实时双向通信、多玩家状态同步与服务器主动推送等核心机制帮助理解从握手协议到游戏逻辑落地的完整流程。压缩包共488个文件大小约2.96MB以391个js脚本为主承担前端交互与逻辑控制另有5个go文件实现服务器端核心模块2个html提供登录与主界面入口55个jpg及多张png、ico构成界面素材md文档辅助说明。包内服务端代码与页面逻辑相互配合目录划分清晰便于按模块对照学习可以直接在此基础上扩展游戏玩法、消息协议与房间管理。目前已有93人学习下载适合希望快速搭建实时游戏原型、掌握WebSocket数据帧处理与连接管理或需要参考断线重连、安全加密、负载均衡等优化思路的开发者。1. 基于 WebSocket 的在线游戏开发 Demo先别急着写游戏逻辑连接管理才是重头拿到一个叫「基于websocket 的在线游戏开发Demo.zip」的压缩包很多人第一反应是解压、找入口、跑起来看画面。但一个 WebSocket 在线游戏 Demo 的真正价值不在它演示的玩法有多炫而在于它把「实时通信的骨架」立起来了。这类 Demo 通常由三部分组成一个 WebSocket 服务端、一个浏览器或客户端的连接层、以及一两条完整的消息收发链路。适合谁看想做联机对战、协同操作、实时白板这类应用的后端开发者以及刚接触实时通信、想搞明白「连接怎么维持、消息怎么同步」的学生。反直觉的结论是游戏逻辑本身可能只占这个 Demo 的 30% 工作量剩下 70% 都在处理连接生命周期、心跳超时、消息顺序和广播策略。2. 在线游戏为什么非 WebSocket 不可从轮询到全双工的三个硬道理2.1 短轮询和长轮询在游戏场景里撑不过五分钟在 WebSocket 普及之前浏览器里做实时交互最常见的思路是 HTTP 轮询客户端每隔几百毫秒发一次请求问服务器「有没有新状态」。短轮询的延迟取决于轮询间隔间隔设 500ms那从操作发生到另一端看到至少多出半秒的感知延迟。对射击类、动作类游戏来说半秒足以让整个体验崩掉。更麻烦的是 HTTP 的头部开销。一个普通 HTTP 请求的头部少说两三百字节加上 Cookie、跨域头经常奔着八百字节去。每秒轮询两次一个客户端就是 1.6KB 的纯头部开销100 个在线玩家就是 160KB/s这还不算服务器解析请求、构造响应的 CPU 消耗。长轮询把请求挂起等数据稍微好一点但服务器为了挂住这些请求连接数会被死死占住而且每次数据到达后还要重新建立请求本质上还是「一问一答」的模式。WebSocket 解决的是「服务器主动推」的问题。游戏里某个玩家移动了坐标服务器得立刻把这个状态推给同房间的其他人。HTTP 做不到主动推WebSocket 的全双工通道天然支持。这个 Demo 里你看到的「一个客户端发消息其他客户端实时收到」走的就是这条通道。2.2 一次握手换一个长连接帧格式比 HTTP 省在哪WebSocket 的建立过程是先走一次 HTTP 协议客户端发一个带 Upgrade: websocket 头的请求服务器返回 101 状态码之后这条 TCP 连接就变成了一个双向的 WebSocket 通道。握手只发生一次之后所有数据都用 WebSocket 帧格式传输。帧的头部开销极小。一个未分片、负载小于 126 字节的文本帧头部只有 2 到 6 个字节。对比动辄几百字节的 HTTP 头省下来的带宽非常可观。游戏里的移动消息通常只有几十字节用 WebSocket 传输时头部占比极低这是它能扛住高频小消息的根本原因。帧结构里几个关键字段值得关注FIN 标志位标识这是不是最后一个分片opcode 区分文本帧、二进制帧和 ping/pong 控制帧mask 位标识客户端到服务器的数据是否掩码。浏览器到服务器的帧必须带掩码这是协议规定所以抓包时你会看到客户端发出的帧负载都做过 XOR 处理而服务器下发的帧不做掩码。这个细节在调试时容易把人搞蒙——明明是同一份数据客户端抓到的和服务器抓到的长得不一样。2.3 帧同步和状态同步Demo 属于哪一派在线游戏的同步方案大体分两派帧同步和状态同步。帧同步是所有客户端跑同样的逻辑服务器只广播操作指令每个客户端各自计算结果。它的优点是带宽占用低、支持人数多缺点是必须保证所有客户端逻辑完全一致一旦有版本差异就全乱套。状态同步是服务器保存所有状态客户端把操作发给服务器服务器计算后把结果广播给所有人。它的优点是服务器权威、防作弊容易、客户端可以简化缺点是服务器计算压力大。绝大多数的 WebSocket 游戏 Demo 走的是状态同步的简化版。你看 Demo 里的代码大概率是客户端发「我按了方向键」服务器收到后更新坐标再把坐标广播给同房间的人。这种模式最容易理解和验证也是从业者最常用来搭原型的方式。真要做帧同步对网络抖动和逻辑一致性的要求会高一个量级不适合放在入门 Demo 里。3. 把 Demo 跑通最小可玩的连接建立与坐标广播3.1 先拆压缩包服务端、客户端、协议说明三样缺一不可解压后先别急着运行花两分钟把目录结构看一遍。一个结构完整的 WebSocket 在线游戏 Demo 至少包含三部分服务端目录、客户端目录、协议说明文档。服务端可能是 Node.js、Python、Java 或者 Go 写的不管哪个入口文件一般叫 server、index 或 main 开头。客户端如果是浏览器版会有一个 index.html 和对应的 js 文件如果对接的是游戏引擎可能是 unity 或 cocos 的工程目录。协议说明文档是判断这个 Demo 值不值得看的第一个指标。写清楚的 Demo 会有一份 protocol.md 或者 README 里的协议章节里面定义了消息类型连接、加入房间、移动、离开、心跳。没有协议说明的 Demo 也能跑但你会花大量时间在代码里翻字符串常量去猜哪条消息是干嘛的。我一般建议先看协议再看代码把消息类型列出来再对照服务端代码看每类消息的处理分支这样最快建立整体认知。3.2 用 Node.js 起一个最小 WebSocket 服务端最常见的 Demo 服务端是用 Node.js 加 ws 库写的代码量少依赖轻。下面是一个最小可跑的服务器骨架// server.js const { WebSocketServer } require(ws); const wss new WebSocketServer({ port: 8080, // 监听端口注意不要和常见端口冲突 maxPayload: 64 * 1024 // 单条消息上限 64KB防止客户端发超大包 }); wss.on(connection, (ws, req) { const clientId req.headers[sec-websocket-key].slice(0, 8); ws.id clientId; console.log([连接] ${clientId}当前在线${wss.clients.size}); ws.on(message, (data, isBinary) { const text data.toString(); console.log([收到] ${clientId}: ${text}); // 消息解析后分发见 3.3 节 }); ws.on(close, () { console.log([断开] ${clientId}剩余${wss.clients.size}); }); });这段代码里有两个参数值得说明。port是监听端口如果用 Spring Boot 或其他框架做服务端端口位置不一样——Spring Boot 在 application.yml 里改server.portNode.js 直接在构造参数里改。maxPayload是单条消息的负载上限设置它能挡住恶意客户端发超大帧拖死服务器。wss.clients是 ws 库维护的连接集合广播就是遍历它。客户端的连接代码更简单// client.html 里的核心逻辑 const ws new WebSocket(ws://localhost:8080); ws.onopen () { console.log(连接已建立); ws.send(JSON.stringify({ type: join, name: 玩家A })); }; ws.onmessage (event) { const msg JSON.parse(event.data); console.log(收到服务器消息, msg); // 根据 msg.type 分发到渲染逻辑 }; ws.onclose () { console.log(连接断开准备重连...); }; ws.onerror (err) { console.error(连接出错, err.message); };注意onmessage里收到的是字符串需要JSON.parse转成对象。很多新手在客户端收到数据后不一致处理——有些地方直接打印event.data有些地方当成对象用结果拿到undefined。统一在入口处解析一次后面全按对象处理这是最不容易出错的做法。3.3 玩家坐标广播Demo 里「能玩」的标志连接建立只是第一步在线游戏 Demo 的标配是坐标同步。下面这组消息定义和分发逻辑是常见做法// 服务器端消息分发 ws.on(message, (data) { const msg JSON.parse(data.toString()); switch (msg.type) { case join: // 把新玩家加入房间广播新玩家加入事件 broadcast({ type: playerJoined, id: ws.id, name: msg.name }); break; case move: // 更新玩家坐标带上服务器时间戳 players[ws.id] { x: msg.x, y: msg.y, ts: Date.now() }; broadcast({ type: move, id: ws.id, x: msg.x, y: msg.y, ts: Date.now() }); break; case leave: broadcast({ type: playerLeft, id: ws.id }); break; } }); function broadcast(msg) { const text JSON.stringify(msg); wss.clients.forEach(client { if (client.readyState 1) { // 1 OPEN只给还活着的连接发 client.send(text); } }); }这里的消息结构在设计上有个关键点每条消息都带id和时间戳ts。id让客户端知道这条消息是谁的状态ts用来处理乱序——后面讲网络抖动时会细说。broadcast函数里检查了readyState避免往一个已经关闭的连接上发送数据这在新手代码里经常被漏掉漏掉的后果是服务器抛错严重时进程直接崩溃。客户端收到移动消息后更新的不是任意位置而是先检查消息序号或时间戳只接受比本地更新的状态。这样设计之后坐标同步链路就完整了。跑通这一步你已经掌握了 WebSocket 在线游戏的核心——多人实时状态分发。4. WebSocket 心跳机制实现Demo 能跑线上不能没有心跳4.1 浏览器 API 不给发 Ping 帧你只能自己造心跳WebSocket 协议本身定义了 ping 和 pong 两种控制帧服务器可以发 ping客户端协议栈会自动回 pong不需要业务代码参与。但问题是浏览器环境里的 WebSocket API 没有暴露「发送 ping 帧」的接口。你调用ws.send()只能发数据帧控制帧完全被浏览器托管了。这就导致一个尴尬的局面服务端想检测「这个浏览器客户端还活着吗」没法直接发一个协议层 ping 让浏览器自动回应。协议层的 pong 响应确实会自动发生但它不经过onmessage你的业务代码根本看不到。所以在线游戏的主流做法是在应用层实现一套自定义心跳客户端定时发一条心跳消息服务器记录最后活跃时间超时没收到就判定连接已死。这不是 Demo 里可以省略的环节。一个在线游戏连接如果客户端因为断网、休眠、切换网络而死亡TCP 层可能很长时间都发现不了——这就是所谓的「死连接」或「半开连接」。服务器以为对方还活着继续往这个连接上广播数据内存和文件描述符被一点点耗尽。不做心跳清理的服务器跑几天后连接数会涨到内存爆炸。4.2 心跳参数怎么设间隔 15 秒、超时 45 秒、重连退避先看代码这是 WebSocket 心跳机制实现的标准模板在 Demo 的服务端和客户端各加一段// 客户端每 15 秒发一次心跳 const HEARTBEAT_INTERVAL 15000; // 单位毫秒 let heartbeatTimer null; function startHeartbeat() { stopHeartbeat(); heartbeatTimer setInterval(() { if (ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: heartbeat, ts: Date.now() })); } }, HEARTBEAT_INTERVAL); } function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer null; } }// 服务器端超过 45 秒没收到心跳直接断开 const HEARTBEAT_TIMEOUT 45000; // 必须大于客户端发送间隔 const HEARTBEAT_CHECK_INTERVAL 10000; wss.on(connection, (ws) { ws.lastAlive Date.now(); ws.on(message, (data) { const msg JSON.parse(data.toString()); if (msg.type heartbeat) { ws.lastAlive Date.now(); // 收到心跳刷新最后活跃时间 return; // 心跳消息不广播不进入业务分发 } // 其他业务消息处理... }); }); // 定时扫描找出超时的连接并踢掉 const scanTimer setInterval(() { const now Date.now(); wss.clients.forEach(client { if (now - client.lastAlive HEARTBEAT_TIMEOUT) { console.log([心跳超时] 强制断开 ${client.id}); client.terminate(); // 直接终止 TCP 连接比 close 更暴力 } }); }, HEARTBEAT_CHECK_INTERVAL);参数设置有三个要点。第一HEARTBEAT_TIMEOUT必须大于HEARTBEAT_INTERVAL而且要留出网络抖动的余量。客户端每 15 秒发一次服务器设 45 秒超时意味着客户端可以连续漏掉两次心跳才被判死——这能容忍大部分瞬断场景。第二扫描间隔HEARTBEAT_CHECK_INTERVAL决定了清理的灵敏度设 10 秒意味着最坏情况下一个死连接会在超时后最多 10 秒才被清理。第三terminate()与close()的区别close()走正常关闭握手可能被卡住terminate()直接销毁底层 TCP 连接用于清理死连接更可靠。4.3 心跳消息的带宽开销几乎可以忽略有人担心心跳会占用带宽。算一笔账一条 JSON 心跳消息约 30 字节客户端每 15 秒一条单个连接约 2 字节每秒。1000 个在线连接心跳总带宽约 2KB/s完全可忽略。真正的风险不在带宽而在服务器端收到心跳后做的处理——如果心跳消息走了完整的业务分发逻辑还触发了广播那才是灾难。所以代码里我专门加了return让心跳消息止步于lastAlive刷新。另外一个重要约定心跳消息不参与业务消息的序号计算。有些团队把心跳也纳入消息序列号递增会导致业务消息序号跳变客户端做乱序检测时容易误判丢包。心跳应该是一个纯粹的心跳除了时间戳什么都不带时间戳也只用于调试不用于业务判序。5. 常见问题与避坑在线游戏 Demo 四个必踩的坑5.1 现象玩家位置疯狂回跳和瞬移跑通坐标广播后你可能会发现玩家在别人屏幕上不是平滑移动而是一顿一顿地回跳——走到前面突然闪回后面然后再往前走。这个坑在弱网环境里尤其明显。原因有两层。第一是网络延迟和抖动导致消息乱序玩家先发送了位置 Ax10后发送位置 Bx12但 B 先到达服务器并被广播出去A 后到达客户端先画了 x12 又变成 x10看起来就是回跳。第二是客户端渲染时直接采用最新收到的坐标没有做任何平滑处理。解决分两步走。先治标给移动消息带上客户端时间戳或自增序号客户端只接受序号更新的消息丢弃迟到的旧消息。再治本渲染端做插值不平移立即跳而是把目标位置缓存每帧向目标位置移动一小段距离。代码层面就是在move消息里加上seq字段// 客户端发送移动消息时带上自增序号 let seq 0; function sendMove(x, y) { ws.send(JSON.stringify({ type: move, x, y, seq: seq, ts: Date.now() })); } // 客户端接收时只处理比当前序号新的消息 let lastSeq -1; ws.onmessage (event) { const msg JSON.parse(event.data); if (msg.type move msg.seq lastSeq) { lastSeq msg.seq; targetX msg.x; targetY msg.y; } };5.2 现象房间人数超过 20 人延迟开始暴涨Demo 在小范围内几个人一切正常一旦模拟 20 个以上的客户端服务器和客户端的延迟都开始恶化。这是典型的广播风暴服务器对每一条移动消息都做全量广播而每个客户端移动频率假设 10 次/秒20 个客户端就是每秒 200 条广播50 个客户端每秒 500 条。客户端要处理 499 条别人的消息其中大部分对自己不可见。解决方向有三层。第一层按房间隔离只给同房间的客户端广播这是在线游戏的基本功。Demo 里如果只有一个全局房间把玩家分组即可消息里带上roomId。第二层降低广播频率把每帧发送改成固定频率发送比如每 50 毫秒合并一次移动增量合并掉中间状态。第三层分场景削峰只广播进入自己视野范围的单位这是大型多人游戏的方案Demo 阶段做前两层就够了。5.3 现象客户端收到的消息糊成一团解析全乱如果你在客户端看到这样的现象一次 onmessage 里拿到了好几条 JSON或者一条 JSON 被截成两半说明撞上了 TCP 的粘包与拆包问题。原理是WebSocket 协议保证「一条消息完整地到达」但业务层如果自己定义了类似「消息头 负载」的二进制协议就绕过了 WebSocket 的帧边界退化成裸 TCP 传输必须自己处理消息边界。Demo 里常见的做法是直接用JSON.stringify后send消息天然带边界不会粘包。如果你看到的是二进制协议就得自己设计长度前缀——业务消息边界问题就暴露了。这个坑的规避办法很简单除非有二进制传输的硬需求否则一律用 JSON 字符串作为消息格式。每条send对应一条完整消息onmessage收到一次就是一条。另外maxPayload参数要设合理防止把多帧拼接成超大消息导致内存压力。5.4 现象服务器显示连接还在但客户端早已消失客户端直接拔网线、笔记本合盖休眠、手机切到飞行模式这些场景下 TCP 连接不会立刻发出断开信号服务器端会一直保留着连接显示readyState OPEN但往里面发数据已经石沉大海。日志里这些「死连接」越来越多内存和文件描述符持续增长直到某一天服务器莫名崩溃。解决手段就是第 4 章整个章节做的事应用层心跳。服务器记录每个连接的最后活跃时间定时扫描超过阈值直接terminate()。此外再补一道防线服务器端往客户端发送数据时发现写入出错立刻主动清理该连接不要等心跳超时。这两道防线配合死连接基本能在 1 分钟内被清干净。6. 进阶玩法用 Chrome 开发者工具抓消息延迟再加一个协议版本号6.1 用开发者工具的 WebSocket 面板验证消息频率跑完 Demo 之后第一件值得做的事是打开 Chrome 的开发者工具切到 Network 面板找到 WebSocket 类型的请求点开可以看到实时的帧列表。每一帧都标了方向绿色是收红色是发、消息类型、负载大小和时间戳。用这个面板能直接验证两件事心跳是否按预期发送、移动消息的广播频率是否合理。更细的验证方式是写一段脚本统计消息间隔。Connection 面板里每条帧都有时间挑 20 条连续的同类型消息算平均间隔。如果心跳间隔和设定值 15 秒一致说明心跳链路正常如果移动消息的间隔忽大忽小说明网络抖动已经存在后续插值逻辑不能省。这个面板就是 Demo 调优的「黑匣子」把黑匣子用起来比盲改参数高效得多。6.2 强制习惯协议版本号是改消息格式前的后悔药最后分享一个我用血泪教训换来的习惯消息里从第一天就带上协议版本号。很多 Demo 的消息结构是{ type: move, x, y }后来要加坐标速度字段变成{ type: move, x, y, vx, vy }。如果服务器端先改了、客户端没跟上老客户端发来的消息缺字段服务器解析后坐标全变 0玩家瞬间瞬移。带上版本号的方案很简单握手成功后客户端主动上报自己的协议版本服务器判断版本兼容性或者每条消息带v: 1字段。一旦字段变更老客户端发来的消息能被识别并兼容处理而不是解析出错误数据。这个习惯在 Demo 阶段看起来是多余的一行字段但项目到了多人协作、客户端发版不同步的阶段它能帮你省掉大量排查时间。从跑通连接、实现坐标广播、补齐心跳到解决乱序和广播风暴一个在线游戏 Demo 的完整落地路径大致如此。我自己的经验是每次搭实时应用原型先把心跳、消息序号、版本号这三个骨架字段写进协议再开始写玩法逻辑。有了这三样后面加功能都是顺水推舟。希望这套拆解能帮你在 WebSocket 在线游戏的方向上少走几段弯路。本文还有配套的精品资源点击获取