
简介基于WebSocket的在线游戏开发Demo是一份面向Web前端与后端开发者的入门级实践资源演示如何借助WebSocket实现实时双向通信的游戏环境。资源共488个文件压缩包约2.96MB其中以391个js脚本承载游戏逻辑与客户端交互55个jpg及10个png等图片资源用于界面与素材5个go文件实现服务器端核心逻辑另有html入口页面、配置文件等结构清晰方便对照学习。目前已有93人学习下载。Demo覆盖WebSocket握手过程、帧结构与数据编码并展示玩家位置同步、服务器推送、多用户广播等典型游戏场景。通过阅读Go服务端源码可理解使用Go搭建WebSocket服务、以JSON交换数据的方法同时还能接触到连接稳定性、WSS安全传输、负载均衡与数据压缩等进阶优化思路。适合希望从零掌握实时在线游戏通信机制、快速搭建可运行Demo的开发者参考。1. 先用 30 秒搞懂WebSocket 在线游戏 Demo 到底值不值得下载做 Web 游戏或者实时交互工具的人多半都经历过这种尴尬HTTP 轮询把服务器拖到喘不过气前端拿到数据还要自己拼状态好不容易写完联调一进内网就卡成幻灯片。这个标题里的「在线游戏开发 Demo.zip」说白了就是一套把 WebSocket 从握手到断线重连跑通的最小示例程序——通常是 Python/Node.js 做服务端推送浏览器客户端负责渲染和响应外加一个房间或简单对战逻辑。它的价值不在于游戏玩法有多炫而在于把“服务端主动推、客户端被动收”这套机制焊死给你看什么消息格式、什么心跳策略、什么状态同步节奏跑起来就一目了然。适合谁想给自己的项目加实时推送能力的后端或全栈开发者以及刚接触 WebSocket、想用最小成本验证技术选型的新手。标题里带 Demo 意味着代码不完整但结构齐全你得有把它改造成生产代码的心理准备。下面我把这套 Demo 拆成六个部分协议怎么选、服务端怎么写、客户端怎么收、房间状态怎么做、踩了哪些坑、以及最后如何验证和压测。读完你不仅能跑起来还能说出每行代码为什么这么写。2. 在线游戏为什么非 WebSocket 不可选型理由与 Demo 的最小消息协议2.1 为什么 HTTP 轮询和 SSE 扛不住游戏场景在线游戏核心动作是“操作”与“状态”的频繁双向交换玩家按下方向键客户端要把指令发给服务器服务器判定后又要把整个房间的新状态推给所有客户端。HTTP 是“请求-响应”模型客户端不发起请求服务器就没法主动开口轮询倒是能模拟实时但空转请求占了九成以上服务器连接数上去之后延迟和成本一起涨。SSE 解决了服务器单向推送的问题但客户端到服务器的通道还是靠 HTTP 请求做不了低延迟的频繁上行指令。WebSocket 的优势是「一次握手全双工长连接」TCP 之上建立持久通道服务器可以随时把数据帧推到浏览器浏览器也能随时发帧上去头部开销只有 2 字节左右。对于在线游戏 Demo 来说最直接的收益是消息延迟从“秒级轮询间隔”降到“毫秒级帧间隔”而且同一套连接上可以跑高频战斗指令、低频聊天、系统通知等不同优先级的消息。2.2 Demo 里最常见的设计消息即 JSON信令即字符串看这类 Demo 的源码十有八九会看到一个结构客户端发送{type: action, data: {...}}服务端广播{type: state, data: {...}}。type 字段是消息分类data 是具体负载。别小看这个设计它几乎是所有轻量在线游戏的消息协议原型。# 服务端消息处理的核心分发逻辑 # 收到客户端上行消息后按 type 路由到对应处理器 async def handle_message(websocket, raw_message): # 约定所有上行消息都是 JSON解析失败直接丢弃并计数 try: msg json.loads(raw_message) except json.JSONDecodeError: logger.warning(invalid message from client: %s, raw_message[:80]) return msg_type msg.get(type, ) # type 是动作名对应不同的业务处理函数 if msg_type join: await player_join(websocket, msg.get(data, {})) elif msg_type move: await player_move(websocket, msg.get(data, {})) elif msg_type heartbeat: # 心跳是客户端主动上报服务端只需更新在线时间 await handle_heartbeat(websocket) else: logger.info(unknown type: %s, msg_type)这段代码看起来平平无奇但它是整个 Demo 的“总闸门”。所有上行消息都会经过这里所以解析失败不能抛异常要捕获、要投诉、要丢弃——否则恶意客户端发一个畸形数据包就能把你的协程打崩。路由用 if-elif 在消息类型少于 10 个时完全够用超过 10 个再考虑字典映射或注册表结构。注意我把日志截断到 80 字符避免把整段脏数据刷进日志文件这是生产环境里很实际的卫生习惯。2.3 下行消息服务端主动推才是 WebSocket 的灵魂上行消息搞清楚之后下行推送是 Demo 的“爽点”所在。服务器收到一个玩家的移动指令不能只回给这个人要把整个房间的最新状态广播给所有在线玩家。# 广播最新游戏状态给房间内所有客户端 async def broadcast_state(room_id): # room_id 对应一个客户端连接集合 room rooms.get(room_id) if not room: return # 构造统一状态帧按 tick 序号推进客户端以此判断是否有丢帧 state_payload { type: state, tick: room.tick, players: [ {id: p.id, x: p.x, y: p.y, hp: p.hp} for p in room.players ], timestamp: time.time() } # 先序列化一次而不是每个客户端单独 json.dumps encoded json.dumps(state_payload) for ws in list(room.clients): # send 过程中客户端可能已断开必须捕获 ConnectionClosed try: await ws.send(encoded) except Exception as exc: logger.warning(broadcast failed to %s: %s, ws.remote_address, exc) await disconnect_cleanup(ws, room_id) # tick 自增客户端可据此补帧或触发重连 room.tick 1这里有两个关键参数值得停下来看tick 和 timestamp。tick 是服务端的逻辑帧序号客户端收到连续两个 state 帧时如果 tick 不连续说明中间有丢消息客户端可以主动请求一次状态全量同步。timestamp 则用于客户端插值和延迟补偿。序列化放在循环外很多人会忽略这一点——房间里有 10 个人时一次性序列化比循环里序列化省 9 次 CPU 开销。广播时用list(room.clients)拷贝一份遍历是为了避免发送过程中某个客户端断开、从集合里移除导致迭代器失效。从选型角度看如果你的游戏是回合制、状态变化不频繁用 WebSocket 都算奢侈如果是动作类、MOBA 类WebSocket 依然不是终点还要叠加 UDP 或 WebRTC DataChannel。但 Demo 阶段WebSocket 是性价比最高的起点——浏览器原生支持服务端库成熟调试工具完善先跑通再去优化传输层。3. 服务端怎么落盘用 Python 还是 Node.js以及心跳和处理循环的最小实现3.1 选 Python 还是 Node.js看你的团队语言栈这类标题下的 Demo 源码服务端用 Pythonwebsockets 库或 FastAPI 的 WebSocket 接口和 Node.jsws 库或 Socket.IO各占半壁江山。我做过的在线小游戏 Demo 选的是 Python FastAPI理由不是性能碾压 Node而是状态逻辑用 Python 写起来更像写业务类型约束少、迭代快、团队里随便一个人都能看懂。Node.js 的 ws 库性能更高、内存占用更小适合你已经确定要往高并发方向走的情况。如果只是学机制两个都行不必纠结。3.2 Python 端最小可跑的服务端连接管理 心跳 自动清理下面是一个用 FastAPI 写的 WebSocket 服务端骨架它涵盖了在线游戏 Demo 的三大核心连接注册、心跳监控、断线清理。跑通它你就有了一张可以往上贴业务逻辑的“桌子”。# 在线游戏 WebSocket 服务端最小骨架FastAPI uvicorn import asyncio import time from fastapi import FastAPI, WebSocket, WebSocketDisconnect app FastAPI() # 全局连接池key 是客户端 idvalue 是 (websocket, 最近心跳时间) connections {} # 心跳超时阈值单位秒超过这个值判定死连接主动踢掉 HEARTBEAT_TIMEOUT 15 app.websocket(/ws/game) async def game_endpoint(ws: WebSocket): # 握手阶段accept 成功才算建立连接 await ws.accept() client_id fclient-{int(time.time() * 1000)} connections[client_id] (ws, time.time()) try: # 收消息循环每次收到消息同时刷新心跳时间 while True: raw await ws.receive_text() # 这里把上次心跳时间刷成当前时间表示连接还活着 connections[client_id] (ws, time.time()) await handle_message(ws, raw) # 复用上一章的 json 路由 except WebSocketDisconnect: pass except Exception as exc: logger.warning(unexpected error: %s, exc) finally: # 无论正常断开还是异常退出都要清理连接池 connections.pop(client_id, None) await ws.close() async def heartbeat_monitor(): # 后台任务每 5 秒扫描一次连接池清理超时连接 while True: now time.time() dead_clients [ cid for cid, (_, last) in connections.items() if now - last HEARTBEAT_TIMEOUT ] for cid in dead_clients: ws, _ connections.pop(cid, None) if ws: await ws.close(code4001, reasonheartbeat timeout) await asyncio.sleep(5) app.on_event(startup) async def startup(): asyncio.create_task(heartbeat_monitor())逻辑说明connections字典是全部状态每个 WebSocket 连接都对应一个最近活跃时间。客户端只要发任何消息就刷新这个时间不需要单独心跳帧也可以维持连接——我把“收到任何消息”都视作心跳。但实际项目中客户端可能长时间不发业务指令这时候需要有专门的{type: heartbeat}帧每隔几秒发一次。服务端的扫描间隔要小于超时阈值一般设超时时间 / 3即 5 秒扫一次。两个参数的关系是超时时间决定多长的僵死连接算死扫描间隔决定你多快发现它。这里有个容易被忽略的参数ws.close(code4001, reasonheartbeat timeout)。4001 是自定义关闭码客户端收到后可以区分“服务端主动踢”和“网络异常中断”从而决定是重连还是提示用户。关闭码在 WebSocket 协议里是 1000-49991000 是正常关闭4000-4999 留给应用自定义别用它做业务逻辑只做连接管理。3.3 心跳机制的三种写法客户端定时器、服务端扫描、以及反向探测心跳机制实现是这类 Demo 最容易糊弄的部分但恰恰是最影响线上存活率的部分。常见的有三种做法第一种是上面的示例服务端被动刷新主动扫描。优点是实现简单缺点是死连接至少存活一个超时周期。第二种是服务端每隔固定时间主动 ping 一条{type: ping}客户端必须回 pong否则累计超时踢掉。第三种是双向心跳客户端和服务端各自维护last_active任何一边发现超时就主动断开。Demo 里用第一种足够生产环境建议至少用第二种因为被动刷新无法识别“连接还开着但收不到数据”的半死连接。# 服务端主动 ping 的定时任务第二种心跳策略 async def ping_monitor(): while True: for cid, (ws, last) in list(connections.items()): # 距离上次活跃超过 10 秒主动发 ping 探活 if time.time() - last 10: try: await ws.send_text(json.dumps({type: ping})) except Exception: await disconnect_cleanup(cid) await asyncio.sleep(5)注意我把list(connections.items())放在 for 循环里原因和广播一致——这个任务每 5 秒遍历一次遍历期间可能有客户端 connect/disconnect直接用 dict 的迭代器会报RuntimeError: dictionary changed size during iteration。这是踩坑高频点先记住。4. 浏览器端怎么接入连接生命周期、状态渲染与断线重连4.1 从 new WebSocket() 到 onmessage一个最小客户端骨架服务端有了浏览器端是另一半。很多 Demo 里客户端写得很随意但合格的客户端骨架应该包含五个生命周期回调onopen、onmessage、onerror、onclose、以及手动关闭。下面这段是前端最小可用的连接管理代码放在 Vue 或 React 里可以包成一个 hook 或 class。// 浏览器端 WebSocket 客户端最小管理类 class GameClient { constructor(url, { heartbeatInterval 5000 } {}) { this.url url; this.ws null; this.heartbeatTimer null; // 记录当前 tick用于检测丢帧 this.lastTick 0; // 重连参数失败后延迟递增最大 30 秒 this.reconnectDelay 1000; this.maxReconnectDelay 30000; // 手动关闭标记区分刻意关闭和意外断开 this.manuallyClosed false; } connect() { // 每次连接都要新建 WebSocket 实例旧实例不能复用 this.ws new WebSocket(this.url); this.ws.onopen () { console.log(connected at, new Date().toISOString()); // 连接成功后先发一个加入请求告诉服务端“我来了” this.send({ type: join, data: { name: this.playerName } }); // 启动心跳定时发一个 type: heartbeat 的帧 this.startHeartbeat(); // 重连成功重置延迟 this.reconnectDelay 1000; }; this.ws.onmessage (event) { this.handleMessage(JSON.parse(event.data)); }; this.ws.onerror (err) { // 注意onerror 之后一定会接 onclose所以错误处理只记日志 console.error(websocket error, err); }; this.ws.onclose (evt) { clearInterval(this.heartbeatTimer); // 不是手动关闭才走自动重连逻辑 if (!this.manuallyClosed) { this.scheduleReconnect(); } }; } send(obj) { // readyState 为 1 表示 OPEN发送前必须检查 if (this.ws this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify(obj)); } else { console.warn(drop message, socket not ready, obj); } } startHeartbeat() { this.heartbeatTimer setInterval(() { this.send({ type: heartbeat, data: { ts: Date.now() } }); }, 3000); } scheduleReconnect() { // 指数退避重连1s - 2s - 4s ... 封顶 30s setTimeout(() { console.log(reconnecting...); this.connect(); }, this.reconnectDelay); this.reconnectDelay Math.min(this.reconnectDelay * 2, this.maxReconnectDelay); } handleMessage(msg) { // 按 type 分发心跳回包直接忽略 if (msg.type ping) { this.send({ type: pong, data: {} }); return; } if (msg.type state) { this.renderState(msg.data); // 检测 tick 连续性发现跳帧主动请求全量同步 if (msg.tick ! this.lastTick 1) { this.send({ type: req_full_state, data: {} }); } this.lastTick msg.tick; } } }这段代码里的关键参数是 heartbeatInterval 和 reconnectDelay。心跳间隔设 3000 毫秒服务端超时设 15000 毫秒形成 1:5 的关系能容忍两次心跳丢失才判定死线。重连延迟用指数退避而不是固定 1 秒是为了避免大量客户端同时崩溃后同时撞向服务器造成“重连风暴”。见过不少踩坑案例——服务器一重启几千个客户端同时重连瞬间又把服务器压垮指数退避就是后悔药能显著缓解这个现象。4.2 渲染层策略全量渲染还是增量更新在线游戏 Demo 的客户端最常见的渲染陷阱是把每次收到的state帧直接塞进 canvas 重新绘制。如果 tick 频率是 20Hz每秒 20 帧每帧 10 个玩家全量重绘在本地没问题但一旦变成 40Hz、50 人和高分辨率背景性能立刻崩。比较好的做法是状态和渲染分离收到 state 只更新游戏状态对象渲染循环用requestAnimationFrame独立跑每帧从状态对象里取数据绘制。// requestAnimationFrame 渲染循环每帧只画当前状态快照 gameLoop() { const render () { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); // 遍历状态对象里的玩家列表逐个绘制 for (const p of this.gameState.players) { this.drawPlayer(p); // 画图形、血条、ID } requestAnimationFrame(render); }; requestAnimationFrame(render); }这段的核心思想是让 WebSocket 消息频率和浏览器渲染频率解耦。WebSocket 收到 30Hz 的状态帧requestAnimationFrame 在 60Hz 显示器上跑 60 帧中间用线性插值补间玩家移动看起来会非常顺滑。Demo 阶段不做插值也行但至少要明白WebSocket 只管数据到达渲染决定画面平滑度两者不是一回事。4.3 用 websocket test client 做联调前的自测动作写完前后端第一步不要急着用浏览器。打开你的 WebSocket test clientChrome 的 Simple WebSocket Client 插件或命令行里的 wscat手动连接ws://localhost:8000/ws/game发一段 JSON 看看服务端回不回包。这个动作能帮你快速分开问题连接失败是服务端没起、还是端口不对、还是握手被网关拦了。等 test client 跑通了再动浏览器你会少掉 70% 的联调时间。5. 房间与状态同步Demo 变产品的必经岔路口5.1 房间层设计连接池拆成房间消息只广播给同房间在线游戏哪怕只有两个人对战也需要“房间”这个中间层。连接池是全局的但消息广播必须限定在房间内否则所有玩家会收到所有人的状态帧数据量和信息泄漏同时失控。房间设计在 Demo 里通常是一个 dictroom_id - {players: [], clients: []}。玩家加入时指定 room_id服务端校验房间是否存在、是否满员然后把他加进去。# 房间管理的核心数据结构 rooms {} # room_id - {players: {}, clients: {}} async def player_join(ws, data): room_id data.get(room_id, default) player_name data.get(name, anonymous) # 首次加入自动创建房间Max 4 人示例 if room_id not in rooms: rooms[room_id] {players: {}, clients: {}} room rooms[room_id] if len(room[clients]) 4: await ws.send_text(json.dumps({ type: error, data: {message: room full} })) await ws.close(code4002, reasonroom full) return # 玩家 id 用 uuid不要用自增数字避免跨房间猜测 import uuid player_id str(uuid.uuid4())[:8] room[players][player_id] { name: player_name, x: 0, y: 0, hp: 100, } room[clients][player_id] ws # 一个玩家对应一个连接 await ws.send_text(json.dumps({ type: joined, data: {player_id: player_id, players: list(room[players].values())} }))注意player_id用uuid.uuid4()前 8 位而不是len(room[clients]) 1原因很具体自增 id 会让客户端能猜出其他玩家的 id一旦消息协议里没有做鉴权就等于给了别人伪造消息的钥匙。Demo 里可以无所谓但要养成这个习惯。房间容量限制写死在代码里没问题但是要把“房间满了”和“房间不存在”返回不同的错误码和关闭码不然客户端无法判断是换房间还是等一会再进。实际项目中我一般再加一个room_state字段标记 wait / playing / ended 三种状态玩家中途加入时直接拒绝或设为旁观者。5.2 状态同步增量 diff 还是整包快照房间里的状态帧有两种发布模式。整包快照每个 tick 广播全部玩家的 x、y、hp最简单但人数上去后数据量线性增长30Hz * 50 人 * 100 字节 150KB/s 每客户端带宽吃不消。增量 diff只广播本 tick 有变化的玩家和属性省流量但要求客户端能正确合并。Demo 阶段先做整包快照但设计数据结构时就要给增量留接口——把每个玩家的属性拆成固定字段将来做 diff 时按字段比较即可。代码里可以这样留def player_snapshot(player): # 所有玩家快照统一走这里后续做 diff 时只需比较两个快照 return { id: player[id], x: round(player[x], 2), # 保留两位小数减少 diff 误报 y: round(player[y], 2), hp: player[hp], }round到两位小数这个细节很多人在做增量同步时会遇到“坐标明明没动却一直 diff 出来”的怪现象原因就是服务端运算产生了0.30000000000000004这种浮点误差。提前把坐标精度归一化等于给 diff 算法吃了定心丸。5.3 离线玩家怎么处理保留一段时间还是立即清除玩家断网不等于退出游戏。浏览器突然卡顿、手机切后台、Wi-Fi 闪断都可能触发 WebSocket 断开但玩家大概率几秒后会回来。粗暴地立即清除玩家对象会导致他重连后发现自己没了体验极差。常见做法是保留 30 到 60 秒的“幽灵时间”——断开时标记disconnected_at不清除玩家实体但把它的位置标记为静止、把它的操作排队挂起如果重连成功直接复用原数据超时未归才清除。# 断线保留逻辑重连时复用原玩家实体 async def player_reconnect(ws, data): player_id data.get(player_id) if player_id in rooms[default][players]: player rooms[default][players][player_id] # 更新连接对象保留位置血量状态 rooms[default][clients][player_id] ws await ws.send_text(json.dumps({ type: welcome_back, data: {players: rooms[default][players]} }))这个“断线保留”机制是 Demo 和产品之间最容易拉开差距的地方。Demo 代码十有八九是断线直接清掉但真正跑在线上的游戏服务器必须处理“玩家掉线后重连”这个高频场景不然一局游戏打不完。6. 避坑清单WebSocket 在线游戏 Demo 里最常见的 5 个坑6.1 服务器在 Nginx 后面连不上忘记配置 Upgrade 头现象本地ws://localhost:8000/ws/game连接正常部署到服务器后用wss://域名连接一直报 400 或 502。原因Nginx 反代 WebSocket 需要显式声明 Upgrade 和 Connection 头默认配置只转发普通 HTTP 请求。解决location /ws/ { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; # 默认 60s 会导致每 60 秒强制断线 }注意proxy_set_header Connection upgrade是固定字符串不要用$connection_upgrade之类的映射变量除非你明确知道$http_upgrade为空时该传什么。proxy_read_timeout是另一个高频坑不设的话 Nginx 默认 60 秒没有任何上行数据就掐断连接游戏看起来像每 60 秒规律掉线一次。6.2 心跳不能防一切NAT 超时才是真凶现象客户端心跳正常服务端也一直有收到但游戏还是会掉线重连。原因运营商 NAT 设备可能在任何时间内化掉连接客户端只能发现 TCP 层还连着实际链路中间已经断了。解决仅靠应用层心跳不够要结合 TCP keepalive 和更激进的重连策略。具体到代码客户端send()成功不代表对方收到要依赖服务端回包确认链路可用。提示遇到“心跳着还掉线”的问题先检查服务器到公网之间是否有 NAT 或负载均衡设备再看中间层的空闲超时配置。6.3 消息变乱序不是 WebSocket 的问题是你没揉 tick现象多个客户端并发操作时A 玩家看到自己的位置和 B 玩家看到的不一致。原因WebSocket 保证单条连接内消息有序但不同玩家发的消息到达服务端的顺序天然不同服务端处理完再广播到达各客户端的顺序也可能不同。解决不要依赖到达顺序做状态判定一律以服务端广播里的 tick 为准。6.4 客户端收到 state 但画面不动canvas 绘制循环没跑现象控制台能看到 WebSocket 消息正常打印但页面上玩家没动。原因WebSocket 的 onmessage 里只更新了数据没有触发 canvas 重绘或者requestAnimationFrame在标签页切到后台时被浏览器暂停。解决确认 onmessage 里调了renderState并且渲染循环用requestAnimationFrame而不是setInterval。6.5 断线重连后状态错乱缺少全量同步机制现象玩家重连成功但画面上一半是自己一半是别人或者位置和服务器对不上。原因重连后只接收增量状态缺少一次全量状态同步。解决在onopen后的 join 消息里带上prev_player_id服务端识别到是重连时除了返回welcome_back还要立即广播一次全量快照而不是等下一个 tick。7. 把 Demo 改成可用的在线游戏验证压测、消息压缩和协议版本化Demo 跑通只是开始真正能上线还要做三件事协议兼容性规划、消息体积压测、以及断线重连的最终验证。第一是协议版本化。在线游戏只要用户不换浏览器客户端代码就是“流动作战”你没法强制所有人瞬间升级。所以从第一天起每条消息都该带ver字段。服务端收到低版本客户端的消息可以正常处理但返回时带上提醒收到高版本消息直接回unsupported_version。加一个字段的成本几乎为零但给将来的迭代留了极大的空间算是“后悔药”之一。第二是消息压缩。WebSocket 本身有 permessage-deflate 扩展浏览器默认支持服务端要在握手时把压缩打开但压缩对 64 字节以下的小帧反而增大开销所以小消息不要压缩大状态帧才压。第三是压测验证。不要只开两个浏览器自己跟自己玩就以为没问题至少用脚本模拟 100 个并发连接每个连接每 100 毫秒发一次 move 消息观察服务端 CPU、内存和消息延迟。不用写复杂工具一个 Python 脚本配合asyncio就能跑出基础并发数据。最后是我个人的习惯每次改完协议字段一定先用 websocket test client 手动连一次然后写一个只有 3 个断言的集成测试——连接成功、收到 state、心跳不被断开。这个习惯拯救过我太多次也建议你保持住。把一个 Demo 做成能跑的在线游戏难的部分从来不是 WebSocket 本身而是消息格式、状态同步和重连机制这些薄弱环节里的决策。希望帮到你。本文还有配套的精品资源点击获取