1. 从零搭建一个能扛住实时对战的游戏服务端
做联机卡牌对战最让人头疼的从来不是前端画得漂不漂亮,而是服务端怎么把两个玩家的操作实时、有序、不丢包地同步起来。我前后用 HTML+JS+WebSocket 这套组合做过好几个联机 Demo,从最早的“两个浏览器窗口互相发消息”到后来能支撑完整回合制卡牌流程的服务端,中间踩的坑足够写一本小册子。这篇就围绕“搭建游戏服务端”这个环节,把整个思路、选型、代码骨架和排查经验完整拆一遍。
先明确这个服务端到底要干什么。它不是一个简单的消息转发器,而是一个权威状态机:所有卡牌的位置、血量、回合归属、连锁结算顺序,都以服务端为准,客户端只负责渲染和发送“意图”。玩家点了一张卡,客户端发的是“我要召唤这张卡”,而不是“把这张卡移到场上”——后者是结果,前者是意图。这个区别决定了整个服务端的架构走向,也是新手最容易搞反的地方。
适合谁来参考?如果你已经能用 HTML+JS 画出卡牌界面、能用 WebSocket 收发简单消息,但一到“两个玩家怎么同步”“回合怎么切换”“操作冲突怎么处理”就卡住,那这篇就是写给你的。我会从协议设计讲到房间管理,再到断线重连和状态校验,尽量把每一步的“为什么”说清楚,代码给的是可直接跑的骨架,不是伪代码。
2. 服务端整体架构与核心技术选型
2.1 为什么是 WebSocket 而不是轮询
卡牌对战的核心体验是“对手操作后我这边要立刻看到”。如果用 HTTP 轮询,客户端每隔几百毫秒问一次“有更新吗”,延迟高不说,服务器还要承受大量空请求。WebSocket 建立一次连接后保持长通道,服务端可以主动推送,对手一出牌我这边几十毫秒内就能收到。对于回合制卡牌来说,这个实时性刚好够用,又不像 WebRTC 那样需要处理复杂的 NAT 穿透。
选 WebSocket 还有一个隐性好处:它天然是有状态连接。每个连接对应一个玩家,服务端能记住“这个 socket 属于哪个房间的哪个玩家”,断线时也能通过连接标识做重连映射。HTTP 是无状态的,每次请求都要重新带身份,做实时对战反而更麻烦。
2.2 权威服务端模型:客户端只发意图
这是整个架构里最重要的一条原则。我早期做过一个“客户端算好结果再同步”的版本,结果两个玩家同时操作时状态直接打架,A 客户端算出对手血量 3000,B 客户端算出 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方便排查时序问题。这个结构前后端共用,定义一份常量表:
| 消息类型 | 方向 | 作用 |
|---|---|---|
| join | C→S | 请求加入房间 |
| ready | C→S | 准备就绪 |
| action | C→S | 提交操作意图 |
| state | S→C | 广播完整状态 |
| event | S→C | 广播单个事件(如抽卡动画) |
| error | S→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,但对排查的帮助巨大。