简介:这份资源是面向Web前端与游戏开发初学者的WebSocket在线游戏实战Demo,聚焦实时双向通信在游戏场景中的落地,帮助读者理解握手流程、数据帧结构、多用户状态同步与服务器主动推送等核心机制,适合具备基础JavaScript能力、希望从零搭建实时对战环境的开发者练手。压缩包共488个文件,以391个js脚本为主体,承载服务端逻辑与客户端交互代码,另含55张jpg与10张png界面素材、14个ico图标、5个go源文件及少量html页面与md说明,整体约2.96MB,体量轻便、便于本地运行调试。目前已有94人学习下载。Demo覆盖连接建立、消息广播、游戏逻辑同步与断线重连等环节,读者可据此梳理服务端会话管理与客户端数据收发的完整链路,并参考其中的数据交换格式与安全传输思路,为后续开发实时在线游戏积累可复用的实现经验。
1. 一个把“实时对战”拆开给你看的 WebSocket 游戏 Demo
最近在整理一个联机小游戏的联调问题时,我又把这份基于 WebSocket 的在线游戏开发 Demo 翻出来跑了一遍。它做的事情很聚焦:用 WebSocket 把服务端和浏览器之间的双向通道打通,让两个玩家能在同一个房间里实时同步位置、状态和操作,而不是靠轮询去“猜”对方在哪。对想入门实时联机、又不想一上来就啃大型游戏引擎的开发者来说,这个 Demo 的价值在于它把“连接建立、房间管理、消息广播、状态同步”这几件事拆得足够小,小到你能在一个下午里把整条链路读明白。它适合两类人:一类是刚接触 WebSocket、想找个能跑起来的完整例子;另一类是想给现有网页项目加实时能力、需要一份可改造骨架的熟手。
2. 技术栈与目录结构:先看清这套骨架怎么搭的
2.1 技术选型:为什么是 WebSocket 而不是轮询
在线游戏最怕的就是延迟和状态不一致。传统 HTTP 轮询要么间隔太长导致操作滞后,要么间隔太短把服务端压垮,而且每次请求都要重新带一遍头部信息。WebSocket 在握手之后保持一条长连接,服务端可以主动把其他玩家的状态推过来,这正是实时对战需要的模型。这个 Demo 的服务端用 Node.js 起一个 WebSocket 服务,浏览器端用原生 WebSocket API 连接,渲染层用 Canvas 画简单的游戏对象。整套东西没有引入重型框架,目的是让协议层和游戏逻辑层保持清晰的分界。
常见做法是服务端用ws这个库来管理连接,它比原生http模块手写升级握手要省事得多,同时又能拿到每个连接的readyState和事件回调。客户端这边,连接、收发消息、断线重连都封装在一个通信模块里,游戏逻辑只关心“收到什么消息、更新什么状态”,不直接碰 socket 对象。这种分层在 Demo 阶段看起来有点多余,但等你真要加房间、加匹配、加断线恢复时,就会庆幸当初没把 socket 调用散落在渲染代码里。
2.2 目录结构:每个文件负责什么
一份能跑起来的 Demo,目录通常不会太深,但职责要分得开。下面是我按这类项目常见组织方式还原的结构,实际拿到压缩包后可以对照着看:
websocket-game-demo/ ├── server/ │ ├── index.js # 服务端入口,创建 WebSocket 服务 │ ├── roomManager.js # 房间创建、加入、销毁 │ └── gameLoop.js # 服务端 tick,广播状态 ├── client/ │ ├── index.html # 页面入口,Canvas 容器 │ ├── net.js # WebSocket 连接与消息封装 │ ├── game.js # 游戏对象与渲染逻辑 │ └── input.js # 键盘/鼠标输入采集 ├── shared/ │ └── protocol.js # 消息类型常量,前后端共用 └── package.jsonshared/protocol.js这个文件值得单独说一句。前后端如果各写一套消息类型字符串,改一个字段名就要两边一起改,漏一处就是线上事故。把消息类型、字段名、默认值集中到一个文件里,服务端require、客户端通过构建或直接引用,能省掉大量“对不上”的排查时间。Demo 阶段可以直接用 CommonJS 或 ES Module 共享,正式项目里再考虑打包工具处理。
2.3 启动流程:三条命令把环境跑起来
拿到压缩包后,先确认本机 Node.js 版本。这类 Demo 一般对版本不敏感,Node 16 以上都能跑。进入项目根目录后按下面顺序操作:
# 安装依赖,主要是 ws 这个 WebSocket 库 npm install # 启动服务端,默认监听 3000 端口 node server/index.js # 另开一个终端,起一个静态文件服务给 client 用 npx serve client服务端起来后,浏览器打开静态服务给出的地址,比如http://localhost:5000。开两个标签页,分别用方向键控制,能看到两个页面里的方块各自移动、并且对方的位置也在同步变化,就说明整条链路通了。如果只有一个页面能动、另一个没反应,先看服务端控制台有没有打印连接建立和消息广播的日志,再看浏览器开发者工具的 Network 面板里 WebSocket 帧有没有正常收发。
提示:静态服务和 WebSocket 服务端口不同,浏览器会有跨域限制。Demo 里常见做法是服务端在握手阶段不校验 Origin,或者静态服务加一个代理把
/ws转发到 3000 端口。跑不通时优先查这一层,而不是怀疑游戏逻辑。
3. 核心模块拆解:一条消息从输入到广播的完整链路
3.1 服务端连接管理:每个 socket 都是一个玩家
服务端入口要做的事很明确:创建 WebSocket 服务、给每个新连接分配身份、把消息路由到对应处理函数。下面这段是这类 Demo 里最典型的结构:
// server/index.js const WebSocket = require('ws'); const { MSG } = require('../shared/protocol'); const roomManager = require('./roomManager'); const wss = new WebSocket.Server({ port: 3000 }); wss.on('connection', (ws) => { // 给每个连接挂一个玩家上下文,后续消息都从这里取 ws.player = { id: null, roomId: null, x: 0, y: 0 }; ws.on('message', (raw) => { let msg; try { msg = JSON.parse(raw); } catch (e) { return ws.send(JSON.stringify({ type: MSG.ERROR, reason: 'bad_json' })); } switch (msg.type) { case MSG.JOIN: roomManager.join(ws, msg.roomId); break; case MSG.INPUT: roomManager.handleInput(ws, msg.payload); break; case MSG.LEAVE: roomManager.leave(ws); break; default: ws.send(JSON.stringify({ type: MSG.ERROR, reason: 'unknown_type' })); } }); ws.on('close', () => roomManager.leave(ws)); });这段代码的关键点有三个。第一,ws.player是挂在连接对象上的上下文,玩家 id、房间 id、坐标都放这里,避免用全局 Map 再维护一份映射,减少状态不同步的可能。第二,消息解析用try/catch包住,坏消息直接回错误而不是让进程崩掉,这是联机服务的基本素养。第三,close事件里一定要调leave,否则玩家断线后房间里的幽灵对象会一直存在,别人看到的是一个不动的方块,排查起来很费劲。
参数上,port改成你环境里空闲的端口即可;MSG常量来自共享协议文件,新增消息类型时两边同步加,不要用魔法字符串。handleInput收到的是玩家输入,服务端不应该直接信任客户端传来的坐标,常见做法是只接收“方向 + 时间戳”,由服务端根据速度和 tick 间隔算出新位置,这样能挡住一部分改坐标的作弊。
3.2 房间管理:加入、广播与清理
房间是这类游戏 Demo 的核心抽象。一个房间持有若干玩家连接,负责把某个玩家的状态变化广播给同房间的其他人。下面是一个精简版实现:
// server/roomManager.js const { MSG } = require('../shared/protocol'); const rooms = new Map(); // roomId -> Set<ws> function join(ws, roomId) { if (!rooms.has(roomId)) rooms.set(roomId, new Set()); const room = rooms.get(roomId); ws.player.id = `${roomId}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; ws.player.roomId = roomId; room.add(ws); // 告诉新玩家当前房间已有成员,便于客户端初始化 ws.send(JSON.stringify({ type: MSG.JOINED, selfId: ws.player.id, peers: [...room].filter(c => c !== ws).map(c => c.player) })); broadcast(roomId, { type: MSG.PEER_JOIN, player: ws.player }, ws); } function broadcast(roomId, msg, except) { const room = rooms.get(roomId); if (!room) return; const data = JSON.stringify(msg); for (const client of room) { if (client !== except && client.readyState === 1) { client.send(data); } } } function leave(ws) { const roomId = ws.player.roomId; if (!roomId || !rooms.has(roomId)) return; const room = rooms.get(roomId); room.delete(ws); broadcast(roomId, { type: MSG.PEER_LEAVE, id: ws.player.id }); if (room.size === 0) rooms.delete(roomId); // 空房间及时回收 } module.exports = { join, leave, broadcast };rooms用Map而不是普通对象,是因为房间 id 可能由客户端传入,用对象会有原型链污染的风险,Map的键更干净。broadcast里判断readyState === 1对应 WebSocket 的 OPEN 状态,避免往已关闭的连接写数据抛异常。leave里空房间删除这一步很多人会漏,Demo 跑一会儿内存里堆一堆空 Set,虽然量小不至于出事,但养成习惯对后面做匹配系统有好处。
JOINED消息里带peers是为了让新加入的客户端一次性拿到房间里其他人的初始状态,否则要等下一次广播才能看到别人,体验上会有一个短暂的空窗。PEER_JOIN则通知老玩家有新成员进来,客户端据此创建对应的渲染对象。
3.3 客户端通信封装:连接、重连与消息分发
客户端这边最容易写乱。把 socket 操作直接塞进游戏循环,后面加断线重连会非常痛苦。比较稳妥的做法是单独一个net.js:
// client/net.js const { MSG } = require('../shared/protocol'); class Net { constructor(url) { this.url = url; this.handlers = new Map(); this.ws = null; this.reconnectDelay = 1000; } connect() { this.ws = new WebSocket(this.url); this.ws.onopen = () => { this.reconnectDelay = 1000; // 连上后重置退避 this.emit('open'); }; this.ws.onmessage = (evt) => { const msg = JSON.parse(evt.data); this.emit(msg.type, msg); }; this.ws.onclose = () => { this.emit('close'); // 指数退避重连,避免服务端刚重启就被打满 setTimeout(() => this.connect(), this.reconnectDelay); this.reconnectDelay = Math.min(this.reconnectDelay * 2, 10000); }; } on(type, fn) { if (!this.handlers.has(type)) this.handlers.set(type, []); this.handlers.get(type).push(fn); } emit(type, payload) { (this.handlers.get(type) || []).forEach(fn => fn(payload)); } send(type, payload) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type, payload })); } } } module.exports = new Net('ws://localhost:3000');handlers用 Map 存事件回调,游戏逻辑通过net.on(MSG.PEER_MOVE, fn)订阅,通信层不关心谁在监听。重连用指数退避,第一次 1 秒、第二次 2 秒,封顶 10 秒,这个策略在本地开发时可能感觉不到,但部署到真实网络后能明显减少服务端抖动时的连接风暴。send前判断readyState,避免连接还没建立就发消息导致静默丢失。
注意:重连成功后服务端会把它当成一个新连接,原来的玩家 id 和房间信息都丢了。Demo 阶段可以接受重新加入房间,正式项目里需要在
JOIN消息里带一个客户端生成的会话标识,服务端据此恢复状态。这个点后面改造章节会展开。
3.4 游戏循环与状态同步:tick 驱动而不是事件驱动
实时游戏的状态推进通常靠固定间隔的 tick,而不是每次输入就立刻广播。服务端按固定频率(比如每秒 20 次)推进世界状态,然后把变化广播出去:
// server/gameLoop.js const { MSG } = require('../shared/protocol'); const roomManager = require('./roomManager'); const TICK_MS = 50; // 20 次/秒 setInterval(() => { for (const [roomId, room] of roomManager.rooms) { const snapshot = []; for (const client of room) { // 根据输入方向推进位置,服务端是唯一权威 client.player.x += client.player.vx || 0; client.player.y += client.player.vy || 0; snapshot.push({ id: client.player.id, x: client.player.x, y: client.player.y }); } roomManager.broadcast(roomId, { type: MSG.SNAPSHOT, players: snapshot }); } }, TICK_MS);TICK_MS决定同步频率,50 毫秒对应 20Hz,对这类方块移动的 Demo 足够流畅,再高会明显增加带宽和 CPU。vx、vy是玩家输入时设置的速度分量,服务端每 tick 累加到坐标上,客户端只负责把收到的快照画出来。这种“服务端权威 + 客户端表现”的模型是联机游戏的标准做法,客户端不做预测也能跑,只是操作手感会有一点延迟,后面可以加本地预测来优化。
广播整份快照而不是增量,在玩家数量少的时候最简单也最不容易出错。等房间人数上去、带宽成为瓶颈,再考虑只广播变化的玩家或者做兴趣管理。Demo 阶段不要过早优化,先把链路跑通。
4. 避坑与排查:联调时最容易翻车的几个点
4.1 现象:两个页面都连上了,但互相看不到对方
原因通常是房间 id 不一致。客户端加入时如果各自生成随机房间号,两个人根本不在同一个房间,自然收不到彼此的广播。解决方法是让房间 id 由 URL 参数或输入框统一指定,比如?room=test1,两边用同一个值。排查时在服务端join里打印roomId和当前房间人数,一眼就能看出来。
4.2 现象:消息发出去了,服务端没反应
先确认发的是不是合法 JSON。客户端如果直接send一个对象而没有JSON.stringify,浏览器会把它转成[object Object],服务端JSON.parse直接抛错。另一个常见原因是消息类型字符串拼错,比如服务端期望input、客户端发的是INPUT,大小写不一致。把类型常量集中到shared/protocol.js就是为了消灭这类问题,改的时候只改一处。
4.3 现象:玩家断线后,房间里还留着一个不动的方块
这是close事件里没做清理,或者清理了但没广播PEER_LEAVE。检查ws.on('close')是否调用了leave,以及leave里是否向剩余玩家广播了离开消息。还有一种隐蔽情况:客户端刷新页面时旧连接可能还没触发close,新连接已经进来了,短时间内同一个玩家出现两个对象。可以在join时按会话标识去重,或者接受短暂重复、等旧连接超时后自然清理。
4.4 现象:本地能跑,换台机器就连不上
多半是地址写死成了localhost或127.0.0.1。客户端new WebSocket('ws://localhost:3000')在别的机器上打开时,localhost 指向的是那台机器自己,当然连不上。改成服务端的局域网 IP,或者用相对路径配合反向代理。另外检查防火墙有没有放行 3000 端口,这个坑在跨机器联调时非常常见。
4.5 现象:玩一会儿浏览器标签页卡死
通常是消息频率太高或者渲染层每帧都在重建对象。服务端 tick 设得太密、广播数据量又大,客户端onmessage里做重活,主线程被占满。先把TICK_MS调大观察是否缓解,再把渲染改成复用对象、只更新坐标,不要每次快照都new一堆东西。用浏览器 Performance 面板录一段,看是网络回调耗时还是绘制耗时,定位清楚再改。
5. 二次开发:从 Demo 到能用的联机骨架
拿到这份 Demo 之后,最值得先动的是断线恢复,因为它直接决定这套骨架能不能往真实项目上靠。前面提过,重连后服务端会把玩家当新人,房间和状态全丢。改造思路是在客户端首次连接时生成一个sessionId存在sessionStorage里,JOIN消息带上它;服务端维护sessionId -> player的映射,重连时如果命中就把新连接挂回原来的玩家上下文,并回发当前房间快照。这样刷新页面或短暂断网后,玩家还能回到原来的位置,而不是从零开始。
// 服务端 join 里增加会话恢复逻辑(示意) function join(ws, roomId, sessionId) { const existing = sessionStore.get(sessionId); if (existing && existing.roomId === roomId) { // 旧连接如果还开着,先踢掉,避免双开 if (existing.ws.readyState === 1) existing.ws.close(); existing.ws = ws; ws.player = existing; ws.send(JSON.stringify({ type: MSG.RESUMED, player: existing })); return; } // 走正常的新玩家流程,并写入 sessionStore }第二个改造点是输入预测。现在客户端要等服务端快照回来才更新位置,网络稍差就能感觉到操作“粘手”。常见做法是客户端收到输入后立刻本地移动,同时记录一个待确认队列,收到服务端快照时对比自己预测的位置和服务端位置,偏差超过阈值就平滑纠正回来。这个机制不需要改服务端协议,纯客户端就能加,是性价比很高的一步。
第三个方向是房间匹配。现在房间 id 靠手动指定,可以加一个简单的匹配队列:玩家点“开始”后进入等待池,凑够两人就分配房间并通知双方。服务端用一个数组存等待中的连接,每次有新人进来就检查长度,够数就shift出两个建房间。逻辑不复杂,但要注意等待中的连接断线时从池子里移除,否则会匹配到一个已经关掉的 socket。
| 改造点 | 改动位置 | 难度 | 收益 |
|---|---|---|---|
| 断线恢复 | 服务端 join + 客户端 sessionId | 中 | 高,决定能否用于真实场景 |
| 输入预测 | 仅客户端 game.js | 低 | 中,明显改善手感 |
| 房间匹配 | 服务端新增 matchmaker | 低 | 中,去掉手动指定房间 |
| 状态压缩 | 服务端广播格式 | 中 | 低,人数多了才需要 |
我自己的习惯是每加一个改造点,先用两个标签页加一个手动断网开关验证一遍,确认断线、重连、状态恢复三条路径都走通再往下做。有一次跳过这步直接加匹配,结果等待池里混进了已断开的连接,匹配成功后一方永远收不到开始消息,查了半天才发现是清理逻辑漏了。从那以后我每次动连接生命周期相关的代码,都强制把断线场景先跑一遍。希望这份拆解帮到你,把 Demo 跑起来之后,先从断线恢复下手,这套骨架就能往能用的方向走一大步。
本文还有配套的精品资源,点击获取