简介:这份资源是面向Web前端与游戏开发入门者的WebSocket在线游戏开发实战Demo,聚焦实时双向通信在游戏场景中的落地,帮助读者理解持久连接、帧结构、握手流程等核心概念,并掌握多用户同步、服务器主动推送等典型实现思路。压缩包共488个文件,以391个JavaScript文件为主体,承载客户端逻辑与socket.io通信代码,另有55张jpg与10张png图片、14个ico图标用于界面与资源展示,5个Go文件对应服务端逻辑,辅以少量html页面、md说明及zip依赖包,整体约2.96MB,结构轻量便于本地运行调试。目前已有94人学习关注。Demo覆盖连接建立、数据帧处理、游戏状态广播与客户端重连等环节,读者可据此理解JSON数据交换格式的用法,并延伸思考WSS加密、负载均衡与数据压缩等优化方向,适合作为实时在线游戏开发的入门参考。
1. 从“轮询卡成 PPT”说起:这套 WebSocket 在线游戏 Demo 到底解决什么
做过实时对战 Demo 的人都踩过同一个坑:前端用setInterval每 500ms 拉一次状态,两个人还好,四个人同屏就开始飘,操作延迟肉眼可见,服务端日志里全是重复请求。这不是代码写得烂,是 HTTP 轮询这套机制本身不适合“状态高频变化 + 多端同步”的场景。这套基于 WebSocket 的在线游戏开发 Demo,切入的正是这个痛点——把“客户端问服务端要状态”翻转为“服务端主动推状态”,让房间内的位置、动作、比分在毫秒级完成广播。它适合三类人:正在做网络编程课设、需要交一个能跑起来的实时通信项目的学生;想给毕设补一个“多人同步”模块的开发者;以及要快速验证房间制游戏服务端骨架的从业者。整套 Demo 覆盖连接建立、房间管理、状态广播、断线处理四条主线,是一个能拆开改、也能整体跑的完整闭环。
2. 需求与技术选型:为什么是 WebSocket 而不是轮询或长连接
2.1 先把需求边界划清楚
在线游戏 Demo 的需求看着简单,真拆开有四层。第一层是连接层:多个客户端要能同时接入,服务端要能识别“谁是谁”,这要求每条连接有唯一标识,且连接生命周期可管理。第二层是房间层:玩家不能全挤在一个大厅里,要能创建房间、加入房间、退出房间,房间内成员变化要实时通知到房内所有人。第三层是同步层:玩家的位置、朝向、动作指令要以足够低的延迟广播给同房间其他玩家,这是整个 Demo 的核心体验点。第四层是容错层:玩家关页面、断网、刷新,服务端不能崩,房间不能变成“幽灵房间”,其他玩家要能看到有人离开。
这四层决定了技术选型的底线:必须支持服务端主动推送、必须支持长连接、必须能承载多连接并发。HTTP 短轮询在第一层就出局——每次请求都要重新握手,连接状态无处安放;长轮询勉强能撑第二层,但第三层的广播延迟会被“请求-响应”模型拖垮,一个玩家移动要等下一个轮询周期才能被看到,体验直接崩。WebSocket 在协议层就解决了双向通信,握手一次后连接常驻,服务端随时可以send,这正是房间制游戏需要的模型。
提示:选型时不要只看“能不能通”,要看“状态放哪”。轮询模型下状态只能放服务端内存或数据库,每次请求重建上下文;WebSocket 模型下连接本身就是上下文载体,玩家对象可以挂在连接上,这是架构上的本质差别。
2.2 技术栈的取舍逻辑
这套 Demo 的技术栈走的是“最小可跑 + 可扩展”路线。服务端用 Node.js 配ws库,原因是ws足够轻,没有 Socket.IO 那层封装带来的黑匣子感,事件回调直接暴露,适合学习和二次开发;如果换成 Socket.IO,房间、重连、降级都帮你做了,但你也失去了对协议细节的控制,课设答辩时被问“底层怎么走的”容易答不上来。前端用原生 JavaScript 加 Canvas 渲染,不引框架,目的是让“消息怎么收、状态怎么画”这条链路清晰可见,读者拿到手能直接看懂每一行数据流向。
通信协议上,Demo 采用 JSON 文本帧而非二进制帧。这是个有意的取舍:JSON 可读性强,调试时打开浏览器 Network 面板的 WS 标签就能看到每条消息的原文,排查“为什么这个玩家没动”时不用写解码脚本;代价是体积比二进制大,但对一个 Demo 级别的并发量(通常个位数到几十人)完全够用。如果后续要压到上百人同房,再换ArrayBuffer加自定义协议头,这是明确的演进路径,不是一开始就要上的复杂度。
| 选型项 | 方案 | 理由 | 何时该换 |
|---|---|---|---|
| 服务端框架 | Node.js + ws | 轻量、事件直观、易调试 | 需要集群时换带 Redis 适配的方案 |
| 前端渲染 | 原生 Canvas | 无框架依赖,数据流清晰 | 界面复杂后引入渲染库 |
| 消息格式 | JSON 文本帧 | 可读、易排查 | 同房人数上百时换二进制 |
| 房间存储 | 服务端内存 Map | 零依赖、读写快 | 多进程部署时换共享存储 |
2.3 消息协议的设计原则
协议设计是这套 Demo 里最容易被低估的部分。很多初学者上来就socket.send(JSON.stringify(player)),把整个玩家对象无差别广播,结果消息体越来越大,前端还要自己判断“这条消息是移动还是加入房间”。正确的做法是给每条消息一个type字段做路由,data字段只带该类型需要的最小载荷。这样前端收到消息先看type再分发处理,服务端也能按类型做不同的广播范围控制——比如move只广播给同房间,join要广播给房内所有人并回执给新玩家。
协议还要预留“服务端权威”的余地。Demo 阶段可以让客户端上报位置、服务端直接转发,但协议里应该保留服务端校验的入口,否则后期想做防作弊就得推翻重来。我一般会在move消息里带上客户端时间戳和序号,服务端收到后可以做简单的合理性判断(位移是否超过阈值、序号是否乱序),这套机制在 Demo 里可以先注释掉,但字段要留。
3. 总体架构与房间管理:连接、房间、广播三层怎么搭
3.1 三层结构的职责划分
整套 Demo 的架构可以拆成三层,每层职责单一,改一层不影响另一层。连接层负责 WebSocket 的握手、心跳、断线检测,它不关心业务,只维护“哪些连接还活着”;房间层负责房间的创建销毁、成员增删、房间内广播,它持有房间与连接的映射关系;业务层负责解析具体消息类型,把move、action、chat这类指令翻译成对房间层的调用。三层之间通过明确的数据结构通信,连接层向上暴露connectionId,房间层向上暴露roomId和成员列表,业务层不直接碰 WebSocket 对象。
这种划分的好处是排查问题时能快速定位。玩家说“我动了别人没看到”,先看连接层心跳是否正常(连接没断),再看房间层两人是否在同一roomId(房间对不上),最后看业务层move消息是否被正确广播(广播范围错了)。三层各有一个日志前缀,出问题看日志就能判断卡在哪一层,不用满代码找。
3.2 连接建立与身份绑定
连接建立阶段要做三件事:分配唯一连接 ID、初始化玩家状态、把连接注册进全局连接表。下面这段是服务端的核心骨架,展示了从握手到注册的完整链路。
const WebSocket = require('ws'); const { v4: uuidv4 } = require('uuid'); const wss = new WebSocket.Server({ port: 8080 }); // 全局连接表:connectionId -> { ws, player, roomId } const clients = new Map(); wss.on('connection', (ws, req) => { const connectionId = uuidv4(); // 玩家初始状态,roomId 为空表示尚未进房 const player = { id: connectionId, name: 'player_' + connectionId.slice(0, 4), x: 0, y: 0 }; clients.set(connectionId, { ws, player, roomId: null }); // 把连接 ID 回执给客户端,后续消息都带这个 ID ws.send(JSON.stringify({ type: 'welcome', data: { id: connectionId } })); ws.on('message', (raw) => { let msg; try { msg = JSON.parse(raw); } catch (e) { // 非法 JSON 直接丢弃,不让脏数据进业务层 return ws.send(JSON.stringify({ type: 'error', data: 'invalid json' })); } handleMessage(connectionId, msg); }); ws.on('close', () => { handleDisconnect(connectionId); }); });这段代码的关键点在clients这个 Map 的结构设计:value 里同时挂了ws(通信句柄)、player(业务状态)、roomId(房间归属)。三者放在一起,是因为断线时需要用roomId找到房间、用player通知其他人、用ws做最后的清理,分开存会导致断线处理要查三张表。welcome消息回执连接 ID 是必须的,客户端后续所有消息都要带上它,服务端才能知道“这条消息是谁发的”——WebSocket 的message回调只给你ws对象,不给你业务身份,身份映射得自己维护。
3.3 房间的创建、加入与广播
房间管理的核心是一个rooms结构,键是roomId,值是房间对象,里面存成员 ID 集合和房间元信息。创建房间时生成roomId并把创建者加进去;加入房间时校验房间是否存在、是否满员,然后把新成员 ID 塞进集合,并向房内所有人广播player_joined;退出时从集合删除,集合空了就销毁房间,避免幽灵房间堆积。
const rooms = new Map(); // roomId -> { members: Set<connectionId>, maxSize: 4 } function createRoom(connectionId) { const roomId = 'room_' + Math.random().toString(36).slice(2, 8); rooms.set(roomId, { members: new Set([connectionId]), maxSize: 4 }); clients.get(connectionId).roomId = roomId; return roomId; } function joinRoom(connectionId, roomId) { const room = rooms.get(roomId); if (!room) return { ok: false, reason: 'room_not_found' }; if (room.members.size >= room.maxSize) return { ok: false, reason: 'room_full' }; room.members.add(connectionId); clients.get(connectionId).roomId = roomId; // 广播给房内除自己外的所有人 broadcast(roomId, { type: 'player_joined', data: clients.get(connectionId).player }, connectionId); return { ok: true, roomId }; } function broadcast(roomId, message, excludeId) { const room = rooms.get(roomId); if (!room) return; const payload = JSON.stringify(message); room.members.forEach((id) => { if (id === excludeId) return; const client = clients.get(id); // readyState 为 OPEN 才发,避免向已断开的连接写数据抛异常 if (client && client.ws.readyState === WebSocket.OPEN) { client.ws.send(payload); } }); }broadcast里的readyState判断是血泪经验:向一个正在关闭的连接send会抛异常,如果没包 try-catch,一条异常就能把整个广播循环打断,导致后面的人收不到消息。excludeId参数用于“广播给除自己外的人”,移动同步场景下自己不需要收自己的位置,省一次无效传输。maxSize设成 4 是 Demo 的默认值,改这个数字就能调整房间容量,不需要动其他逻辑。
3.4 状态同步的消息流
一次完整的移动同步是这样走的:客户端按下方向键,本地先做预测渲染(让操作手感不延迟),同时发move消息带新坐标;服务端收到后更新clients里对应player的x、y,然后调broadcast把新坐标推给同房间其他人;其他人收到后更新本地对应玩家的渲染位置。这条链路里服务端不做复杂校验,只做状态存储和转发,保证延迟最低。如果要做服务端权威,就在更新前加一步“位移是否合理”的判断,不合理就回滚并下发正确坐标,这是后续演进的事,Demo 阶段先保证链路通。
4. 核心模块实现:消息路由、心跳保活与断线清理
4.1 消息路由的分发设计
消息路由是业务层的入口,所有客户端消息都从这里进。设计上用一个handlers对象做类型到处理函数的映射,新增消息类型只需加一个键,不用改switch或if-else链。这种写法在消息类型多起来之后优势明显,也方便单测时单独调用某个 handler。
const handlers = { create_room: (connectionId, data) => { const roomId = createRoom(connectionId); sendTo(connectionId, { type: 'room_created', data: { roomId } }); }, join_room: (connectionId, data) => { const result = joinRoom(connectionId, data.roomId); sendTo(connectionId, { type: 'join_result', data: result }); }, move: (connectionId, data) => { const client = clients.get(connectionId); if (!client || !client.roomId) return; // 未进房不允许移动 // 更新服务端持有的玩家状态 client.player.x = data.x; client.player.y = data.y; broadcast(client.roomId, { type: 'player_moved', data: { id: connectionId, x: data.x, y: data.y } }, connectionId); }, leave_room: (connectionId) => { handleDisconnect(connectionId, true); } }; function handleMessage(connectionId, msg) { const handler = handlers[msg.type]; if (!handler) { return sendTo(connectionId, { type: 'error', data: 'unknown type: ' + msg.type }); } handler(connectionId, msg.data || {}); }move处理里那句“未进房不允许移动”是必要的防御:客户端可能因为时序问题在join_result回来之前就发了move,如果不拦,client.roomId为null时broadcast会静默失败,表现为“我动了但没人看到”,排查起来很费时间。leave_room复用handleDisconnect并传true表示主动离开,这样主动退出和被动断线走同一套清理逻辑,不会出现两条路径行为不一致的问题。
4.2 心跳保活机制
WebSocket 连接在中间有代理或网络抖动时可能“假死”——TCP 层没断,但实际已经不通,服务端还占着连接和房间名额。心跳机制就是解决这个的:服务端定时给每个连接发ping,客户端回pong,超过一定次数没回就判定为死连接并清理。
const HEARTBEAT_INTERVAL = 30000; // 30 秒一次 const MAX_MISSED = 2; // 连续两次没回就清理 // 给每个连接挂一个心跳状态 wss.on('connection', (ws) => { ws.isAlive = true; ws.missedPings = 0; ws.on('pong', () => { ws.isAlive = true; ws.missedPings = 0; }); }); setInterval(() => { wss.clients.forEach((ws) => { if (ws.missedPings >= MAX_MISSED) { // 连续多次无响应,主动终止,触发 close 事件走清理 return ws.terminate(); } ws.missedPings += 1; ws.ping(); // 底层会触发客户端的 pong 自动回复 }); }, HEARTBEAT_INTERVAL);这里用 WebSocket 协议层的ping/pong帧而不是应用层自定义心跳消息,原因是协议层心跳不占业务消息通道,也不会被业务层的 JSON 解析逻辑干扰。terminate()而不是close()是有意的:对假死连接调close()可能等不到对端响应,terminate()直接断 TCP,确保资源立即释放。MAX_MISSED设 2 意味着最坏情况下 60 秒判定死连接,这个值可以根据实际网络环境调,内网可以更激进,公网环境建议保守一点。
4.3 断线清理的完整链路
断线清理要处理四件事:从房间成员集合移除、通知房内其他人、从全局连接表删除、房间空了就销毁。这四步顺序不能乱,先移除再通知,否则广播时还会给已断开的连接发消息;先删连接表再销毁房间,否则销毁逻辑里查不到玩家信息。
function handleDisconnect(connectionId, isVoluntary) { const client = clients.get(connectionId); if (!client) return; // 已清理过,防止重复执行 const { roomId } = client; if (roomId) { const room = rooms.get(roomId); if (room) { room.members.delete(connectionId); // 通知房内剩余玩家 broadcast(roomId, { type: 'player_left', data: { id: connectionId, voluntary: !!isVoluntary } }); // 房间空了就销毁,避免幽灵房间 if (room.members.size === 0) { rooms.delete(roomId); } } } clients.delete(connectionId); }if (!client) return这行是后悔药:close事件和主动leave_room可能同时触发清理,没有这行守卫就会重复广播player_left,前端收到两次“某人离开”可能导致渲染异常。voluntary字段让前端能区分“主动退出”和“掉线”,前者可以正常移除玩家,后者可以显示“连接中断”提示,体验上更友好。
4.4 前端接收与渲染的对接
前端侧的核心是一个消息分发器加一个渲染循环。消息到达后按type更新本地玩家状态表,渲染循环每帧从状态表读数据画到 Canvas,两者解耦,避免“收到消息就重绘”导致的渲染抖动。
const players = new Map(); // id -> { x, y, name } const ws = new WebSocket('ws://localhost:8080'); ws.onmessage = (event) => { const msg = JSON.parse(event.data); switch (msg.type) { case 'welcome': myId = msg.data.id; break; case 'player_joined': players.set(msg.data.id, msg.data); break; case 'player_moved': // 只更新坐标,不触发重绘,交给渲染循环统一处理 Object.assign(players.get(msg.data.id) || {}, msg.data); break; case 'player_left': players.delete(msg.data.id); break; } }; function renderLoop() { ctx.clearRect(0, 0, canvas.width, canvas.height); players.forEach((p) => drawPlayer(p)); requestAnimationFrame(renderLoop); } renderLoop();player_moved里用Object.assign而不是直接替换对象,是为了保留name等不随移动变化的字段。渲染循环用requestAnimationFrame而不是setInterval,前者跟随浏览器刷新率,画面更顺滑,也不会在标签页后台时空转耗电。这套“消息更新状态、循环读取状态”的模式是实时游戏前端的标准做法,换成任何渲染引擎都适用。
5. 避坑与排查:那些让 Demo 跑不起来的常见问题
5.1 连接建立成功但收不到任何消息
现象是浏览器控制台显示 WebSocket 已连接,但onmessage一次都没触发。原因通常是服务端send的时机早于客户端onmessage绑定——服务端在connection回调里立刻发welcome,而客户端可能还在执行new WebSocket()之后的同步代码,消息到了但监听器还没挂上。解决方法是客户端把onmessage赋值放在new WebSocket()之后的第一时间,或者服务端把首条消息延迟到收到客户端第一条消息后再发。我一般选前者,改一行代码的事。
5.2 广播时抛异常导致部分玩家收不到
现象是房间里有三个人,A 移动后 B 收到了、C 没收到,服务端日志里有一条WebSocket is not open的报错。原因是广播循环里某个连接的readyState不是OPEN,send抛异常打断了forEach。解决方法是广播前判断readyState,并且给send包 try-catch,单条失败不影响后续。这个坑在玩家频繁进出房间时特别容易触发,因为断开的连接可能还没从集合里清掉。
5.3 玩家刷新后房间出现“幽灵成员”
现象是玩家刷新页面后,其他人看到两个同名玩家,一个能动能一个不动。原因是刷新触发了新的 WebSocket 连接,旧连接还没被服务端判定为断开,新旧两个连接都挂在房间里。解决方法是心跳机制要真的生效(MAX_MISSED别设太大),同时前端在beforeunload时主动发leave_room,双保险。另外player的name如果允许重复,排查时会更混乱,建议 Demo 阶段就用连接 ID 前缀做唯一名。
5.4 本地能跑、部署后连不上
现象是本地ws://localhost:8080一切正常,部署到服务器后前端报连接失败。原因通常是协议没从ws://改成wss://,或者反向代理没配置 WebSocket 的升级头。解决方法是前端根据页面协议动态拼 WebSocket 地址(https页面用wss),代理层要显式放行Upgrade和Connection头。这个坑在课设答辩现场演示时翻车率极高,部署前一定要在目标环境实测一遍。
5.5 消息顺序错乱导致位置回跳
现象是玩家移动时偶尔出现“往前走一步又跳回去”的抖动。原因是网络传输不保证顺序,两条move消息可能后发的先到,前端按到达顺序更新坐标就会回跳。解决方法是在消息里带递增序号,前端只接受序号大于当前值的消息,旧消息直接丢弃。Demo 阶段可以先不做,但协议字段要预留,否则后期补这个机制要改前后端两边。
6. 验收与答辩:怎么证明这套 Demo 真的跑通了
验收这套 Demo,我一般不看“能不能动”,看四个可量化的点。第一,多端同步延迟:开两个浏览器窗口进同一房间,一端移动,另一端肉眼观察延迟,正常应该在 100ms 以内,超过 300ms 就要查是不是心跳间隔或广播逻辑有问题。第二,房间隔离:开三个窗口,两个进房间 A、一个进房间 B,A 房内的移动不能出现在 B 房,这是验证broadcast的roomId过滤是否生效。第三,断线恢复:直接关掉一个窗口,另一窗口应在心跳周期内收到player_left,房间人数正确减一。第四,异常输入:用控制台手动发一条非法 JSON 或未知type,服务端应返回error而不是崩溃。
答辩时最容易被问的三个问题,提前准备好答案。问“为什么不用 Socket.IO”,答“需要控制协议细节,ws更透明,且房间逻辑自己实现便于讲清楚原理”。问“并发上限多少”,答“单进程内存房间,Demo 级别几十连接无压力,瓶颈在广播循环的同步写,上量要换异步队列加多进程”。问“怎么防作弊”,答“当前是客户端权威,协议里已预留服务端校验入口,加位移阈值判断即可切换为服务端权威”。这三个问题答得出来,说明你不是抄了个 Demo,是真读懂了每条链路。
| 验收项 | 操作 | 通过标准 |
|---|---|---|
| 同步延迟 | 双窗口同房移动 | 目测延迟低于 100ms |
| 房间隔离 | 三窗口分两房 | 跨房消息不串 |
| 断线清理 | 关闭一个窗口 | 60 秒内房内收到离开通知 |
| 异常容错 | 发非法 JSON | 返回 error 且服务不崩 |
| 房间销毁 | 全员退出 | 房间从内存移除 |
从那以后我每次交这类实时通信项目,都强制自己先跑一遍“关窗口”和“发脏数据”这两个动作——它们最能暴露连接生命周期管理的漏洞,而这两处恰恰是演示时最容易被忽略、被追问时最容易露怯的地方。希望帮到你。
本文还有配套的精品资源,点击获取