WebSocket实时对战游戏开发实战:双人联机Demo全解析

📅 发布时间:2026/10/21 8:00:26
WebSocket实时对战游戏开发实战:双人联机Demo全解析
简介一份基于WebSocket的在线游戏开发完整示例工程面向游戏前端、后端开发者以及希望掌握实时通信技术的入门学习者演示如何在Web环境中搭建低延迟、双向通信的多人游戏基础架构。资源共488个文件压缩包整体约2.96MB其中391个JavaScript脚本覆盖客户端连接管理、界面交互与游戏逻辑5个Go语言服务端文件负责处理连接建立、握手校验、用户会话维护和消息广播55个JPG、10个PNG及14个ICO提供了游戏角色场景、界面插图与程序图标另有2个HTML入口页面、2个Markdown说明文档以及少量配置文件目录组织清晰便于按模块查阅。压缩包内既有Go语言编写的服务端源码也有可直接运行的Web前端页面可完整还原从WebSocket握手、数据帧收发与解析到多用户状态同步、服务器主动推送消息的完整流程并涉及断线重连、WSS安全传输等扩展思路。已有93人学习下载适合希望结合实例理解WebSocket协议细节、掌握实时游戏同步与通信优化技巧的开发者参考实践。1. 在线游戏开发Demo为什么实时对战必须用WebSocket如果你做过带实时交互的网页应用大概率踩过轮询的坑前端每隔一两秒向后端要一次数据数据没变也要白跑一趟延迟还高得离谱。我在写一个双人实时对战小游戏时深有体会——用轮询做位置同步两个角色动起来跟放幻灯片似的根本没法玩。后来换成WebSocket连接建立后服务端可以直接把状态推给所有客户端延迟从秒级降到几十毫秒效果完全不一样。这份基于WebSocket的在线游戏Demo就是把完整的实时对战链路做了出来一个Node.js写的WebSocket服务端配合浏览器原生WebSocket客户端附带一个简易游戏渲染层。适合刚入门实时通信、想搞懂WebSocket在游戏里怎么用、以及想找一份能直接跑起来改一改的工程参考的开发者。2. 技术栈与架构Node.js ws 双端全链路2.1 为什么选WebSocket而不是HTTP轮询或SSE做实时游戏最核心的需求是「服务端主动推数据」。HTTP协议是请求-响应模式服务端没法主动给客户端发消息轮询只能靠客户端频繁发请求来“假装实时”带宽浪费严重延迟还受请求间隔限制。SSE虽然支持服务端推送但它是单向的客户端要传操作给服务端还是得额外走HTTP请求。WebSocket最大的差别在于全双工——一条TCP连接上两端可以随时互发数据连接建立后没有HTTP头部的重复开销消息帧很轻特别适合游戏里高频的位置同步和操作指令传输。这个Demo用到的WebSocket实现是Node.js生态里最常用的ws库不是Socket.IO那种带自动重连和房间机制的封装而是更底层的裸WebSocket协议实现。选它有几个原因一是API设计贴近浏览器原生WebSocket服务端代码和客户端逻辑能一一对应理解起来不费力二是没有多余的魔法所有连接管理、消息收发都能看到原始形态对学习协议本身帮助更大三是ws库的性能在纯Node实现里算相当能打的日常Demo和中小型游戏场景完全够用。2.2 技术栈选型与目录结构整个Demo前后端用的是同一门语言——JavaScript前端直接跑在浏览器里后端跑在Node.js上不用搞跨语言联调那一套。前端没有用框架游戏渲染用原生CanvasWebSocket用浏览器的内置API。这样安排的好处是零构建步骤不装webpack、不配babel打开HTML就能跑把注意力全留在通信逻辑上。online-game-websocket/ ├── server/ │ ├── index.js # WebSocket服务端入口连接管理和启动 │ ├── gameRoom.js # 游戏房间逻辑玩家加入、状态维护 │ └── protocol.js # 消息解析和协议定义 ├── client/ │ ├── index.html # 游戏页面Canvas画布和UI │ ├── game.js # 客户端游戏循环渲染、输入、位置更新 │ └── network.js # 客户端WebSocket封装连接、心跳、消息发送 └── package.json # 依赖声明和启动脚本我这个Demo是按「服务端管状态、客户端管表现」的思路来分工的。服务端维护所有玩家的实时坐标收到某玩家的移动消息后更新状态再把最新位置广播给同房间其他玩家客户端只负责把键盘操作翻译成移动指令发出去并接收服务端广播的位置数据渲染到Canvas上。这样架构的好处是逻辑清晰后续想改成帧同步或者加入碰撞判定只需要在服务端加代码客户端不用大改。2.3 消息传递链路一条移动指令怎么走完全程一次完整的按键移动消息链路大致长这样客户端捕获键盘按下事件组装一个JSON消息通过WebSocket发送给服务端服务端收到后解析消息类型根据currentGame识别房间更新对应玩家的坐标然后遍历房间内所有在线连接把更新后的游戏状态广播出去各个客户端收到广播后解析消息里的玩家坐标列表重绘Canvas画面。消息格式是JSON这是WebSocket使用中最直观的序列化方式人眼能读、调试方便。协议层定义了四种基础消息类型join表示加入房间leave表示离开move表示客户端上报的移动操作gameState表示服务端广播的完整游戏状态。每条消息固定带一个type字段后续字段根据type不同而变化解析的时候先按type分流再做字段校验。这种设计在真正的游戏项目里很常见属于简单可扩展的消息协议雏形。3. 核心代码拆解从连接建立到位置同步的完整链路3.1 服务端入口创建WebSocket服务器的标准姿势服务端入口代码是整个Demo的心脏连接管理、事件绑定、消息入口都从这里开始。我用ws库创建了一个WebSocket服务器实例监听8888端口并给connection事件绑定了处理函数每次有客户端连进来都会走一遍初始化流程。const WebSocket require(ws); const { handleConnection, handleMessage, handleClose } require(./gameRoom); const PORT 8888; const wss new WebSocket.Server({ port: PORT }); wss.on(connection, (ws, req) { // 从请求URL中取出玩家名字实际项目中也可以走token鉴权 const playerName decodeURIComponent(req.url.split(?name)[1] || 玩家); const client handleConnection(ws, playerName); ws.on(message, (data) { try { const msg JSON.parse(data.toString()); handleMessage(client, msg); } catch (e) { // 防单条消息解析失败拖垮整个连接打印错误后跳过 console.error(消息解析失败:, e.message); } }); ws.on(close, () { handleClose(client); }); ws.on(error, (err) { console.error(连接异常:, err.message); }); }); console.log(WebSocket服务已启动监听端口 ${PORT});端口号可以按需调整如果8888被占用改成其他空闲端口就行。req.url.split(?name)[1]是拿URL参数做玩家身份识别浏览器里new WebSocket时可以拼接查询字符串但要注意服务端拿到的req.url是带路径和参数的完整地址。这里对消息做了JSON.parse的try-catch包裹因为WebSocket消息是流式的客户端发来的data可能是Buffer对象先toString再解析更稳妥。每个ws连接对象上挂载了自己的onmessage、onclose、onerror处理器互不干扰这就是WebSocket天然支持多客户端并发的基础。3.2 游戏房间模块状态维护与广播的核心逻辑游戏房间模块管着所有玩家状态和消息路由。当一条move消息到达时服务端要做的不是直接转发给所有人而是先确认消息里的坐标字段是否合法更新该玩家在房间里的状态再把完整快照广播出去。这一段代码是整个Demo里最值得反复看的部分。// gameRoom.js 核心逻辑 const rooms new Map(); // 房间ID - { players: MapplayerId, playerState } function handleConnection(ws, name) { const playerId player_${Date.now()}_${Math.random().toString(16).slice(2, 6)}; const playerState { x: 100, y: 100, name, id: playerId }; // Demo简化版默认只有一个房间方便双人直接对战 const roomId room-1; if (!rooms.has(roomId)) { rooms.set(roomId, { players: new Map() }); } const room rooms.get(roomId); room.players.set(playerId, playerState); ws.playerId playerId; ws.roomId roomId; // 新玩家加入后把完整状态广播给房间里所有人 broadcastToRoom(roomId, { type: gameState, roomId, data: serializeRoom(room) }); return { ws, playerId, roomId }; } function handleMessage(client, msg) { const { ws, playerId, roomId } client; const room rooms.get(roomId); if (!room || !room.players.has(playerId)) return; if (msg.type move) { const { x, y } msg.data || {}; // 服务端做基础校验坐标必须是数字防止脏数据污染状态 if (typeof x ! number || typeof y ! number) { ws.send(JSON.stringify({ type: error, message: 坐标格式错误 })); return; } const state room.players.get(playerId); state.x Math.max(0, Math.min(800, x)); // 限制在画布边界内 state.y Math.max(0, Math.min(600, y)); broadcastToRoom(roomId, { type: gameState, roomId, data: serializeRoom(room) }); } } function broadcastToRoom(roomId, message) { const room rooms.get(roomId); if (!room) return; const data JSON.stringify(message); // 遍历所有玩家连接逐个send room.players.forEach((state, id) { const client [...wss.clients].find(c c.playerId id); if (client client.readyState WebSocket.OPEN) { client.send(data); } }); }代码里有几个细节值得注意。玩家ID用时间戳加随机数生成避免并发时撞ID坐标校验逻辑特意限制在画布边界内防止客户端发来负数坐标把角色搞出画布外面广播前要把Map序列化成普通JSON对象因为Map不能直接被JSON.stringify转成想要的格式。serializeRoom函数把players这个Map转成数组方便客户端直接遍历渲染。消息带上roomId字段虽然Demo只有一个房间但留着这个字段为后续扩多房间做了准备客户端以后可以根据roomId判断自己该听哪个房间的消息。3.3 客户端WebSocket封装连接、心跳与消息路由客户端网络模块是浏览器端的通信核心。浏览器原生WebSocket对象在onmessage里收到的数据是Blob或字符串这里统一转成字符串再JSON.parse连接建立后立即发join消息加入房间每隔一段时间发ping包检测连接是否还活着。心跳检测这部分特别重要运营一个WebSocket服务时如果客户端断网但没有正常发送close帧服务端会长期保留一条死连接资源越攒越多玩家也会在房间里变成“幽灵”。// network.js 客户端WebSocket封装 class GameNetwork { constructor(url, callbacks) { this.url url; this.callbacks callbacks || {}; // { onGameState, onError } this.socket null; this.heartbeatTimer null; this.reconnectAttempts 0; } connect(roomName) { // 把玩家名拼到URL里服务端从query中解析身份 this.socket new WebSocket(${this.url}?name${encodeURIComponent(roomName)}); this.socket.onopen () { console.log(WebSocket连接成功); // 启动心跳每10秒发一次ping服务端收到后回pong this.heartbeatTimer setInterval(() { if (this.socket.readyState WebSocket.OPEN) { this.socket.send(JSON.stringify({ type: ping })); } }, 10000); }; this.socket.onmessage (event) { const msg JSON.parse(event.data); switch (msg.type) { case gameState: this.callbacks.onGameState this.callbacks.onGameState(msg.data); break; case error: console.error(服务端返回错误:, msg.message); this.callbacks.onError this.callbacks.onError(msg.message); break; default: break; } }; this.socket.onclose () { console.log(连接已关闭); clearInterval(this.heartbeatTimer); // 简单重连逻辑先尝试3次每次间隔2秒 if (this.reconnectAttempts 3) { this.reconnectAttempts; setTimeout(() this.connect(roomName), 2000); } }; this.socket.onerror (err) { console.error(连接错误:, err); }; } sendMove(x, y) { // 发送移动指令前先检查连接状态 if (this.socket this.socket.readyState WebSocket.OPEN) { this.socket.send(JSON.stringify({ type: move, data: { x, y } })); } } }心跳定时器在连接断开时要同步清理不然连接重连后会出现多个定时器同时发ping的情况我在早期版本里就踩过这个坑——断网一次重连后心跳包每秒发好几个纯属资源浪费。重连逻辑这里做得比较保守只试3次防无限循环。实际项目里一般会做成指数退避第一次失败等1秒第二次等2秒第三次等4秒递增上去避免服务端重启瞬间所有客户端同时疯狂重连导致雪崩。消息路由用switch-case按type分流每个case负责一个消息类型的处理后续加消息类型直接加case分支就行扩展性还算舒服。3.4 客户端游戏渲染与操作上报Canvas渲染部分相对直接每帧清空画布、读取本地玩家坐标并检测键盘状态、把位置发给服务端、绘制服务端同步来的所有玩家位置。// game.js 客户端渲染与按键处理 const canvas document.getElementById(gameCanvas); const ctx canvas.getContext(2d); const keys new Set(); // 记录当前按下的键用Set避免重复触发 document.addEventListener(keydown, (e) keys.add(e.key)); document.addEventListener(keyup, (e) keys.delete(e.key)); const gameState { players: [] // 服务端广播的玩家位置列表 }; let lastMoveSent 0; const MOVE_INTERVAL 50; // 每50ms最多发一条移动消息约20帧/秒 function gameLoop(timestamp) { ctx.clearRect(0, 0, canvas.width, canvas.height); // 每50ms汇总一次当前按键拼成move消息发出去 if (timestamp - lastMoveSent MOVE_INTERVAL) { let dx 0, dy 0; if (keys.has(ArrowUp)) dy - 5; if (keys.has(ArrowDown)) dy 5; if (keys.has(ArrowLeft)) dx - 5; if (keys.has(ArrowRight)) dx 5; // 只要有任何方向键按下就更新本地坐标并上报 if (dx ! 0 || dy ! 0) { const me gameState.players.find(p p.id myId); if (me) { me.x dx; me.y dy; network.sendMove(me.x, me.y); } } lastMoveSent timestamp; } // 绘制所有玩家位置自己和对手用不同颜色区分 gameState.players.forEach(p { ctx.fillStyle p.id myId ? #e74c3c : #2ecc71; ctx.beginPath(); ctx.arc(p.x, p.y, 12, 0, Math.PI * 2); ctx.fill(); // 头顶绘制玩家名字 ctx.fillStyle #333; ctx.font 12px sans-serif; ctx.fillText(p.name, p.x - 20, p.y - 18); }); requestAnimationFrame(gameLoop); } network.connect(A同学); network.callbacks.onGameState (data) { // 服务端广播全量状态直接替换本地列表 gameState.players data.players; myId data.selfId; // 服务端告诉客户端自己是谁 };MOVE_INTERVAL限制在50毫秒也就是每秒最多发20条消息这算是在流畅度和网络开销之间取了个平衡值。如果间隔设到10毫秒消息量翻5倍手感未必有明显提升如果设到200毫秒画面就会出现肉眼可见的卡顿因为它本质上把位置更新的频率压到每秒5次了两个角色移动时一卡一卡的。画布上绘制的是每个玩家的x、y坐标用圆形表示玩家并在头顶显示名字方便区分。这里收到服务端的gameState广播后直接整体替换本地列表没有做插值平滑所以网络不稳定时会有轻微的瞬移感这个在后面的改进入章会说。4. 运行与自测三步把Demo跑起来再验证联机4.1 环境准备与依赖安装跑这个Demo唯一的前置要求是本地装了Node.js。装好之后打开一个终端进入项目根目录执行依赖安装cd online-game-websocket npm install装的依赖只有ws这一个包package.json里如果还配了nodemon这种开发辅助工具也会在这次安装里一起装上。如果安装速度特别慢多半是镜像源的问题可以临时切换成国内镜像再装一次。装完依赖后建议先看一眼node_modules目录确认ws已经就位再往后走因为后续启动服务端时如果模块缺失Node.js会直接抛MODULE_NOT_FOUND异常。4.2 启动服务端服务端启动就是一条命令node server/index.js看到控制台打印“WebSocket服务已启动监听端口 8888”就说明服务端起来了。服务端进程会一直占据终端窗口不要关掉它也不要再往这个窗口输入命令否则会中断进程。想改端口的话直接改index.js里的PORT变量即可。如果启动时报端口被占用最常见的原因是上一个服务端实例没关干净。在Windows上可以用netstat -ano | findstr 8888查出占用进程的PID然后去任务管理器结束它Mac或Linux上可以用lsof -i:8888找到占用进程再杀掉。4.3 客户端怎么访问双人联机自测客户端是用浏览器直接打开的HTML文件不过这里有个细节WebSocket的连接地址是ws://localhost:8888如果你直接用file://协议双击打开HTML浏览器会允许WebSocket连接到本地服务端吗答案是允许因为WebSocket不受同源策略限制file协议页面连接本地WebSocket服务端没有问题。具体操作分成两步验证。先打开第一个标签页访问client/index.html页面加载后会自动连接WebSocket并加入房间此时页面中间偏左上角会渲染出一个红色圆点。紧接着打开第二个标签页访问同一个地址页面渲染出一个绿色圆点。现在在第二个标签页里按键盘方向键你会看到第一个标签页里的绿色圆点跟着移动——这就说明双人联机已经打通了服务端把位置更新推送到了所有客户端。自测的时候有几件事值得做一下打开浏览器开发者工具的控制台观察网络连接状态和心跳日志把服务端停掉再按方向键控制台会打印连接关闭的日志并显示重连尝试在服务端的终端里也能看到连接建立和关闭的日志记录。这些日志是排查问题最重要的原始依据比看画面盲猜强得多。4.4 用wscat做无浏览器接口测试如果手边暂时没有浏览器或者只想验证服务端协议是否正确可以装一个wscat命令行工具它和WebSocket服务端的交互方式与浏览器几乎一致npm install -g wscat wscat -c ws://localhost:8888?name测试玩家连接成功后手动输入一条协议消息{type:move,data:{x:200,y:150}}如果服务端正确执行了消息分发和广播你会在wscat的输出里立刻收到一条gameState广播。因为房间里可能只有你一个客户端收到的广播里players数组也只有你一个人的数据。这算是跳过前端直接验证服务端逻辑的快捷手段当浏览器里的联机表现异常时用wscat发消息可以快速定位问题在客户端还是服务端。5. 常见问题与排查连接失败、卡顿和幽灵玩家的四个翻车点5.1 页面刚打开就报WebSocket连接失败现象打开index.html后控制台瞬间出现红色报错类似WebSocket connection to ws://... failed。原因绝大多数情况下是服务端没启动或者启动在了别的端口上。浏览器发起连接时服务端还没listenTCP握手直接被拒绝。另一种可能是连接地址写错了比如把WebSocket地址拼成了http://localhost:8888——WebSocket的URL scheme是ws://不是http://虽然底层都是TCP但握手时用的路径和头部完全不同。解决按顺序排查——先确认终端里有没有node server/index.js的进程再看监听端口是不是8888最后检查network.js里的url变量是不是ws://localhost:8888。用telnet localhost 8888试试端口通不通也是定位问题的一招。5.2 JSON.parse突然报错消息格式对不上现象客户端或服务端执行JSON.parse时抛出SyntaxError严重的直接导致该连接的消息处理中断。原因这条很玄学——发消息的人和收消息的人对数据格式的理解不一致。比如服务端广播的是JSON.stringify({ type: gameState, data: ... })但客户端在onmessage里直接用原始字符串做逻辑判断没做JSON.parse或者另一端发的消息根本不是JSON而是裸字符串。还有一个隐蔽的坑浏览器WebSocket收到的event.data可能是Blob对象直接JSON.parse会报错必须先转成文本。解决我的习惯是通信模块里所有消息入口统一做一次字符串化再解析不让更上层的业务代码接触到原始消息格式。客户端这边event.data如果是Blob先await event.data.text()服务端那边Buffer就调.toString()。拿到字符串后统一JSON.parse解析失败兜底打一条错误日志不把异常抛到外层炸掉整个事件循环。5.3 对手断网了但他的角色还停在原地当幽灵现象游戏过程中某个玩家的游戏画面直接卡在某个位置不动像雕塑一样但其他人还能正常对战。原因服务端没有做掉线清理。WebSocket连接的关闭不一定是优雅的——玩家直接合上笔记本、拔了网线、或者移动网络切换TCP连接不会立刻发close帧服务端的ws对象在短时间内以为连接还活着。这个Demo在handleClose里只是从房间移除玩家并广播一次但触发close事件本身有延迟在这段延迟窗口里其他人看到的就是一个静止的角色。解决在服务端加一层心跳超时机制——客户端每10秒发一次ping服务端记录每个连接的最后一次ping时间每20秒扫描一遍所有连接超过阈值还没收到ping的直接判定失活强制调用terminate()并走handleClose清理流程。这次扫描可以在handleMessage里顺手做也可以单独加一个setInterval定时跑。5.4 两个客户端能连上服务端但互相看不到对方的位置现象两个标签页都显示连接成功页面上都渲染出了自己的角色但移动时对方画面里自己纹丝不动。原因问题往往出在消息路由环节。房间ID对不上是最常见的——客户端拼URL时把roomId传进去但服务端解析的字段名和客户端拼的字段名不一致导致它俩进了不同的房间或者服务端在handleMessage更新坐标后broadcastToRoom时用了全局wss.clients遍历而不是按房间遍历如果Demo里恰好有测试残留的旧连接参与广播就会出现坐标分发的混乱。解决先看服务端启动日志里两个客户端的连接信息确认它们被分配到了同一个roomId。再看服务端收到move消息时控制台有没有打对应的处理日志。最后用一个简单方法排障在客户端收到gameState时console.log一条带data长度和玩家ID的消息对比一下两个页面的打印内容。如果数据里始终只有自己的ID说明广播链路没通重点查broadcastToRoom的roomId匹配逻辑。6. 二次开发思路从Demo到可玩游戏的四个改造点这个Demo最值得改的方向是把当前的“状态同步”升级成“服务端权威计算”。现在的架构是客户端上报坐标、服务端只做边界校验就广播防作弊能力为零——任何一个懂WebSocket的玩家都可以改network.js里的sendMove直接把自己的坐标改成{x: 800, y: 600}角色瞬间传送到地图角落。要加固的话把移动逻辑整体挪进服务端客户端只上报“按下了哪个方向键”服务端收到按键状态后以固定步长更新坐标再把新位置广播出去。这样坐标永远是服务端算出来的客户端没法伪造。第二个值得做的是把房间系统补全。目前所有玩家固定进room-1多人同时在线会全部挤在一起。改造点很清晰在join消息里增加roomId字段服务端在handleConnection里按roomId查找已有房间——如果没有就创建新房间有就直接加入广播范围也从“全房间”细化到“同房间内”。配合一个简单的房间列表接口就能做成“创建房间、加入好友房间”的体验。第三个方向是换掉自研心跳重连逻辑接入Socket.IO。Socket.IO内部实现了自动重连、房间机制、二进制数据支持还自带ACK确认虽然它不是裸WebSocket协议但在浏览器兼容性和断线处理上的成熟度比自己造轮子高很多。如果你的目标是快速做一个能上线的小游戏用Socket.IO是省钱省力的选择如果目标是深入理解WebSocket协议本身那在现在这个Demo基础上继续打磨心路历程更有价值。最后可以加一层插值平滑。现在每次收到gameState直接替换坐标网络抖动时角色会瞬移。常见做法是渲染层和目标坐标之间做线性插值本地维护一个渲染坐标每帧向收到的目标坐标移动一小段距离让视觉上达到平滑连续的效果。这个改动只涉及game.js里的绘图循环不碰通信逻辑适合作为第一次动刀的安全练手。我从这个Demo里学到的最重要一课是多人实时联网游戏调试难度比单机高一个量级因为问题可能出在客户端、服务端、网络链路任何一个环节。从那以后我每次自测都强制走一遍wscat发消息、双标签页联机、杀进程测掉线这三步流程能省下大量“明明代码看着没问题就是不动”的折腾时间。希望这份拆解能让你少走点弯路。本文还有配套的精品资源点击获取