本文是一个长期工程实践系列的开篇,目标是从零实现一个 Balatro 风格的游戏后端,并逐步演进为一个具备完整工程能力的实时服务系统。
- 项目地址:balatro-realtime-backend
- 📌 对应代码版本:
init project with poker hand evaluator and unit test(2026-03-12) - ⚠️ 注意:仓库代码会持续迭代,本文基于指定 commit 进行讲解
一开始我只是想写一个“牌型判断”的小功能。
但写着写着,我发现一个问题:
👉 如果只是把逻辑写出来,这个项目是没有任何工程价值的。
因为现实中的后端系统,从来不是一堆函数拼起来的,而是:
- 有状态
- 有流程
- 有边界
- 有扩展能力
于是我决定做一件更难的事:
👉 从0开始,做一个“真正可运行”的实时游戏后端。
这个系列,就是这个过程的完整记录。
📚 系列文章:
(1)项目规划与牌型判断实现(当前)
(2)NestJS框架搭建与项目结构设计
(3)洗牌、发牌与服务端牌堆状态管理
(4)出牌 / 弃牌 / 补牌
(5)得分计算与回合结算(开发中)
一、为什么做这个项目?
这个项目的目标,并不是写一个“算法 demo”,也不是简单复刻游戏逻辑。
而是通过一个真实可运行的卡牌游戏后端,系统性练习后端工程能力:
- 状态机设计
- WebSocket 实时通信
- Redis 缓存分层
- MySQL 持久化
- 游戏状态恢复
- Docker 化部署
- 单元测试与日志系统
- 可扩展规则系统(Modifier / Effect)
为什么选 Balatro?
因为它刚好具备几个非常适合工程拆解的特点:
- 回合制状态清晰
- 规则复杂但可拆分
- 卡牌效果天然可扩展
- 非常适合逐步工程化演进
二、本系列规划
这个项目不会“一步到位”,而是按阶段逐步推进:
阶段一:单局游戏流程
目标:做出一个可运行的游戏后端
包括:
- Game Engine
- 回合状态管理
- 发牌 / 出牌 / 弃牌 / 补牌
- 得分计算
- 回合结算
- WebSocket 主流程
当前进度:
✔ 已完成:牌型判断
阶段二:关卡与盲注系统
- 小盲 / 大盲 / Boss
- 目标分数递增
- 回合推进
- 局结束判断
阶段三:持久化与缓存
- MySQL
- Redis
- 游戏恢复
- 对局记录
- 状态分层设计
阶段四:工程化
- Docker Compose
- 日志系统
- 单元测试
- 架构设计
- README 完善
阶段五:效果系统(核心)
- Joker 系统
- Modifier
- EventBus
- Effect 扩展机制
阶段六:商店系统
- 奖励池
- 权重随机
- 刷新 / 购买
阶段七:特殊卡牌
- 塔罗牌 / 星球牌 / 幻灵牌
阶段八:扩展与AI
- 房间系统
- 排行榜
- AI 对局
- Agent 扩展
三、技术栈设计
当前技术规划:
- Node.js
- TypeScript
- NestJS
- WebSocket
- Jest
- Redis
- MySQL
- Docker
前期只做“纯逻辑”,后期逐步接入框架与工程体系。
四、牌型判断实现
牌型判断,是整个系统的基础能力。
后面所有内容(得分、效果系统、Modifier)都会依赖它。
1. 目标
输入:1~5 张牌
输出:牌型等级(数值)
支持牌型:
| 牌型 | 分值 |
|---|---|
| straightFlush | 9 |
| fourOfAKind | 8 |
| fullHouse | 7 |
| flush | 6 |
| straight | 5 |
| threeOfAKind | 4 |
| twoPair | 3 |
| onePair | 2 |
| highCard | 1 |
2. 规则说明
点数:
- 2 ~ 10, J, Q, K, A
- A 默认 14
- 特殊顺子:A2345 → A 当 1
花色:
- H ♥️
- S ♠️
- D ♦️
- C ♣️
3. 核心实现思路
这里没有选择“暴力枚举所有牌型”,而是拆成三步:
- 解析牌
- 统计花色 / 点数
- 组合判断牌型
这样做的原因是:
👉 后面需要支持 Modifier / Effect 系统,这种结构更容易扩展和插入规则。
关键点:
- 使用
Set判断顺子是否重复 - 使用
rankCount判断对子 / 三条 / 四条 - 使用排序判断连续性
- 特殊处理 A2345
4. 核心代码
function getCardType(cards: string[]): number {
const userCard = parseCard(cards)
const suitCount = Object.values(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;
}
}
if (sortedRanks.join() === "2,3,4,5,14") {
isStraight = true;
}
}
}
const rankCounts = Object.values(checkRankCount(userCard));
if (isStraight && isFlush && sortedRanks[0] === 10) return 10;
if (isStraight && isFlush) return 9;
if (rankCounts.includes(4)) return 8;
if (rankCounts.includes(3) && rankCounts.includes(2)) return 7;
if (isFlush) return 6;
if (isStraight) return 5;
if (rankCounts.includes(3)) return 4;
if (rankCounts.filter(c => c === 2).length === 2) return 3;
if (rankCounts.includes(2)) return 2;
return 1;
}
五、单元测试
使用 Jest 做基础测试。
核心验证:
- 所有牌型覆盖
- 边界情况(A2345)
- 非法输入
运行:
npm test
结果:
12 passed, 0 failed
六、这一阶段的意义
这一篇其实只做了一件事:
👉 把“游戏规则”从脑子里落到代码里
但这是整个项目最关键的一步。
因为:
- 后面所有系统都依赖它
- 它决定了数据结构设计
- 它决定了状态流转方式
如果这一层不稳,后面全部要推翻重来。
七、下一步
写到这里,其实问题才刚刚开始。
目前这个实现有一个明显限制:
👉 所有逻辑都是“函数级别”,没有任何状态管理。
但在真实后端中:
- 玩家是谁?
- 当前回合在哪一步?
- 状态如何流转?
这些问题,都还没被解决。
下一篇,我会把这套逻辑“搬进一个真正的服务结构里”,开始处理第一个关键问题:
👉 状态从哪里来?又该由谁维护?
结语
这个系列不会做成“教程式”。
而是更偏向:
一个真实后端项目从 0 到 1 的完整记录
如果你也在做:
- 游戏后端
- 实时系统
- 或者想提升工程能力
这个系列应该会有帮助。
如果你也在做类似项目,或者有不同的设计思路,欢迎一起交流 👍