Introduccion al aprendizaje mediante proyectos de juegos
Construir un juego de cartas es una de las formas mas efectivas de consolidar habilidades de JavaScript. A diferencia de los ejercicios aislados, un proyecto completo obliga a combinar manipulacion del DOM, gestion de estado, eventos, temporizadores, almacenamiento local y estilos visuales en un unico flujo coherente. El clasico juego de memoria, en el que el jugador debe encontrar pares de cartas, resulta ideal porque su logica es comprensible y al mismo tiempo introduce conceptos que aparecen en aplicaciones reales.
En 2026 el enfoque recomendado sigue siendo vanilla JavaScript para este tipo de proyectos de aprendizaje. Evitar frameworks permite concentrarse en los mecanismos fundamentales del lenguaje y del navegador. Una vez dominados, la migracion a librerias o frameworks resulta mucho mas natural. Este articulo detalla el proceso completo de creacion de un juego de memoria interactivo, desde la estructura HTML basica hasta la persistencia de la mejor puntuacion y el diseno adaptable a distintos tamanos de pantalla.
El recorrido cubre la generacion dinamica de cartas, el barajado aleatorio, la animacion de volteo, la deteccion de coincidencias, el control de turnos, el contador de movimientos, un temporizador y el uso de localStorage. Cada seccion incluye ejemplos de codigo listos para adaptar y ampliar.
Estructura basica del documento HTML
El punto de partida es un documento HTML minimo que contenga un contenedor para el tablero, una zona de estadisticas y botones de control. La semantica clara facilita tanto el desarrollo como la accesibilidad.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Juego de Memoria</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="game-container">
<header>
<h1>Juego de Memoria</h1>
<div class="stats">
<span id="moves">Movimientos: 0</span>
<span id="timer">Tiempo: 0s</span>
<span id="best">Mejor: --</span>
</div>
<button id="restart">Reiniciar</button>
</header>
<div id="game-board" class="board"></div>
</div>
<script src="script.js"></script>
</body>
</html>
El tablero se deja vacio a proposito. Las cartas se generaran por completo desde JavaScript. Esta decision facilita el barajado y permite cambiar el numero de pares sin modificar el HTML.
Diseno del tablero con CSS Grid
CSS Grid ofrece un control preciso sobre la disposicion de las cartas. Una cuadrícula de cuatro columnas funciona bien para dieciseis cartas (ocho pares). Las propiedades de gap, aspect-ratio y justify-items producen un resultado equilibrado.
.board {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
max-width: 600px;
margin: 0 auto;
padding: 16px;
}
.card {
aspect-ratio: 3 / 4;
position: relative;
cursor: pointer;
transform-style: preserve-3d;
transition: transform 0.4s ease;
}
.card.flipped {
transform: rotateY(180deg);
}
.card-face {
position: absolute;
inset: 0;
backface-visibility: hidden;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.card-front {
background: linear-gradient(135deg, #4a90d9, #357abd);
color: white;
}
.card-back {
background: #f5f5f5;
transform: rotateY(180deg);
}
La propiedad transform-style: preserve-3d junto con backface-visibility permite el efecto de volteo tridimensional. La clase flipped aplica la rotacion que revela la cara posterior.
Generacion dinamica de las cartas
En lugar de escribir cada carta a mano, se define un array de simbolos y se duplica para formar los pares. Despues se baraja y se insertan los elementos en el DOM.
const symbols = ["🍎", "🍌", "🍒", "🍇", "🍉", "🍓", "🍍", "🥝"];
let cards = [...symbols, ...symbols];
function shuffle(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
function createBoard() {
const board = document.getElementById("game-board");
board.innerHTML = "";
cards = shuffle([...symbols, ...symbols]);
cards.forEach((symbol, index) => {
const card = document.createElement("div");
card.classList.add("card");
card.dataset.symbol = symbol;
card.dataset.index = index;
card.innerHTML = `
<div class="card-face card-front">?</div>
<div class="card-face card-back">${symbol}</div>
`;
card.addEventListener("click", flipCard);
board.appendChild(card);
});
}
El algoritmo de Fisher-Yates utilizado en la funcion shuffle garantiza una distribucion uniforme y evita los sesgos del metodo sort con comparacion aleatoria. Esta es una de las tecnicas fundamentales de barajado que conviene interiorizar.
Logica de volteo y control de estado
El corazon del juego reside en la gestion del estado de las cartas seleccionadas. Se necesitan variables para la primera carta, la segunda carta y un bloqueo temporal que impide clics mientras se evalua una pareja.
let firstCard = null;
let secondCard = null;
let lockBoard = false;
let moves = 0;
let matchedPairs = 0;
function flipCard() {
if (lockBoard) return;
if (this === firstCard) return;
if (this.classList.contains("matched")) return;
this.classList.add("flipped");
if (!firstCard) {
firstCard = this;
return;
}
secondCard = this;
moves += 1;
document.getElementById("moves").textContent = `Movimientos: ${moves}`;
checkForMatch();
}
La funcion evita que el jugador vuelva a hacer clic en la misma carta o en cartas ya emparejadas. El contador de movimientos se actualiza en cada intento de pareja.
Deteccion de coincidencias
Cuando se han seleccionado dos cartas se compara su atributo data-symbol. Si coinciden se marcan como emparejadas; si no, se voltean de nuevo tras un breve retraso.
function checkForMatch() {
const isMatch = firstCard.dataset.symbol === secondCard.dataset.symbol;
lockBoard = true;
if (isMatch) {
firstCard.classList.add("matched");
secondCard.classList.add("matched");
matchedPairs += 1;
resetTurn();
if (matchedPairs === symbols.length) {
endGame();
}
} else {
setTimeout(() => {
firstCard.classList.remove("flipped");
secondCard.classList.remove("flipped");
resetTurn();
}, 800);
}
}
function resetTurn() {
[firstCard, secondCard] = [null, null];
lockBoard = false;
}
El retraso de ochocientos milisegundos da tiempo al jugador a memorizar las posiciones antes de que las cartas se oculten de nuevo. Este intervalo puede ajustarse segun la dificultad deseada.
Temporizador y finalizacion de la partida
Un temporizador sencillo aumenta la tension y permite comparar partidas. Se inicia con el primer clic y se detiene cuando se encuentran todos los pares.
let timerInterval = null;
let seconds = 0;
function startTimer() {
if (timerInterval) return;
timerInterval = setInterval(() => {
seconds += 1;
document.getElementById("timer").textContent = `Tiempo: ${seconds}s`;
}, 1000);
}
function stopTimer() {
clearInterval(timerInterval);
timerInterval = null;
}
function endGame() {
stopTimer();
saveBestScore();
setTimeout(() => {
alert(`¡Completado en ${moves} movimientos y ${seconds} segundos!`);
}, 300);
}
La llamada a startTimer puede integrarse dentro de flipCard la primera vez que el jugador interactua con el tablero.
Persistencia con localStorage
Guardar la mejor puntuacion motiva al jugador a mejorar. localStorage ofrece una API sencilla para almacenar datos que sobreviven al cierre del navegador.
function saveBestScore() {
const current = { moves, seconds };
const stored = localStorage.getItem("memoryBest");
let best = stored ? JSON.parse(stored) : null;
if (
!best ||
current.moves < best.moves ||
(current.moves === best.moves && current.seconds < best.seconds)
) {
localStorage.setItem("memoryBest", JSON.stringify(current));
best = current;
}
document.getElementById("best").textContent =
`Mejor: ${best.moves} mov / ${best.seconds}s`;
}
function loadBestScore() {
const stored = localStorage.getItem("memoryBest");
if (stored) {
const best = JSON.parse(stored);
document.getElementById("best").textContent =
`Mejor: ${best.moves} mov / ${best.seconds}s`;
}
}
La funcion de carga se invoca al iniciar la aplicacion. La de guardado se ejecuta unicamente cuando se completa una partida y el resultado mejora el registro anterior.
Reinicio del juego
El boton de reinicio debe restaurar el estado completo: barajar de nuevo, limpiar clases, reiniciar contadores y temporizador.
function restartGame() {
stopTimer();
seconds = 0;
moves = 0;
matchedPairs = 0;
firstCard = null;
secondCard = null;
lockBoard = false;
document.getElementById("moves").textContent = "Movimientos: 0";
document.getElementById("timer").textContent = "Tiempo: 0s";
createBoard();
}
document.getElementById("restart").addEventListener("click", restartGame);
Es importante detener el intervalo del temporizador para evitar multiples contadores activos tras reinicios sucesivos.
Animaciones y feedback visual
Ademas del volteo basico, se pueden anadir transiciones de escala o cambios de color cuando se encuentra una pareja. Una clase matched con un ligero efecto de brillo refuerza la sensacion de logro.
.card.matched .card-back {
background: #a8e6cf;
box-shadow: 0 0 12px rgba(76, 175, 80, 0.6);
}
.card:hover:not(.flipped):not(.matched) {
transform: scale(1.03);
}
Las animaciones deben ser sutiles. Excesos de movimiento distraen y pueden provocar mareo en algunos usuarios. La preferencia del sistema por reduccion de movimiento puede respetarse mediante la media query prefers-reduced-motion.
Diseno responsive
En pantallas pequenas cuatro columnas pueden resultar demasiado estrechas. Las media queries permiten ajustar el numero de columnas y el tamaño de fuente.
@media (max-width: 500px) {
.board {
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.card-face {
font-size: 1.5rem;
}
}
@media (min-width: 800px) {
.board {
grid-template-columns: repeat(4, 1fr);
max-width: 700px;
}
}
Probar el juego en distintos anchos de ventana durante el desarrollo evita sorpresas en dispositivos reales. El uso de unidades relativas y aspect-ratio contribuye a que las cartas mantengan sus proporciones.
Posibles mejoras y extensiones
Una vez funciona la version basica se pueden anadir niveles de dificultad (mas pares), temas visuales intercambiables, sonidos de volteo y acierto, o un modo contrarreloj. Tambien es posible persistir el estado de una partida a medias para reanudarla mas tarde.
La separacion clara entre la logica del juego y la presentacion facilita estas ampliaciones. Mantener las funciones pequenas y con una unica responsabilidad reduce el riesgo de introducir errores al anadir caracteristicas nuevas.
Errores frecuentes y como evitarlos
Uno de los fallos mas comunes es olvidar el bloqueo del tablero mientras se evalua una pareja, lo que permite voltear mas de dos cartas. Otro es no limpiar los event listeners al regenerar el tablero, lo que provoca comportamientos inesperados tras varios reinicios. Utilizar innerHTML para vaciar el contenedor elimina los listeners antiguos de forma automatica.
El uso de == en lugar de === al comparar simbolos puede producir resultados incorrectos si los tipos no coinciden. Mantener la disciplina de las comparaciones estrictas evita este tipo de bugs sutiles.
Conclusiones
Construir un juego de memoria con JavaScript puro consolida competencias esenciales: generacion dinamica de elementos, gestion de estado, control de eventos, animaciones CSS, algoritmos de barajado y persistencia de datos. Cada una de estas piezas aparece de forma recurrente en aplicaciones web profesionales.
El proyecto es lo suficientemente pequeno como para completarse en pocas sesiones y lo bastante rico como para admitir mejoras continuas. Al finalizarlo el desarrollador dispone de una base solida para afrontar interfaces interactivas mas complejas y para comprender con mayor profundidad el funcionamiento interno de librerias y frameworks que abstraen estos mismos mecanismos.
