本系列记录:从 0 到 1 实现一个 Balatro 风格的游戏后端系统。涵盖规则实现、架构设计、WebSocket 通信、模块拆分以及工程化演进。
- 项目地址:balatro-realtime-backend
- 📌 对应代码版本:
feat: add deck shuffling and server-side dealing state(2026-05-06) - ⚠️ 本文基于该 commit 讲解,仓库代码可能已更新
- 📌 对应代码版本:
📚 系列文章:
(1)项目规划与牌型判断实现
(2)NestJS框架搭建与项目结构设计
(3)洗牌、发牌与服务端牌堆状态管理(当前)
(4)出牌 / 弃牌 / 补牌
(5)得分计算与回合结算
(6)Blind关卡状态设计与回合推进实现(开发中)
✅前两篇里,我已经完成了:
- 牌型判断
- NestJS 架构搭建
- Game / Poker 模块拆分
- WebSocket 接入
但到这里,其实还有一个问题没有真正解决:游戏还没有“状态”。之前的逻辑,本质上都还是: 输入 -> 计算 -> 返回结果
比如:
- 输入牌
- 判断牌型
- 返回结果
这类逻辑虽然已经可以运行,但它更像:“静态规则计算”,而不是:“真正运行中的游戏”。
因为真实游戏一定会涉及:
- 当前牌堆剩余多少牌
- 玩家当前手牌是什么
- 当前回合进行到哪里
- 剩余多少次出牌机会
也就是说:
游戏必须开始“记住状态”。
所以这一篇,我开始正式进入:
🎯 服务端游戏状态管理阶段。也就是让服务器“记住游戏”
✅本篇实现了什么
这一篇主要实现:
- 服务端牌堆状态管理
- 洗牌逻辑
- 发牌流程
- deck / hand 状态维护
重点会解决:
- 服务端如何维护一副完整扑克牌
- 如何实现可控洗牌
- 发牌过程中如何保证状态一致
- 如何设计
deck数据结构
这一阶段完成后:
项目开始从“规则判断”进入“动态游戏状态管理”。
一、为什么要由服务端控制发牌
一开始我先问了自己一个问题:
如果让客户端主导发牌,会发生什么?
很快脑子里就冒出了很多问题:
- 掉线之后还能恢复当前牌局么?
- 是否更容易作弊?
- 游戏过程能不能存档?
- 能不能做对局复盘?
- 网络波动后状态还能衔接么?
这些问题想了一圈之后,我发现:
把状态交给客户端,风险太大了。
因为:
- 状态同步
- 数据校验
- 游戏存档
- 逻辑计算
这些本来就是服务端更擅长的事情。
所以这一阶段,我明确了一件事:
👉 牌堆状态必须由服务端维护。
而不是交给客户端。
二、什么是“牌堆状态”
当前阶段,服务器会维护两类核心状态:
-
deck:当前剩余牌堆 -
hand:当前玩家手牌
其中:
-
deck:只保存还未发出的牌 -
hand:保存已经发给玩家的牌
当前结构:
interface Card {
rank: number;
suit: Suit;
}
Deck = {
deck: Card[],
hand: Card[],
}
三、洗牌实现
这里一开始其实先确定了一件事:
当前游戏到底是“有限牌堆”还是“无限牌堆”?
而 Balatro 很明显属于:
- 固定 52 张牌
- 固定牌面
- 固定牌堆数量
并且:每张牌出现的位置概率应该完全相等。
所以这里最终选择:Fisher-Yates shuffle(官方解释: “The Fisher–Yates shuffle is an algorithm for shuffling a finite sequence. The algorithm produces an unbiased permutation: every permutation is equally likely.”)
核心其实就两个词:
- 有限序列
- 等概率
刚好符合当前需求。
为什么不用 sort 随机
这里其实还有一种非常常见的写法:
deck.sort(() => Math.random() - 0.5)
但这种方式:并不能保证真正的“等概率随机”。所以并不适合作为真正洗牌算法。
而 Fisher-Yates 的核心优势就在:每次交换概率完全均匀。
四、发牌逻辑
1. 发牌代码
核心逻辑:
const hand = this.serializeCards(deck.splice(0, handSize));
2. 为什么使用 splice
这里一开始其实有考虑:slice,但后面还是改成了:splice
原因很简单:slice 本质上是:copy,原数组不会修改。
但发牌真正需要的是:发出去之后,deck 本身就要减少。
也就是说:发牌的本质是“修改状态”。
如果使用 slice,则还需要:
- remainDeck
- 再重新赋值
无论步骤还是状态维护,都会更麻烦。
所以最后还是选择 deck.splice(0, handSize) 直接修改原牌堆。
3. 发牌真正的本质是什么
很多时候会把发牌理解成:
从数组里取几张牌。
但在服务端游戏设计里:
发牌本质上其实是“状态变化”。
具体表现为:
deck减少hand增加- 状态被记录
所以这里真正重要的不是:取牌,而是:状态更新
五、当前阶段的状态设计
从这一阶段开始:
服务器开始真正维护“运行中的游戏状态”。
当前阶段先实现基础版:
type GameState = {
playerId: string;
deck: Card[]; // remain deck
hand: string[]; // current hand
playsLeft: number;
discardsLeft: number;
round: number;
score: number;
};
当前只是先把:GameState 基础结构写出来。暂时先放在:poker.types.ts 后面可能会继续调整位置和结构。
六、与当前结构的关系
当前阶段已经存在:
PokerServiceGameService
其中:
-
PokerService负责:“牌”相关规则与工具能力 -
GameService负责:游戏流程与状态
目前因为还没有:room,player 等概念。
所以很多状态还暂时放在:poker 里。
后面随着复杂度增加:这些状态还会继续拆分。
为什么现在不继续细拆
因为我发现:
项目初期过度拆分,反而容易边界混乱。
现阶段比起“绝对正确架构”,更重要的是:
系统能稳定推进。
七、代码实现
1. 当前发牌调用链
Client
↓
GameGateway
↓
GameService
↓
PokerService.dealCards()
↓
修改服务器牌堆状态
↓
返回 hand
2. 洗牌实现
private initPlayerState(playerId: string): GameState {
const deck = this.shuffleDeck(this.getBaseDeck());
this.gameStates[playerId] = {
playerId: playerId,
deck: deck,
hand: [],
playsLeft: 5,
discardsLeft: 3,
round: 1,
score: 0,
};
Logger.log(`-${playerId}- initPlayerState: ${JSON.stringify(this.gameStates[playerId])}`);
return this.gameStates[playerId];
}
private shuffleDeck(deck: Card[]): Card[] {
for (let i = deck.length - 1; i > 0; i--) {
const randomNum = Math.floor(Math.random() * (i + 1));
[deck[i], deck[randomNum]] = [deck[randomNum], deck[i]];
}
return deck;
}
3. 发牌实现
let playerState: GameState;
playerState = this.initPlayerState(playerId);
const deck = playerState.deck;
const hand = this.serializeCards(deck.splice(0, handSize));
这里:
- 服务端内部使用
Card[] - 返回给前端统一转:
string[]
转换逻辑:
private serializeCards(cards: Card[]): string[] {
return cards.map((card) => {
const rank = NUMBER_TO_RANK_MAP[card.rank] ?? card.rank;
return `${rank}${card.suit}`;
});
}
4. dealCards
当前设计中:round === 1 表示:新游戏开始。
这一阶段服务器会:
- 创建新牌堆
- 洗牌
- 初始化玩家状态
- 保存 GameState
public dealCards(data: DealCardsInput): DealResult {
const { playerId, handSize, round } = data;
let playerState: GameState;
if (round === 1) {
playerState = this.gameStates[playerId];
if (!playerState) {
playerState = this.initPlayerState(playerId);
}
} else {
playerState = this.gameStates[playerId];
}
if (!playerState) {
return { hand: [], remainingDeckCount: 0, playsLeft: 0 };
}
const deck = playerState.deck;
const hand = this.serializeCards(deck.splice(0, handSize));
playerState.hand = hand;
playerState.deck = deck;
playerState.round = round;
if (round != 1) playerState.playsLeft--;
return {
remainingDeckCount: deck.length,
playsLeft: playerState.playsLeft,
hand
};
}
八、总结
1. 当前阶段完成内容
- ✔ 牌堆状态管理
- ✔ 洗牌逻辑
- ✔ 发牌流程
项目开始从:规则判断, 进入:游戏状态控制
2. 这一阶段真正的变化
前两篇更多还是:输入 -> 计算 -> 返回结果
而从这一阶段服务器开始真正维护:
- 当前剩余牌堆
- 当前手牌
- 当前回合
- 剩余操作次数
也就是说:
项目开始从“功能实现”进入“游戏引擎状态管理”。
3. 当前阶段的几个设计原则
这一阶段我其实慢慢开始明确几个原则:
- 状态必须由服务端控制
- 游戏逻辑本质是“状态变化”
- 初期优先保证流程可运行
- 架构应该随着复杂度逐步演进
这些东西后面会在回合系统、出牌、补牌、结算里会反复出现。
4. 下一步
当前阶段服务器已经可以维护:
- deck
- hand
- 基础 GameState
下一步会开始进入:
- 出牌
- 弃牌
- 补牌
- 回合推进
也意味着:GameState 会逐渐成为整个游戏后端的核心。