
Metroidvania
Exploras un gran mapa interconectado, casi siempre en 2D, donde muchas zonas siguen cerradas hasta que consigues la habilidad o el objeto que te deja pasar, y por eso vuelves una y otra vez sobre tus pasos.
También se llama: plataformas de exploración
Mecánicas que lo definen
- Un solo mundo continuo, dividido en salas o zonas conectadas, en lugar de niveles que se juegan en orden.
- Caminos bloqueados por «candados» (una pared alta, un bloque que no se rompe, un hueco demasiado estrecho) que se abren con «llaves»: habilidades u objetos permanentes.
- Volver atrás (backtracking) a zonas conocidas con una habilidad nueva, que las hace jugar de otra manera.
- Un mapa que se completa al explorar y que funciona como memoria del jugador.
- Combate y plataformas en tiempo real, a menudo con jefes que custodian las mejoras.
Bucle de juego
Explorar hasta topar con un camino cerrado, recordarlo, encontrar en otra parte la habilidad que lo abre, volver y descubrir una zona nueva con nuevos caminos cerrados.
Demo propia
Controles. Flechas o A/D para moverte, espacio, flecha arriba o W para saltar. Con el doble salto, vuelve a pulsar saltar en el aire. En móvil, los botones en pantalla.
Abrir en pantalla completaUn mundo pequeño de cinco salas conectadas. Al principio una pared morada te corta el paso hacia la meta; en una sala de abajo está el orbe del doble salto, y con él vuelves sobre tus pasos para superar la pared. Un minimapa muestra las salas que ya visitaste.
Hecha con Kaplay, en unas 204 líneas de JavaScript.
Cómo está hecha
Configuración y física del salto
Se crea el juego con botones virtuales (left, right, jump) para que funcione con teclado o con pantalla táctil. La física del salto es la misma de la demo de plataformas. Su altura importa para el diseño, porque con estas constantes un salto sube unos 120 píxeles, lo justo para trepar tres baldosas pero no cinco, y en esa diferencia se apoya el candado del mapa.
const k = kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [22, 24, 44], buttons: { left: { keyboard: ["left", "a"] }, right: { keyboard: ["right", "d"] }, jump: { keyboard: ["space", "up", "w"] }, }, }); touchControls(k, [{ button: "left", label: "◀" }, { button: "right", label: "▶" }, { button: "jump", label: "⤒", side: "right" }]); // Same jump physics as the platformer demo. const GRAVITY = 1600; const SPEED = 200; const JUMP_FORCE = 620; // ≈ 120 px high: enough for 3 tiles, not for 5 const COYOTE_TIME = 0.1; const TILE = 32; const ROOM_W = 640; const ROOM_H = 11 * TILE; // 352 px: one room fills the screen setGravity(GRAVITY);El mundo como cuadrícula de salas
El mundo es un diccionario de salas con coordenadas («0,0», «1,0»…), y cada sala es un mapa de texto de 20 × 11 baldosas. Un borde abierto es una puerta a la sala vecina, y un hueco en el suelo o en el techo conecta con la de abajo o la de arriba. Las salas no están en fila: forman un circuito (A → B → E → D → A). Sin la habilidad solo puedes bajar por los huecos, y con ella también puedes subir.
// The world is a grid of rooms; each room is a text map of 20 × 11 tiles. // An open edge is a door to the neighbouring room. // # wall · % the tall wall (the lock) · D double-jump orb (the key) · G goal const ROOMS = { "0,0": [ "####################", "# #", "# #", "# #", "# #", "# #", "# ### #", "# ", "# ", "# ", "############ ######", ], "1,0": [ "####################", "# ", "# ", "# ", "# ", "# %%%%%", "# %%%%%", " %%%%%", " %%%%%", " %%%%%", "######## ##########", ], "2,0": [ "####################", " #", " #", " #", " #", "##### #", "##### #", "##### #", "##### #", "##### G #", "####################", ], "0,1": [ "############ ######", "# #", "# #", "# #", "# #", "# ## #", "# #", "# ", "# ", "# ", "####################", ], "1,1": [ "######## ##########", "# #", "# #", "# #", "# #", "# ## #", "# #", " #", " #", " D #", "####################", ], }; const wall = (c) => [rect(TILE, TILE), color(c), area(), body({ isStatic: true }), "solid"]; const buildRoom = (key) => addLevel(ROOMS[key], { tileWidth: TILE, tileHeight: TILE, tiles: { "#": () => wall(rgb(84, 96, 140)), "%": () => wall(rgb(150, 90, 200)), D: () => [circle(10), color(120, 255, 200), pos(16, 16), anchor("center"), area(), "orb"], G: () => [rect(20, 40), color(255, 220, 90), pos(6, -8), area(), "goal"], }, });El jugador
El jugador es un rectángulo con area() y body(), igual que en un plataformas. Es un objeto aparte del nivel, por eso sobrevive cuando se destruye una sala y se construye la siguiente.
const player = add([rect(22, 30), color(120, 200, 255), pos(112, 320), area(), body(), anchor("bot"), "player"]); onButtonDown("left", () => player.move(-SPEED, 0)); onButtonDown("right", () => player.move(SPEED, 0));La habilidad como llave
Es la mecánica del género. El doble salto es un contador de saltos en el aire que vale 0 hasta que recoges el orbe y 1 después. La pared morada es el «candado», un obstáculo que el jugador ve pronto, que no puede superar y que recuerda. El orbe es la «llave», pero a diferencia de una llave de puerta no abre un solo sitio, sino que cambia lo que el jugador puede alcanzar en todo el mapa. Por eso, al conseguirla, el jugador vuelve a lugares conocidos que ahora se juegan distinto.
// The double jump: one extra jump in mid-air, but only after picking up the orb. let airTime = 0; let airJumps = 0; player.onUpdate(() => { airTime = player.isGrounded() ? 0 : airTime + dt(); if (player.isGrounded()) airJumps = hasDoubleJump ? 1 : 0; }); onButtonPress("jump", () => { if (airTime < COYOTE_TIME) airTime = 1; // normal jump (with coyote time) else if (airJumps > 0) airJumps--; // jump in mid-air, if you have the ability else return; player.jump(JUMP_FORCE); }); onButtonRelease("jump", () => { if (player.vel.y < 0) player.vel.y *= 0.45; // variable jump height }); // The orb is the "key": it does not open one door, it changes what the player can reach. player.onCollide("orb", (o) => { destroy(o); hasDoubleJump = true; airJumps = 1; updateHud(); const msg = t({ es: "¡Doble salto! Salta otra vez en el aire.", en: "Double jump! Jump again in mid-air." }); add([text(msg, { size: 18, font: FONT }), pos(320, 70), anchor("center"), color(120, 255, 200), opacity(1), lifespan(3, { fade: 0.5 })]); });Pasar de una sala a otra
Cuando el jugador sale de la pantalla por un borde, se destruye la sala actual, se construye la vecina y el jugador aparece en el borde opuesto conservando su velocidad. Al subir por un hueco del suelo se le da un pequeño impulso extra para que asome y pueda apartarse del agujero. Si no existe sala en esa dirección, no pasa nada.
// Leaving the screen through an open edge loads the neighbouring room. function enterRoom(x, y) { if (!ROOMS[`${x},${y}`]) return false; // no room there: stay put if (level) destroy(level); room = [x, y]; level = buildRoom(`${x},${y}`); if (hasDoubleJump) level.get("orb").forEach(destroy); visited.add(`${x},${y}`); return true; } enterRoom(0, 0); updateHud(); player.onUpdate(() => { const p = player.pos; const move = (dx, dy) => enterRoom(room[0] + dx, room[1] + dy); if (p.x < 0 && move(-1, 0)) p.x = ROOM_W - 14; else if (p.x > ROOM_W && move(1, 0)) p.x = 14; else if (p.y > ROOM_H && move(0, 1)) p.y = TILE; else if (p.y < 0 && move(0, -1)) { p.y = ROOM_H; // A small push upwards so you pop out of the hole in the floor above. player.vel.y = Math.min(player.vel.y, -560); } });El minimapa
El minimapa solo dibuja las salas visitadas, y la sala actual va en otro color. En un mundo donde hay que volver atrás, el mapa es la memoria del jugador, porque le recuerda dónde estaban los caminos cerrados y qué le queda por explorar.
// Only rooms you have visited appear: the map grows as you explore. add([ z(20), { draw() { drawRect({ pos: vec2(ROOM_W - 74, 4), width: 70, height: 30, color: rgb(10, 10, 20), opacity: 0.8 }); for (const key of visited) { const [x, y] = key.split(",").map(Number); const here = x === room[0] && y === room[1]; const c = here ? rgb(120, 200, 255) : rgb(110, 115, 150); drawRect({ pos: vec2(ROOM_W - 70 + x * 22, 7 + y * 13), width: 20, height: 11, color: c }); } }, }, ]);La meta
La meta dorada está detrás del candado. Al tocarla termina la partida y se muestra el tiempo y cuántas salas visitaste.
player.onCollide("goal", () => go("end", Math.round(time() - startTime), visited.size));Escenas
El juego tiene tres escenas (title, game y end), y go() salta de una a otra. Cada partida empieza sin la habilidad y con el mapa vacío.
function screen(title, subtitle, next) { add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 40)), anchor("center")]); const sub = text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }); add([sub, pos(center().add(0, 30)), anchor("center"), color(200, 210, 255)]); onButtonPress("jump", next); onKeyPress("enter", next); onClick(next); } scene("title", () => screen( "Metroidvania", t({ es: "Flechas para moverte, espacio para saltar. Explora las salas y llega a la meta dorada.\nUna pared es demasiado alta… por ahora. Pulsa espacio o toca para empezar.", en: "Arrows to move, space to jump. Explore the rooms and reach the golden goal.\nOne wall is too high… for now. Press space or tap to start.", }), () => go("game"), ), ); scene("end", (seconds, rooms) => screen( t({ es: "¡Llegaste a la meta!", en: "You reached the goal!" }), t({ es: `Tiempo: ${seconds} s. Salas visitadas: ${rooms}/5. Pulsa espacio para repetir.`, en: `Time: ${seconds} s. Rooms visited: ${rooms}/5. Press space to play again.`, }), () => go("game"), ), ); go("title");
Qué simplificamos
- Hay una sola habilidad (el doble salto) y un solo candado (la pared alta); los juegos del género encadenan muchas, y cada una abre zonas nuevas y atajos.
- No hay enemigos, vida, jefes ni puntos de guardado; la demo se centra en la estructura del mapa.
- Cada sala ocupa exactamente una pantalla y la cámara no se desplaza dentro de ella; en la mayoría de los juegos del género la cámara sigue al jugador por salas más grandes.
Para modificarla
- Agrega una segunda habilidad, como romper bloques de un color con una tecla, y un segundo candado que la necesite. Piensa en qué orden se encuentran y si el jugador puede quedarse atascado.
- Haz que el minimapa también muestre, con un punto, las salas donde viste un candado que aún no puedes abrir.
- Dibuja en papel un mapa de 8 salas con dos habilidades y escríbelo en ROOMS. Comprueba que se pueda terminar sin quedar encerrado en ninguna sala.
Código completo
// Metroidvania demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, touchControls, pixelDensity } from "../common.js";
// #region setup
const k = kaplay({
width: 640,
height: 360,
letterbox: true, pixelDensity,
background: [22, 24, 44],
buttons: {
left: { keyboard: ["left", "a"] },
right: { keyboard: ["right", "d"] },
jump: { keyboard: ["space", "up", "w"] },
},
});
touchControls(k, [{ button: "left", label: "◀" }, { button: "right", label: "▶" }, { button: "jump", label: "⤒", side: "right" }]);
// Same jump physics as the platformer demo.
const GRAVITY = 1600;
const SPEED = 200;
const JUMP_FORCE = 620; // ≈ 120 px high: enough for 3 tiles, not for 5
const COYOTE_TIME = 0.1;
const TILE = 32;
const ROOM_W = 640;
const ROOM_H = 11 * TILE; // 352 px: one room fills the screen
setGravity(GRAVITY);
// #endregion
// #region world
// The world is a grid of rooms; each room is a text map of 20 × 11 tiles.
// An open edge is a door to the neighbouring room.
// # wall · % the tall wall (the lock) · D double-jump orb (the key) · G goal
const ROOMS = {
"0,0": [
"####################",
"# #",
"# #",
"# #",
"# #",
"# #",
"# ### #",
"# ",
"# ",
"# ",
"############ ######",
],
"1,0": [
"####################",
"# ",
"# ",
"# ",
"# ",
"# %%%%%",
"# %%%%%",
" %%%%%",
" %%%%%",
" %%%%%",
"######## ##########",
],
"2,0": [
"####################",
" #",
" #",
" #",
" #",
"##### #",
"##### #",
"##### #",
"##### #",
"##### G #",
"####################",
],
"0,1": [
"############ ######",
"# #",
"# #",
"# #",
"# #",
"# ## #",
"# #",
"# ",
"# ",
"# ",
"####################",
],
"1,1": [
"######## ##########",
"# #",
"# #",
"# #",
"# #",
"# ## #",
"# #",
" #",
" #",
" D #",
"####################",
],
};
const wall = (c) => [rect(TILE, TILE), color(c), area(), body({ isStatic: true }), "solid"];
const buildRoom = (key) =>
addLevel(ROOMS[key], {
tileWidth: TILE,
tileHeight: TILE,
tiles: {
"#": () => wall(rgb(84, 96, 140)),
"%": () => wall(rgb(150, 90, 200)),
D: () => [circle(10), color(120, 255, 200), pos(16, 16), anchor("center"), area(), "orb"],
G: () => [rect(20, 40), color(255, 220, 90), pos(6, -8), area(), "goal"],
},
});
// #endregion
scene("game", () => {
let room = [0, 0];
let level = null;
let hasDoubleJump = false;
const visited = new Set();
const startTime = time();
const hud = add([text("", { size: 15, font: FONT }), pos(12, 8), z(20)]);
const updateHud = () =>
(hud.text = t({ es: "Habilidad: ", en: "Ability: " }) +
(hasDoubleJump ? t({ es: "doble salto", en: "double jump" }) : t({ es: "ninguna", en: "none" })));
// #region player
const player = add([rect(22, 30), color(120, 200, 255), pos(112, 320), area(), body(), anchor("bot"), "player"]);
onButtonDown("left", () => player.move(-SPEED, 0));
onButtonDown("right", () => player.move(SPEED, 0));
// #endregion
// #region abilities
// The double jump: one extra jump in mid-air, but only after picking up the orb.
let airTime = 0;
let airJumps = 0;
player.onUpdate(() => {
airTime = player.isGrounded() ? 0 : airTime + dt();
if (player.isGrounded()) airJumps = hasDoubleJump ? 1 : 0;
});
onButtonPress("jump", () => {
if (airTime < COYOTE_TIME) airTime = 1; // normal jump (with coyote time)
else if (airJumps > 0) airJumps--; // jump in mid-air, if you have the ability
else return;
player.jump(JUMP_FORCE);
});
onButtonRelease("jump", () => {
if (player.vel.y < 0) player.vel.y *= 0.45; // variable jump height
});
// The orb is the "key": it does not open one door, it changes what the player can reach.
player.onCollide("orb", (o) => {
destroy(o);
hasDoubleJump = true;
airJumps = 1;
updateHud();
const msg = t({ es: "¡Doble salto! Salta otra vez en el aire.", en: "Double jump! Jump again in mid-air." });
add([text(msg, { size: 18, font: FONT }), pos(320, 70), anchor("center"), color(120, 255, 200), opacity(1), lifespan(3, { fade: 0.5 })]);
});
// #endregion
// #region rooms
// Leaving the screen through an open edge loads the neighbouring room.
function enterRoom(x, y) {
if (!ROOMS[`${x},${y}`]) return false; // no room there: stay put
if (level) destroy(level);
room = [x, y];
level = buildRoom(`${x},${y}`);
if (hasDoubleJump) level.get("orb").forEach(destroy);
visited.add(`${x},${y}`);
return true;
}
enterRoom(0, 0);
updateHud();
player.onUpdate(() => {
const p = player.pos;
const move = (dx, dy) => enterRoom(room[0] + dx, room[1] + dy);
if (p.x < 0 && move(-1, 0)) p.x = ROOM_W - 14;
else if (p.x > ROOM_W && move(1, 0)) p.x = 14;
else if (p.y > ROOM_H && move(0, 1)) p.y = TILE;
else if (p.y < 0 && move(0, -1)) {
p.y = ROOM_H;
// A small push upwards so you pop out of the hole in the floor above.
player.vel.y = Math.min(player.vel.y, -560);
}
});
// #endregion
// #region minimap
// Only rooms you have visited appear: the map grows as you explore.
add([
z(20),
{
draw() {
drawRect({ pos: vec2(ROOM_W - 74, 4), width: 70, height: 30, color: rgb(10, 10, 20), opacity: 0.8 });
for (const key of visited) {
const [x, y] = key.split(",").map(Number);
const here = x === room[0] && y === room[1];
const c = here ? rgb(120, 200, 255) : rgb(110, 115, 150);
drawRect({ pos: vec2(ROOM_W - 70 + x * 22, 7 + y * 13), width: 20, height: 11, color: c });
}
},
},
]);
// #endregion
// #region goal
player.onCollide("goal", () => go("end", Math.round(time() - startTime), visited.size));
// #endregion
});
// #region scenes
function screen(title, subtitle, next) {
add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 40)), anchor("center")]);
const sub = text(subtitle, { size: 16, font: FONT, align: "center", width: 560 });
add([sub, pos(center().add(0, 30)), anchor("center"), color(200, 210, 255)]);
onButtonPress("jump", next);
onKeyPress("enter", next);
onClick(next);
}
scene("title", () =>
screen(
"Metroidvania",
t({
es: "Flechas para moverte, espacio para saltar. Explora las salas y llega a la meta dorada.\nUna pared es demasiado alta… por ahora. Pulsa espacio o toca para empezar.",
en: "Arrows to move, space to jump. Explore the rooms and reach the golden goal.\nOne wall is too high… for now. Press space or tap to start.",
}),
() => go("game"),
),
);
scene("end", (seconds, rooms) =>
screen(
t({ es: "¡Llegaste a la meta!", en: "You reached the goal!" }),
t({
es: `Tiempo: ${seconds} s. Salas visitadas: ${rooms}/5. Pulsa espacio para repetir.`,
en: `Time: ${seconds} s. Rooms visited: ${rooms}/5. Press space to play again.`,
}),
() => go("game"),
),
);
go("title");
// #endregionRetos de diseño
- Diseñar el grafo de candados y llaves para que el jugador nunca quede encerrado sin salida, aunque explore en un orden inesperado.
- Guiar sin hacer lineal el mundo; enseñar cada candado antes de dar su llave, para que el jugador sepa adónde volver.
- Que volver atrás no aburra, con atajos que se abren, teletransportes o habilidades que hacen más rápido recorrer lo ya visto.
- Que cada habilidad sirva tanto para moverse como para el combate, y decidir si se permite romper la secuencia prevista (sequence breaking).
Historia
- 1986OrigenMetroid
Metroid (Nintendo, 1986) no fue el primero de su tipo, pero es el más influyente; el nombre une Metroid y Castlevania.
Metroidvania (Wikipedia) - 1987Castlevania II: Simon's Quest
Castlevania II: Simon's Quest prueba un mundo abierto y no lineal con elementos de rol, antes de que la serie vuelva por un tiempo a los niveles en fila.
Metroidvania (Wikipedia) - 1994Super Metroid
Super Metroid pule la exploración con mejoras que abren el mapa y un mapa automático; su estructura abierta invita a romper la secuencia prevista.
Super Metroid (Wikipedia) - 1997Castlevania: Symphony of the Night
Castlevania: Symphony of the Night, considerado el metroidvania que define el género, suma un gran castillo que explorar, objetos que abren caminos y experiencia como en un juego de rol.
Metroidvania (Wikipedia) - 2004Cave Story
Daisuke Amaya publica gratis Cave Story, que hizo él solo en su tiempo libre, y ayuda a que el metroidvania vuelva como formato viable para el desarrollo independiente.
Cave Story (Wikipedia) - 2009
Shadow Complex, de Chair Entertainment para Xbox 360, se diseña partiendo de la idea de que Super Metroid es la cumbre del diseño de juegos en 2D.
Metroidvania (Wikipedia) - 2017Hollow Knight
Hollow Knight, del estudio independiente Team Cherry, recibe gran aclamación de la crítica con un enorme reino interconectado y supera los 15 millones de copias vendidas.
Hollow Knight (Wikipedia)
Juegos de referencia
1986
Metroid
La cazarrecompensas Samus explora el planeta Zebes, un laberinto de zonas unidas por puertas y ascensores. Objetos permanentes como la Morph Ball o las bombas abren pasajes antes inaccesibles, y a menudo hay que volver sobre tus pasos para avanzar.
1987
Castlevania II: Simon's Quest
Deja atrás los niveles en fila del primer Castlevania por un mundo abierto que se recorre y se revisita libremente, con pueblos, tiendas, elementos de rol y un ciclo de día y noche que cambia a los enemigos.
1994
Super Metroid
Samus vuelve a Zebes con un mapa automático que se completa al explorar. Mejoras como el Grapple Beam, el Speed Booster o el Space Jump abren zonas antes bloqueadas, y su estructura abierta permite saltarse el orden previsto, lo que lo hizo muy popular entre los speedrunners.
1997
Castlevania: Symphony of the Night
Alucard explora un enorme castillo no lineal cuyas zonas se abren al conseguir objetos y transformaciones (murciélago, lobo, niebla). Suma experiencia, niveles y equipo de los juegos de rol, y a mitad de camino revela un segundo castillo invertido.
2004
Cave Story
Plataformas de exploración con disparos que una sola persona programó, dibujó, escribió y musicalizó en su tiempo libre durante unos cinco años. Se publicó gratis y se difundió de boca en boca; mostró que un desarrollador solo podía competir con los grandes estudios.
2017
Hollow Knight
Un pequeño caballero explora el reino subterráneo de Hallownest, formado por grandes zonas interconectadas que no imponen un único camino. Mejoras de movimiento como el impulso, el salto en la pared o el salto doble abren nuevas rutas. Un estudio independiente lo financió en parte con Kickstarter.
2018
MetroCUBEvania
Metroidvania diminuto para PICO-8 en el que eres un cubo que va ganando habilidades, como el doble salto o una zambullida que rompe muros débiles, hasta llegar a un jefe final. Su autor lo publicó jugable en el navegador.
2019
Xycore
Aventura de exploración y disparos hecha en menos de 13 kilobytes para js13kGames 2019, inspirada en Metroid y Hero Core. Recorres un planeta desconocido, encuentras mejoras escondidas y vences a cuatro jefes para destruir el núcleo del reactor.
Ejercicio
Antes de tocar la demo, dibuja en papel su mapa de cinco salas y marca dónde está el candado y dónde la llave. Después añade al mapa de texto una sexta sala con un segundo candado (por ejemplo, un pasillo bajo que exige agacharse) y comprueba que el mundo se pueda terminar sin quedar encerrado.