简介:基于WebSocket的在线游戏开发Demo,是一套可直接运行的实时联机游戏示例。它面向希望快速理解全双工通信与游戏状态同步的前后端开发者,帮助解决从连接建立、消息推送到多玩家协同的常见问题。压缩包共包含488个文件,以JavaScript脚本为主,另有Go服务端源码、HTML页面以及jpg、png、ico等图形素材,整体体积仅2.96MB,目录按服务端、客户端与静态资源分层,代码结构清晰,便于快速定位。工程内提供了服务器端连接管理、消息广播、JSON数据交换,以及客户端登录、主界面渲染和事件处理等关键模块,能直观看到一次完整交互背后的数据帧流转。目前已有93人学习,小巧而完整,适合一边阅读源码一边动手调试,也可作为后续加入房间机制、断线重连与安全加密的改造起点。
1. 项目定位:一个把WebSocket从协议变成玩法的练手Demo
很多开发者第一次接触WebSocket,都是在聊天室或者消息推送的例子里知道“它能让服务端主动推数据”。但聊天室其实只用了WebSocket最基础的能力——收发字符串。真正的实时对战游戏里,WebSocket承载的是每秒几十次的指令交换、坐标修正、状态同步,还要处理掉线重连、断线补帧、房间匹配这类问题。这个Demo就是冲着这个场景去的:你打开一个网页,创建房间,等另一个人加入,然后两个人开始玩一局实时对战的小游戏。整个过程中,你按键的每一次方向变化、服务端的每一次位置修正,都通过WebSocket在两端之间实时传递。
这个Demo适合两类人。第一类是刚接触WebSocket、想知道它除了聊天还能干什么的前端或后端开发者,你能在这里看到一套完整的消息协议设计和前后端交互流程;第二类是准备做联机小游戏但没想清楚技术方案的人,这个Demo从连接管理、房间分配到游戏循环都给了可跑的代码,比看三五篇分离的教程更能帮你建立整体认知。整个项目不依赖任何复杂的游戏引擎,一个浏览器加一个Node.js环境就能跑起来。
2. 整体架构:前端、服务端与消息协议的三层设计
2.1 技术选型:为什么服务端选Node.js而不是Python或Java
说清楚这个问题,你才能判断这个Demo的代码值不值得细读。服务端用的是Node.js配合ws库实现,而不是Java的Netty或者Python的websockets。理由很直接:这个Demo的核心通信逻辑是事件分发——收到客户端消息,解析类型,分发给对应的房间处理器。Node.js的异步事件模型天然贴合这个场景,代码写起来也短,一个文件能完成的事没必要拆成多线程模型。再看客户端,HTML5原生WebSocket API加Canvas绘制,不引任何游戏引擎,这样你能看清楚每一帧是谁画的、每一次网络事件是怎么触发的,而不是被引擎封装的黑匣子挡住。
2.2 目录结构:一眼看穿前后端边界
拿到压缩包解开后,你会看到这样的目录结构:
project-root/ ├── server/ │ ├── index.js # 服务端入口,创建WebSocket服务器 │ ├── room.js # 房间管理模块,维护房间状态 │ ├── game.js # 游戏逻辑模块,判定胜负和边界 │ └── protocol.js # 消息类型定义与序列化辅助 ├── client/ │ ├── index.html # 游戏页面骨架 │ ├── main.js # 客户端逻辑,连接、渲染、输入采集 │ └── style.css # 界面样式 └── README.md # 运行说明与协议文档server下的四个文件各司其职:index.js负责启动和连接生命周期,room.js管理房间的创建、加入、销毁,game.js是纯逻辑计算——蛇的移动、食物生成、碰撞检测,protocol.js统一了消息的type和payload格式。client下的main.js则负责把WebSocket收到的状态数据画到Canvas上。这种“逻辑层与网络层分离”的写法很关键:game.js里没有任何socket相关的代码,你完全可以把里面的函数单独拿出来做单元测试。
2.3 消息协议:一套JSON约定贯穿所有交互
整个Demo的通信基础是一套极简的JSON消息协议,每一条消息都包含type和payload两个字段。type用字符串标识消息意图,payload携带对应的数据。我挑几个最核心的类型放在表格里,方便你对照源码看:
| type | 方向 | payload示例 | 作用 |
|---|---|---|---|
| create_room | client→server | {} | 请求创建房间 |
| join_room | client→server | { roomId: "abc123" } | 加入指定房间 |
| room_ready | server→client | { players: 2, roomId: "abc123" } | 通知房间已满,准备开局 |
| input | client→server | { direction: "up", seq: 12 } | 上报客户端操作指令 |
| state_sync | server→client | { snakes: [...], foods: [...], tick: 88 } | 服务端广播全量游戏状态 |
| game_over | server→client | { winner: "A", reason: "撞墙" } | 发送对局结果 |
协议的扩展性留给二次开发很大的空间,你想加一个“观战”功能,只需要增加一个spectate类型并广播给观战者,对现有的事件分发逻辑没有任何侵入。有一点值得注意:input消息里带了一个seq序号,这是客户端为每次按键生成的递增序列号,服务端拿到后可以用来做指令去重和顺序校验,后面讲核心模块时你会看到它怎么发挥作用。
3. 核心模块拆解:一条指令从按键到屏幕上移动的完整链路
3.1 服务端连接管理:从握手到心跳保活
服务端入口index.js里做的不只是创建一个WebSocket服务器那么简单。连接管理至少要处理三件事:新连接注册、异常断开清理、心跳超时判断。先看连接注册这块的代码:
const WebSocket = require('ws'); const { handleMessage } = require('./protocol'); const RoomManager = require('./room'); const wss = new WebSocket.Server({ port: 8080 }); const rooms = new RoomManager(); wss.on('connection', (ws) => { ws.isAlive = true; ws.playerId = null; ws.roomId = null; // 心跳保活:每30秒发送ping,客户端需回pong ws.on('pong', () => { ws.isAlive = true; }); ws.on('message', (raw) => { const msg = handleMessage(raw); if (!msg) return; // 将消息交给协议分发器处理 dispatch(ws, msg); }); ws.on('close', () => { // 断开时从房间移除并通知对手 if (ws.roomId) rooms.leaveRoom(ws.roomId, ws.playerId); }); }); // 定时清除失活连接 setInterval(() => { wss.clients.forEach((ws) => { if (!ws.isAlive) return ws.terminate(); ws.isAlive = false; ws.ping(); }); }, 30000);这段代码里有两个设计值得细说。第一个是ws.isAlive标记配合ping/pong机制:服务端每30秒主动发一次ping,正常连接的客户端会自动回pong,只要收到pong就把isAlive置回true。下一轮检测时,那些isAlive还是false的连接会被terminate掉。这套机制能及时清掉那些异常断网的“僵尸连接”,避免房间被半死的玩家占着。
第二个是connect阶段给每个连接打上playerId和roomId两个标记,这两个标记在后续的消息分发里起到路由作用——消息进来先看playerId是谁,再看roomId属于哪个房间,然后决定是单发还是广播。如果你在自己的项目里扩展这个Demo,这两个字段就是做权限校验和消息路由的入手点。
3.2 房间分配:两名玩家凑齐后自动开局
room.js的核心职责是把两个玩家拉到同一个房间里。房间的状态机很简单:等待中、对局中、已结束。关键逻辑在于:当房间满员的那一刻,服务端要主动广播room_ready消息,并且把双方的游戏状态初始化好。
class Room { constructor(id) { this.id = id; this.players = []; this.state = 'waiting'; // waiting | playing | finished this.tick = 0; } addPlayer(player) { this.players.push(player); player.roomId = this.id; if (this.players.length === 2) { this.state = 'playing'; this.players.forEach((p, idx) => { p.playerId = idx === 0 ? 'A' : 'B'; p.send(JSON.stringify({ type: 'room_ready', payload: { roomId: this.id, playerId: p.playerId, canvasWidth: 600, canvasHeight: 600 } })); }); this.startGameLoop(); } } }addPlayer方法在房间满员时做两件事:分配玩家身份标识(A或B),然后启动游戏循环。注意我这里用的是startGameLoop,而不是直接在这段代码里写死移动逻辑——因为游戏循环是一个独立的定时器驱动的过程,它每100毫秒推进一次游戏状态,然后广播给房间里的所有玩家。这个设计体现了网络游戏常用的“服务端权威”思想:玩家的移动逻辑由服务端统一计算,客户端只负责上报输入和渲染状态,这样能有效防止作弊。
3.3 游戏状态同步:增量与全量之间的取舍
游戏状态同步是联机对战最核心的问题。这个Demo采用的是“服务端定帧+全量广播”的策略:服务端每100ms推进一次游戏逻辑,然后把整个游戏状态——所有蛇的坐标、食物的位置——打包广播给两个客户端。这样做的优势是客户端实现简单,不需要处理复杂的增量合并。
broadcastState() { const state = { type: 'state_sync', payload: { tick: this.tick, snakes: this.snakes, // [{ id: 'A', body: [{x,y},...] }, ...] foods: this.foods } }; const data = JSON.stringify(state); this.players.forEach(p => p.send(data)); }这段代码逻辑很直白,但你要注意payload的size问题。一局贪吃蛇游戏,蛇身体节点数量一般在几十到几百之间,用JSON全量序列化,一条消息大概2到5KB,在本地局域网环境下完全没有压力。但如果你要把这个Demo改造成支持更多玩家或更大地图的对战游戏,全量同步的带宽消耗会呈线性增长,那时候就需要考虑只广播“发生变化的节点”或者用二进制协议压缩空间了。我从这段代码里学到的经验是:先跑通全量同步,再用性能工具量出瓶颈做优化,不要一开始就上复杂性。
3.4 客户端渲染与输入采集:本地响应优先还是等待服务端?
客户端main.js里最值得研究的是输入采集与状态应用之间的关系。很多新手写联机游戏容易在“等服务端确认后才更新画面”这个误区里绕圈:按键、发出指令、等服务端广播新状态、渲染新状态,这样的链路延迟在本地网络可能只有几十毫秒,感觉不明显,一旦走公网就会觉得手感黏滞。这个Demo的处理方式相对务实——客户端持续用当前已知的状态渲染画面,同时不断上报输入指令:
// 键盘监听:每次按键直接发送,不做本地预测 document.addEventListener('keydown', (e) => { const dirMap = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' }; if (dirMap[e.key]) { ws.send(JSON.stringify({ type: 'input', payload: { direction: dirMap[e.key], seq: ++inputSeq } })); } });这里的inputSeq是每次按键自增的序号。为什么带上它?因为在WebSocket消息可能乱序到达的弱网环境下,服务端需要一种手段判断哪条指令是最新的——如果先发出的“向右”和稍后发出的“向上”同时到达,合理的处理是丢弃过期的指令。
// 服务端收到input时的处理 handleInput(player, msg) { const room = rooms.get(player.roomId); if (!room || room.state !== 'playing') return; const playerObj = room.getPlayer(player.playerId); // 指令序号校验:客户端新序号必须比当前序号大,否则视为过期指令 if (msg.payload.seq <= playerObj.lastSeq) return; playerObj.lastSeq = msg.payload.seq; // 方向校验:禁止反向(比如正在向右走时直接向左) if (isOpposite(playerObj.direction, msg.payload.direction)) return; playerObj.direction = msg.payload.direction; }指令序号校验加反向校验,这一小段逻辑就是整个手感体验的地基。方向校验的技术点在于:如果允许蛇在向右移动的瞬间直接转向左,蛇头会立刻穿进自己的身体,这在传统贪吃蛇规则里本身就是一次碰撞死亡。服务端在这里拦一道,既保证了规则严谨性,也避免了两个客户端渲染出不同结果。
3.5 游戏循环:服务端权威的帧驱动设计
游戏循环放在room.js里,每隔100ms执行一次tick。每一次tick会推进蛇的坐标、检测吃食物、判胜负,然后调用broadcastState广播全量状态。你可能会问,为什么100ms一帧,而不是更常见的16ms一帧?原因很简单:浏览器端的Canvas渲染能达到60FPS,但网络同步游戏并不需要那么高的状态刷新率。100ms一帧等于每秒10次状态同步,对这个体量的游戏来说足够了,更重要的是它给服务端留出了充足的运算余量。
实际跑起来,如果客户端网络出现短暂抖动,画面会表现为“蛇的移动有轻微卡顿”,但不会出现永久的不同步——因为服务端下一次广播会把所有客户端拉回一致的状态。这个设计思路我给一个关键词:最终一致性。它不追求每一帧所有客户端都一模一样,但保证每隔一段时间,所有客户端看到的游戏状态是同一个版本。理解了这一点,你的WebSocket游戏就不会出现“各玩各的”的尴尬情况。
4. 运行与联调:把Demo跑起来并验证全流程
4.1 环境准备与启动服务端
这个Demo的运行依赖不多,核心就是Node.js环境,建议用12以上的版本,npm内置支持,不需要额外安装包管理器。拿到压缩包后,在根目录执行依赖安装:
cd 项目根目录 npm installnpm install会读取package.json里的依赖列表,安装ws这个WebSocket库。装完依赖后直接启服务:
node server/index.js看到命令行输出“WebSocket server running on ws://localhost:8080”就算启动成功。服务默认监听8080端口,如果你本地8080被其他程序占用,可以修改index.js里的端口配置。
4.2 双人联调的三种验证方法
启动服务后,打开两个浏览器窗口,都访问client/index.html这个页面。第一个窗口点击“创建房间”,页面会生成一个房间ID;第二个窗口输入这个ID点击“加入房间”。当第二个玩家加入的瞬间,两个页面同时收到room_ready消息,游戏自动开始。
在联调过程中,你会发现一个有趣的细节:两个窗口如果不在同一台电脑上,只要网络互通,一样能跑通。服务的绑定地址是0.0.0.0或指定的局域网IP,另外一台设备通过http://你的IP:8080/client/index.html访问页面,WebSocket连接会自动指向服务地址。如果你拿着这个Demo做课堂演示,这点很实用——用两台设备能更真实地感受网络延迟带来的影响。
4.3 用浏览器开发者工具观察消息流
联调阶段最有价值的调试手段是看WebSocket帧。在Chrome开发者工具里切换到Network标签页,刷新页面找到那个类型为websocket的请求,点进去可以看到Messages选项卡,这里展示的就是客户端与服务端往来的一切消息。
你会看到消息类型的有序排列:刚进入页面时有一条client发起的create_room,服务端返回房间ID,第二个玩家加入后收到room_ready,之后每100毫秒收到一条state_sync。这些消息的时间间隔就是服务端tick的频率。如果在消息列表里发现某一帧state_sync间隔超过100ms,说明服务端可能发生了阻塞——例如游戏逻辑里有耗时操作,这时候就可以针对性优化game.js里的算法了。
这里有一个排查思路要养成习惯:先确认服务端日志里的消息收发时间点,再对比客户端的帧时间线,就能很快定位延迟是发生在网络传输还是本地渲染。这个Demo没写复杂日志,但你在二次开发时,一定要在服务端的广播函数里加上时间戳日志,这会是你在公网环境下排查丢帧问题的第一手证据。
5. 避坑清单:调WebSocket游戏最容易遇到的5个实际问题
5.1 页面反复弹错“connection closed before a reply was received”
现象:浏览器控制台经常报这个错,连接始终建立不起来,但服务端看起来明明正常启动了。
原因:我把话说直接点,八成是协议升级被什么中间层干扰了。最常见的情况是页面通过nginx或其他反向代理访问,而代理没有配置WebSocket转发支持。nginx默认对HTTP请求做转发,但WebSocket的Connection: Upgrade头需要特殊处理,不配置proxy_set_header就是干瞪眼。
解决:本地调试直接通过IP加端口访问页面,不走代理。如果必须走代理,请确保nginx配置里加上这两行:
proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";5.2 蛇在屏幕上“瞬移”
现象:游戏玩着玩着,蛇的位置突然跳了一大段,然后又恢复平滑移动。
原因:这是全量状态同步丢帧后的表现。当某一条state_sync消息因网络延迟没有按时到达,客户端短暂地停留在旧状态,下一次广播到达时会突然跳到最新状态,视觉上就是瞬移。
解决:客户端可以为state_sync消息做一个简单的缓冲——保留最近两三帧的状态,按tick序号依次渲染,不要“来一条画一帧”。截帧平滑是第一次接触联机游戏时最容易忽略的优化点,这个Demo为了保持代码简洁没做缓冲,你二次开发时我会强烈建议加上。
5.3 一个玩家退出后,另一个玩家的游戏卡死
现象:对战过程中一个人直接关掉浏览器,另一个人的页面画面静止,没有任何提示。
原因:服务端虽然写了close事件清理房间,但客户端没有监听close后通知对手的逻辑。房间被销毁了,对手玩家的websocket还挂着,却再也收不到任何消息,看起来就是卡死。
解决:在服务端的房间销毁代码里补一条游戏中断通知,给剩余玩家发送game_over,payload里带一个reason字段值为opponent_left。客户端收到这个类型后弹出提示并返回等待页面,而不是干等。
5.4 公网环境下操作明显有延迟感
现象:本地联调手感顺滑,一旦部署到公网服务器,键盘按下到画面反馈能感觉到停顿。
原因:这是物理距离决定的网络往返时间,本地回环延迟零点几毫秒,跨地域公网延迟可能几十甚至上百毫秒。再加上WebSocket是TCP协议,有拥塞控制机制,丢包后会出现重传等待,延迟会更明显。
解决:我先说这个Demo该怎么做:看一下state_sync的频率是不是过快,打包时去掉无关字段减小消息体积,再从代码上做输入优化。例如客户端可以让蛇在“上、下、左、右”四个方向按键之外有个“碰壁预警”,在蛇头距离墙壁只剩一格时就让蛇提前转向,用预判抵消一部分网络延迟。这个思路属于“客户端预测”的雏形,真正完整的预测回滚方案比这个复杂得多,但作为起步够用了。
5.5 两个玩家的屏幕显示结果不一样
现象:一局打完了,A窗口看到自己赢了,B窗口显示自己赢了,判负的人不服气。
原因:这算是分布式系统里典型的“状态分叉”问题。虽然服务端是权威的,但如果客户端做了一些本地渲染逻辑——比如根据本地输入瞬间更新蛇的走向,那状态分叉就不可避免。服务端最终判定A赢,广播给B的时候B已经本地画了自己赢的画面,自然对不上。
解决:所有胜负判定数据必须只信服务端。客户端一旦收到state_sync里带有game_over类型的消息,就停止本地的一切渲染更新,以服务端报文为准展示结果。我在自己二次开发的版本里加了一个简单的对策:在客户端渲染函数里加一个“终局锁定”逻辑,收到终局消息后忽略所有后续状态更新。
6. 二次开发:把Demo改造成可以上线的小游戏的三个着手点
6.1 从“一个房间两人玩”扩展观的观众体系
拿到源码后最先值得下手的是房间模块。room.js里目前只维护players数组,且长度到2就锁定。你可以增加一个spectators数组,房间进入playing状态后,后来加入的人被分配到观众席。
addSpectator(player) { this.spectators.push(player); player.roomId = this.id; player.playerId = null; player.send(JSON.stringify({ type: 'spectate_ready', payload: { roomId: this.id } })); }观众与玩家的区别在update循环里体现:每次tick广播时,观众也要收到state_sync,但不需要为他们计算游戏逻辑。这是一次典型的“读多写少”改造,实现了它你就能明白直播类应用的底层逻辑。
6.2 从全量同步改造增量同步
这是性能优化的重头戏。当前广播的是所有蛇的所有节点坐标,如果地图变大、蛇变长,单条消息体积会涨得很快。我建议的改造路径是:在game.js里维护一个dirty标记,每当蛇的坐标发生变化,就把变化的部分记录下来,广播时只发这些变化数据。客户端的渲染函数改为“先应用增量更新,再完整渲染”。
拿我自己的经验举例,某次改造实现了“仅发送蛇头的坐标和尾部删除的信息”,消息体积瞬间降到了原来的五分之一。不过要提醒你:增量同步引入的复杂度在调试阶段会带来不少麻烦,你需要先在两个客户端之间反复验证状态一致性。
6.3 给游戏加一个简单的观战视角
最后一个建议更贴合前端改造。现在client/main.js里,所有玩家用的都是同一个Canvas视角。如果你想要观战效果,可以在页面里加一个下拉框,切换跟随视角到任意一位玩家——这需要客户端保持所有玩家的完整状态数据。只要你保存了每帧state_sync的完整快照,多视角切换就只是一个渲染层的问题,完全不碰服务端代码。这也是这个Demo分层清晰带来的红利:游戏逻辑、网络同步、渲染展示三个模块各有独立的文件,改A不会碰坏B。
从我拆过的源码包来看,这个Demo最让我认可的不是某一段代码写得多漂亮,而是它把“网络游戏”这件事拆成了可独立理解、可独立替换的几块。你改room可以研究联机机制,改game可以调整玩法规则,改main可以重构渲染效果,每一层都有清晰的边界。如果你之前看完几篇教程还是不知道从哪下手写一个联机小游戏,从这份代码开始改,比从零开始容易得多。希望帮到你。
本文还有配套的精品资源,点击获取