从0到1实现 Balatro 游戏后端(2):NestJS框架搭建与项目结构设计

本系列记录:从 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 版本进行说明。

✅本篇实现了什么

在上一篇中,我们已经完成了牌型判断逻辑。

在这篇文章中,我们将原本的单文件逻辑迁移到 NestJS 框架中,并重点解决以下问题:

  • 如何将原有逻辑迁移到模块化后端架构
  • 为什么要拆分 GamePoker 模块
  • 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通信、网关分发、数据库存储、模块拆分以及更低耦合的业务结构。

如果继续使用原生方式手动组织代码,随着逻辑不断增加,代码很容易出现职责混乱、依赖关系复杂的问题,后期维护成本也会越来越高。

因此,在这一阶段决定引入后端框架,对项目结构进行一次整体迁移,为后续功能扩展建立更加清晰和稳定的架构基础。

在框架的选择上,我对比了ExpressPomelo等常见的Node.js后端框架,最终选择使用NestJS作为本项目的基础框架。

选择NestJS的主要原因包括:

  • NestJS提供了完善的模块化结构,便于拆分不同业务领域
  • 内置的依赖注入(DI)机制可以有效降低模块之间的耦合度
  • 框架本身对WebSocket、数据库等功能提供了官方支持
  • 更适合构建长期维护的后端项目,而不是一次性的脚本代码

相比继续使用原生方式手动组织文件,使用NestJS可以让整个项目从一开始就具备更清晰的层次结构,也方便后续逐步扩展新的功能模块。

二、为什么要把 poker 单独做成 module,而不是写在 game 里

1. 为什么要拆分 poker 模块

在项目结构设计时,并没有将所有的逻辑都放在game模块中,而是将与牌型判断相关的逻辑单独拆分为poker模块。

这样拆分的原因在于职责的明确划分。从游戏后端的角度来看,不同类型的功能本身就属于不同的领域。

例如在扑克牌游戏中,牌本身的规则属于一类逻辑,出牌、发牌等游戏流程属于另一类逻辑,而房间管理、玩家连接等又是完全不同的功能模块。

如果将这些内容全部写在同一个模块中,随着功能不断增加,代码结构会逐步变得混乱,不同逻辑之间的依赖关系也会越来越难以维护。

因此,在项目初期就按照业务领域进行模块划分,让每个模块只负责单一职责,可以有效提升代码的可读性和可扩展性。

另一方面,NestJS本身就是一个以模块化为核心设计理念的框架。

通过ModuleServiceGateway等结构,可以很自然的将不同业务拆分到独立的模块中,并通过依赖注入的方式进行组合。

既然选择使用Nest,那么按照其推荐的模块化方式组织项目结构,也更符合框架本身的设计思想。

这种设计在后续扩张时会更加明显。

例如当项目需要加入房间系统时,可以直接新增room模块,而不需要将新的逻辑塞入已有的gamepoker中。

通过保持模块之间的低耦合,不仅可以让代码结构更加清晰,也可以在出现问题时更容易定位和修改。

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内部再通过servicegateway等角色划分职责,让业务逻辑、通信入口和通用能力各自独立。

在当前项目中,牌型判断属于纯业务规则,并且逻辑完全围绕扑克牌本身开展,与游戏流程控制并不是同一个职责范围。

因此在迁移到NestJS结构时,将handEvaluator.ts相关代码放入poker模块中,并封装到PokerService内部,而不是继续作为独立文件存在。

迁移后的结构中

  • GameService只负责游戏流程的控制
  • PokerService负责牌型计算等规则逻辑
  • Gateway负责接收客户端消息并分发到对应的业务模块
    这种分层方式可以有效降低模块之间的耦合度,也为后续增加新功能提供了更清晰的扩展路径。

五、WebSocket 请求是如何从 Gateway 到 Service 再到 PokerService 的

1. 请求逻辑

在模块之间的调用关系上,

通过在poker.module.tsexports: [PokerService],在game.module.tsimports: [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.tspoker.types.ts,而不是全部卸载service中。

这种方式可以让业务逻辑更加专注于计算本身,同时也方便在不同文件之间复用相同的类型和配置。

由于当前常量和类型只与扑克牌规则相关,因此将这些文件放在poker模块目录下,而不是直接放在src根目录。

按照业务领域组织目录结构,可以让项目从一开始就保持清晰的层次,在后续增加新的模块时,也可以自然的扩展,而不会破坏已有结构。

3. 代码实现

下面代码展示了从客户端请求到牌型计算的完整调用链:
ClientGatewayGameServicePokerService

建议重点关注:

  • 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 实现实时通信
  • 构建 GatewayService → 业务模块的调用链

这套架构不仅适用于当前项目,也可以作为开发其他实时系统或游戏后端的参考模板。

3. 为什么这一步重要

这一阶段的完成,意味着项目已经从"单文件脚本"升级为"可扩展的后端架构",为后续加入回合系统、卡牌效果系统以及AI模块打下了基础。

4. 下一步计划

下一阶段将开始实现当局游戏的基础流程,包括洗牌、发牌以及手牌管理等核心逻辑,逐步从规则计算过渡到完整的游戏流程控制。

本系列将持续记录从规则实现到工程化落地的全过程。

相关推荐
无所事事O_o1 小时前
二次验证码TOTP 使用说明
后端·二次验证码·谷歌验证器
ltl2 小时前
Multi-Head Attention:为什么要分多个头
后端
ltl2 小时前
Scaled Dot-Product:那个根号 d_k 是怎么来的'
后端
折哥的程序人生 · 物流技术专研4 小时前
《Java 100 天进阶之路》第17篇:Java常用包装类与自动装箱拆箱深入
java·开发语言·后端·面试
小新同学^O^5 小时前
简单学习 --> WebSocket
java·websocket·网络协议·学习
IT_陈寒5 小时前
为什么Java的Stream并行处理反而变慢了?
前端·人工智能·后端
zzzzzz3105 小时前
Gemini CLI 深度实战:Google 官方终端 AI 代理的完全指南
node.js
孙6903425 小时前
swf 图片转 pdf
java·后端
长安不见6 小时前
从CompletionService的一个错误用法谈起
后端