简介:这份压缩包围绕基于 WebSocket 的在线游戏开发 Demo 展开,面向希望理解实时双向通信、玩家状态同步与服务器推送机制的前后端开发者,也适合作为课程设计或小型联机项目的参考案例。包内共 488 个文件,以 JavaScript 客户端逻辑与 Go 服务端代码为主,辅以 jpg、png、ico 图形素材、html 页面及少量 md 说明和 zip 依赖包,整体约 2.96MB,目录便于按客户端、服务端与静态资源分层查阅。目前已有 94 人学习,说明其作为入门级实战样例具有一定参考价值。内容涉及 WebSocket 握手、数据帧传输、JSON 数据交换、多用户同步、断线重连与 WSS 安全等要点,读者可据此梳理实时游戏通信链路,理解连接管理、消息广播和基础游戏逻辑同步的实现方式,并借鉴其中的排错与优化思路。
1. 从“位置慢半拍”说起
有位读者拿着一个压缩包问我:双人联机小游戏用定时器每 200 毫秒拉一次接口同步坐标,结果两个人明明在同一条局域网里,画面里对手却总是慢半拍,一跑起来就像在瞬移。这个「基于 websocket 的在线游戏开发 Demo」处理的正是这类问题——它把实时对战里最基础的三件事拆成一套能直接跑起来的最小骨架:长连接的生命周期管理、房间与玩家匹配、状态广播与客户端校正。前端是一张 Canvas 画布加键盘输入,后端是一个常驻进程,两端用 WebSocket 全双工通信,服务端以 30Hz 的固定步长推进世界状态并广播快照。它不是游戏引擎,也不带美术资源,但你要做一个自己的实时对战 Demo,拿它当底座比从零写握手、心跳、重连、房间回收要省太多时间。适合已经会写 CRUD、想第一次把“实时同步”这件事跑通的后端和全栈开发者。
2. 技术选型与工程结构:从目录树看清这个 Demo 的分层
在动手改任何一行代码之前,先搞清楚它为什么用 WebSocket、分了哪几层、每个文件管什么事。实时对战项目的翻车,八成不是逻辑写错,而是分层没想清楚,把网络、状态、渲染揉在一个文件里,后面加一个功能就要动全身。
2.1 为什么是 WebSocket,而不是轮询或 SSE
先把几种方案摆在一起对比,选型的理由才站得住。这个 Demo 的场景是“两个人高频地互相看见对方在动”,特点是双向、高频、小包、低延迟。短轮询的问题不是慢,是浪费——不管状态有没有变化,客户端都要按间隔发一次完整 HTTP 请求,每次都带一整套请求头,服务端还得重新做一次路由和鉴权。长轮询稍微好一点,但每次推送完都要重建连接,延迟取决于服务端挂起超时,抖动很大。
| 方案 | 服务端主动推送 | 单次开销 | 双向 | 适合的场景 |
|---|---|---|---|---|
| 短轮询 | 不支持 | 完整 HTTP 头 + 新建连接 | 否 | 秒级状态刷新 |
| 长轮询 | 半支持 | 完整 HTTP 头 + 反复重建 | 否 | 兼容老旧网关 |
| SSE | 支持 | 一次 HTTP 头 | 否(只能下行) | 单向通知、日志流 |
| WebSocket | 支持 | 2~6 字节帧头 | 是 | 实时对战、协作编辑 |
表格里最后一行就是选它的理由。握手阶段复用了一次 HTTP Upgrade,之后每帧只多几字节帧头,服务端可以随时主动推。代价是连接变成有状态的资源,必须自己管心跳、管超时、管断线重连——这也是后面几章的主要内容。
2.2 三层结构:渲染层、网关层、房间逻辑层
整个 Demo 按职责切成三层,层与层之间只通过消息对象通信,不互相直接调用。
客户端一侧分渲染层和网络层:网络层只负责建连、收发、重连和心跳应答,拿到消息后通过事件抛出去;渲染层订阅消息,把快照插值画到 Canvas 上,同时把键盘输入按固定频率上行。两层之间没有共享的可变状态,唯一的耦合点是协议里的消息类型。
服务端一侧分网关会话层和房间逻辑层:网关层为每条连接建一个 session 对象,管玩家 id、所在房间、最后心跳时间;房间逻辑层管世界状态,包括玩家的坐标、速度、输入队列,以及那个 30Hz 的 tick 循环。房间层完全不知道 WebSocket 的存在,它的step()只吃一个dt,往外吐快照——这一点很关键,意味着你可以把同样的房间逻辑搬到单元测试里跑,不用起服务器。
还有一个shared/protocol.js被前后端共用,消息类型常量写在一处,改协议不会出现一边改了一边忘了的情况。
2.3 目录结构逐个文件说明
这套结构不复杂,但每个文件都有明确边界,二次开发时基本能按“我要改什么”直接定位到文件。
websocket-game-demo/ ├── client/ │ ├── index.html # 画布 + 一个房间号输入框 │ ├── main.js # 装配:建连 -> 绑输入 -> 起渲染循环 │ ├── net/ │ │ ├── socket.js # 连接封装、事件分发、指数退避重连 │ │ └── protocol.js # 客户端侧的编解码与校验 │ ├── game/ │ │ ├── input.js # 键盘采集 + 30Hz 固定上行 │ │ ├── predict.js # 本地预测与服务器快照校正 │ │ └── render.js # 快照插值、双缓冲绘制 │ └── style.css ├── server/ │ ├── index.js # HTTP 静态服务 + WebSocket 升级 │ ├── config.js # 端口、tick 频率、房间上限 │ ├── gateway/ │ │ ├── session.js # 连接级状态:玩家 id、房间、心跳时间 │ │ └── heartbeat.js # 定时 ping,超时踢人 │ └── room/ │ ├── room.js # 房间实体:玩家表、输入队列、step │ ├── roomManager.js # 房间注册表:加入、退出、空房回收 │ └── tick.js # 固定步长循环,带时间累加 ├── shared/ │ └── protocol.js # 前后端共用的消息类型常量 ├── scripts/ │ └── bot.js # 无头压测客户端 └── package.jsonconfig.js里的几个值是这个 Demo 最常被改的入口,先把它们拿出来看:
// server/config.js module.exports = { port: Number(process.env.PORT) || 8080, // 服务端推进世界的频率。30 是不卡又不至于把带宽打穿的折中值 tickHz: 30, // 广播频率通常低于或等于 tick,这里设成 15 表示每 2 个 tick 发一次快照 snapshotHz: 15, // 单房间最大人数,Demo 里只做双人,留点余量方便扩展 maxPlayersPerRoom: 4, // 超过这个时间没收到 pong 就认定连接已死 heartbeatTimeoutMs: 15000, // 空房间保留时间,避免玩家秒退秒进时反复创建房间 emptyRoomTtlMs: 10000, };tickHz和snapshotHz分离是刻意设计的:世界推进要够细,位置积分才不会跳;但快照不需要发那么勤,客户端有插值可以补。这两个值分开调,是这个 Demo 后面调手感的两个旋钮。
2.4 十分钟跑起来:安装、启动、双窗口验证
依赖很轻,只有ws一个运行时库,其余都是 Node 内置模块。Node 版本建议 18 以上,因为内置 fetch 和 WebSocket 客户端在调试时更好用。
# 1. 检查 Node 版本,低于 18 会有 crypto 和 stream 的兼容问题 node -v # 2. 安装依赖(只有 ws 一个) npm install # 3. 启动,默认监听 8080 npm run dev启动成功后终端会打出监听地址。浏览器开两个窗口访问同一个地址,在两个窗口里输入相同的房间号再点加入,就能看到对方那个方块。这一步只验证了连通性,真正的同步效果要等两边都动起来才看得出来:用 WASD 在一个窗口里移动,另一个窗口里的方块应该跟得比较顺,允许有几十毫秒的滞后,但不应该有顿挫感。
如果两个窗口都只看到自己,先翻服务端日志有没有join记录,再看浏览器 Network 面板里/ws那条连接是不是 101。这一步排查的细节放到第 5 章展开。
3. 服务端拆解:一个玩家从握手到进入房间的完整链路
服务端是整个同步逻辑的权威方,客户端发上来的只是“意图”,位置由服务端算。这一章按一条连接的完整生命周期走:握手建连、会话绑定、加入房间、收输入、推进 tick、广播快照、清理回收。
3.1 握手与连接生命周期管理
入口文件要解决的问题很朴素:同一个端口既能把index.html发出去,又能把/ws升级成 WebSocket,避免线上再开一个端口、多配一条反向代理规则。
// server/index.js const http = require('http'); const path = require('path'); const fs = require('fs'); const { WebSocketServer } = require('ws'); const config = require('./config'); const { RoomManager } = require('./room/roomManager'); const { attachSession } = require('./gateway/session'); const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' }; // 1. 静态资源走普通 HTTP,Demo 不引框架,读文件即可 const server = http.createServer((req, res) => { const rel = req.url === '/' ? '/index.html' : req.url.split('?')[0]; const filePath = path.join(__dirname, '../client', rel); fs.readFile(filePath, (err, data) => { if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('not found'); } res.writeHead(200, { 'Content-Type': MIME[path.extname(filePath)] || 'application/octet-stream' }); res.end(data); }); }); // 2. 复用同一个 server 做 Upgrade,端口只占一个 const wss = new WebSocketServer({ server, path: '/ws' }); const manager = new RoomManager(config); wss.on('connection', (ws, req) => { // 3. 每条连接一个 session,session 持有玩家身份和房间引用 const session = attachSession(ws, req, manager); ws.session = session; ws.on('message', (raw) => session.onMessage(raw)); ws.on('close', () => session.destroy()); ws.on('error', (err) => console.error('[ws error]', session.id, err.message)); }); server.listen(config.port, '0.0.0.0', () => { console.log(`listening on http://localhost:${config.port}`); });这段代码有三个地方值得注意。第一,path: '/ws'是硬约束,其他路径的 Upgrade 请求会被ws库直接拒绝,这本身就是一层过滤。第二,session挂在ws实例上(ws.session = session),心跳定时器遍历wss.clients时才能拿到每个连接的业务状态。第三,listen的第二个参数写0.0.0.0而不是默认值,否则同局域网的另一台设备连不上——这个坑第 5 章还会提。
3.2 房间与匹配:玩家状态机与座位分配
房间这一层不碰网络,纯粹是数据结构和规则。房间管理器维护一张roomId -> Room的表,负责加入、退出和空房回收。
// server/room/roomManager.js class Room { constructor(id) { this.id = id; this.tick = 0; this.players = new Map(); // playerId -> { x, y, vx, vy, inputs: [] } this.emptyAt = 0; } add(session) { const spawnX = 120 + this.players.size * 160; this.players.set(session.id, { x: spawnX, y: 300, vx: 0, vy: 0, inputs: [] }); } remove(playerId) { this.players.delete(playerId); if (this.players.size === 0) this.emptyAt = Date.now(); } pushInput(playerId, msg) { const p = this.players.get(playerId); if (!p || typeof msg.seq !== 'number') return; // 只留最近 5 帧,防止客户端断线重连后一次性补发把队列撑爆 p.inputs.push({ seq: msg.seq, keys: msg.keys }); if (p.inputs.length > 5) p.inputs.shift(); } } class RoomManager { constructor(config) { this.config = config; this.rooms = new Map(); } join(roomId, session) { let room = this.rooms.get(roomId); if (!room) { room = new Room(roomId); this.rooms.set(roomId, room); } if (room.players.size >= this.config.maxPlayersPerRoom) { throw new Error('room full'); } room.add(session); return room; } leave(roomId, playerId) { const room = this.rooms.get(roomId); if (!room) return; room.remove(playerId); } // 每个 tick 结束扫一遍,把超时的空房删掉,否则内存会一直涨 sweep(now) { for (const [id, room] of this.rooms) { if (room.players.size === 0 && now - room.emptyAt > this.config.emptyRoomTtlMs) { this.rooms.delete(id); } } } }join里房间满了直接抛错,由 session 层接住并转成err消息下发,这样逻辑层不需要知道 WebSocket 消息长什么样。emptyRoomTtlMs的存在是为了避免“秒退秒进”时房间被反复创建销毁,但回收一定要做,否则房间表就是个只增不减的 Map,跑一晚上内存曲线会很难看。
3.3 协议设计:opcode、心跳与序号
消息用 JSON 编码,牺牲一点带宽换可读性。对 Demo 来说这个取舍是值的——用浏览器的 Network 面板直接能看明文帧,调试成本低一大截。真要上生产再换 ArrayBuffer 手工打包也不迟,协议层留了统一的编解码入口。
// shared/protocol.js const OP = { JOIN: 'join', // C -> S 加入房间 { room } JOINED: 'joined', // S -> C 分配身份 { playerId, roomId, tick } INPUT: 'input', // C -> S 输入帧 { seq, keys } SNAPSHOT: 'snap', // S -> C 快照 { tick, ack, players } PING: 'ping', // S -> C 心跳 { ts } PONG: 'pong', // C -> S 心跳应答 { ts } ERROR: 'err', // S -> C 错误 { code, message } }; module.exports = { OP };seq和ack是这套协议里最关键的两个字段。客户端每发一帧输入就自增seq;服务端处理完某一帧后,把已处理的最大seq通过快照的ack带回去。客户端收到后就能知道自己哪些输入已经被确认、哪些还在路上,这正是下一章预测校正的基础。少了这两个字段,客户端只能全量猜测,画面就会抖。
心跳定时器的写法有个细节:
// server/gateway/heartbeat.js const { OP } = require('../../shared/protocol'); function startHeartbeat(wss, intervalMs = 5000, timeoutMs = 15000) { return setInterval(() => { const now = Date.now(); for (const ws of wss.clients) { const s = ws.session; if (!s) continue; if (now - s.lastPong > timeoutMs) { // 半开连接上 close 会一直等对端回应,这里直接 terminate ws.terminate(); continue; } ws.send(JSON.stringify({ t: OP.PING, ts: now })); } }, intervalMs); } module.exports = { startHeartbeat };用terminate()而不是close()是血泪经验:网络断在中间的那种半开连接,close()会一直等对端回 FIN,wss.clients里就永远挂着一个死连接,时间长了连接数越堆越高。terminate()是直接销毁 socket,代价是会丢未发完的数据,但心跳超时的连接本来也不该再要了。
3.4 tick 循环与状态广播
世界推进用固定步长,不用requestAnimationFrame那种随缘节奏,也不依赖setInterval的真实精度。原因是setInterval在容器和后台标签页里会被节流,直接拿它的间隔当dt会让位置积分忽快忽慢。
// server/room/tick.js class TickLoop { constructor(stepMs, onTick) { this.stepMs = stepMs; this.onTick = onTick; this.acc = 0; this.last = 0; this.timer = null; } start() { this.last = Date.now(); // 用一半的周期去唤醒,真实步长靠累加器补齐,吃掉定时器抖动 this.timer = setInterval(() => { const now = Date.now(); this.acc += now - this.last; this.last = now; let guard = 0; while (this.acc >= this.stepMs && guard++ < 5) { this.onTick(this.stepMs); this.acc -= this.stepMs; } }, this.stepMs / 2); } stop() { clearInterval(this.timer); this.timer = null; } }累加器加guard上限,是为了防止进程被挂起很久之后一次补上几百帧,把 CPU 打满。补 5 帧之后直接丢弃剩余累积时间,宁可让世界“跳一下”,也别让服务器卡死。
房间每推进一个 tick,就把上一 tick 收集到的输入消费掉,做一次积分,然后按snapshotHz决定这一帧发不发:
// server/room/broadcast.js(节选) function stepRoom(room, stepMs, manager) { room.tick++; const dt = stepMs / 1000; for (const p of room.players.values()) { // 一个 tick 只消费最新一帧输入,旧的在 pushInput 里已经被截断 const input = p.inputs.pop(); if (input) { p.vx = (input.keys.right ? 1 : 0) - (input.keys.left ? 1 : 0); p.vy = (input.keys.down ? 1 : 0) - (input.keys.up ? 1 : 0); p.ack = input.seq; } const speed = 220; // 像素/秒 p.x = clamp(p.x + p.vx * speed * dt, 0, 800); p.y = clamp(p.y + p.vy * speed * dt, 0, 600); } } function broadcast(room, config) { if (room.tick % Math.round(config.tickHz / config.snapshotHz) !== 0) return; const payload = JSON.stringify({ t: 'snap', tick: room.tick, players: [...room.players].map(([id, p]) => ({ id, x: Math.round(p.x), y: Math.round(p.y), ack: p.ack || 0, })), }); for (const session of room.sessions) { if (session.ws.readyState === 1) session.ws.send(payload); } }坐标取整(Math.round)不是为了省字节,而是为了减少无意义的浮点抖动导致客户端反复重绘。广播时把每条玩家记录单独带上ack,客户端就能精确知道自己被确认到哪一帧。
4. 客户端拆解:输入上行、预测校正与断线重连
客户端要解决的问题和服务端对称:把键盘的离散操作打包成固定频率的上行帧,把收到的低频快照画成高频画面,还得在网络抖一下的时候不让玩家感觉“被传送”。
4.1 连接建立与心跳保活
连接封装的第一要求是“断了能自己回来”,而且回来的时候不能把服务器打崩。重连间隔用指数退避,连上就重置。
// client/net/socket.js export class GameSocket { constructor(url) { this.url = url; this.retry = 500; // 首次重连等 500ms this.handlers = new Map(); this.connect(); } 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)); } connect() { this.ws = new WebSocket(this.url); this.ws.onopen = () => { this.retry = 500; // 连上立刻重置退避,别让下次断线等太久 this.emit('open'); }; this.ws.onmessage = (e) => { const msg = JSON.parse(e.data); if (msg.t === 'ping') return this.send({ t: 'pong', ts: msg.ts }); this.emit(msg.t, msg); }; this.ws.onclose = () => { this.emit('close'); // 1.6 倍增长,封顶 8 秒,避免服务重启瞬间被所有客户端同时打满 setTimeout(() => this.connect(), this.retry); this.retry = Math.min(this.retry * 1.6, 8000); }; } send(msg) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(msg)); } } }退避上限 8 秒是个经验值:再短,服务器重启时上千个客户端会形成脉冲;再长,玩家会觉得“卡死了”。指数增长则保证单个客户端在网络瞬断时能很快回来。
4.2 输入采集与上行节流
键盘事件是离散且密集的,长按一个键浏览器会以系统重复率持续触发keydown。如果每触发一次就发一条消息,一次长按能打出几百帧,服务端输入队列直接被冲垮。
// client/game/input.js const keys = { up: false, down: false, left: false, right: false }; const MAP = { KeyW: 'up', KeyS: 'down', KeyA: 'left', KeyD: 'right' }; let seq = 0; export function bindInput(socket, intervalMs = 33) { window.addEventListener('keydown', (e) => { if (MAP[e.code]) { keys[MAP[e.code]] = true; e.preventDefault(); } }); window.addEventListener('keyup', (e) => { if (MAP[e.code]) keys[MAP[e.code]] = false; }); // 失焦时把所有键清零,否则切走再回来会出现「一直往右走」 window.addEventListener('blur', () => { for (const k in keys) keys[k] = false; }); // 固定 30Hz 上行,和服务器 tick 对齐,不跟键盘事件走 setInterval(() => { socket.send({ t: 'input', seq: ++seq, keys: { ...keys } }); }, intervalMs); }这里有两个必须做的动作。一是preventDefault(),WASD 在部分浏览器里会触发页面滚动或者焦点跳转。二是blur时清空按键状态——这是踩过的坑,用户按着 D 切到别的标签页,keyup永远不会触发,回来之后方块就一直向右撞墙。
上行固定 30Hz 而不是跟着键盘走,还有个好处:即使玩家一秒内只按了一次键,服务端也能持续收到“保持不动”的输入帧,ack序号才会稳定推进,不会出现长时间没有确认导致客户端积压一堆待重放输入。
4.3 客户端预测与服务器校正
不预测的话,玩家按下方向键到看见自己动,中间要等一个完整往返:上行 30ms、服务端处理一 tick、下行 30ms。手感上就是“按键粘手”。预测的思路是本地立刻按同样的规则算一遍,等服务端快照回来再对齐。
// client/game/predict.js export class Predictor { constructor(stepMs) { this.stepMs = stepMs; this.pending = []; // 已发但未被 ack 的输入 this.local = { x: 400, y: 300 }; } pushInput(input) { this.pending.push(input); this.local = step(this.local, input, this.stepMs); } // 收到快照:以服务器坐标为准,把未确认的输入重放一遍 reconcile(snapshot, myId) { const server = snapshot.players.find((p) => p.id === myId); if (!server) return; this.pending = this.pending.filter((i) => i.seq > (server.ack || 0)); let state = { x: server.x, y: server.y }; for (const input of this.pending) { state = step(state, input, this.stepMs); } // 偏差超过阈值才硬切,小偏差交给插值平滑,避免画面抖 const dx = state.x - this.local.x; const dy = state.y - this.local.y; if (Math.hypot(dx, dy) > 24) this.local = state; } } // 和服务端 stepRoom 必须用同一套积分规则,否则越预测越偏 function step(state, input, stepMs) { const speed = 220; const dt = stepMs / 1000; const vx = (input.keys.right ? 1 : 0) - (input.keys.left ? 1 : 0); const vy = (input.keys.down ? 1 : 0) - (input.keys.up ? 1 : 0); return { x: Math.min(800, Math.max(0, state.x + vx * speed * dt)), y: Math.min(600, Math.max(0, state.y + vy * speed * dt)), }; }这段代码的核心是那个“偏差超过 24 像素才硬切”的阈值。如果每帧都无条件把本地坐标覆盖成服务器坐标,玩家会看到自己持续地被“拽一下”,也就是常说的橡皮筋效应。小偏差留给渲染层做插值过渡,观感上才像是“滑过去”而不是“跳过去”。另外step()的积分公式必须和服务端stepRoom()一字不差,速度值、边界值任何一处不同,都会让预测结果持续偏移,最后仍然要靠硬切,预测等于白做。
4.4 渲染插值:把 15Hz 的快照画成流畅画面
服务端 15Hz 广播,屏幕 60Hz 刷新,中间差着 4 倍。直接拿最新快照画,人物就是一格一格挪。做法是维护两个快照(上一帧和当前帧),用一个 0 到 1 之间的插值因子alpha做线性过渡,alpha随每帧时间推进,超过 1 就切换到下一组。
// client/game/render.js(节选) let prev = null, curr = null, alpha = 0; function onSnapshot(snap) { prev = curr; curr = snap; alpha = 0; } function frame(dt) { if (!prev || !curr) return; // 以快照间隔为分母推进 alpha,让插值和真实时间对齐 alpha = Math.min(1, alpha + dt / (1000 / 15)); const t = alpha * alpha * (3 - 2 * alpha); // smoothstep,起止更顺 for (const p of curr.players) { const old = prev.players.find((q) => q.id === p.id); if (!old) continue; drawBox(old.x + (p.x - old.x) * t, old.y + (p.y - old.y) * t); } }插值带来的代价是画面永远落后一个快照间隔(约 66ms),但对战游戏里这点延迟换来的流畅度是完全划算的。真正不能忍的是抖动,而不是固定的小滞后。
5. 避坑与排查:联调阶段最容易翻车的 5 个点
这一章的五条都是实际跑起来之后才暴露的,每一条都按“看到什么现象、为什么、怎么改”来说。前面章节讲的是怎么设计,这一章讲的是设计之外还会遇到什么。
5.1 握手成功之后立刻被断开
现象:浏览器 Network 面板里/ws显示 101 Switching Protocols,状态是成功,但紧接着连接变成 closed,前后不到一秒,服务端没有任何业务日志。
原因:ws库默认会对升级请求的Origin做校验,如果页面的来源和监听地址对不上,握手会在升级完成后被服务端主动关闭。另一种情况是中间隔了一层反向代理,代理没有透传Upgrade和Connection头,或者自身的读超时设得比心跳间隔还短,连接被代理先掐了。
解决:先确认是不是代理问题——本地直连能不能稳定保持,如果能,就是代理配置的事,需要显式开启 Upgrade 透传,并把代理空闲超时调到大于心跳间隔的两倍。如果本地也会被断,就在WebSocketServer的构造函数里显式配置verifyClient,把允许的来源列出来,别图省事直接返回true放行所有来源。另外把服务端on('close')里的code和reason打出来,1006 通常意味着底层连接被异常切断,1008 是策略拒绝,这两个 code 能直接把排查方向减半。
5.2 两个人画面里的位置对不上
现象:A 在自己屏幕上已经跑到地图右侧了,B 那边看到的 A 还停在中间偏左,而且不是稳定滞后,是时远时近。
原因:九成是 tick 频率和广播频率没对齐导致的重复消费。如果stepRoom每个 tick 都从输入队列取一帧,而客户端上行频率又和服务端 tick 不成整数比,就会出现某些 tick 取到两帧、某些 tick 一帧都没取到,位置积分自然忽快忽慢。另一种可能是Math.round用在了错误的地方——服务端把坐标取整后再发给客户端,客户端却拿未取整的本地预测值去比较,误差永远消不掉。
解决:让客户端上行频率和服务端 tick 保持 1:1 或者整数倍关系,Demo 里两边都是 30Hz。输入队列只在stepRoom里消费,而且一个 tick 只消费一帧,多出来的直接丢弃——丢掉的输入本来就过期了,重放它对位置没有帮助,只会让状态更乱。坐标统一在广播出口取整,客户端内部计算全用浮点,比较的时候用容差而不是相等。
5.3 断线重连之后房间里出现两个自己
现象:网络抖了一下,客户端自动重连成功,但房间里出现两个同名的方块,一个能动一个不动,或者在两个位置之间跳。
原因:重连时客户端带着新的连接对象重新发join,但服务端还留着旧连接对应的 session 和房间成员记录。旧连接可能还没被心跳判定超时,players表里就同时存在两个记录,快照里自然就有两个人。如果客户端每次都生成新的玩家 id,问题会更明显。
解决:做两件事。第一,客户端在重连成功后,如果本地存着上次的玩家 id,就在join消息里带上它,服务端优先复用这个身份,把旧 session 从房间里摘掉再绑定新的。第二,服务端在join处理逻辑里加一道「同 id 先退出」的保护,就是第 3 章那段if (this.roomId) manager.leave(...),它能把客户端没有正确清理的情况兜住。身份复用做得好,玩家重连回来位置还在原地,体验上几乎无感。
5.4 跑一晚上内存曲线一路上涨
现象:本地开着两个窗口挂了一宿,第二天看进程内存从几十兆涨到几百兆,rooms表的长度只增不减。
原因:房间回收没生效。RoomManager.sweep()如果只在某个定时器里调用,而那个定时器又在房间创建时才启动,就会出现「房间建了但没人扫」的情况。还有一种更隐蔽的:room.sessions数组里存了 session 的强引用,玩家退出时只从players表删了,sessions数组没同步清理,被删掉的 session 连同它的 ws 一起泄漏。
解决:把sweep()挂到全局唯一的定时器上,和心跳放同一个循环,每 5 秒扫一次,别依赖房间自己的生命周期。清理时两个数据结构一起删,players和sessions保持同步。另外在ws.on('close')里打印一下当前wss.clients.size和rooms.size,这两个数字能长期稳定在预期范围内,才说明回收真的生效了。
5.5 电脑上能连,手机上打不开
现象:同一局域网里,笔记本访问localhost:8080一切正常,拿手机输入笔记本的 IP 地址,页面能开但游戏连不上,或者页面干脆打不开。
原因:server.listen(port)不带第二参数时默认绑定在::或127.0.0.1,具体取决于 Node 版本和系统配置,某些环境下只监听回环地址,外部设备根本连不进来。另外浏览器对非 HTTPS 页面调用某些 API 有限制,虽然原生 WebSocket 在 HTTP 页面下可以用,但如果页面里用了别的需要安全上下文的接口,就会在移动端直接失败。
解决:显式写server.listen(port, '0.0.0.0'),把监听地址摊开。同时确认系统防火墙放行了这个端口。做移动端联调时,客户端里的 WebSocket 地址不要硬编码localhost,用location.host拼接,页面从哪个地址打开的就连哪个地址,省得来回改配置。地址这块处理干净,跨设备联调能省掉大量“是不是代码问题”的无效怀疑。
6. 二次开发:换掉规则层,别动协议层
这个 Demo 的价值不在它现在能玩什么,而在它把「实时同步」这套骨架搭好了。改的时候有个原则:协议层和连接层尽量别动,动的是规则层。
| 改动目标 | 需要动的文件 | 不能忘的配套动作 |
|---|---|---|
| 换游戏规则(碰撞、得分、胜负) | server/room/room.js的step | 客户端predict.js的step必须同步改 |
| 加房间配置(地图、速度) | server/config.js+JOINED消息 | 客户端在joined回调里存下来 |
| 调手感(tick、广播频率) | config.js的tickHz/snapshotHz | 客户端插值分母跟着改,否则会抖 |
| 换编码(JSON 到二进制) | shared/protocol.js编解码 | 两端同时发版,老客户端要兼容一版 |
第一件要做的事是把step的规则抽出来。现在它写在两个地方,服务端一份客户端一份,任何一次改动都得两边同步。我会把它挪到shared/step.js,前后端直接引用同一个函数——这个改动看着不起眼,但它把“两边规则不一致”这个最大的隐性 bug 来源直接掐掉了。
验证改动有没有效果,别只靠肉眼看。scripts/bot.js这个无头客户端就是干这个的:起 20 个 bot 进同一个房间,每个以 30Hz 发随机输入,跑五分钟看服务端 CPU 和内存曲线是否平稳、快照消息的收发有没有积压。想测弱网,在客户端send里随机延迟 50 到 300 毫秒再发,观察预测校正的硬切阈值是不是需要调。这套流程跑下来,比手动开两个窗口来回点要靠谱得多。
我踩过的最深一次坑,是在一个类似的项目里把积分规则只改了服务端,客户端还在用旧的,结果本地预测永远偏,硬切阈值一直触发,画面抖得像在抽帧,查了两个晚上才定位到一行速度常量。从那以后我每次改step都强制走一遍「两边 diff + bot 压测 + 弱网模拟」的流程,宁可多花二十分钟,也不想再体验那种玄学排查。这份骨架已经把困难的部分做完了,剩下的就是换成你自己的规则——希望帮到你。
本文还有配套的精品资源,点击获取