本系列记录:从 0 到 1 实现一个 Balatro 风格的游戏后端系统,包括规则实现、架构设计、WebSocket 通信、模块拆分以及后续工程化改造。
- GitHub地址: balatro-realtime-backend
- 📌 本文对应代码版本:commit:
feat: initialize NestJS modules, add WebSocket gateway, migrate hand evaluator(2026年3月16日 16:36) - ⚠️ 注意:由于项目持续迭代,当前仓库代码可能已发生变化,本文内容基于该 commit 版本进行说明。
- 📌 本文对应代码版本:commit:
✅本篇实现了什么
在上一篇中,我们已经完成了牌型判断逻辑。
在这篇文章中,我们将原本的单文件逻辑迁移到 NestJS 框架中,并重点解决以下问题:
- 如何将原有逻辑迁移到模块化后端架构
- 为什么要拆分
Game与Poker模块 WebSocket在游戏后端中的作用Gateway->Service-> 业务模块的调用链设计
👉 最终将得到一个具备模块化结构、支持 WebSocket 调用的后端基础框架。
文章目录
- [一、为什么要迁移到 NestJS](#一、为什么要迁移到 NestJS)
- [二、为什么要把 poker 单独做成 module,而不是写在 game 里](#二、为什么要把 poker 单独做成 module,而不是写在 game 里)
-
- [1. 为什么要拆分 poker 模块](#1. 为什么要拆分 poker 模块)
- [2. 总结](#2. 总结)
- [三、为什么使用 WebSocket 而不是 HTTP](#三、为什么使用 WebSocket 而不是 HTTP)
-
- [1. 为什么使用 WebSocket](#1. 为什么使用 WebSocket)
- [2. 总结](#2. 总结)
- [四、为什么 handEvaluator 不再是单文件,而是放进 PokerService](#四、为什么 handEvaluator 不再是单文件,而是放进 PokerService)
- [五、WebSocket 请求是如何从 Gateway 到 Service 再到 PokerService 的](#五、WebSocket 请求是如何从 Gateway 到 Service 再到 PokerService 的)
-
- [1. 请求逻辑](#1. 请求逻辑)
- [2. 核心调用链设计](#2. 核心调用链设计)
- 六、当前项目结构设计及相关代码
-
- [1. 结构目录](#1. 结构目录)
- [2. 代码结构说明](#2. 代码结构说明)
- [3. 代码实现](#3. 代码实现)
-
- [1. game.gateway.ts](#1. game.gateway.ts)
- [2. game.service.ts](#2. game.service.ts)
- [3. poker.service.ts](#3. poker.service.ts)
- [4. poker.constants.ts](#4. poker.constants.ts)
- [5. poker.types.ts](#5. poker.types.ts)
- 七、总结
-
- [1. 当前阶段完成内容](#1. 当前阶段完成内容)
- [2. 本阶段架构总结](#2. 本阶段架构总结)
- [3. 为什么这一步重要](#3. 为什么这一步重要)
- [4. 下一步计划](#4. 下一步计划)
一、为什么要迁移到 NestJS
在第一篇中,牌型判断逻辑是以handEvaluator.ts的形式直接使用原生TypeScript编写,所有逻辑集中在单个文件中完成。
这种方式在项目初期非常简单直接,但随着功能逐渐增加,单文件结构在扩展性和可维护性方面会逐渐暴露问题。
从一开始决定实现Balatro后端时,我就已经预期后续会逐步加入更多功能,例如 WebScoekt通信、网关分发、数据库存储、模块拆分以及更低耦合的业务结构。
如果继续使用原生方式手动组织代码,随着逻辑不断增加,代码很容易出现职责混乱、依赖关系复杂的问题,后期维护成本也会越来越高。
因此,在这一阶段决定引入后端框架,对项目结构进行一次整体迁移,为后续功能扩展建立更加清晰和稳定的架构基础。
在框架的选择上,我对比了Express、Pomelo等常见的Node.js后端框架,最终选择使用NestJS作为本项目的基础框架。
选择NestJS的主要原因包括:
- NestJS提供了完善的模块化结构,便于拆分不同业务领域
- 内置的依赖注入(DI)机制可以有效降低模块之间的耦合度
- 框架本身对WebSocket、数据库等功能提供了官方支持
- 更适合构建长期维护的后端项目,而不是一次性的脚本代码
相比继续使用原生方式手动组织文件,使用NestJS可以让整个项目从一开始就具备更清晰的层次结构,也方便后续逐步扩展新的功能模块。
二、为什么要把 poker 单独做成 module,而不是写在 game 里
1. 为什么要拆分 poker 模块
在项目结构设计时,并没有将所有的逻辑都放在game模块中,而是将与牌型判断相关的逻辑单独拆分为poker模块。
这样拆分的原因在于职责的明确划分。从游戏后端的角度来看,不同类型的功能本身就属于不同的领域。
例如在扑克牌游戏中,牌本身的规则属于一类逻辑,出牌、发牌等游戏流程属于另一类逻辑,而房间管理、玩家连接等又是完全不同的功能模块。
如果将这些内容全部写在同一个模块中,随着功能不断增加,代码结构会逐步变得混乱,不同逻辑之间的依赖关系也会越来越难以维护。
因此,在项目初期就按照业务领域进行模块划分,让每个模块只负责单一职责,可以有效提升代码的可读性和可扩展性。
另一方面,NestJS本身就是一个以模块化为核心设计理念的框架。
通过Module、Service、Gateway等结构,可以很自然的将不同业务拆分到独立的模块中,并通过依赖注入的方式进行组合。
既然选择使用Nest,那么按照其推荐的模块化方式组织项目结构,也更符合框架本身的设计思想。
这种设计在后续扩张时会更加明显。
例如当项目需要加入房间系统时,可以直接新增room模块,而不需要将新的逻辑塞入已有的game或poker中。
通过保持模块之间的低耦合,不仅可以让代码结构更加清晰,也可以在出现问题时更容易定位和修改。
2. 总结
这种"按业务领域拆分模块"的方式,是后端项目中非常常见的一种设计模式。
在复杂系统中,将规则逻辑、流程控制、通信入口拆分为不同的模块,可以有效降低耦合度,并提升系统的可扩展性。
这种设计不仅适用于游戏后端,在电商、推荐系统等场景中同样适用。
三、为什么使用 WebSocket 而不是 HTTP
1. 为什么使用 WebSocket
在通信方式的选择上,本项目没有使用HTTP,而是选择使用WebSocket。
HTTP通常采用的请求-响应的短链接模式,客户端发送请求,服务器返回结果,一次请请求结束后连接就可关闭,这种方式更适合查询类或一次性操作的接口。
而WebSocket则提供了长连接能力,客户端与服务端在建立连接后可以持续进行双向通行,服务端不仅可以响应客户端请求,也可以在任意时刻主动向客户端推送数据,更适合需要频繁交互和状态同步的应用场景。
Balatro作为一款以回合制和实时反馈为核心的游戏,客户端需要不断向服务端发送出牌操作,服务端也需要根据当前游戏状态实时返回结果,同时还需在后续扩展中支持房间、玩家连接以及状态同步等功能。
如果使用HTTP接口,不仅需要频繁建立请求,还需要额外处理状态保存问题,实现起来会更加复杂。
因此在项目初期就决定使用WebSocket作为主要通行方式,并结合NestJS提供的Gateway模块进行实现。
让后端可以以事件驱动的方式处理客户端消息,同时为后续增加房间系统、玩家管理以及数据库存储等功能预留扩展空间。
2. 总结
相比 HTTP的请求-响应模式,WebSocket 更适合需要持续状态交互的系统。
在游戏后端中,这种通信方式几乎是标准选择,尤其是涉及实时操作、房间系统、状态同步等场景时。
四、为什么 handEvaluator 不再是单文件,而是放进 PokerService
在第一版实现中,牌型判断逻辑全部写在handEvaluator.ts单个文件中,这种方式在功能较少时非常直接,但当项目开始引入NestJS并进行模块化拆分后,继续保持单文件结构已经不再适合后续扩展。
NestJS是一个以模块化和分层设计为核心的框架,通常会按照业务领域划分不同的module,每个module内部再通过service、gateway等角色划分职责,让业务逻辑、通信入口和通用能力各自独立。
在当前项目中,牌型判断属于纯业务规则,并且逻辑完全围绕扑克牌本身开展,与游戏流程控制并不是同一个职责范围。
因此在迁移到NestJS结构时,将handEvaluator.ts相关代码放入poker模块中,并封装到PokerService内部,而不是继续作为独立文件存在。
迁移后的结构中
GameService只负责游戏流程的控制PokerService负责牌型计算等规则逻辑Gateway负责接收客户端消息并分发到对应的业务模块
这种分层方式可以有效降低模块之间的耦合度,也为后续增加新功能提供了更清晰的扩展路径。
五、WebSocket 请求是如何从 Gateway 到 Service 再到 PokerService 的
1. 请求逻辑
在模块之间的调用关系上,
通过在poker.module.ts中exports: [PokerService],在game.module.ts中imports: [PokerModule],使game模块可以使用poker模块中导出的PokerService,从而实现不同业务模块之间的解耦调用。
但game.gateway.ts接收到客户端发送的event消息时,Gateway作为通信入口,会先将请求交给GameService进行流程处理。
在进行到需要牌型计算时,GameService再调用PokerService中的相关方法,由poker模块负责具体的规则计算逻辑。
计算完成后,结果会按照调用顺序逐层返回,
从PokerService -> GameService -> Gateway -> 客户端。
这种调用方式将通信入口、流程控制以及规则计算分别放在不同模块中,即保持了代码结构的清晰,也符合NestJS模块化和依赖注入的设计思想,为后续的扩展打下更好的基础。
2. 核心调用链设计
客户端请求不会直接进入业务逻辑,而是经过如下调用链:
Client -> Gateway -> GameService -> PokerService
这样设计的原因是:
Gateway: 负责通信入口GameService: 负责流程控制PokerService: 负责规则计算
这种分层方式可以让每一层只关注自己的职责,从而提升系统的可维护性和扩展性。
六、当前项目结构设计及相关代码
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.gateway.ts:WebSocket入口
- game.service.ts:游戏流程控制
- poker.service.ts:牌型计算逻辑
在结构划分上,根据当前游戏的功能将不同职责拆分为独立的module。
与游戏流程控制以及WebSocket通信相关的内容放在game模块中,
与牌型判断和扑克牌规则相关的逻辑放在poker模块中,
通过模块划分让不同领域的代码各自独立,避免所有逻辑集中在同一个文件或目录中。
在poker模块内部,将常量定义和类型定义分别拆分为poker.constants.ts和poker.types.ts,而不是全部卸载service中。
这种方式可以让业务逻辑更加专注于计算本身,同时也方便在不同文件之间复用相同的类型和配置。
由于当前常量和类型只与扑克牌规则相关,因此将这些文件放在poker模块目录下,而不是直接放在src根目录。
按照业务领域组织目录结构,可以让项目从一开始就保持清晰的层次,在后续增加新的模块时,也可以自然的扩展,而不会破坏已有结构。
3. 代码实现
下面代码展示了从客户端请求到牌型计算的完整调用链:
Client→Gateway→GameService→PokerService建议重点关注:
Gateway如何接收WebSocket消息GameService如何承接流程控制PokerService如何完成牌型计算
1. game.gateway.ts
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
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作为规则层模块,不依赖任何游戏流程状态,仅负责纯计算逻辑。这种"纯函数式服务"的设计,可以让规则层在测试、复用以及未来扩展时更加灵活。
ts
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
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
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. 下一步计划
下一阶段将开始实现当局游戏的基础流程,包括洗牌、发牌以及手牌管理等核心逻辑,逐步从规则计算过渡到完整的游戏流程控制。
本系列将持续记录从规则实现到工程化落地的全过程。