Crear un juego de cartas no consiste únicamente en mostrar cartas en pantalla. Hay que modelar el mazo, las manos, los turnos, la puntuación, las tiendas y todos los estados intermedios que hacen que una partida pueda continuar sin romperse.
Por eso construí Balatro Web Clone, un juego web inspirado en Balatro y desarrollado con React, TypeScript y Vite. El objetivo fue reproducir el flujo principal de una partida y, al mismo tiempo, practicar cómo separar una lógica de dominio compleja de los componentes visuales.
El flujo de una partida
La aplicación sigue el flujo principal del juego:
Menú
↓
Elegir baraja
↓
Seleccionar blind
↓
Jugar manos o descartar cartas
↓
Superar el objetivo
↓
Comprar jokers en la tienda
↓
Avanzar al siguiente blind
Cada fase tiene acciones y reglas diferentes. El jugador puede seleccionar una baraja, jugar manos de póker para conseguir chips y multiplicador, comprar jokers y avanzar por los distintos niveles de dificultad.
Separar el estado de la interfaz
El estado completo de la partida se centraliza en el hook useGameState. Los componentes reciben el estado que necesitan y callbacks como playHand, discardCards, buyJoker o advanceToNextBlind.
Esto evita que cada pantalla tenga que conocer cómo se modifica internamente la partida. Por ejemplo, RoundPanel no calcula por su cuenta qué ocurre al jugar una mano: comunica la acción al hook y recibe el nuevo estado cuando React vuelve a renderizar.
La separación también facilita añadir nuevas fases o reglas sin convertir los componentes visuales en bloques de lógica difícil de mantener.
Las reglas viven en módulos independientes
La lógica del juego está separada en varios módulos:
deck.tscrea, baraja y permite robar cartas.handEvaluator.tsdetecta si las cartas forman una pareja, doble pareja, trío, escalera u otra mano.score.tscalcula los chips y el multiplicador de la mano.joker.tscontiene el catálogo de jokers y sus efectos.blinds.tsdefine los niveles, objetivos y bosses.
La fórmula principal de puntuación es:
(chips base de la mano + chips de las cartas) × multiplicador
Los jokers pueden modificar los chips, añadir multiplicador o aplicar multiplicadores adicionales. Mantener estas reglas fuera de la interfaz permite probarlas y modificarlas sin depender de cómo se dibuja cada pantalla.
Un estado persistente
Una partida puede durar bastante, así que añadí persistencia con localStorage. El módulo storage/localStorage.ts guarda y recupera el estado serializado de la partida.
El hook solo guarda durante fases reales de juego: blindSelect, playing y shop. No tiene sentido persistir el menú o una pantalla finalizada, y limitar las fases evita recuperar estados incompletos o irrelevantes.
Al volver a montar la pantalla de juego, la aplicación intenta cargar la partida guardada. Si no existe, crea un estado inicial y comienza desde el menú.
Evitar repetir bosses demasiado pronto
Un detalle que necesitaba algo más de cuidado era la selección de bosses. En lugar de elegirlos completamente al azar en cada nivel, la partida mantiene una lista serializable de bosses pendientes.
Cuando aparece un boss se elimina de esa lista. Cuando todos han aparecido, la lista vuelve a barajarse. De esta forma no se repite un boss hasta completar el ciclo y el estado se puede guardar junto al resto de la partida.
Lo que me llevo del proyecto
El reto principal no fue construir una pantalla concreta, sino coordinar muchas reglas pequeñas sin perder el control del estado. El proyecto me sirvió para practicar:
- Modelado de un dominio con muchas transiciones.
- Estado centralizado en React mediante un hook propio.
- Separación entre lógica de negocio y componentes visuales.
- Persistencia de estado en el navegador.
- Navegación entre pantallas con React Router.
El juego está disponible en la demo web y el código completo en GitHub.