Proyectos · JavaScript · Avanzado

Juego de la serpiente

El clásico de los móviles de antes, dibujado en un canvas: la serpiente avanza sola, crece al comer, choca contra los bordes y contra sí misma, guarda el récord y se juega con flechas o deslizando el dedo.

  • 5 pasos
  • 2 horas aproximadamente
  • 185 líneas de código
  • Editor en vivo

Lo que vas a construir

Programar un juego obliga a pensar en el tiempo: cada pocos milisegundos el mundo avanza un paso. Vas a representar el tablero como una rejilla, la serpiente como una lista de casillas y el bucle del juego con setInterval, y a dibujarlo todo en un <canvas>. Al final, funcionará igual con el teclado que deslizando el dedo.

Lo que vas a practicar

  • Dibujar en un canvas
  • Representar el juego con datos (una rejilla)
  • El bucle de juego con setInterval
  • Detectar colisiones
  • Controles con teclado y con gestos
Resultado final

Antes de empezar, conviene haber hecho estas lecciones

Paso 1 de 5

El tablero

Un <canvas> es un lienzo en el que se dibuja con JavaScript. Se le dan 400 × 400 píxeles internos y el CSS lo escala al ancho disponible con aspect-ratio: 1 para que siga siendo cuadrado en el móvil.

touch-action: none hace que deslizar el dedo sobre el tablero no desplace la página: lo usarás en el último paso.

index.html
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Serpiente</title>
  <link rel="stylesheet" href="estilos.css">
</head>
<body>
  <main class="juego">
    <div class="marcador">
      <span>Puntos: <strong id="puntos">0</strong></span>
      <span>Récord: <strong id="record">0</strong></span>
    </div>
    <canvas id="tablero" width="400" height="400" aria-label="Tablero del juego de la serpiente"></canvas>
    <p id="mensaje" class="mensaje" aria-live="polite">Pulsa una flecha (o desliza el dedo) para empezar</p>
  </main>
  <script src="app.js"></script>
</body>
</html>
estilos.css
* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  background: #052e16;
  color: #dcfce7;
  font-family: system-ui, sans-serif;
}

.juego { width: min(94vw, 420px); text-align: center; }

.marcador { display: flex; justify-content: space-between; margin-bottom: 8px; font-size: 1.1rem; }

canvas {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 1;
  border-radius: 12px;
  background: #14532d;
  touch-action: none; /* que deslizar el dedo no mueva la página */
}

.mensaje { min-height: 1.5em; font-weight: 600; }
app.js
// El JavaScript llega en el paso 2

Paso 2 de 5

La serpiente es una lista de casillas

El juego no se piensa en píxeles sino en casillas de una rejilla de 20 × 20. La serpiente es un array de posiciones (la primera es la cabeza) y la comida, una posición más.

dibujarCasilla traduce una casilla a píxeles multiplicando por su tamaño y pinta un cuadrado con fillRect, dejando un píxel de margen para que se vea la separación.

app.js
const tablero = document.querySelector("#tablero");
const ctx = tablero.getContext("2d");

const CASILLAS = 20; // el tablero es una rejilla de 20 × 20
const TAM = tablero.width / CASILLAS; // tamaño de cada casilla en píxeles

let serpiente = [{ x: 10, y: 10 }, { x: 9, y: 10 }, { x: 8, y: 10 }]; // la cabeza es la primera
let comida = { x: 15, y: 10 };

function dibujarCasilla(x, y, color) {
  ctx.fillStyle = color;
  ctx.fillRect(x * TAM + 1, y * TAM + 1, TAM - 2, TAM - 2);
}

function dibujar() {
  ctx.clearRect(0, 0, tablero.width, tablero.height);
  dibujarCasilla(comida.x, comida.y, "#f87171");
  serpiente.forEach((parte, i) => dibujarCasilla(parte.x, parte.y, i === 0 ? "#bef264" : "#4ade80"));
}

dibujar();

Paso 3 de 5

Movimiento y teclado

Avanzar es añadir una cabeza nueva en la dirección actual (unshift) y quitar la última casilla (pop). setInterval lo repite cada 120 milisegundos: ese es el bucle del juego.

Las flechas cambian la dirección. Se ignora la contraria a la actual, porque girar 180° haría que la serpiente se chocara consigo misma al instante.

app.jsLo nuevo, resaltado
const tablero = document.querySelector("#tablero");
const ctx = tablero.getContext("2d");

const CASILLAS = 20;
const TAM = tablero.width / CASILLAS;
const VELOCIDAD = 120; // milisegundos entre cada movimiento

let serpiente = [{ x: 10, y: 10 }, { x: 9, y: 10 }, { x: 8, y: 10 }];
let comida = { x: 15, y: 10 };
let direccion = { x: 1, y: 0 }; // hacia la derecha

function dibujarCasilla(x, y, color) {
  ctx.fillStyle = color;
  ctx.fillRect(x * TAM + 1, y * TAM + 1, TAM - 2, TAM - 2);
}

function dibujar() {
  ctx.clearRect(0, 0, tablero.width, tablero.height);
  dibujarCasilla(comida.x, comida.y, "#f87171");
  serpiente.forEach((parte, i) => dibujarCasilla(parte.x, parte.y, i === 0 ? "#bef264" : "#4ade80"));
}

function mover() {
  const cabeza = { x: serpiente[0].x + direccion.x, y: serpiente[0].y + direccion.y };
  serpiente.unshift(cabeza); // nueva cabeza delante…
  serpiente.pop(); // …y se quita la cola: la serpiente avanza sin crecer
  dibujar();
}

const TECLAS = {
  ArrowUp: { x: 0, y: -1 },
  ArrowDown: { x: 0, y: 1 },
  ArrowLeft: { x: -1, y: 0 },
  ArrowRight: { x: 1, y: 0 },
};

document.addEventListener("keydown", (event) => {
  const nueva = TECLAS[event.key];
  if (!nueva) return;
  event.preventDefault(); // que las flechas no hagan scroll
  // No se puede dar media vuelta sobre uno mismo
  if (nueva.x === -direccion.x && nueva.y === -direccion.y) return;
  direccion = nueva;
});

setInterval(mover, VELOCIDAD);
dibujar();

Paso 4 de 5

Comer, crecer y chocar

Si la cabeza llega a la comida, no se quita la cola: la serpiente crece una casilla y aparece comida nueva en una casilla libre. Si la cabeza sale del tablero o cae sobre la propia serpiente, la partida termina.

La dirección pedida se guarda en siguiente y se aplica en el próximo movimiento. Así, pulsar dos flechas muy rápido entre dos movimientos no puede provocar la media vuelta prohibida.

app.jsLo nuevo, resaltado
const tablero = document.querySelector("#tablero");
const ctx = tablero.getContext("2d");
const textoPuntos = document.querySelector("#puntos");
const mensaje = document.querySelector("#mensaje");

const CASILLAS = 20;
const TAM = tablero.width / CASILLAS;
const VELOCIDAD = 120;

let serpiente;
let comida;
let direccion;
let siguiente; // la dirección pedida, que se aplica en el próximo movimiento
let puntos;
let bucle = null;

function dibujarCasilla(x, y, color) {
  ctx.fillStyle = color;
  ctx.fillRect(x * TAM + 1, y * TAM + 1, TAM - 2, TAM - 2);
}

function dibujar() {
  ctx.clearRect(0, 0, tablero.width, tablero.height);
  dibujarCasilla(comida.x, comida.y, "#f87171");
  serpiente.forEach((parte, i) => dibujarCasilla(parte.x, parte.y, i === 0 ? "#bef264" : "#4ade80"));
}

function ocupada(x, y) {
  return serpiente.some((parte) => parte.x === x && parte.y === y);
}

// Comida en una casilla libre al azar
function nuevaComida() {
  let casilla;
  do {
    casilla = { x: Math.floor(Math.random() * CASILLAS), y: Math.floor(Math.random() * CASILLAS) };
  } while (ocupada(casilla.x, casilla.y));
  return casilla;
}

function reiniciar() {
  serpiente = [{ x: 10, y: 10 }, { x: 9, y: 10 }, { x: 8, y: 10 }];
  direccion = { x: 1, y: 0 };
  siguiente = direccion;
  puntos = 0;
  textoPuntos.textContent = "0";
  comida = nuevaComida();
  dibujar();
}

function finDePartida() {
  clearInterval(bucle);
  bucle = null;
  mensaje.textContent = `¡Fin! Has hecho ${puntos} puntos. Pulsa una flecha para jugar otra vez.`;
}

function mover() {
  direccion = siguiente;
  const cabeza = { x: serpiente[0].x + direccion.x, y: serpiente[0].y + direccion.y };
  const fuera = cabeza.x < 0 || cabeza.y < 0 || cabeza.x >= CASILLAS || cabeza.y >= CASILLAS;
  if (fuera || ocupada(cabeza.x, cabeza.y)) {
    finDePartida();
    return;
  }
  serpiente.unshift(cabeza);
  if (cabeza.x === comida.x && cabeza.y === comida.y) {
    puntos += 1; // al comer no se quita la cola: la serpiente crece
    textoPuntos.textContent = String(puntos);
    comida = nuevaComida();
  } else {
    serpiente.pop();
  }
  dibujar();
}

const TECLAS = {
  ArrowUp: { x: 0, y: -1 },
  ArrowDown: { x: 0, y: 1 },
  ArrowLeft: { x: -1, y: 0 },
  ArrowRight: { x: 1, y: 0 },
};

function girar(nueva) {
  if (!bucle) {
    reiniciar();
    mensaje.textContent = "";
    bucle = setInterval(mover, VELOCIDAD);
  }
  if (nueva.x === -direccion.x && nueva.y === -direccion.y) return;
  siguiente = nueva;
}

document.addEventListener("keydown", (event) => {
  const nueva = TECLAS[event.key];
  if (!nueva) return;
  event.preventDefault();
  girar(nueva);
});

reiniciar();

Paso 5 de 5

Récord y controles táctiles

El récord se guarda en localStorage al terminar cada partida (dentro de try…catch, porque el almacenamiento puede no estar disponible).

Para el móvil, los eventos pointerdown y pointerup miden cuánto se ha deslizado el dedo: si el movimiento horizontal es mayor que el vertical, gira a izquierda o derecha; si no, arriba o abajo. Los movimientos de menos de 20 píxeles se toman como un toque y se ignoran.

app.jsLo nuevo, resaltado
const tablero = document.querySelector("#tablero");
const ctx = tablero.getContext("2d");
const textoPuntos = document.querySelector("#puntos");
const mensaje = document.querySelector("#mensaje");
const textoRecord = document.querySelector("#record");

const CASILLAS = 20;
const TAM = tablero.width / CASILLAS;
const VELOCIDAD = 120;

let serpiente;
let comida;
let direccion;
let siguiente; // la dirección pedida, que se aplica en el próximo movimiento
let puntos;
let bucle = null;
let record = leerRecord();
textoRecord.textContent = String(record);

function leerRecord() {
  try {
    return Number(localStorage.getItem("serpiente-record")) || 0;
  } catch {
    return 0;
  }
}

function guardarRecord() {
  try {
    localStorage.setItem("serpiente-record", String(record));
  } catch {
    // sin almacenamiento: el récord solo dura esta partida
  }
}

function dibujarCasilla(x, y, color) {
  ctx.fillStyle = color;
  ctx.fillRect(x * TAM + 1, y * TAM + 1, TAM - 2, TAM - 2);
}

function dibujar() {
  ctx.clearRect(0, 0, tablero.width, tablero.height);
  dibujarCasilla(comida.x, comida.y, "#f87171");
  serpiente.forEach((parte, i) => dibujarCasilla(parte.x, parte.y, i === 0 ? "#bef264" : "#4ade80"));
}

function ocupada(x, y) {
  return serpiente.some((parte) => parte.x === x && parte.y === y);
}

// Comida en una casilla libre al azar
function nuevaComida() {
  let casilla;
  do {
    casilla = { x: Math.floor(Math.random() * CASILLAS), y: Math.floor(Math.random() * CASILLAS) };
  } while (ocupada(casilla.x, casilla.y));
  return casilla;
}

function reiniciar() {
  serpiente = [{ x: 10, y: 10 }, { x: 9, y: 10 }, { x: 8, y: 10 }];
  direccion = { x: 1, y: 0 };
  siguiente = direccion;
  puntos = 0;
  textoPuntos.textContent = "0";
  comida = nuevaComida();
  dibujar();
}

function finDePartida() {
  clearInterval(bucle);
  bucle = null;
  if (puntos > record) {
    record = puntos;
    textoRecord.textContent = String(record);
    guardarRecord();
  }
  mensaje.textContent = `¡Fin! Has hecho ${puntos} puntos. Pulsa una flecha para jugar otra vez.`;
}

function mover() {
  direccion = siguiente;
  const cabeza = { x: serpiente[0].x + direccion.x, y: serpiente[0].y + direccion.y };
  const fuera = cabeza.x < 0 || cabeza.y < 0 || cabeza.x >= CASILLAS || cabeza.y >= CASILLAS;
  if (fuera || ocupada(cabeza.x, cabeza.y)) {
    finDePartida();
    return;
  }
  serpiente.unshift(cabeza);
  if (cabeza.x === comida.x && cabeza.y === comida.y) {
    puntos += 1; // al comer no se quita la cola: la serpiente crece
    textoPuntos.textContent = String(puntos);
    comida = nuevaComida();
  } else {
    serpiente.pop();
  }
  dibujar();
}

const TECLAS = {
  ArrowUp: { x: 0, y: -1 },
  ArrowDown: { x: 0, y: 1 },
  ArrowLeft: { x: -1, y: 0 },
  ArrowRight: { x: 1, y: 0 },
};

function girar(nueva) {
  if (!bucle) {
    reiniciar();
    mensaje.textContent = "";
    bucle = setInterval(mover, VELOCIDAD);
  }
  if (nueva.x === -direccion.x && nueva.y === -direccion.y) return;
  siguiente = nueva;
}

document.addEventListener("keydown", (event) => {
  const nueva = TECLAS[event.key];
  if (!nueva) return;
  event.preventDefault();
  girar(nueva);
});

// En el móvil: deslizar el dedo sobre el tablero
let inicioToque = null;
tablero.addEventListener("pointerdown", (event) => {
  inicioToque = { x: event.clientX, y: event.clientY };
});
tablero.addEventListener("pointerup", (event) => {
  if (!inicioToque) return;
  const dx = event.clientX - inicioToque.x;
  const dy = event.clientY - inicioToque.y;
  inicioToque = null;
  if (Math.max(Math.abs(dx), Math.abs(dy)) < 20) return; // un toque, no un deslizamiento
  girar(Math.abs(dx) > Math.abs(dy) ? { x: Math.sign(dx), y: 0 } : { x: 0, y: Math.sign(dy) });
});

reiniciar();

Retos para seguir

Has terminado el proyecto. Si quieres ir más allá, prueba a añadirle esto por tu cuenta:

  1. Aumenta la velocidad cada 5 puntos (tendrás que reiniciar el intervalo).
  2. Añade una tecla para pausar y reanudar la partida.
  3. Haz que la serpiente aparezca por el lado contrario al salir del tablero en lugar de chocar.

Conceptos clave

Repasa la ruta de JavaScript o elige otro proyecto.