资讯详情

从零搭建实时对战游戏服务端:WebSocket权威状态机与房间管理实战

📅 2026/10/17 23:53:14 | 华诺云谱 👁 阅读
从零搭建实时对战游戏服务端:WebSocket权威状态机与房间管理实战
1. 从零搭建一个能扛住实时对战的游戏服务端做联机卡牌对战最让人头疼的从来不是前端画得漂不漂亮而是服务端怎么把两个玩家的操作实时、有序、不丢包地同步起来。我前后用 HTMLJSWebSocket 这套组合做过好几个联机 Demo从最早的“两个浏览器窗口互相发消息”到后来能支撑完整回合制卡牌流程的服务端中间踩的坑足够写一本小册子。这篇就围绕“搭建游戏服务端”这个环节把整个思路、选型、代码骨架和排查经验完整拆一遍。先明确这个服务端到底要干什么。它不是一个简单的消息转发器而是一个权威状态机所有卡牌的位置、血量、回合归属、连锁结算顺序都以服务端为准客户端只负责渲染和发送“意图”。玩家点了一张卡客户端发的是“我要召唤这张卡”而不是“把这张卡移到场上”——后者是结果前者是意图。这个区别决定了整个服务端的架构走向也是新手最容易搞反的地方。适合谁来参考如果你已经能用 HTMLJS 画出卡牌界面、能用 WebSocket 收发简单消息但一到“两个玩家怎么同步”“回合怎么切换”“操作冲突怎么处理”就卡住那这篇就是写给你的。我会从协议设计讲到房间管理再到断线重连和状态校验尽量把每一步的“为什么”说清楚代码给的是可直接跑的骨架不是伪代码。2. 服务端整体架构与核心技术选型2.1 为什么是 WebSocket 而不是轮询卡牌对战的核心体验是“对手操作后我这边要立刻看到”。如果用 HTTP 轮询客户端每隔几百毫秒问一次“有更新吗”延迟高不说服务器还要承受大量空请求。WebSocket 建立一次连接后保持长通道服务端可以主动推送对手一出牌我这边几十毫秒内就能收到。对于回合制卡牌来说这个实时性刚好够用又不像 WebRTC 那样需要处理复杂的 NAT 穿透。选 WebSocket 还有一个隐性好处它天然是有状态连接。每个连接对应一个玩家服务端能记住“这个 socket 属于哪个房间的哪个玩家”断线时也能通过连接标识做重连映射。HTTP 是无状态的每次请求都要重新带身份做实时对战反而更麻烦。2.2 权威服务端模型客户端只发意图这是整个架构里最重要的一条原则。我早期做过一个“客户端算好结果再同步”的版本结果两个玩家同时操作时状态直接打架A 客户端算出对手血量 3000B 客户端算出 2800谁也说服不了谁。后来改成权威服务端客户端只发操作意图服务端校验合法性、执行结算、广播新状态。具体来说客户端发的是这样的消息{ type: summon, cardId: card_001, position: attack }服务端收到后做三件事检查是不是当前回合玩家的操作、检查这张卡在不在手牌、检查场上区域够不够全部通过才执行召唤然后把新的完整状态广播给房间内所有玩家。任何一步不通过就回一条错误消息给发起者状态不变。这个模型的好处是状态只有一个真相来源永远不会出现两端不一致。代价是服务端要承担全部规则逻辑代码量比“转发式”服务端大不少但这是值得的。2.3 技术栈的取舍服务端语言我选的是 Node.js理由很直接前端已经是 JS卡牌规则逻辑可以在前后端复用同一套数据结构和部分校验函数减少“前端算一套、后端算一套”的重复劳动。WebSocket 库用ws轻量、API 简单、社区成熟不需要引入 Socket.IO 那种带自动降级和房间封装的重量级方案——我们的房间逻辑自己写反而更可控。存储方面房间状态全部放内存。卡牌对战是短会话一局打完房间就销毁没必要落库。玩家账号、卡组这些持久化数据如果要做再单独接数据库和实时对战逻辑解耦。这个分层很重要别把实时状态和持久数据混在一起。提示内存存房间状态意味着服务端重启会丢所有对局。开发阶段完全没问题上线前如果要保证不丢局需要加一层状态快照或 Redis 持久化但那是后期优化不要一开始就上。3. 通信协议设计消息格式与回合同步机制3.1 消息格式的统一约定协议设计的第一原则是可扩展。我见过有人用纯字符串summon:card_001这种格式加个字段就要改解析逻辑很快就乱了。统一用 JSON并且固定一个外层结构{ type: 消息类型, seq: 12, payload: { ... }, timestamp: 1700000000000 }type决定服务端怎么处理seq是客户端自增的序列号用于去重和确认payload装具体数据timestamp方便排查时序问题。这个结构前后端共用定义一份常量表消息类型方向作用joinC→S请求加入房间readyC→S准备就绪actionC→S提交操作意图stateS→C广播完整状态eventS→C广播单个事件如抽卡动画errorS→C操作被拒绝ping/pong双向心跳保活state和event分开是有讲究的。state是权威快照客户端收到后直接覆盖本地状态event是增量提示比如“对手抽了一张卡”客户端只播动画不改状态。这样即使 event 丢了下一次 state 同步也能纠正回来容错性更好。3.2 回合与优先权的同步卡牌对战的回合不是简单的“你一次我一次”。以常见的规则为例一个回合分抽卡阶段、准备阶段、主要阶段、战斗阶段、结束阶段某些阶段还有优先权转移比如对手可以在你召唤时发动陷阱。服务端要维护一个阶段状态机const PHASES [draw, standby, main1, battle, main2, end];每个房间记录currentPhase和currentPlayer。玩家提交操作时服务端先判断这个操作在当前阶段是否合法。比如战斗阶段才能宣言攻击主要阶段才能召唤。优先权转移用一个priorityHolder字段记录默认是回合玩家对手发动陷阱后优先权回到回合玩家。这里有个容易忽略的点操作队列。如果两个玩家几乎同时发操作服务端要按到达顺序串行处理不能并发。Node.js 是单线程事件循环天然串行但要注意在异步操作比如读数据库期间不要处理新消息否则会乱序。我的做法是每个房间维护一个操作队列消息进来先入队处理完一个再出队下一个。3.3 状态校验的边界服务端校验要严到什么程度我的经验是宁可拒绝不可放过。客户端传来的任何数据都不可信。校验清单至少包括操作者是不是当前优先权持有者操作类型在当前阶段是否允许涉及的卡牌是否在合法区域手牌、场上、墓地资源是否足够比如召唤需要的祭品目标是否合法攻击对象是否存在任何一条不满足回error消息并附上原因码客户端据此提示玩家。不要试图“帮玩家修正”那会引入更多歧义。4. 房间管理与玩家连接生命周期4.1 房间的创建与匹配房间是服务端的核心容器。一个房间对象至少包含{ id: room_xxx, players: [{ id, socket, deck, hand, field, hp }, ...], state: waiting | playing | ended, turn: 0, currentPlayer: 0, currentPhase: draw, priorityHolder: 0, actionQueue: [] }匹配逻辑我做得比较简单玩家点“开始对战”后进入匹配队列队列里凑够两人就创建房间。如果要支持好友对战就加一个房间号机制创建者拿到房间号另一人输入房间号加入。两种模式共用同一套房间对象只是入口不同。房间 ID 用随机字符串生成不要用自增数字否则容易被猜到房间号乱入。我一般用crypto.randomUUID()或者时间戳加随机数拼接。4.2 连接与断线处理WebSocket 连接建立后服务端先不分配房间等玩家发join消息再绑定。每个连接上挂一个playerId方便反查。断线是必然会发生的处理策略分两种对局中短暂断线保留玩家在房间里的位置标记为disconnected给一个重连窗口比如 60 秒。期间对手的操作正常处理断线玩家的状态冻结。重连时用playerId找回房间服务端把当前完整状态推给他。超时未重连判定该玩家认输房间结束通知对手。重连的关键是状态可恢复。因为服务端持有完整状态重连时直接推一份state消息即可客户端不需要自己缓存。这也是权威服务端模型的又一个好处。注意重连窗口期间要暂停回合计时如果有的话否则断线玩家回来发现回合已经过了体验很差。4.3 心跳保活WebSocket 连接在中间有代理或长时间无数据时可能被静默断开。服务端要主动发心跳setInterval(() { rooms.forEach(room { room.players.forEach(p { if (p.socket.readyState WebSocket.OPEN) { p.socket.send(JSON.stringify({ type: ping, timestamp: Date.now() })); } }); }); }, 30000);客户端收到ping回pong服务端记录最后一次pong时间超过两个心跳周期没回就判定断线。心跳间隔 30 秒是个经验值太短浪费流量太长断线检测不及时。5. 核心代码骨架与关键环节实现5.1 服务端启动与连接接入先搭最基础的骨架。用ws库起一个 WebSocket 服务const WebSocket require(ws); const crypto require(crypto); const wss new WebSocket.Server({ port: 8080 }); const rooms new Map(); const matchQueue []; wss.on(connection, (socket) { socket.playerId crypto.randomUUID(); socket.roomId null; socket.on(message, (raw) { let msg; try { msg JSON.parse(raw); } catch (e) { return socket.send(JSON.stringify({ type: error, code: BAD_JSON })); } handleMessage(socket, msg); }); socket.on(close, () { handleDisconnect(socket); }); });这段代码里有两个细节值得说。第一JSON.parse一定要包 try-catch客户端发来的数据不可信解析失败直接回错误不能让异常把服务端搞崩。第二playerId在连接建立时就分配而不是等 join 时再分配这样断线重连时可以用它做映射。5.2 消息分发与操作入队handleMessage是总入口按type分发function handleMessage(socket, msg) { switch (msg.type) { case join: return handleJoin(socket, msg); case ready: return handleReady(socket); case action: return enqueueAction(socket, msg); case pong: return (socket.lastPong Date.now()); default: return socket.send(JSON.stringify({ type: error, code: UNKNOWN_TYPE })); } }enqueueAction把操作塞进房间队列然后触发处理function enqueueAction(socket, msg) { const room rooms.get(socket.roomId); if (!room) return socket.send(JSON.stringify({ type: error, code: NO_ROOM })); room.actionQueue.push({ socket, msg }); processQueue(room); } function processQueue(room) { if (room.processing) return; room.processing true; while (room.actionQueue.length 0) { const { socket, msg } room.actionQueue.shift(); try { handleAction(room, socket, msg); } catch (e) { socket.send(JSON.stringify({ type: error, code: ACTION_FAILED, detail: e.message })); } } room.processing false; }processing标志位防止重入保证同一时刻只有一个操作在处理。这个模式在单线程的 Node.js 里足够用不需要真正的锁。5.3 操作校验与状态广播handleAction是规则核心。以召唤为例function handleAction(room, socket, msg) { const playerIndex room.players.findIndex(p p.id socket.playerId); if (playerIndex ! room.currentPlayer) { return socket.send(JSON.stringify({ type: error, code: NOT_YOUR_TURN })); } if (room.currentPhase ! main1 room.currentPhase ! main2) { return socket.send(JSON.stringify({ type: error, code: WRONG_PHASE })); } const player room.players[playerIndex]; const cardIndex player.hand.findIndex(c c.id msg.payload.cardId); if (cardIndex -1) { return socket.send(JSON.stringify({ type: error, code: CARD_NOT_IN_HAND })); } // 执行召唤 const [card] player.hand.splice(cardIndex, 1); card.position msg.payload.position; player.field.push(card); // 广播新状态 broadcastState(room); }broadcastState把房间状态序列化后发给所有玩家function broadcastState(room) { const snapshot serializeRoom(room); room.players.forEach(p { if (p.socket.readyState WebSocket.OPEN) { p.socket.send(JSON.stringify({ type: state, payload: snapshot })); } }); }serializeRoom要过滤掉不能给客户端看的信息比如对手的手牌内容。给每个玩家单独生成一份视图自己的手牌是明牌对手的手牌只给数量。这个“按玩家视角序列化”是卡牌游戏服务端的标配别偷懒直接广播完整状态。5.4 回合推进与阶段切换回合推进由服务端主动触发。玩家发endTurn操作后服务端切换currentPlayer重置阶段到draw然后执行抽卡function endTurn(room) { room.currentPlayer 1 - room.currentPlayer; room.turn 1; room.currentPhase draw; room.priorityHolder room.currentPlayer; const player room.players[room.currentPlayer]; if (player.deck.length 0) { const card player.deck.pop(); player.hand.push(card); } else { // 卡组抽空判负 return endGame(room, 1 - room.currentPlayer); } broadcastState(room); }阶段切换可以做成自动的也可以让玩家手动点“进入战斗阶段”。我倾向于关键阶段手动、次要阶段自动减少玩家操作负担。具体怎么切取决于你实现的规则集服务端只要保证currentPhase的流转是受控的就行。6. 常见问题排查与实战避坑经验6.1 状态不同步的排查思路最常见的现象是“我这边看到对手场上有三张卡对手那边只有两张”。排查顺序先看服务端日志确认broadcastState有没有正常发出发了几次。检查客户端是不是漏处理了某条state消息或者处理时抛异常中断了。检查serializeRoom有没有按玩家视角正确过滤别把对手手牌数量算错。确认没有两个操作并发处理导致状态覆盖。我遇到过一次原因是客户端在收到state后做动画动画期间又收到一条state后一条被前一条的动画回调覆盖了。解决办法是给状态加版本号客户端只接受版本号更大的状态。6.2 消息乱序与重复WebSocket 本身保证消息有序但客户端重连后可能重发未确认的操作。服务端用seq去重每个玩家记录lastSeq收到seq lastSeq的消息直接丢弃。这个机制在弱网环境下特别有用。问题现象可能原因解决方向操作无响应消息类型未注册检查 switch 分支状态回退旧 state 覆盖新 state加版本号只接受更新的重复执行重连后重发seq 去重对手看不到我的操作广播漏了某个玩家检查 players 遍历服务端崩溃JSON 解析异常try-catch 包裹6.3 性能与内存的注意事项房间对象要及时销毁。对局结束后从roomsMap 里删掉断开所有 socket 的房间引用否则内存会慢慢涨。我一般在对局结束 30 秒后清理给客户端留一点时间播结算动画。另外broadcastState发的是完整状态如果卡牌很多、状态很大每条消息可能几十 KB。优化方向是只发变化的部分增量同步但增量同步的复杂度高很多容易出 bug。我的建议是先用全量同步把功能跑通等确实有性能瓶颈再优化。回合制卡牌的操作频率很低全量同步完全扛得住。提示开发阶段可以在服务端加一个/debug的 HTTP 接口返回所有房间的当前状态排查问题时直接浏览器打开看比翻日志快得多。6.4 安全相关的底线虽然是 Demo但有几条底线要守。第一永远不要相信客户端传来的玩家 ID用 socket 上绑定的playerId反查。第二操作频率要做限制防止有人写脚本疯狂发消息简单的令牌桶就够。第三消息体大小设上限ws可以在创建 Server 时传maxPayload防止超大消息打爆内存。这些在开发阶段加上后期就不用返工。7. 我个人在实际操作中的几点体会搭这个服务端最深的体会是先想清楚状态归谁管再动手写代码。我最早那版就是没想清楚客户端和服务端各算各的调试时两边日志对不上查了一整晚才发现是结算顺序不一致。后来改成权威服务端虽然服务端代码多了几百行但调试时间反而大幅下降因为任何时候只要看服务端状态就知道“真相”是什么。另一个体会是协议要早定、定死。我中途改过一次消息格式结果客户端所有处理函数都要跟着改牵一发动全身。现在我的习惯是先把消息类型表列出来每种消息的 payload 结构写清楚再开始写代码后面基本不用大改。最后分享一个小技巧服务端加一个“回放”功能把每个房间收到的所有操作按顺序存下来。出 bug 时把操作序列重放一遍能稳定复现问题比靠记忆描述“我当时点了什么”靠谱得多。这个功能实现起来很简单就是往数组里 push但对排查的帮助巨大。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑