Skip to main content

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 });
});
Top-Down Adventure: The World
Una habitación con paredes de ladrillo y suelo verde — dos salas conectadas por puertas

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 });
});
Top-Down Adventure: Movement
Muévete con las flechas o WASD — el personaje mira en cada dirección

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.

Top-Down Adventure: Walls
Intenta caminar hacia las paredes — el personaje se detiene en vez de atravesarlas

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.

Top-Down Adventure: NPCs
Acércate a un NPC y míralo de frente para ver el mensaje de hablar

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);
}
Top-Down Adventure: Dialogue
Mira a un NPC y pulsa Z para abrir el diálogo — pulsa Z de nuevo para avanzar por las líneas

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.

Top-Down Adventure: Camera
Explora un mundo más grande con scroll, varias habitaciones y NPCs con los que hablar

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) % frameCount mientras 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 mclear y 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