Cómo programar el juego Buscaminas desde cero
Un tutorial paso a paso para construir el clásico Buscaminas de Windows en HTML, CSS y JavaScript. Sin canvas: una rejilla de divs, una función recursiva para vaciar zonas seguras y un truco de bordes de los 90 para el bisel gris de toda la vida.

El Buscaminas es uno de esos juegos que llevaba instalado en todos los Windows del cole y que casi nadie recuerda haber "descargado": simplemente estaba ahí. La mecánica es puro razonamiento: hay un tablero con minas escondidas, cada casilla que descubres sin explotar te dice cuántas minas tiene alrededor, y con esa cifra vas deduciendo dónde es seguro pisar y dónde no. Nada de reflejos, nada de tiempo de reacción. Solo lógica y, al principio de cada partida, algo de suerte.
En este tutorial lo construyo desde cero en HTML, CSS y JavaScript, sin canvas y sin dependencias. Como en el tutorial de Parejas, todo el tablero es una rejilla de elementos normales del DOM, lo que lo hace un proyecto agradecido si vienes del maquetado web más que de la programación de videojuegos clásica. La parte con más chicha no es visual: es la función que vacía de un tirón una zona segura del tablero, y la decisión de cuándo colocar las minas.
La idea antes de tocar código
El tablero clásico "principiante" es una rejilla de 9×9 con 10 minas, así que es la configuración que voy a usar. Cada casilla tiene cuatro datos: si tiene mina, si está revelada, si tiene bandera, y cuántas minas hay entre sus ocho vecinas. Ese último número es el que ves escrito cuando descubres una casilla sin mina.
Hay una decisión de diseño que conviene tomar antes de escribir nada: ¿cuándo se colocan las minas? La respuesta ingenua —al empezar la partida— tiene un problema: el jugador podría perder en su primer clic sin haber tenido ninguna oportunidad de razonar. La solución habitual, y la que usa el Buscaminas de Windows desde hace décadas, es colocar las minas después del primer clic, evitando la casilla pulsada y sus vecinas. Así el primer clic siempre abre un hueco razonable para empezar a deducir.
El esqueleto HTML
La estructura es mínima: un panel superior con el contador de minas, la carita de reinicio y el cronómetro (los tres elementos que cualquiera que haya jugado a esto reconoce al instante), y el tablero debajo.
<div id="bm-prototipo">
<div class="bm-panel">
<div class="bm-lcd" id="bm-minas">010</div>
<button class="bm-carita" id="bm-reiniciar" aria-label="Nueva partida">🙂</button>
<div class="bm-lcd" id="bm-tiempo">000</div>
</div>
<div class="bm-tablero" id="bm-tablero"></div>
<p id="bm-mensaje" class="bm-mensaje"></p>
</div>
El tablero, igual que en Parejas, empieza vacío: JavaScript lo rellena con 81 <div> cada vez que hay que repintar.
El bisel gris: un truco de CSS de los 90
Antes de la lógica, un capricho visual. El aspecto "botón hundido / botón elevado" de las interfaces de Windows 95 no se hacía con box-shadow —ni existía todavía—, se hacía con bordes de cuatro colores distintos. Un borde claro arriba e izquierda y uno oscuro abajo y derecha simulan una superficie elevada, iluminada desde la esquina superior izquierda. Invertir el orden simula una superficie hundida.
.bm-celda {
border: 2px solid;
border-color: #fff #808080 #808080 #fff; /* elevada */
background: #c0c0c0;
}
.bm-celda.revelada {
border: 1px solid #999;
background: #d0d0d0; /* hundida, sin relieve */
}
border-color acepta hasta cuatro valores en el orden arriba-derecha-abajo-izquierda, igual que margin o padding. Con solo dos tonos de gris y ese orden, el ojo interpreta relieve donde no hay más que color plano. Es una de esas ilusiones que cuestan tres líneas y que hoy casi nadie usa, pero que le sientan perfectas a un homenaje retro.
Los números de casillas vecinas llevan también los colores originales del juego: 1 en azul, 2 en verde, 3 en rojo, y así hasta el 8, cada vez más oscuro. Ese detalle de memoria muscular —todo el mundo que ha jugado a esto reconoce el azul del "1" sin pensarlo— vale la pena respetarlo tal cual.
Colocar las minas tras el primer clic
Como decidimos antes, las minas no existen hasta que el jugador hace el primer clic. La función recibe el índice de esa primera casilla y la excluye a ella y a sus ocho vecinas del sorteo.
function vecinos(indice) {
const fila = Math.floor(indice / COLUMNAS);
const columna = indice % COLUMNAS;
const resultado = [];
for (let df = -1; df <= 1; df++) {
for (let dc = -1; dc <= 1; dc++) {
if (df === 0 && dc === 0) continue;
const f = fila + df, c = columna + dc;
if (f >= 0 && f < FILAS && c >= 0 && c < COLUMNAS) {
resultado.push(f * COLUMNAS + c);
}
}
}
return resultado;
}
function colocarMinas(indiceSeguro) {
const prohibidas = new Set([indiceSeguro, ...vecinos(indiceSeguro)]);
let colocadas = 0;
while (colocadas < MINAS) {
const idx = Math.floor(Math.random() * TOTAL);
if (prohibidas.has(idx) || celdas[idx].mina) continue;
celdas[idx].mina = true;
colocadas++;
}
celdas.forEach((celda, idx) => {
if (celda.mina) return;
celda.adyacentes = vecinos(idx).filter(v => celdas[v].mina).length;
});
}
La función vecinos convierte un índice plano en su fila y columna, genera las ocho posiciones alrededor y descarta las que se salen del tablero. Se reutiliza tres veces en todo el juego: aquí para la zona prohibida, para contar minas adyacentes, y más abajo para la cascada de revelado. Un sorteo con reintento (while que descarta índices repetidos o prohibidos) es más simple de leer que barajar todo el tablero, y con solo 10 minas en 81 casillas el número de reintentos es insignificante.
La cascada: revelar en profundidad
Esta es la pieza que le da al Buscaminas esa sensación de "abrir de golpe" media esquina del tablero. Si una casilla no tiene ninguna mina vecina (su número es 0), no tiene sentido obligar al jugador a hacer clic en cada una de sus vecinas por separado: se revelan solas, y si alguna de ellas también tiene 0 vecinas minadas, el proceso se repite desde ahí.
function revelar(indice) {
const celda = celdas[indice];
if (celda.revelada || celda.bandera) return;
if (!minasColocadas) {
colocarMinas(indice);
iniciarCronometro();
}
celda.revelada = true;
celdasReveladas++;
if (celda.mina) {
perder(indice);
return;
}
if (celda.adyacentes === 0) {
vecinos(indice).forEach(v => {
if (!celdas[v].revelada && !celdas[v].bandera) revelar(v);
});
}
if (!terminado && celdasReveladas === TOTAL - MINAS) {
ganar();
}
}
Es una función recursiva clásica: se llama a sí misma sobre cada vecina cuando la casilla actual no tiene minas alrededor, y la condición de parada llega sola en cuanto encuentra una casilla con al menos un vecino minado (esa sí se revela, pero no sigue expandiendo). Con un tablero de 81 casillas la recursión nunca baja tanto como para preocuparse por el límite de pila; en un tablero de miles de casillas habría que convertirla en una versión iterativa con una pila explícita, pero aquí sería complicar el código sin necesidad real.
La comprobación de victoria es deliberadamente simple: en el Buscaminas ganas cuando has revelado todas las casillas que no son minas, no cuando has marcado las minas correctamente. Por eso basta con comparar celdasReveladas contra TOTAL - MINAS después de cada revelado.
Banderas: ratón, móvil y un modo alternativo
Marcar una casilla sospechosa con una bandera es, en escritorio, el clásico clic derecho. El navegador abre su menú contextual por defecto en ese evento, así que hay que cancelarlo explícitamente:
div.addEventListener('contextmenu', e => {
e.preventDefault();
alternarBandera(idx);
});
En móvil no existe el clic derecho, así que hace falta un gesto equivalente: mantener pulsado. Se implementa con un setTimeout que arranca en touchstart y se cancela si el dedo se levanta o se mueve antes de tiempo:
div.addEventListener('touchstart', () => {
pressTimer = setTimeout(() => {
banderaLarga = true;
alternarBandera(idx);
}, 450);
}, { passive: true });
div.addEventListener('touchend', e => {
clearTimeout(pressTimer);
if (banderaLarga) e.preventDefault(); // evita que el toque también "revele"
});
El e.preventDefault() en touchend es necesario porque, si la pulsación larga ya puso la bandera, no queremos que el navegador dispare además el clic sintético que normalmente sigue a un toque: revelaría la casilla que se acaba de marcar. Aun así, la pulsación larga es un gesto que mucha gente no descubre por sí sola, así que además añadí un botón explícito de "Modo bandera" que invierte el comportamiento del toque mientras está activo. Nunca está de más un control visible cuando el gesto oculto no es evidente.
El contador de minas y el cronómetro
El contador de minas restantes y el cronómetro reproducen las pantallas LCD rojas del original, y comparten una misma función de formateo a tres dígitos:
function pad(n, len) {
const negativo = n < 0;
const texto = Math.min(Math.abs(n), 10 ** len - 1)
.toString()
.padStart(len - (negativo ? 1 : 0), '0');
return negativo ? `-${texto}` : texto;
}
El contador de minas es, en realidad, minas totales menos banderas colocadas —y el Buscaminas clásico deja que ese número se vuelva negativo si te pasas de banderas, así que pad sabe reservar un carácter para el signo cuando hace falta. El cronómetro arranca en el primer clic (el mismo momento en que se colocan las minas) y se detiene al ganar o perder, con un tope de 999 segundos porque la pantalla solo tiene tres dígitos, como en el original.
Perder, ganar y la carita
Cuando el jugador pisa una mina, se revelan todas las minas del tablero (para que vea dónde estaban las demás) y la que ha explotado se marca aparte para dibujarla en un rojo más intenso:
function perder(indiceExplotada) {
terminado = true;
clearInterval(intervaloTiempo);
caritaEl.textContent = '😵';
celdas.forEach(celda => { if (celda.mina) celda.revelada = true; });
celdas[indiceExplotada].explotada = true;
mensajeEl.textContent = 'Boom. Otra vez será.';
}
Ganar hace lo simétrico: coloca banderas automáticamente en las minas que quedaran sin marcar, para que el tablero final se vea "resuelto" del todo, y cambia la carita a gafas de sol. La carita, además de indicar el estado, es el botón de reinicio: un clic en ella siempre vuelve a inicializar(), tal y como funciona en el juego real.
Cosas que se pueden añadir
La base ya es un Buscaminas jugable completo, pero hay margen de sobra para seguir: niveles de dificultad con tableros más grandes (16×16 con 40 minas, el "intermedio" clásico, o 30×16 con 99 minas, el "experto"), un récord de mejor tiempo guardado en localStorage por nivel, y sobre todo el "chording": en el juego original, hacer clic en una casilla ya revelada cuyo número coincide con las banderas puestas a su alrededor revela automáticamente todas las vecinas sin bandera. Es la técnica que separa a quien juega despacio de quien resuelve un tablero de expertos en segundos, y encajaría de forma natural sobre la misma función revelar que ya tenemos.
El prototipo funcional
Aquí abajo está el juego completo y jugable: clic para descubrir, clic derecho (o pulsación larga en móvil) para marcar con bandera.
Otros tutoriales de la serie: Picas y Fijas · Parejas · 2048 · Serpiente.
Buscaminas
Clic para descubrir, clic derecho para marcar con bandera.
En móvil: mantén pulsado para marcar, o activa el modo bandera.


