Cómo construir una aventura top-down
Este tutorial fue escrito en febrero de 2026, para la v2 del motor.
Las aventuras top-down son uno de los géneros retro más reconocibles. El motor no tiene colisión de tiles, NPCs ni diálogos integrados — vamos a construir todo eso desde cero con un tilemap, algunas flags de sprites y una máquina de estados. Al final tendremos un mundo con scroll, habitaciones, paredes, NPCs con los que hablar y un cuadro de diálogo.
Dibujando el mundo
Todo juego top-down empieza con un mundo. Necesitamos dos tipos de tiles — floor y wall — y el sistema de tilemap para organizarlos en habitaciones.
Cada definición de sprite incluye un array de flags. Poner la flag 0 a true marca un tile como sólido. Usaremos esta convención para las colisiones más adelante.
El sprite del suelo es verde oscuro (3) con un par de píxeles verde claro (11) como textura. La pared es gris oscuro (5) con líneas de mortero gris claro (6) en un patrón de ladrillos. buildWorld rellena una cuadrícula de 16×16 con suelo, coloca paredes en el perímetro y añade paredes interiores para crear pasillos:
engine.scope(({ start, cls, mset, map }) => {
const sprites = {
floor: [
[
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 11, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 11, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
],
[false, false, false, false, false, false, false, false],
],
wall: [
[
5, 5, 5, 6, 5, 5, 5, 6,
5, 5, 5, 6, 5, 5, 5, 6,
6, 6, 6, 6, 6, 6, 6, 6,
5, 6, 5, 5, 5, 6, 5, 5,
5, 6, 5, 5, 5, 6, 5, 5,
6, 6, 6, 6, 6, 6, 6, 6,
5, 5, 5, 6, 5, 5, 5, 6,
5, 5, 5, 6, 5, 5, 5, 6,
],
[true, false, false, false, false, false, false, false],
],
};
function buildWorld() {
for (let y = 0; y < 16; y++) {
for (let x = 0; x < 16; x++) {
mset(x, y, 'floor');
}
}
for (let i = 0; i < 16; i++) {
mset(i, 0, 'wall');
mset(i, 15, 'wall');
mset(0, i, 'wall');
mset(15, i, 'wall');
}
for (let y = 1; y <= 7; y++) {
if (y === 5 || y === 6) continue;
mset(7, y, 'wall');
}
for (let x = 1; x <= 14; x++) {
if (x >= 6 && x <= 9) continue;
mset(x, 11, 'wall');
}
mset(3, 4, 'wall');
mset(4, 4, 'wall');
mset(11, 4, 'wall');
mset(12, 4, 'wall');
}
function init() {
buildWorld();
}
function draw() {
cls(0);
map(0);
}
start({ sprites, sounds: {}, init, draw, target });
});
La flag 0 es solo una convención — el motor no asigna significado a ninguna flag en particular. Elegimos la flag 0 para "sólido" porque es la primera y fácil de recordar.
Moviendo al jugador
Ahora necesitamos un personaje. Vamos a definir cuatro sprites direccionales para que el jugador mire hacia donde se mueve — sombrero rojo (8), piel melocotón (15), cuerpo azul oscuro (1), botas marrones (4).
El movimiento es alineado a la cuadrícula. El jugador se mueve tile a tile en pasos de 8 píxeles pero interpola suavemente entre posiciones en cada frame para que no parezca un teletransporte. tileX/tileY registran la posición lógica en la cuadrícula, px/py registran la posición en píxeles en pantalla. Cada frame, px y py se acercan a tileX * 8 y tileY * 8. Cuando llegan, leemos la entrada para la siguiente dirección.
La entrada se lee en cada frame — no solo cuando el jugador termina un paso. Horizontal y vertical se comprueban por separado para poder cambiar de eje sin soltar la tecla actual. lastAxis registra qué dirección se pulsó sola más recientemente, y el eje más nuevo gana:
engine.scope(({ start, cls, spr, btn, mset, map }) => {
const sprites = {
floor: [
[
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 11, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 11, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
],
[false, false, false, false, false, false, false, false],
],
wall: [
[
5, 5, 5, 6, 5, 5, 5, 6,
5, 5, 5, 6, 5, 5, 5, 6,
6, 6, 6, 6, 6, 6, 6, 6,
5, 6, 5, 5, 5, 6, 5, 5,
5, 6, 5, 5, 5, 6, 5, 5,
6, 6, 6, 6, 6, 6, 6, 6,
5, 5, 5, 6, 5, 5, 5, 6,
5, 5, 5, 6, 5, 5, 5, 6,
],
[true, false, false, false, false, false, false, false],
],
playerDown: [
[
-1, -1, 8, 8, 8, 8, -1, -1,
-1, 8, 8, 8, 8, 8, 8, -1,
-1, 15, 15, 15, 15, 15, 15, -1,
-1, 15, 1, 15, 15, 1, 15, -1,
-1, -1, 1, 1, 1, 1, -1, -1,
-1, 1, 1, 1, 1, 1, 1, -1,
-1, -1, 1, -1, -1, 1, -1, -1,
-1, -1, 4, -1, -1, 4, -1, -1,
],
[false, false, false, false, false, false, false, false],
],
playerUp: [
[
-1, -1, 8, 8, 8, 8, -1, -1,
-1, 8, 8, 8, 8, 8, 8, -1,
-1, 8, 8, 8, 8, 8, 8, -1,
-1, 8, 8, 8, 8, 8, 8, -1,
-1, -1, 1, 1, 1, 1, -1, -1,
-1, 1, 1, 1, 1, 1, 1, -1,
-1, -1, 1, -1, -1, 1, -1, -1,
-1, -1, 4, -1, -1, 4, -1, -1,
],
[false, false, false, false, false, false, false, false],
],
playerLeft: [
[
-1, -1, 8, 8, 8, -1, -1, -1,
-1, 8, 8, 8, 8, 8, -1, -1,
-1, 15, 15, 15, 15, -1, -1, -1,
1, 15, 15, 15, -1, -1, -1, -1,
-1, -1, 1, 1, 1, -1, -1, -1,
-1, 1, 1, 1, 1, -1, -1, -1,
-1, -1, 1, -1, 1, -1, -1, -1,
-1, -1, 4, -1, 4, -1, -1, -1,
],
[false, false, false, false, false, false, false, false],
],
playerRight: [
[
-1, -1, -1, 8, 8, 8, -1, -1,
-1, -1, 8, 8, 8, 8, 8, -1,
-1, -1, -1, 15, 15, 15, 15, -1,
-1, -1, -1, -1, 15, 15, 15, 1,
-1, -1, -1, 1, 1, 1, -1, -1,
-1, -1, 1, 1, 1, 1, -1, -1,
-1, -1, 1, -1, 1, -1, -1, -1,
-1, -1, 4, -1, 4, -1, -1, -1,
],
[false, false, false, false, false, false, false, false],
],
};
let tileX = 3, tileY = 3;
let px, py;
let moving = false;
let facing = 'down';
let lastAxis = 'h';
const speed = 2;
const facingSprite = {
down: 'playerDown',
up: 'playerUp',
left: 'playerLeft',
right: 'playerRight',
};
function buildWorld() {
for (let y = 0; y < 16; y++) {
for (let x = 0; x < 16; x++) {
mset(x, y, 'floor');
}
}
for (let i = 0; i < 16; i++) {
mset(i, 0, 'wall');
mset(i, 15, 'wall');
mset(0, i, 'wall');
mset(15, i, 'wall');
}
for (let y = 1; y <= 7; y++) {
if (y === 5 || y === 6) continue;
mset(7, y, 'wall');
}
for (let x = 1; x <= 14; x++) {
if (x >= 6 && x <= 9) continue;
mset(x, 11, 'wall');
}
mset(3, 4, 'wall');
mset(4, 4, 'wall');
mset(11, 4, 'wall');
mset(12, 4, 'wall');
}
function init() {
buildWorld();
px = tileX * 8;
py = tileY * 8;
}
function update() {
let dx = 0, dy = 0;
if (btn('ArrowLeft') || btn('a')) dx = -1;
else if (btn('ArrowRight') || btn('d')) dx = 1;
if (btn('ArrowUp') || btn('w')) dy = -1;
else if (btn('ArrowDown') || btn('s')) dy = 1;
if (dx !== 0 && dy === 0) lastAxis = 'h';
if (dy !== 0 && dx === 0) lastAxis = 'v';
if (dx !== 0 && dy !== 0) {
if (lastAxis === 'h') dx = 0;
else dy = 0;
}
if (dx === -1) facing = 'left';
else if (dx === 1) facing = 'right';
else if (dy === -1) facing = 'up';
else if (dy === 1) facing = 'down';
if (!moving && (dx !== 0 || dy !== 0)) {
tileX += dx;
tileY += dy;
moving = true;
}
if (moving) {
const tx = tileX * 8;
const ty = tileY * 8;
if (px < tx) px = Math.min(px + speed, tx);
else if (px > tx) px = Math.max(px - speed, tx);
if (py < ty) py = Math.min(py + speed, ty);
else if (py > ty) py = Math.max(py - speed, ty);
if (px === tx && py === ty) moving = false;
}
}
function draw() {
cls(0);
map(0);
spr(facingSprite[facing], px, py);
}
start({ sprites, sounds: {}, init, update, draw, target });
});
Fíjate que el jugador atraviesa las paredes. El movimiento alineado a la cuadrícula no respeta automáticamente las flags de los tiles — eso viene ahora.
Colisiones con paredes
El jugador atraviesa las paredes porque nada comprueba si el tile de destino es sólido. Necesitamos una función isSolid que lea el tile en una posición de la cuadrícula con mget y compruebe la flag 0 con fget:
function isSolid(x, y) {
const tile = mget(x, y);
return tile && fget(tile, 0);
}
Antes de actualizar tileX/tileY, comprobamos el destino. ¿Sólido? No nos movemos. El jugador aún gira para mirar en esa dirección — simplemente no avanza:
if (!moving && (dx !== 0 || dy !== 0)) {
const nx = tileX + dx;
const ny = tileY + dy;
if (!isSolid(nx, ny)) {
tileX = nx;
tileY = ny;
moving = true;
}
}
Una función y un if. El movimiento alineado a la cuadrícula hace que la colisión sea trivial — un solo tile que comprobar por movimiento, sin casos especiales ni matemáticas de sub-píxel.
Añadiendo NPCs
Un mundo sin personajes es solo arquitectura. Los NPCs son objetos simples — una posición en la cuadrícula, un sprite, un nombre y unas líneas de diálogo:
const npcs = [
{ tileX: 3, tileY: 2, sprite: 'npc', name: 'Old Man',
lines: ['Welcome, traveler!', 'Explore this place.', 'Watch out for walls.'] },
{ tileX: 10, tileY: 6, sprite: 'npc', name: 'Guard',
lines: ['The south room', 'is mostly empty.', 'Nothing to see...'] },
];
El sprite del NPC usa lavanda (13) para el pelo y azul (12) para el cuerpo — lo bastante distinto del jugador de sombrero rojo como para diferenciarlos de un vistazo.
Los NPCs bloquean el movimiento igual que las paredes. Renombramos isSolid a isBlocked y añadimos una comprobación de posición de NPCs:
function isBlocked(x, y) {
const tile = mget(x, y);
if (tile && fget(tile, 0)) return true;
return npcs.some(n => n.tileX === x && n.tileY === y);
}
Para saber cuándo el jugador está junto a un NPC, comprobamos el tile al que mira. Una tabla de direcciones mapea cada string de orientación a un offset en la cuadrícula:
const dir = { left: [-1, 0], right: [1, 0], up: [0, -1], down: [0, 1] };
const [fdx, fdy] = dir[facing];
const fx = tileX + fdx;
const fy = tileY + fdy;
nearNpc = npcs.find(n => n.tileX === fx && n.tileY === fy) || null;
Cuando nearNpc tiene valor y el jugador no está a medio paso, aparece un mensaje de "Z to talk" en la parte inferior de la pantalla.
Sistema de diálogos
El mensaje de hablar funciona, pero pulsar Z no hace nada todavía. Necesitamos una máquina de estados — dos modos: 'explore' para movimiento normal, 'dialogue' para leer el texto del NPC.
Cuando el jugador pulsa Z cerca de un NPC, cambiamos al modo diálogo, guardamos qué NPC habla y empezamos en la línea 0:
let mode = 'explore';
let dialogNpc = null;
let dialogLine = 0;
// en update, antes del movimiento:
if (nearNpc && (btnp('z') || btnp(' '))) {
mode = 'dialogue';
dialogNpc = nearNpc;
dialogLine = 0;
return;
}
En modo diálogo, el movimiento se ignora. Z avanza a la siguiente línea. Después de la última línea, volvemos a explorar:
if (mode === 'dialogue') {
if (btnp('z') || btnp(' ')) {
dialogLine++;
if (dialogLine >= dialogNpc.lines.length) {
mode = 'explore';
dialogNpc = null;
dialogLine = 0;
}
}
return;
}
El diálogo muestra el nombre del NPC en amarillo, la línea actual en blanco y un indicador "Z..." para que sepas que hay que seguir pulsando:
if (mode === 'dialogue' && dialogNpc) {
text(dialogNpc.name, 4, 88, 10);
text(dialogNpc.lines[dialogLine], 4, 100, 7);
text('Z...', 108, 112, 6);
}
btnp (button pressed) devuelve true solo en el primer frame que se mantiene pulsada una tecla. Esto evita que una sola pulsación salte varias líneas de diálogo de golpe.
Un mundo más grande
Hasta ahora el mundo cabe exactamente en pantalla — 16×16 tiles a 8 píxeles cada uno llena el canvas de 128×128. Los juegos de aventura reales tienen mundos más grandes que la vista. Vamos a expandir a 32×24 tiles y añadir scroll con camera.
La cámara se centra en el jugador y se limita a los bordes del mundo para no mostrar espacio vacío más allá de los límites:
const MAP_W = 32, MAP_H = 24;
const CANVAS = 128;
camX = Math.max(0, Math.min(MAP_W * 8 - CANVAS, px - 60));
camY = Math.max(0, Math.min(MAP_H * 8 - CANVAS, py - 60));
Llamamos a camera(camX, camY) antes de renderizar. map solo dibuja una pantalla de tiles por defecto, así que calculamos en qué celda empieza la cámara y lo pasamos como origen — 17 columnas y filas desde la celda visible, posicionadas en las coordenadas correctas del mundo. Los elementos de UI como el diálogo deben quedarse fijos en pantalla, así que llamamos a creset() antes de dibujarlos:
function draw() {
cls(0);
camera(camX, camY);
const cx = Math.floor(camX / 8);
const cy = Math.floor(camY / 8);
map(0, cx, cy, 17, 17, cx * 8, cy * 8);
for (const n of npcs) {
spr(n.sprite, n.tileX * 8, n.tileY * 8);
}
spr(facingSprite[facing], px, py);
creset();
if (mode === 'dialogue' && dialogNpc) {
text(dialogNpc.name, 4, 88, 10);
text(dialogNpc.lines[dialogLine], 4, 100, 7);
text('Z...', 108, 112, 6);
} else if (nearNpc && !moving) {
text('Z to talk', 38, 112, 7);
}
}
El mundo más grande tiene dos alas conectadas por un pasillo, habitaciones en la mitad sur y tres NPCs repartidos por distintas zonas. Explora y verás cómo la cámara te sigue a donde vayas.
creset() reinicia el offset de la cámara a (0, 0). Todo lo que se dibuje después se renderiza en coordenadas de pantalla — perfecto para elementos de HUD, cuadros de diálogo y marcadores.
Para ir más allá
- Sprites animados — alterna entre 2-3 sprites de frames de caminata usando
Math.floor(frame / 8) % frameCountmientras el jugador se mueve - Objetos recogibles — coloca sprites coleccionables en ciertos tiles, elimínalos de un array de objetos cuando el jugador pase por encima, lleva el inventario en una lista aparte
- Puertas con llave — marca ciertos tiles de pared con la flag 1 como puertas, solo permite el paso cuando el jugador tenga el objeto llave correspondiente en su inventario
- Múltiples mapas — almacena varias cuadrículas de tiles como arrays e intercámbialas cuando el jugador pise un tile de salida, reiniciando el tilemap con
mcleary reconstruyendo desde la nueva cuadrícula - Movimiento de NPCs — dale a cada NPC una ruta de patrulla como un array de coordenadas de tiles y muévelos un paso con un temporizador, pausando el movimiento durante el diálogo
- Efectos de sonido — usa
sfx()para reproducir un sonido de pisada en cada transición de tile, un tintineo al abrir el diálogo y un sonido de recogida para los objetos