简介:基于WebSocket的在线游戏开发Demo以完整工程压缩包形式提供,主要面向Web前端、Node.js/Go后端开发者以及刚接触实时通信的初学者。资源从WebSocket握手与帧结构讲起,覆盖多用户同步、服务器推送、断线重连、WSS安全传输、负载均衡和数据压缩等关键话题,并给出可运行的服务器端与客户端实现。压缩包共488个文件,其中JavaScript脚本占391个,承担前端交互与通信逻辑;另有Go语言服务端源码、HTML入口页面、图片图标及Markdown说明文档,整体大小仅2.96MB,结构轻量、易于快速上手验证。目前已有93人学习下载,适合用来了解在线游戏实时交互的工程化写法。通过阅读代码与联调Demo,读者能掌握连接建立、JSON消息广播、用户会话管理以及常见异常处理等落地细节,为进一步开发完整的实时对战游戏打下基础。
1. 基于 WebSocket 的在线游戏 Demo:一个能跑起来的多人在线骨架
我做在线游戏开发的第一课,不是读协议文档,而是把一个 WebSocket 在线游戏 Demo 拆开跑通。这个压缩包里的东西不复杂:一个 WebSocket 服务端、一个前端页面、一套最简单的消息协议,但它把「浏览器 ↔ 服务器双向实时通信」这条链路完整串起来了。你拿到的不是游戏成品,而是一个可以改、可以拆、可以往里填玩法逻辑的骨架。适合谁?刚接触 WebSocket、想知道实时对战怎么落地的新手,也适合被 HTTP 轮询折磨过、想看看长连接到底怎么做的后端开发。它能解决的核心问题很具体:连接怎么建、消息怎么推、心跳怎么保活、断线怎么处理。下面我从协议原理讲到跑通步骤,再把我踩过的坑一并交代。
2. WebSocket 协议:在线游戏为什么选它,以及 Demo 里的握手细节
2.1 HTTP 轮询的痛点与 WebSocket 的升级握手
在 WebSocket 普及之前,网页实时游戏最常见的方案是 HTTP 轮询:前端每隔一两秒发一次请求,问服务端「有没有新状态」。这个方案能跑,但代价很直接——每次请求都带着完整的 HTTP 头,服务端要反复处理连接建立和释放,玩家一多,服务器大量资源耗在空转上。
WebSocket 解决的是「一次握手,双向推送」。它复用了 HTTP 的 80/443 端口,通过一次 Upgrade 请求完成协议切换。Demo 里服务端接收连接后,会先做一次握手校验,校验通过后,客户端和服务端之间就建立了一条全双工通道。
我在 Demo 的客户端代码里看到这样的握手触发逻辑:
const ws = new WebSocket('ws://localhost:8080/game'); ws.onopen = () => { console.log('连接建立,协议已切换'); ws.send(JSON.stringify({ type: 'join', roomId: 'room1', playerName: 'player1' })); };这里new WebSocket('ws://localhost:8080/game')并不是直接建立一个 TCP 连接就完事,浏览器会自动完成 HTTP Upgrade 流程。onopen触发时,说明服务端已经返回了101 Switching Protocols状态码,这时候send()方法发送的数据才是真正的 WebSocket 帧,而不是 HTTP 请求体。
服务端在 Demo 里用的是 Spring Boot 的WebSocketHandler,它拦截到的握手请求里带了几个关键头信息。我一般会把它打出来看:
@Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) { String query = request.getURI().getQuery(); System.out.println("握手请求参数: " + query); String token = request.getHeaders().getFirst("Sec-WebSocket-Protocol"); if (token == null || token.isEmpty()) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } return true; }注意这里有个容易被忽略的点:Sec-WebSocket-Protocol头不是必须的,但很多在线游戏 Demo 用它做子协议协商或简单鉴权。如果服务端拒绝了握手,浏览器端的表现不是报错,而是onerror之后onclose,新手很容易误判成「网络不通」。我在排查时习惯先抓握手阶段,确认101状态码有没有返回。
2.2 消息帧格式与 Demo 里的 JSON 协议设计
WebSocket 的帧格式分四种:文本帧、二进制帧、ping/pong 控制帧和关闭帧。Demo 里所有业务消息都走文本帧,用 JSON 封装,这是目前最常见也最省心的做法。二进制帧适合传输大体积数据,比如帧同步游戏里直接塞土玩家的操作序列化字节,但调试成本会高一截。
Demo 里的消息协议我拆了一下,大致是这样一个结构:
{ "type": "move", "roomId": "room1", "playerId": "p1", "data": { "x": 120, "y": 340, "direction": "left" } }type 是消息类型,roomId 用于区分房间,playerId 标识发送者,data 放具体业务数据。这套结构看起来朴素,但胜在稳定:服务端拿到消息后,先按 type 分发到不同处理器,再按 roomId 找到对应房间的会话集合,最后把消息广播给房间里其他玩家。
这里有一点值得跟新手强调:WebSocket 本身不保证消息的「业务语义」,它只保证字节按序到达。所以消息协议的设计直接决定你后面写逻辑痛不痛苦。我在 Demo 基础上扩展过,把 type 扩展成了join、leave、move、shoot、chat、heartbeat六种,每种都有对应的处理分支,代码结构立刻清晰了很多。如果一开始只打算写「发个消息过去」,后面一定会翻车。
2.3 选型权衡:什么场景不该用 WebSocket
拆这个 Demo 的过程中,我意识到一个更重要的点:WebSocket 不是实时方案的银弹。如果你的游戏是回合制、对延迟不敏感,HTTP 轮询甚至 REST 接口反而更简单可靠。WebSocket 适合的是高频双向交互、服务端主动推送的场景,比如动作同步、弹幕、实时协作。
另外还有一个容易被忽略的边界:浏览器对 WebSocket 连接数有限制。同一个域名下,HTTP/1.1 时代的浏览器通常只允许 6 个并发连接,超过之后会排队。虽然 HTTP/2 缓解了这个问题,但如果你在一个页面里同时开多个 WebSocket 连接,很可能出现连接一直 pending 的情况。Demo 里只开了一个连接,这是正确做法——所有业务消息复用同一条连接,而不是每个功能开一条。
3. 把 Demo 跑起来:环境、启动与第一个连接
3.1 源码结构与核心文件
这个 zip 解压后的目录结构大概是这样的:
websocket-game-demo/ ├── server/ # Spring Boot 服务端 │ ├── src/main/java/ │ │ ├── WebSocketConfig.java │ │ ├── GameWebSocketHandler.java │ │ └── GameApplication.java │ └── pom.xml └── client/ # 前端页面 ├── index.html └── game.jsWebSocketConfig.java负责注册 WebSocket 处理器到指定路径,GameWebSocketHandler.java是核心逻辑所在,处理连接建立、消息收发、连接关闭。index.html是游戏页面,game.js封装了连接管理和消息发送。
在动手跑之前,先把服务端配好。Demo 用的是 Maven 管理依赖,如果你本机没有 Maven,直接用 IDE 的导入功能也能识别pom.xml。核心依赖就是spring-boot-starter-websocket,没有它整个服务端起不来。
3.2 启动服务端与客户端
启动服务端之前,先确认端口没有被占用。Demo 默认跑在 8080 端口,如果你本机已经有服务占了 8080,Spring Boot 启动会直接报错。我一般先跑一条命令检查:
lsof -i :8080如果看到有进程占用,有两种处理方式:杀掉占用进程,或者修改 Demo 的端口。修改端口很简单,在application.properties里加一行:
server.port=9090改成 9090 之后,前端game.js里的new WebSocket('ws://localhost:8080/game')也要同步改成ws://localhost:9090/game,否则浏览器连不上。这是新手最容易忽略的联动改动——服务端换了端口,前端连接地址还是旧的,页面看起来像「卡死」,实际是连错了地方。
服务端启动成功之后,日志里会出现 Spring Boot 的启动 banner,以及一行类似Tomcat started on port 9090的提示。这时候打开client/index.html,页面上应该能看到「已连接」的状态,说明握手的 101 切换已经完成。
3.3 用浏览器调试 WebSocket:Network 面板与数据帧
跑通之后,第一件事不是急着改代码,而是打开浏览器开发者工具的 Network 面板,把你看到的连接过程过一遍。
在 Network 面板里,WebSocket 连接会单独显示一条记录,点击它可以看到 Frame 标签页。这里能看到所有经过的数据帧:客户端发的join消息、服务端广播的playerJoined消息、心跳的ping和pong。每条帧都标了方向,向下的箭头是客户端发出的,向上的箭头是服务端返回的。
我习惯把 Network 面板当成 WebSocket 调试的第一现场。很多「消息没收到」的问题,在 Frame 标签页里一眼就能看出来:如果客户端发了消息但 Frame 里没有显示,说明send()根本没执行;如果显示了服务端响应但页面没反应,问题在前端渲染逻辑。用这个面板先定位「消息到底有没有到」,能省掉大量瞎猜的时间。
还有一个技巧:在 Frame 标签页里右键可以复制消息内容,我经常把复制的 JSON 丢到格式化工具里看结构,排查消息字段拼写错误。这个 Demo 的消息都是 JSON 文本帧,调试起来比二进制帧直观太多。
4. 心跳机制与断线重连:在线游戏不掉线的关键
4.1 为什么必须有心跳:代理超时与死连接
WebSocket 连接建立之后,如果长时间没有数据往来,很多中间设备——比如 Nginx、云厂商的负载均衡器、甚至某些运营商的路由设备——会认为这条连接已经闲置,直接把它掐掉。连接被掐掉之后,服务端和客户端都不会立刻感知,因为 TCP 层没有数据流动,两边都以为连接还活着。
这就是所谓的「死连接」:从业务层面看,玩家还挂在房间里;从网络层面看,这条连接已经物理断开。在线游戏里这会导致一个很恶劣的体验:玩家操作没反应,但页面显示「已连接」,让人误以为是游戏卡了。
解决方案就是心跳机制。客户端定期发送一个轻量级的探测消息,服务端收到后回一个确认,这样连接上始终有数据流动,中间设备就不会把它判定为闲置连接。Demo 里的心跳实现走的是业务层心跳,不是 WebSocket 协议自带的 ping/pong 帧。
4.2 心跳实现:业务层心跳与协议层 ping/pong
业务层心跳的实现很简单,客户端每隔一段时间发一条heartbeat消息,服务端收到后原样返回或者回一条heartbeat_ack。我拆的 Demo 里用的就是这种方式:
// client/game.js const HEARTBEAT_INTERVAL = 10000; // 10 秒一次 const HEARTBEAT_TIMEOUT = 5000; // 5 秒没收到 pong 就认为连接异常 let lastPongTime = Date.now(); function startHeartbeat(ws) { setInterval(() => { if (Date.now() - lastPongTime > HEARTBEAT_TIMEOUT) { console.warn('心跳超时,连接疑似失效'); ws.close(); return; } ws.send(JSON.stringify({ type: 'heartbeat', timestamp: Date.now() })); }, HEARTBEAT_INTERVAL); } ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'heartbeat_ack') { lastPongTime = Date.now(); } };这段代码里,HEARTBEAT_INTERVAL是发送心跳的间隔,HEARTBEAT_TIMEOUT是容忍的超时阈值。这里有个经验值:心跳间隔不要设得太短,否则空闲玩家也会给服务器造成不必要的压力;也不要太长,否则中间设备可能已经掐断连接你还没发现。我一般把间隔设在 10 到 30 秒之间,超时阈值设为间隔的两倍左右。
服务端对应的处理逻辑是收到heartbeat消息后,往同一个会话里回一条heartbeat_ack。同时服务端自己也会记录每个连接的最后活跃时间,定期清理那些超过 N 秒没有活跃的会话。这一步是为了防止玩家直接关掉浏览器、没有触发正常的 close 流程,导致服务端留下僵尸连接占着内存。
4.3 断线重连与状态恢复
心跳能发现连接失效,但发现之后怎么办?答案不是让玩家重新打开页面,而是自动重连。Demo 里的重连逻辑做得比较基础,但也够用:
function connectWithRetry() { const ws = new WebSocket('ws://localhost:9090/game'); ws.onclose = () => { console.log('连接关闭,3 秒后重连'); setTimeout(connectWithRetry, 3000); }; return ws; }这段逻辑有一个需要注意的地方:断线重连成功之后,服务端需要知道这个连接对应的是哪个玩家、在哪个房间。所以重连之后的第一个消息必须是恢复身份的join消息,带着之前的playerId和roomId。Demo 里把playerId存在了localStorage里,重连后自动读取,这个设计很实用,否则重连之后玩家就变成「新加入」而不是「回到原房间」。
服务端这边,我建议在join处理器里做幂等处理:如果这个playerId已经在房间的会话集合里,先移除旧会话再加入新会话,避免同一个玩家留下两条连接导致消息重复推送。这个坑我在后面章节详细说。
5. 避坑指南:WebSocket 在线游戏 Demo 常见问题与排查
5.1 现象:连接一直 pending,最后报错
我在跑通这个 Demo 的过程中,第一个遇到的坑就是连接一直卡在 pending 状态,过一会儿浏览器报错WebSocket connection to 'ws://...' failed。
原因有两层。第一层是最常见的:服务端根本没启动,或者启动失败。Spring Boot 启动失败不会影响前端页面加载,所以页面看起来正常,只有 WebSocket 连接在默默失败。第二层是跨域问题:前端页面如果是从file://协议打开的,或者前端部署在 8081 端口、后端跑在 9090 端口,浏览器的同源策略会拦截 WebSocket 握手。注意,WebSocket 的跨域检查和 HTTP 请求不一样,它不依赖 CORS 预检,而是依赖服务端握手时是否允许该 Origin。
解决办法是先在服务端日志里确认有没有收到握手请求。如果完全没有握手请求,问题出在客户端连接地址写错;如果收到了但被拒绝,检查beforeHandshake里的校验逻辑。我后来写服务端的时候,干脆把握手校验改成白名单模式:
String origin = request.getHeaders().getOrigin(); if (origin == null || !origin.startsWith("http://localhost")) { return false; }这样本地调试不会被跨域卡住,部署到线上时再把白名单换成真实域名。
5.2 现象:连接时不时断开,没有任何错误日志
这个坑最折磨人,因为 WebSocket 连接看起来一切正常,但每隔几分钟就断一次,重连后又恢复正常,反复循环。
我当时排查了很久,最后发现是 Nginx 的proxy_read_timeout默认值只有 60 秒。当连接上超过 60 秒没有数据流动,Nginx 就会主动关闭这条连接。而 Demo 里如果心跳间隔设得比 60 秒还长,或者根本没实现心跳,就会出现这种「周期性断连」的诡异现象。
解决办法有两个方向:一个是在 Nginx 配置里调大超时时间;另一个是让心跳间隔小于代理超时时间,保证连接上始终有数据在走。我推荐后者,因为调整 Nginx 超时只是缓解症状,如果中间还有别的网络设备,你不可能一个个去调。正确做法是让应用层心跳主动维持连接活性。
5.3 现象:服务端能收到消息,但推给客户端没反应
这个坑的典型表现是:客户端send()出去了消息,服务端日志里也打印了接收到的内容,但其他玩家收不到广播,或者自己收不到服务端的响应。
我排查时发现,问题出在会话管理的数据结构上。Demo 里如果用ConcurrentHashMap<String, WebSocketSession>管理连接,key 是 sessionId,那么服务端向某个玩家推送消息时,需要先从 map 里取出 session,再调用session.sendMessage()。如果取出 session 时连接已经关闭,sendMessage()会抛异常,而异常被吞掉之后,表现就是「消息发了但没收到」。
解决方法是每次发送前检查 session 状态,发送失败后主动清理过期会话。我在 Demo 代码里加了这样一段:
public void sendToPlayer(String playerId, String message) { WebSocketSession session = sessionMap.get(playerId); if (session == null || !session.isOpen()) { sessionMap.remove(playerId); return; } try { session.sendMessage(new TextMessage(message)); } catch (IOException e) { sessionMap.remove(playerId); } }这里isOpen()检查很关键,它能在发送之前发现失效连接,避免异常堆积。实际生产环境里,我还会在捕获到 IOException 时把清理动作放到一个统一的连接管理类里,而不是散落在各处。
5.4 现象:Spring Boot 启动报端口被占用
这个坑不算 WebSocket 特有,但非常常见。Spring Boot 默认端口是 8080,本机如果有其他 Java 进程、Docker 容器或者某个开发工具占用了 8080,启动就会报Port already in use。
解决方式前面提过,改application.properties里的server.port。但这里有个联动坑:改完服务端端口,前端game.js里的 WebSocket 地址也要同步改,否则前端会连接旧端口,而旧端口上根本没有服务在监听。我在调试时习惯把端口号抽成一个常量,前端和后端各维护一份,改的时候一起改,避免漏掉。
还有一个更隐蔽的情况:Spring Boot 启动端口改了,但 WebSocket 的setAllowedOrigins配置里还写着旧端口对应的 Origin,导致握手被跨域策略拦掉。所以改端口时,三处要同步检查:服务端监听端口、服务端允许的 Origin、前端连接地址。
5.5 现象:多实例部署后,玩家消息串房间
Demo 是单机版的,只有一个服务端实例,所以用本地内存存 sessionMap 没问题。但如果你照着 Demo 的思路上了多实例部署——比如两台服务器跑同一个服务端——就会发现一个严重问题:玩家 A 连接到实例 1,玩家 B 连接到实例 2,A 发消息广播到实例 1 上的 sessionMap,实例 2 上的玩家 B 根本收不到。
这不是 WebSocket 的锅,而是「连接状态存本地内存」这种方式天然不支持水平扩展。解决办法是把 session 与房间的映射关系挪到 Redis 里,或者引入消息中间件做跨实例广播。Demo 没做这一步,所以你把它当单机参考没问题,想直接上生产就得重构这部分。
6. 从 Demo 到可上线:房间管理、广播性能与压测验证
6.1 以房间维度管理连接
Demo 里已经出现了roomId字段,但它的实现只是简单地把消息转发给所有连接。真正要做在线游戏,第一步就是把「全局广播」改成「房间广播」。我在改造时做的第一件事是引入一个RoomManager,它维护Map<String, Set<WebSocketSession>>这样的结构,每个房间对应一组会话。
当一个玩家join进房间时,把这个玩家的 session 加到对应房间的集合里;当玩家leave或连接关闭时,从集合里移除。这样广播消息时只需要遍历目标房间的 session 集合,而不是全量遍历所有连接。这个改动对性能的影响非常直接——玩家越多,全局广播的浪费越严重,按房间隔离是必须的。
6.2 广播优化:合并消息与按需推送
房间广播还有一个优化点:高频率消息的合并。动作同步类消息往往每秒会产生几十条,如果每条都立即推送给所有玩家,会带来大量的消息帧开销。常见做法是做一个简单的「消息合并窗口」:把 50 毫秒内的多条移动消息合并成一条批量消息再广播。这个 Demo 里没做,但我会在改造时加上,因为它对带宽的节省非常可观。
按需推送是另一个层面:不是房间里所有玩家都需要收到所有消息。比如玩家 A 的位置更新只需要推送给 A 视野范围内的玩家,而不是整个房间。这个在 Demo 里肯定没有,但对于真正的在线游戏,这是性能和带宽优化的核心方向。
6.3 压测验证:500 并发连接下的表现
改完以上内容,最后要做的是验证。我一般会写一个简单的压测脚本,用 WebSocket 客户端库模拟大量并发连接,看看服务端在 500 个连接同时在线、每个连接每 5 秒发一条心跳消息时的 CPU 和内存表现。如果服务端是 Spring Boot 默认配置,跑这个量级通常没有太大压力。
真正需要关注的是两个指标:一个是连接建立速率,也就是每秒能成功完成多少次握手;另一个是消息吞吐,也就是每秒能处理多少条业务消息。如果发现握手阶段 CPU 飙高,通常是日志打印太频繁或者握手校验里做了耗时操作——比如查数据库验证 token。把这些操作从beforeHandshake里移到连接建立后的异步任务里,能显著提升握手速率。
拆完这个 Demo,我最大的感受是:WebSocket 在线游戏开发的难点不在协议本身,而在连接的生命周期管理——握手鉴权要精简、心跳要可靠、重连要能恢复状态、广播要按房间隔离。从那以后,我每拿到一个新的 WebSocket 项目,都强制自己先走一遍这套流程:确认握手逻辑、检查心跳间隔、模拟断线重连、压测连接上限。这套流程能挡掉大部分线上翻车的可能性。希望帮到你。
本文还有配套的精品资源,点击获取