Cómo Construir un Juego de Sokoban
Este tutorial fue escrito para la v2 del motor.
Sokoban es uno de esos juegos que parece trivial hasta que llevas tres movimientos y te das cuenta de que arruinaste el nivel. Empujas cajas hacia objetivos. Eso es todo. Pero "eso es todo" esconde un rompecabezas profundo — cada empujón es irreversible (a menos que construyas deshacer), cada pared importa, y un solo movimiento equivocado puede hacer un nivel imposible.
Vamos a construir todo: un juego de rompecabezas basado en cuadrícula con múltiples niveles, un sistema de escenas para navegar entre ellos, una capa de pistas para cuando estés atascado, y animaciones pulidas para que se sienta bien. Al final tendrás un Sokoban jugable con deshacer, selección de niveles y efectos visuales — y habrás visto cómo las APIs de tilemap, sprites, entrada y escenas encajan en un juego real.
El juego se construye por capas. Empezamos con una cuadrícula y un jugador. Luego cajas. Luego objetivos y condiciones de victoria. Luego deshacer. Luego múltiples niveles con transiciones de escena. Luego pistas. Luego reemplazamos el movimiento instantáneo con deslizamiento suave y añadimos partículas. Cada sección agrega una idea y tiene una demo jugable para que veas exactamente qué cambió.
La Cuadrícula
Todo en Sokoban ocurre en una cuadrícula. Paredes, pisos, el jugador, las cajas — todo posicionado en tiles. La API de tilemap maneja las partes estáticas (paredes y pisos), y dibujamos al jugador como un sprite encima.
El nivel es un array 2D donde 1 significa pared y 2 significa piso:
const WALL = 1;
const FLOOR = 2;
const level = [
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 2, 2, 2, 2, 2, 2, 1],
[1, 2, 2, 1, 2, 2, 2, 1],
[1, 2, 2, 2, 2, 2, 2, 1],
[1, 2, 2, 2, 2, 1, 2, 1],
[1, 2, 2, 2, 2, 2, 2, 1],
[1, 2, 2, 2, 2, 2, 2, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
];
Durante init, recorremos este array y llamamos a mset para colocar cada tile. El tilemap recuerda qué hay en cada posición, y map() dibuja todo en una sola llamada:
function init() {
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (level[y][x] === WALL) mset(x, y, 'wall');
else if (level[y][x] === FLOOR) mset(x, y, 'floor');
}
}
}
El nivel tiene 8 tiles de ancho y 8 de alto en un canvas de 128x128, así que lo centramos con un desplazamiento:
const cols = level[0].length;
const rows = level.length;
const ox = Math.floor((128 - cols * 8) / 2);
const oy = Math.floor((128 - rows * 8) / 2);
Ese desplazamiento se pasa a map() y se suma a la posición en píxeles del jugador al dibujar.
El movimiento es basado en cuadrícula — cada pulsación de btnp() mueve al jugador un tile. Manejemos eso en update:
function update() {
let dx = 0, dy = 0;
if (btnp('ArrowLeft')) dx = -1;
else if (btnp('ArrowRight')) dx = 1;
else if (btnp('ArrowUp')) dy = -1;
else if (btnp('ArrowDown')) dy = 1;
if (dx === 0 && dy === 0) return;
const nx = px + dx;
const ny = py + dy;
if (tileAt(nx, ny) !== WALL) {
px = nx;
py = ny;
}
}
btnp se dispara una vez por pulsación — sin auto-repetición. Eso es lo que quieres para un juego de rompecabezas donde cada movimiento cuenta. El helper tileAt verifica el array del nivel y trata lo que está fuera de límites como pared, así que el jugador nunca puede salirse del borde.
El dibujado son tres líneas:
function draw() {
cls(0);
map(0, 0, 0, cols, rows, ox, oy);
spr('player', ox + px * 8, oy + py * 8);
}
Empujando Cajas
Una cuadrícula con un jugador que camina no es Sokoban todavía. Necesitamos cajas — y cajas que sigan las reglas correctas. Puedes empujar una caja caminando hacia ella, pero solo si el tile detrás de la caja (en la dirección que empujas) está libre. Si hay una pared u otra caja detrás, el empuje se bloquea y no te mueves.
Las cajas se almacenan como un array de coordenadas separado, no como tiles. El tilemap maneja el nivel estático — paredes y pisos que nunca cambian. Las cajas son dinámicas, así que viven fuera del tilemap y se dibujan como sprites encima:
let boxes = [
{ x: 3, y: 3 },
{ x: 4, y: 5 },
];
Agreguemos un helper para verificar si hay una caja en un tile dado:
function boxAt(x, y) {
return boxes.find(b => b.x === x && b.y === y);
}
La lógica de movimiento crece un poco. Antes, solo verificábamos si el tile destino era una pared. Ahora hay un tercer caso — el destino tiene una caja:
const nx = px + dx, ny = py + dy;
if (tileAt(nx, ny) === WALL) return;
const box = boxAt(nx, ny);
if (box) {
const bx = nx + dx, by = ny + dy;
if (tileAt(bx, by) === WALL || boxAt(bx, by)) return;
box.x = bx;
box.y = by;
}
px = nx;
py = ny;
Si el destino tiene una caja, verificamos un tile más allá en la misma dirección. ¿Pared u otra caja? Todo el movimiento se bloquea — el jugador se queda en su lugar. De lo contrario, la caja se desliza un tile y el jugador ocupa el lugar donde estaba la caja. El mismo dx/dy que mueve al jugador también mueve la caja, por eso el empuje siempre va en la dirección en que caminas.
El dibujado agrega un bucle — recorrer las cajas y dibujar cada una entre el tilemap y el jugador:
function draw() {
cls(0);
map(0, 0, 0, cols, rows, ox, oy);
for (const b of boxes) {
spr('box', ox + b.x * 8, oy + b.y * 8);
}
spr('player', ox + px * 8, oy + py * 8);
}
Resolviendo el Rompecabezas
Empujar cajas es divertido por unos diez segundos. Lo que lo convierte en un rompecabezas es tener un lugar donde las cajas necesitan ir. Los tiles objetivo marcan dónde debe terminar cada caja, y el nivel se resuelve cuando cada caja está sobre un objetivo.
Los objetivos son un tercer tipo de tile. Agregamos TARGET = 3 junto a pared y piso, y almacenamos las posiciones de los objetivos en su propio array:
const TARGET = 3;
const targets = [
{ x: 5, y: 2 },
{ x: 5, y: 5 },
];
Los objetivos se colocan en el tilemap durante init igual que paredes y pisos — un tile de piso con un diamante amarillo encima. El jugador y las cajas pueden caminar sobre ellos libremente:
for (const t of targets) {
mset(t.x, t.y, 'target');
}
La lógica de verificación de paredes necesita actualizarse. Antes, verificábamos tileAt(nx, ny) !== WALL. Ahora que los objetivos también son transitables, usemos un helper:
function isWalkable(x, y) {
const t = tileAt(x, y);
return t === FLOOR || t === TARGET;
}
Para dar retroalimentación visual, las cajas cambian de color cuando están sobre un objetivo. Verificamos la posición de cada caja contra la lista de objetivos y elegimos el sprite correcto:
function isOnTarget(bx, by) {
return targets.some(t => t.x === bx && t.y === by);
}
// in draw():
for (const b of boxes) {
const name = isOnTarget(b.x, b.y) ? 'box_on' : 'box';
spr(name, ox + b.x * 8, oy + b.y * 8);
}
Naranja significa mal ubicada. Verde significa "esta ya está". El cambio instantáneo de color cuando una caja llega a un objetivo es la primera retroalimentación real que te da el juego — te dice que estás progresando sin necesitar un elemento de interfaz.
La condición de victoria se verifica después de cada movimiento:
function checkWin() {
return boxes.every(b => isOnTarget(b.x, b.y));
}
Cuando cada caja está sobre un objetivo, won cambia a true y la entrada se detiene. Un nivel, un objetivo, dos cajas. Dale una partida — intenta resolverlo en la menor cantidad de movimientos posible.
Deshacer y Reiniciar
Sokoban sin deshacer es cruel. Un empujón equivocado y estás reiniciando todo el nivel. Deshacer transforma el juego de "memoriza la solución" a "experimenta y retrocede" — que es como deberían funcionar los juegos de rompecabezas.
El enfoque es directo: antes de cada movimiento exitoso, guardamos una instantánea del estado del juego. Para deshacer, sacamos la última instantánea y la restauramos. La instantánea solo necesita la posición del jugador y las posiciones de las cajas — todo lo demás (el nivel, el tilemap) es estático:
function snapshot() {
return { px, py, boxes: boxes.map(b => ({ ...b })) };
}
function restore(snap) {
px = snap.px;
py = snap.py;
boxes = snap.boxes.map(b => ({ ...b }));
}
El operador spread en boxes.map(b => ({ ...b })) importa. Sin él, estarías almacenando referencias a los mismos objetos de caja — y cuando una caja se mueve, las posiciones "guardadas" también cambiarían. Cada instantánea necesita sus propias copias.
El historial es simplemente un array usado como pila:
let history = [];
// before a move:
history.push(snapshot());
// on undo:
if (history.length > 0) {
restore(history.pop());
moves--;
}
Z deshace el último movimiento. X reinicia todo el nivel — el jugador vuelve al inicio, las cajas vuelven a sus posiciones iniciales, el historial se limpia:
function resetLevel() {
px = initPx;
py = initPy;
boxes = initBoxes.map(b => ({ ...b }));
history = [];
moves = 0;
won = false;
}
Las posiciones iniciales se almacenan por separado del estado mutable para que resetLevel siempre tenga datos limpios desde los cuales restaurar.
También rastreamos un contador de movimientos. Sube con cada movimiento exitoso y baja al deshacer. Cuando ganas, el contador aparece en el mensaje de victoria — para que veas qué tan eficiente (o no) fue tu solución. Intenta resolver un nivel, luego deshaz todo y resuélvelo de nuevo en menos movimientos.
Niveles y Escenas
Un nivel no es mucho juego. Necesitamos múltiples niveles y una forma de navegar entre ellos — una pantalla de título, una cuadrícula de selección de nivel, el juego en sí, y una pantalla de completado. Este es el mismo patrón de escenas del tutorial de gestión de escenas, aplicado a un juego real.
Cada nivel ahora es un objeto autocontenido con su mapa, posiciones de cajas, posiciones de objetivos e inicio del jugador:
const levels = [
{
map: [
[1, 1, 1, 1, 1, 1],
[1, 2, 2, 2, 2, 1],
[1, 2, 2, 2, 2, 1],
[1, 2, 2, 2, 2, 1],
[1, 2, 2, 2, 2, 1],
[1, 1, 1, 1, 1, 1],
],
boxes: [{ x: 3, y: 3 }],
targets: [{ x: 4, y: 1 }],
player: { x: 1, y: 1 },
},
// ...more levels
];
Cargar un nivel limpia el tilemap y lo reconstruye desde los datos del nivel. Esto importa — diferentes niveles tienen diferentes dimensiones, así que los tiles del nivel anterior se filtrarían si no limpias primero:
function loadLevel(index) {
const lv = levels[index];
mclear();
for (let y = 0; y < lv.map.length; y++) {
for (let x = 0; x < lv.map[0].length; x++) {
if (lv.map[y][x] === WALL) mset(x, y, 'wall');
else if (lv.map[y][x] === FLOOR) mset(x, y, 'floor');
}
}
for (const t of lv.targets) mset(t.x, t.y, 'target');
px = lv.player.x;
py = lv.player.y;
boxes = lv.boxes.map(b => ({ ...b }));
history = [];
moves = 0;
won = false;
}
El sistema de escenas es una sola variable scene — 'title', 'select', 'game', o 'complete'. Tanto update como draw se ramifican según ella. Nada sofisticado:
if (scene === 'title') {
// handle title input and drawing
} else if (scene === 'select') {
// handle level select
} else if (scene === 'game') {
// handle gameplay
} else if (scene === 'complete') {
// handle level complete
}
Las transiciones de escena usan un efecto de barrido — un rectángulo negro se desliza desde la izquierda, cubriendo la pantalla. Cuando llega al otro lado, cambiamos de escena y el rectángulo se desliza de vuelta:
function wipe(targetScene, cb) {
wipeProgress = 0;
wipeTarget = targetScene;
wipeCallback = cb;
}
Durante el barrido de entrada, wipeProgress aumenta de 0 a 128. Cuando llega a 128, la escena cambia y el callback opcional se ejecuta — ahí es donde se ejecuta loadLevel. Luego el barrido se invierte, revelando la nueva escena debajo. El barrido bloquea la entrada durante la transición (la función update retorna temprano mientras wipeTarget está establecido).
La pantalla de selección de nivel dibuja un botón numerado para cada nivel. Los niveles completados tienen números verdes. Las flechas izquierda/derecha cambian la selección, y Enter inicia el nivel seleccionado:
for (let i = 0; i < levels.length; i++) {
const x = 30 + i * 24;
const y = 50;
const done = completed.includes(i);
const sel = i === currentLevel;
rectfill(x, y, x + 18, y + 18, sel ? 12 : 1);
text(String(i + 1), x + 6, y + 5, done ? 11 : 7);
}
Cuando resuelves un nivel, el juego espera medio segundo y luego hace un barrido hacia la pantalla de completado. Desde ahí, Enter va al siguiente nivel (o de vuelta a la selección si los terminaste todos). Escape siempre te lleva de vuelta a la selección de nivel desde el juego.
Capa de Pistas
Los juegos de rompecabezas necesitan una válvula de escape. Cuando alguien lleva cinco minutos mirando el mismo nivel, necesita un empujón — no la solución, solo lo suficiente para desbloquearse. La capa de pistas del tutorial de interfaz de pistas es perfecta para esto.
Presionar H activa y desactiva la capa. Cuando está activa, pasan dos cosas: los objetivos vacíos pulsan para llamar tu atención, y líneas rojas conectan cada caja mal ubicada con su objetivo más cercano. Empecemos con el pulsado.
Usamos una onda sinusoidal en un contador de frames. Los cuadrados objetivo alternan entre amarillo y naranja cada pocos frames — un efecto de respiración que dice "pon una caja aquí":
function drawHints(lv, gox, goy) {
const pulse = Math.sin(frame * 0.15) * 0.5 + 0.5;
const color = pulse > 0.5 ? 10 : 9;
for (const t of lv.targets) {
const tx = gox + t.x * 8;
const ty = goy + t.y * 8;
if (!boxAt(t.x, t.y)) {
rectfill(tx + 1, ty + 1, tx + 7, ty + 7, color);
}
}
La verificación if (!boxAt(t.x, t.y)) omite objetivos que ya tienen una caja encima. No tiene sentido resaltar un objetivo que ya está resuelto.
Las líneas de conexión usan distancia Manhattan para encontrar el objetivo más cercano para cada caja mal ubicada, luego dibujan una línea roja entre sus centros:
for (const b of boxes) {
if (isOnTarget(lv, b.x, b.y)) continue;
const nearest = nearestTarget(lv, b.x, b.y);
if (!nearest) continue;
const bx = gox + b.x * 8 + 4;
const by = goy + b.y * 8 + 4;
const tx = gox + nearest.x * 8 + 4;
const ty = goy + nearest.y * 8 + 4;
line(bx, by, tx, ty, 8);
}
}
El desplazamiento de +4 centra la línea en cada tile de 8x8 en lugar de anclarla a la esquina superior izquierda.
La capa de pistas se dibuja entre el tilemap y los sprites — después de map() pero antes de las llamadas spr() de cajas y jugador. Los objetivos pulsantes quedan detrás de las cajas y las líneas de conexión asoman por debajo de ellas, lo cual se ve bien visualmente. La etiqueta de la tecla H en la esquina cambia de color cuando las pistas están activas para que puedas saber de un vistazo si están encendidas.
Es un empujón, no la solución. Suficiente para desbloquearte sin arruinar el rompecabezas.
Ejemplo Completo
Todo hasta ahora usó movimiento instantáneo — presionas una tecla, el jugador se teletransporta un tile. Funciona, pero se siente plano. El tutorial de efectos visuales explica por qué: sin retroalimentación visual, las acciones no registran. Esta versión final agrega deslizamiento suave, partículas y destello de pantalla para hacer que las mismas mecánicas se sientan vivas.
El deslizamiento suave reemplaza el movimiento instantáneo con una animación de 6 frames. En lugar de actualizar posiciones inmediatamente, almacenamos las coordenadas de inicio y fin e interpolamos entre ellas con easeOutQuad:
function easeOutQuad(t) {
return t * (2 - t);
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
Cuando el jugador se mueve, configuramos el deslizamiento y bloqueamos la entrada hasta que termine:
slidePlayer = { fx: px, fy: py, tx: nx, ty: ny };
sliding = true;
slideFrames = 0;
Durante el deslizamiento, la posición de dibujo del jugador se interpola. La curva de easing hace que el movimiento empiece rápido y desacelere — se siente como si el jugador caminara y se asentara en posición en lugar de deslizarse a velocidad constante:
const t = sliding ? easeOutQuad(Math.min(slideFrames / SLIDE_DURATION, 1)) : 1;
let playerDrawX = gox + lerp(slidePlayer.fx, slidePlayer.tx, t) * 8;
let playerDrawY = goy + lerp(slidePlayer.fy, slidePlayer.ty, t) * 8;
spr('player', Math.floor(playerDrawX), Math.floor(playerDrawY));
Las posiciones reales en la cuadrícula (px, py) solo se actualizan cuando el deslizamiento termina. Durante la animación, el jugador está visualmente entre tiles pero lógicamente aún en la posición anterior. Esto significa que la entrada está bloqueada — no puedes encolar movimientos mientras se desliza, lo que evita que el jugador empuje accidentalmente una caja dos veces.
Las cajas se deslizan de la misma manera. Cuando una caja aterriza en un objetivo, una explosión de partículas verdes se dispara desde el punto de aterrizaje:
if (isOnTarget(lv, slideBoxRef.x, slideBoxRef.y)) {
spawnParticles(
gox + slideBoxRef.x * 8 + 4,
goy + slideBoxRef.y * 8 + 4,
11, 12
);
}
El sistema de partículas es simple. Cada partícula tiene posición, velocidad y tiempo de vida. Cada frame se desplazan, acumulan un poco de gravedad y cuentan hacia abajo:
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx;
p.y += p.vy;
p.vy += 0.05;
p.life--;
if (p.life <= 0) particles.splice(i, 1);
}
Cuando completas un nivel, el juego dispara una gran explosión de partículas amarillas desde el centro de la pantalla y destella en blanco por unos frames. La combinación — deslizamiento suave hacia el objetivo, partículas verdes, breve pausa, destello blanco, explosión amarilla — convierte "lo resolviste" de un mensaje de texto en un evento.
El texto del título y el texto de nivel completado usan un rebote con onda sinusoidal para evitar que se queden estáticos en pantalla:
const pulse = Math.sin(frame * 0.08) * 2;
text('sokoban', 40, 38 + Math.floor(pulse), 7);
Nada de esto cambia la lógica del juego. Las reglas del rompecabezas, el sistema de deshacer, el gestor de escenas, la capa de pistas — todo idéntico a las secciones anteriores. Los efectos visuales son una capa encima. Hacen que el mismo juego se sienta mejor sin tocar las reglas.
Para Seguir Explorando
- Movimientos par — Define una cantidad objetivo de movimientos para cada nivel. Muestra una calificación con estrellas según qué tan cerca estuvo el jugador: 3 estrellas por igualar o superar el par, 2 por acercarse, 1 por pasarse mucho. Muestra el número par en la pantalla de selección para que los jugadores sepan a qué apuntar.
- Temporizador / modo speedrun — Agrega un temporizador opcional que inicie cuando el nivel carga y se detenga cuando ganes. Muestra el mejor tiempo por nivel en la pantalla de selección. Para jugadores competitivos, agrega un tiempo total entre todos los niveles.
- Generación procedural de niveles — Genera niveles resolubles trabajando hacia atrás: empieza con un estado resuelto (todas las cajas en objetivos) e invierte movimientos válidos aleatorios. Esto garantiza que sea resoluble y puede producir contenido ilimitado. Lo difícil es hacer niveles que sean interesantes, no solo resolubles.
- Tiles de hielo y oscuridad — Los tiles de hielo hacen que el jugador (o las cajas) se deslicen hasta chocar con una pared. Los tiles oscuros son de un solo sentido — puedes caminar sobre ellos pero no volver. Ambos añaden profundidad mecánica sin nuevos sprites.
- Editor de niveles — Permite a los jugadores hacer clic para colocar paredes, pisos, objetivos, cajas y la posición inicial del jugador. Exporta el nivel como un objeto JSON que pueden pegar en su propio código. La API de tilemap ya soporta todo lo que necesitas — el editor es solo una interfaz para llamar a
mset. - Efectos de sonido — Combina lo visual con audio: un breve blip al empujar, un golpe satisfactorio cuando una caja llega a un objetivo, una fanfarria al completar un nivel, un clic suave al deshacer. Usa
sfx()con definiciones cortas de forma de onda para cada evento.