- 本系列记录:从 0 到 1 实现一个 Balatro 风格的游戏后端系统, 包括规则实现、架构设计、WebSocket 通信、模块拆分以及后续工程化改造。
- GitHub地址: balatro-realtime-backend
- 📌 本文对应代码版本:
feat: initialize NestJS modules, add WebSocket gateway, migrate hand evaluator(2026年3月16日 16:36) - ⚠️ 注意:由于项目持续迭代,当前仓库代码可能已发生变化,本文内容基于该 commit 版本进行说明。
- 📌 本文对应代码版本:
第一篇里,我只是把牌型判断逻辑跑通了。
但写完之后,我很快意识到一个问题:
如果后面继续往里面加:
- WebSocket 通信
- 游戏状态管理
- 房间系统
- 数据库存储
这个结构迟早会开始失控。
因为第一版的代码,本质上还是:
“单文件逻辑”
而不是一个真正的后端系统。
所以这一篇开始,我决定先不继续堆功能,而是先把整个项目迁移到 NestJS,把后面的架构基础先搭起来。
📚 系列文章:
(1)项目规划与牌型判断实现
(2)NestJS框架搭建与项目结构设计(当前)
(3)洗牌、发牌与服务端牌堆状态管理
(4)玩家手牌操作(出牌 / 弃牌 / 补牌)与状态流转设计
(5)得分计算与单局结算流程实现(开发中)
✅本篇实现了什么
这一篇主要完成:
- 将原本的单文件逻辑迁移到
NestJS - 拆分
Game与Poker模块 - 接入
WebSocket - 建立
Gateway→Service→ 业务模块调用链
最终得到一个:
👉 具备模块化结构,并支持 WebSocket 调用的后端基础框架。
一、为什么要迁移到 NestJS
1. 存在的问题
第一篇中,牌型判断逻辑还是以 handEvaluator.ts 的形式直接使用原生 TypeScript 编写。
所有逻辑都集中在单个文件里。
这种方式在项目初期其实非常直接。
因为:
- 功能少
- 文件少
- 调试也方便
但问题是:
随着功能越来越多,单文件结构迟早会开始变乱。
2. 预期规划
从一开始决定做这个 Balatro 后端时,我其实就已经预期:
后面一定会继续加入:
- WebSocket 通信
- 网关分发
- 数据库存储
- 模块拆分
- 更低耦合的业务结构
如果继续手动组织文件,后面:
- 职责会越来越混乱
- 模块依赖越来越复杂
- 维护成本也会越来越高
所以这一阶段,我决定先把项目迁移到后端框架中,把后面的结构基础提前搭好。
3. 为什么最后选择了 NestJS
当时我对比过:
- Express
- Pomelo
- NestJS
最后还是选择了 NestJS。
主要原因:
- 模块化结构比较完整
- 自带依赖注入(DI)
- 官方支持 WebSocket
- 更适合长期维护项目
相比继续使用原生方式手动组织文件,NestJS 更像是在一开始就给项目建立“结构约束”。
这样后面继续扩展时,不容易失控。
二、为什么要把 poker 单独做成 module,而不是写在 game 里
1. 为什么要拆分 poker 模块
在项目结构设计时,我并没有把所有逻辑都放进 game 模块。
而是单独拆出了一个 poker 模块。
原因其实很简单:
不同逻辑,本身就属于不同领域。
比如:
- 扑克牌规则
- 出牌流程
- 房间管理
- 玩家连接
这些其实是完全不同的东西。
如果全部写在一个模块里,后面代码量一大,很容易变成“一锅粥”。
所以从一开始,我就尽量按“业务领域”拆模块,让每个模块只负责自己的事情。
另一方面,NestJS 本身就是一个非常强调模块化的框架。
既然已经选择了它,我也更倾向于按照它推荐的结构去组织项目。
这样后面扩展时会更自然,比如以后新增:
room/
player/
blind/
modifier/
都可以继续往下拆,而不是把所有逻辑继续塞进 game。
2. 总结
这种“按业务领域拆模块”的方式,其实在很多后端项目里都很常见。
无论是游戏后端,还是电商、推荐系统,本质上都是:
规则、流程、通信,各自拆开。
这样后面维护会轻松很多。
三、为什么使用 WebSocket 而不是 HTTP
1. 为什么使用 WebSocket
在通信方式上,我没有选择 HTTP,而是直接用了 WebSocket。
因为:
HTTP 更适合:
- 查询
- 一次性请求
- 请求-响应模型
而游戏后端更需要:
- 长连接
- 实时交互
- 状态同步
尤其是后面:
- 出牌
- 房间
- 玩家同步
这些功能加进来之后,如果继续使用 HTTP,会需要额外处理大量状态问题。
所以我一开始就决定:
直接按实时系统去设计。
因此项目最终使用:
NestJS GatewayWebSocket
来作为核心通信方案。
2. 总结
相比 HTTP 的请求-响应模式:
WebSocket 更适合这种需要持续状态交互的系统。
尤其是在游戏后端里,基本已经算是标准选择。
四、为什么 handEvaluator 不再是单文件,而是放进 PokerService
第一版里,牌型判断全部写在:
handEvaluator.ts
这种方式在功能少的时候很直接。
但开始模块化之后,再继续保持单文件结构,其实已经不太适合了。
在当前项目中:
-
GameService: 负责流程控制 -
PokerService: 负责规则计算 -
Gateway: 负责通信入口
我开始有意识地把:“规则” 和 “流程” 拆开。
因为牌型判断本质上属于:扑克牌规则,而不是游戏流程。
所以最后我把: handEvaluator.ts 迁移进了:PokerService
这样后面:
- 测试
- 扩展
- AI模拟
- Modifier系统
都会更容易处理。
五、WebSocket 请求是如何从 Gateway 到 Service 再到 PokerService 的
1. 请求逻辑
当前调用链是:
Client
↓
Gateway
↓
GameService
↓
PokerService
客户端消息先进入:Gateway
由 Gateway 负责接收 WebSocket 请求。
之后:
-
GameService: 负责流程控制 -
PokerService: 负责规则计算
计算完成后:
PokerService
↓
GameService
↓
Gateway
↓
Client
再逐层返回。
这种拆分方式,其实也是我后面一直在坚持的一个原则:
通信、流程、规则,尽量分层。
这样后面功能越来越多的时候,结构不会乱掉。
六、当前项目结构设计
1. 当前目录结构
src/
├─ game/
│ ├─ game.gateway.ts
│ ├─ game.module.ts
│ └─ game.service.ts
├─ poker/
│ ├─ poker.constants.ts
│ ├─ poker.module.ts
│ ├─ poker.service.ts
│ └─ poker.types.ts
├─ app.module.ts
└─ main.ts
2. 结构说明
当前结构里:
-
game: 负责流程与通信 -
poker: 负责扑克牌规则
在 poker 模块内部:
constantstypesservice
也分别拆开。
而不是全部塞进一个文件。
这样逻辑会清晰很多。
3. 代码实现
下面代码展示了从客户端请求到牌型计算的完整调用链:
Client→Gateway→GameService→PokerService建议重点关注:
Gateway如何接收WebSocket消息GameService如何承接流程控制PokerService如何完成牌型计算
1. game.gateway.ts
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
WebSocketGateway,
} from "@nestjs/websockets";
import { Logger } from "@nestjs/common";
import { GameService } from "./game.service";
type GatewayClient = WebSocket & {
_socket?: {
remoteAddress?: string;
remotePort?: number;
};
__clientId?: string;
};
@WebSocketGateway()
export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect {
private readonly logger = new Logger(GameGateway.name);
private clients = new Map<string, WebSocket>();
private clientIdCounter = 1;
constructor(private readonly gameService: GameService) {}
handleConnection(@ConnectedSocket() client: GatewayClient) {
const sock = client._socket;
const ip = sock?.remoteAddress;
const port = sock?.remotePort;
const id = ip && port ? `${ip}:${port}` : `client_${this.clientIdCounter++}`;
client.__clientId = id;
this.clients.set(id, client);
this.logger.log(`Client connected: ${ip}:${port}, assigned ID: ${id}`);
}
handleDisconnect(@ConnectedSocket() client: GatewayClient) {
const id = client.__clientId;
if (id) {
this.clients.delete(id);
this.logger.log(`Client disconnected: ${id}`);
} else {
this.logger.log(`Client disconnected: unknown client`);
}
}
@SubscribeMessage("message")
handleMessage(@MessageBody() data: string, @ConnectedSocket() client: GatewayClient): string {
this.logger.log(`Received message from client ${client.__clientId}: ${data}`);
return data;
}
@SubscribeMessage("handEvaluator")
handleHandEvaluator(@MessageBody() data: string[], @ConnectedSocket() client: GatewayClient): number {
const handType = this.gameService.playCard(data);
this.logger.log(`Received hand evaluation request from client ${client.__clientId}: ${JSON.stringify(data)}`);
return handType;
}
}
2. game.service.ts
import { Injectable, Logger } from "@nestjs/common";
import { PokerService } from "../poker/poker.service";
@Injectable()
export class GameService {
private readonly logger = new Logger(GameService.name);
constructor(private readonly pokerService: PokerService) {}
playCard(cards: string[]): number {
const handType = this.pokerService.getCardType(cards);
return handType;
}
}
3. poker.service.ts
PokerService作为规则层模块,不依赖任何游戏流程状态,仅负责纯计算逻辑。 这种“纯函数式服务”的设计,可以让规则层在测试、复用以及未来扩展时更加灵活。
import { Injectable, Logger } from "@nestjs/common";
import { Card, Suit } from "./poker.types";
import { RANK_MAP, CARD_TYPE } from "./poker.constants";
@Injectable()
export class PokerService {
private readonly logger = new Logger(PokerService.name);
constructor() {}
public getCardType(cards: string[]): number {
this.logger.log(`Evaluating hand: ${JSON.stringify(cards)}`);
const userCard = this.parseCard(cards);
const suitCount = Object.values(this.checkSuitCount(userCard));
const isFlush = suitCount.includes(5);
const sortedRanks = userCard.map((card) => card.rank).sort((a, b) => a - b);
let isStraight = false;
if (userCard.length === 5) {
isStraight = true;
const uniqueRanks = new Set(sortedRanks);
if (uniqueRanks.size !== 5) {
isStraight = false;
} else {
for (let i = 1; i < sortedRanks.length; i++) {
if (sortedRanks[i] - 1 != sortedRanks[i - 1]) {
isStraight = false;
break;
}
}
// sortedRanks.join() 判断sortedRanks中的元素是否是2、3、4、5、14(A)。如果是的话,说明这是一个特殊的顺子,A在这里被当作1来使用。
if (sortedRanks.join() === "2,3,4,5,14") isStraight = true;
}
}
const rankCount = this.checkRankCount(userCard);
const rankCounts = Object.values(rankCount);
if (isStraight && isFlush && sortedRanks[0] === 10) return CARD_TYPE.royalFlush;
if (isStraight && isFlush) return CARD_TYPE.straightFlush;
if (rankCounts.includes(4)) return CARD_TYPE.fourOfAKind;
if (rankCounts.includes(3) && rankCounts.includes(2)) return CARD_TYPE.fullHouse;
if (isFlush) return CARD_TYPE.flush;
if (isStraight) return CARD_TYPE.straight;
if (rankCounts.includes(3)) return CARD_TYPE.threeOfAKind;
/**
* count => count === 2 是一个回调函数,判断每个元素是否等于2。filter方法会返回一个新数组,包含所有满足条件的元素。
* 例如,如果rankCounts是[1, 2, 2, 1],那么rankCounts.filter(count => count === 2)会返回[2, 2],因为有两个元素等于2。
* 然后我们检查这个新数组的长度是否等于2,如果是的话,说明我们有两对牌。
*/
if (rankCounts.filter((count) => count === 2).length === 2) return CARD_TYPE.twoPair;
if (rankCounts.includes(2)) return CARD_TYPE.onePair;
return CARD_TYPE.highCard;
return 1;
}
/**
* @param cards ["10H", "JD", "KS", "9C"]
* @returns: [{rank:10,suit:"H"},{rank:11,suit:"D"},{rank:12,suit:"S"},{rank:13,suit:"C"},{rank:14,suit:"D"}]
*/
private parseCard(cards: string[]): Card[] {
const validSuits: Set<Suit> = new Set(["H", "S", "D", "C"]);
return cards.map((card) => {
const suit = card.slice(-1);
const rankStr = card.slice(0, -1) || "0";
const rank = RANK_MAP[rankStr] ?? Number(rankStr);
//这里使用 as Suit 进行类型断言,用于通过 Set<Suit> 的类型检查。这类断言只影响 TypeScript 编译期,不会在运行时做额外校验。
if (!validSuits.has(suit as Suit) || Number.isNaN(rank)) {
throw new Error(`Invalid card format rank: ${card}`);
}
return { rank, suit: suit as Suit };
});
}
/**
* @param cards: [{rank:10,suit:"H"},{rank:11,suit:"D"},{rank:12,suit:"S"},{rank:13,suit:"C"},{rank:14,suit:"D"}]
* @returns: { "3": 1, "5": 1, "8": 1, "10": 1, "11": 1 }
*/
private checkRankCount(cards: Card[]): Record<number, number> {
const rankCount: Record<number, number> = {};
for (const card of cards) {
rankCount[card.rank] = (rankCount[card.rank] || 0) + 1;
}
return rankCount;
}
/**
* @param cards [{rank:10,suit:"H"},{rank:11,suit:"D"},{rank:12,suit:"S"},{rank:13,suit:"C"},{rank:14,suit:"D"}]
* @returns: { H: 1, S: 1, D: 2, C: 1 }
*/
private checkSuitCount(cards: Card[]): Record<Suit, number> {
const suitCount: Record<Suit, number> = { H: 0, S: 0, D: 0, C: 0 };
for (const card of cards) {
suitCount[card.suit]++;
}
return suitCount;
}
}
4. poker.constants.ts
import { HandType } from "./poker.types";
const CARD_TYPE: Record<HandType, number> = {
royalFlush: 10,
straightFlush: 9,
fourOfAKind: 8,
fullHouse: 7,
flush: 6,
straight: 5,
threeOfAKind: 4,
twoPair: 3,
onePair: 2,
highCard: 1,
};
const RANK_MAP: Record<string, number> = {
A: 14,
K: 13,
Q: 12,
J: 11,
};
export { CARD_TYPE, RANK_MAP };
5. poker.types.ts
type Suit = "H" | "S" | "D" | "C";
type HandType =
| "royalFlush"
| "straightFlush"
| "fourOfAKind"
| "fullHouse"
| "flush"
| "straight"
| "threeOfAKind"
| "twoPair"
| "onePair"
| "highCard";
interface Card {
rank: number;
suit: Suit;
}
export type { Suit, HandType, Card };
七、总结
1. 当前阶段完成内容
- ✔ 牌型判断实现
- ✔ NestJS 初始化
- ✔ WebSocket 接入
- ✔ handEvaluator 迁移
- ✔ 可以通过 WebSocket 调用牌型判断
2. 本阶段架构总结
这一阶段,其实最重要的不是“功能”。
而是:
项目开始从“单文件脚本”变成“模块化后端结构”。
目前已经完成:
- 使用
NestJS模块化拆分 - 业务规则与流程控制分离
- 使用
WebSocket实现实时通信 - 建立
Gateway→Service→ 业务模块调用链
3. 为什么这一步重要
做到这里之后,项目后面才能继续往下扩展:
- 回合系统
- 卡牌效果
- 房间系统
- AI模块
否则后面代码量一大,结构一定会开始混乱。
4. 下一步计划
下一阶段会开始实现:
- 洗牌
- 发牌
- 手牌管理
也就是:
真正开始进入“游戏状态管理”阶段。
本系列会持续记录:
从规则实现到工程化落地的整个过程。