Juego Klondike Solitaire en React: Tutorial Paso a Paso
Construir Klondike Solitaire en React parece simple hasta que te topás con la realidad: necesitás representar un estado completo del juego con 7 pilas de tableau, validar cada movimiento según reglas específicas, sincronizar todo con la UI en tiempo real, y hacerlo sin que el shuffle sea sesgado. Según el análisis de Shaishav Patel en Dev.to, el algoritmo Fisher-Yates es la clave para un barajado uniforme, pero la mayoría de los desarrolladores usa Math.random() – 0.5, que introduce sesgos.
En 30 segundos
- Klondike Solitaire requiere 7 tableau columns, 4 foundation piles, stock y waste — el estado no es trivial de representar
- Fisher-Yates genera permutaciones uniformemente aleatorias en O(n), a diferencia de sort() con Math.random() que es sesgado
- Las validaciones de movimiento incluyen: color opuesto + rank consecutivo en tableau, mismo suit en foundation, solo top card es movible
- useReducer es mejor que useState para lógica compleja de juego, evitá mutación directa de arrays
- Los bugs típicos son shuffle sesgado, mutación de estado sin copias, re-renders innecesarios, y falta de sincronización UI-estado
¿Por qué Solitaire es complicado de programar?
Cuando ves alguien jugando Solitaire en una computadora, pensás que es un juego simple. Las reglas son conocidas — movés cartas a columnas en orden descendente, diferente color, y cuando completás un palo, lo mandás a la foundation. El tema es que implementar eso correctamente tiene trampas que no son obvias.
Primero, el estado del juego no es un simple array de cartas. Tenés que representar 7 columnas de tableau (cada una con un orden específico bottom-to-top), 4 foundation piles separadas, una stock pile face-down, una waste pile face-up. Todo eso tiene que sincronizar con la UI sin que se rompa. Segundo, cada movimiento tiene validaciones diferentes según a dónde vayas: tableau acepta solo cartas en orden descendente con color opuesto, foundation acepta solo del mismo suit en orden ascendente. Tercero — y acá viene lo bueno — el shuffle inicial tiene que ser uniformemente aleatorio, cosa que la mayoría no hace bien.
Estructura de datos: Representando cartas y el game state
Empecemos por lo básico: una carta.
Cada carta necesita three propiedades: suit (palo), rank (número/figura), y si está face-up o face-down. El color (rojo/negro) se deriva del suit, no se almacena. Cuando representás el rank, usá 1 para Ace, 11-13 para Jack/Queen/King. Así comparar ranks es simple — solo comparás números.
El estado completo del juego es más complejo. Tenés 7 tableau columns, cada una es un array donde el índice 0 es la carta base (bottom) y el último elemento es la carta jugable (top). Las 4 foundation piles también son arrays, una por suit, y acá sí importa el orden — la última carta es la que está visible. El stock pile es face-down, el waste pile es face-up.
Una estructura típica en React sería:
const gameState = {
tableau: [[], [], [], [], [], [], []], // 7 columnas
foundation: [[], [], [], []], // 4 palos
stock: [], // cartas sin jugar
waste: [], // cartas del stock volteadas
moves: 0,
gameWon: false
}
Cada elemento de tableau, foundation, stock y waste es un objeto carta con `{suit, rank, faceUp}`. La clave acá es que NO almacenás redundancia — el estado es la fuente de verdad. La UI se renderiza desde este estado, no al revés.
El algoritmo Fisher-Yates: Barajando correctamente
Ojo: este es el punto donde 8 de cada 10 implementaciones falla.
La tentación es usar `array.sort(() => Math.random() – 0.5)` para barajar. Se ve simple, funciona… bueno, no. El problema es matemático: comparar-sort no genera una permutación uniforme. Algunos órdenes son más probables que otros. Para un deck de 52 cartas, algunos órdenes nunca van a aparecer.
Fisher-Yates hace lo correcto. El algoritmo es simple: iterás desde la última carta hasta la primera, y para cada posición generás un índice aleatorio entre 0 y esa posición, y swapeás. El resultado: O(n), una sola pasada, y cada permutación es exactamente equiprobable.
function fisherYatesShuffle(array) {
const deck = [...array];
for (let i = deck.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[deck[i], deck[j]] = [deck[j], deck[i]];
}
return deck;
}
Notar: creás una copia con `[…array]` para no mutar el original. Luego iterás de atrás para adelante. Para cada posición `i`, generás un número aleatorio entre 0 e `i` inclusive, y swapeás. El swap con destructuring es limpio: `[deck[i], deck[j]] = [deck[j], deck[i]]`. Cubrimos ese tema en detalle en ejecutar React sin dependencias externas.
Según Sebhastian, este algoritmo fue diseñado específicamente para evitar los sesgos de otros enfoques. Usalo cada vez que necesites un shuffle verdadero.
Reglas de movimiento en Klondike Solitaire
Klondike tiene cuatro tipos de movimiento válido. Validar cada uno es lo que diferencia un juego que se puede completar de uno que se rompe a mitad de partida.
Tableau a tableau
Podés mover una carta (o todo un stack de cartas) a otra columna si la carta destino existe y tiene rank UNO superior a la que movés, y color opuesto. Red (corazones/diamantes) va sobre black (picas/tréboles) y viceversa. Una columna vacía solo acepta un King.
Tableau a foundation
Una carta sube a foundation solo si es del mismo suit que la foundation destino, y el rank es exactamente uno más que la top card de esa foundation. Empezás con Ace (rank 1), y subís hasta King (rank 13).
Stock a waste
Hacés click en el stock, volteás una carta (o tres, dependiendo de la variante) y va al waste. Cuando el stock se vacía, lo recyclás de nuevo.
Validación en código
function canMove(from, to, gameState) {
if (!from) return false;
if (to === 'foundation') {
const foundationPile = gameState.foundation[from.suit];
if (foundationPile.length === 0) return from.rank === 1; // Ace
const topCard = foundationPile[foundationPile.length - 1];
return from.suit === topCard.suit && from.rank === topCard.rank + 1;
}
if (to === 'tableau') {
if (to.length === 0) return from.rank === 13; // King
const topCard = to[to.length - 1];
const oppColor = (from.suit in ['H', 'D']) !== (topCard.suit in ['H', 'D']);
return oppColor && from.rank === topCard.rank - 1;
}
}
Manejo de estado con React Hooks
Cuando el estado es simple (un contador, un toggle), useState alcanza. Pero un juego de cartas tiene lógica que toca varias partes del estado a la vez. Movés una carta, validás, actualizás el tableau, posiblemente actualizás foundation, incrementás el contador de movimientos, checkeás si ganaste. Eso es demasiado para un setState simple.
useReducer es tu amigo acá. Escribís un reducer que procesa acciones: SHUFFLE_DECK, MOVE_CARD, DRAW_FROM_STOCK, UNDO. Cada acción sabe cómo actualizar el estado correctamente.
function gameReducer(state, action) {
switch (action.type) {
case 'MOVE_CARD':
const newState = JSON.parse(JSON.stringify(state)); // deep copy
const from = action.payload.from;
const to = action.payload.to;
if (canMove(from, to, newState)) {
// actualizar newState
return newState;
}
return state;
case 'UNDO':
// revertir al estado anterior
return state.previousState || state;
default:
return state;
}
}
Notar: hiciste un deep copy con `JSON.parse(JSON.stringify())`. No es la más eficiente, pero para 52 cartas está bien. Para algo más grande, usarías immer o estructuras immutable.
Un hook custom que encapsule la lógica del juego también ayuda:
function useGameState() {
const [state, dispatch] = useReducer(gameReducer, initialGameState);
const moveCard = (from, to) => {
dispatch({ type: 'MOVE_CARD', payload: { from, to } });
};
const undoMove = () => {
dispatch({ type: 'UNDO' });
};
return { state, moveCard, undoMove };
} Más contexto en mantener código seguro en repositorios.
Así tu componente principal solo conoce `moveCard` y `undoMove`, sin saber nada de reducer.
Sincronización UI y estado del juego
La UI debe ser una proyección pura del estado. Si el estado cambia, la UI se re-renderiza. Punto. No tenés que hacer click y actualizar manualmente la DOM.
En React, cuando el estado cambia (porque dispatch disparó una acción), el componente se re-renderiza. Eso significa que todos los elementos visuales (cartas en cada columna, foundation piles, stock) se redibujan desde cero. Si el movimiento fue válido, va a estar donde corresponde. Si fue inválido, no va a cambiar.
useEffect entra cuando necesitás side effects: detectar victoria (cuando los 4 foundation tienen 13 cartas cada una), reproducir un sonido, guardar el estado en localStorage.
useEffect(() => {
const allComplete = state.foundation.every(pile => pile.length === 13);
if (allComplete) {
dispatch({ type: 'GAME_WON' });
playWinSound();
}
}, [state.foundation]);
La clave: NO mutés el estado directamente. Si necesitás cambiar un array, creá una copia. Spread operator `[…array]` o `array.slice()` para arrays. Para objetos, spread también: `{…obj, field: newValue}`.
Comparativa: Fisher-Yates vs otros shuffles
| Algoritmo | Complejidad | Uniformidad | Velocidad | Recomendación |
|---|---|---|---|---|
| Fisher-Yates | O(n) | ✓ Uniforme | Rápido | ✓ Usar siempre |
| sort() + Math.random() – 0.5 | O(n log n) | ✗ Sesgado | Lento | ✗ Nunca |
| Knuth shuffle (alias de Fisher-Yates) | O(n) | ✓ Uniforme | Rápido | ✓ Equivalente a Fisher-Yates |
| Riffle shuffle (simulación física) | O(n log n) | ~ Aproximado | Muy lento | Solo si querés realismo |

Ejemplos concretos
Ejemplo 1: Inicializar y barajar
Imaginá que empezás una partida. Creás un deck de 52 cartas (4 suits × 13 ranks), lo barajás con Fisher-Yates, y distribuís en tableau. Las primeras 28 cartas (1+2+3+4+5+6+7) van a tableau con solo la top face-up. Las 24 restantes van al stock.
function dealGame() {
const deck = createDeck(); // 52 cartas
const shuffled = fisherYatesShuffle(deck);
const tableau = [[], [], [], [], [], [], []];
let deckIndex = 0;
for (let col = 0; col < 7; col++) {
for (let row = col; row < 7; row++) {
const card = shuffled[deckIndex++];
card.faceUp = (row === col); // solo top card face-up
tableau[row].push(card);
}
}
const stock = shuffled.slice(28); // cartas restantes
return { tableau, stock, foundation: [[], [], [], []], waste: [] };
}
Ejemplo 2: Mover una carta válida
Tenés el 5 de corazones (red) en tableau, y el 6 de picas (black) en tableau. El usuario clickea en el 5 de corazones y lo arrastra al 6 de picas. El movimiento es válido (red sobre black, rank descendente). El reducer actualiza el estado, y la UI se redibuja — el 5 de corazones ya no está en tableau y aparece en tableau.
Errores comunes al programar juegos en React
1. Shuffle sesgado
El error: Usás `array.sort(() => Math.random() - 0.5)` para barajar. Relacionado: explorar nuevas herramientas JavaScript.
Por qué es un problema: El shuffle no es uniforme. Algunos órdenes aparecen más que otros. Con 52 cartas, algunos órdenes específicos nunca van a suceder.
La solución: Implementá Fisher-Yates. O usá una librería como `lodash.shuffle()` o `crypto.getRandomValues()` para mayor seguridad criptográfica.
2. Mutación directa de estado
El error: Hacés `state.tableau.push(card)` dentro del reducer sin crear una copia primero.
Por qué es un problema: React no detecta cambios de mutación directa. El estado "cambió" pero el componente no se re-renderiza. O se re-renderiza pero con datos inconsistentes. Los bugs que resultan son infernales de debuggear.
La solución: Siempre copia antes de mutar. Spread operator para arrays/objetos superficiales, deep copy para estructuras anidadas.
// MAL
state.tableau.push(card); // BIEN
const newTableau = state.tableau.map((col, i) =>
i === 0 ? [...col, card] : col
);
return { ...state, tableau: newTableau };
3. Falta de validación en movimientos
El error: Permitís cualquier movimiento sin chequear si es válido según las reglas de Solitaire.
Por qué es un problema: El juego se vuelve incompletable. El usuario mueve cartas al azar y llega a un estado donde es imposible ganar. O inventa movimientos que no son legales.
La solución: Implementá `canMove()` y ejecutá la validación antes de actualizar el estado. Si la validación falla, simplemente no actualices nada y avisá al usuario (opcional).
4. Re-renders innecesarios
El error: Cada vez que el usuario hace un movimiento, se re-renderiza todo el árbol de componentes, incluso las partes que no cambiaron. Esto se conecta con lo que analizamos en elegir plataforma para control de versiones.
Por qué es un problema: El juego se vuelve lento, especialmente en móvil. Cada re-render recalcula posiciones, redibuja cartas, etc.
La solución: Usá `React.memo()` para evitar que componentes de cartas se re-rendericen si sus props no cambiaron. Separó el estado por región (tableau, foundation, stock) para que cambios en un lugar no fuerzen re-render de todo.
5. Estado no sincronizado con la UI
El error: Hacés click en una carta y visualmente se mueve, pero el estado del juego no se actualiza. O viceversa: el estado cambió pero la UI no lo refleja.
Por qué es un problema: Las validaciones siguientes fallan porque se basan en un estado "fantasma". El usuario ve una cosa en la pantalla y el juego hace otra.
La solución: La UI SIEMPRE se renderiza desde el estado. No almacenés info de cartas en la DOM. Cada cambio visual debe venir de un cambio de estado que pasa por el reducer.
Preguntas Frecuentes
¿Cómo validar que el usuario no hizo trampa?
El servidor valida cada movimiento. Nunca confíes en lo que dice la UI. Si le enviás un movimiento inválido al backend, ese rechaza. Guardás también un hash de la secuencia de movimientos para detectar deshacer ilegal o replay attacks.
¿Puedo guardar una partida a mitad de camino?
Sí. Serializá el estado completo (tableau, foundation, stock, waste) a JSON y guardalo en localStorage. Al recargar, deserializá y restaurá. Para multiplayer, guardalo en el servidor en vez de localStorage.
¿Cómo implemento deshacer/rehacer?
Mantené un historial de estados. Cada vez que hacés un movimiento válido, guardás una copia del estado anterior. Cuando el usuario aprieta "Undo", restaurás el estado del historial. Rehacer es lo opuesto: si el usuario hizo undo, guardás eso también y podés volver al estado post-undo.
¿Fisher-Yates es criptográficamente seguro?
No. Math.random() usa pseudoaleatoriedad, no es crypto-seguro. Para aplicaciones de dinero o seguridad, usá `crypto.getRandomValues()` en vez de Math.random() dentro del algoritmo Fisher-Yates.
¿Cuál es la mejor forma de representar cartas en JavaScript?
Un objeto con al menos `{suit, rank, faceUp}`. Suit puede ser string ("H", "D", "C", "S") o número (0-3). Rank es 1-13. FaceUp es boolean. Algunos prefieren una clase Card con métodos helper, pero un objeto simple con funciones puras para validar es más React-friendly.
Conclusión
Construir Klondike Solitaire en React te enseña patrones que aplican a cualquier juego o aplicación con estado complejo. La clave está en separar la lógica de negocio (validaciones, movimientos) de la presentación, usar un reducer para manejar cambios de estado, y representar el juego como un árbol de datos — no como eventos en la DOM.
El shuffle es el ejemplo clásico: algo que se ve trivial (barajar cartas) tiene una solución correcta (Fisher-Yates) y 10 soluciones sesgadas. Aprender a implementarlo bien y entender POR QUÉ funciona es inversión para cualquier desarrollador que quiera hacer juegos o trabajar con datos aleatorios.
Si estás usando React y necesitás estado complejo en un juego, probá con useReducer en lugar de useState. Y cuando escribas un barajado, usá Fisher-Yates. No es más difícil, y es la forma correcta.






