
Metroidvania
You explore a large interconnected map, usually 2D, where many areas stay locked until you gain the ability or item that lets you through, so you keep backtracking.
Also known as: search action, platform-adventure, Castletroid
Defining mechanics
- A single continuous world, split into connected rooms or areas, instead of levels played in order.
- Paths blocked by «locks» (a high wall, an unbreakable block, a gap that is too narrow) that open with «keys»: permanent abilities or items.
- Backtracking to familiar areas with a new ability, which makes them play differently.
- A map that fills in as you explore and acts as the player's memory.
- Real-time combat and platforming, often with bosses guarding the upgrades.
Gameplay loop
Explore until you hit a closed path, remember it, find the ability that opens it somewhere else, return and discover a new area with new closed paths.
Our demo
Controls. Arrows or A/D to move, space, up arrow or W to jump. With the double jump, press jump again in mid-air. On mobile, the on-screen buttons.
Open full screenA small world of five connected rooms. At first a purple wall blocks your way to the goal; in a room below lies the double-jump orb, and with it you retrace your steps to get over the wall. A minimap shows the rooms you have already visited.
Made with Kaplay, in about 204 lines of JavaScript.
How it's made
Setup and jump physics
The game is created with virtual buttons (left, right, jump) so it works with a keyboard or a touch screen. The jump physics are the same as in the platformer demo. The jump height matters for the design, because with these constants a jump rises about 120 pixels, enough to climb three tiles but not five, and the map's lock relies on that difference.
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);The world as a grid of rooms
The world is a dictionary of rooms keyed by coordinates («0,0», «1,0»…), and each room is a 20 × 11 tile text map. An open edge is a door to the neighbouring room, and a gap in the floor or ceiling connects to the room below or above. The rooms are not in a row: they form a loop (A → B → E → D → A). Without the ability you can only drop down through the gaps; with it you can climb back up too.
// 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"], }, });The player
The player is a rectangle with area() and body(), just like in a platformer. It is a separate object from the level, which is why it survives when one room is destroyed and the next is built.
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));The ability as a key
This is the genre's mechanic. The double jump is a counter of mid-air jumps that is 0 until you pick up the orb and 1 afterwards. The purple wall is the «lock», an obstacle the player sees early, cannot get past and remembers. The orb is the «key», but unlike a door key it does not open a single place; it changes what the player can reach across the whole map. That is why, once they have it, players return to familiar places that now play differently.
// 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 })]); });Moving between rooms
When the player leaves the screen through an edge, the current room is destroyed, the neighbouring one is built and the player appears on the opposite edge keeping their velocity. When climbing up through a hole in a floor, they get a small extra push so they pop out and can step away from the hole. If there is no room in that direction, nothing happens.
// 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); } });The minimap
The minimap only draws the rooms you have visited, with the current room in another colour. In a world where you have to backtrack, the map is the player's memory, because it reminds them where the closed paths were and what is left to explore.
// 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 }); } }, }, ]);The goal
The golden goal is behind the lock. Touching it ends the game and shows the time and how many rooms you visited.
player.onCollide("goal", () => go("end", Math.round(time() - startTime), visited.size));Scenes
The game has three scenes, title, game and end, and go() jumps between them. Each game starts without the ability and with an empty map.
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");
What we simplified
- There is a single ability (the double jump) and a single lock (the tall wall); games in the genre chain many of them, each opening new areas and shortcuts.
- There are no enemies, health, bosses or save points; the demo focuses on the structure of the map.
- Each room takes up exactly one screen and the camera does not scroll inside it; in most games of the genre the camera follows the player through larger rooms.
To modify it
- Add a second ability, such as breaking blocks of one colour with a key, and a second lock that needs it. Think about the order in which they are found and whether the player can get stuck.
- Make the minimap also mark, with a dot, the rooms where you saw a lock you cannot open yet.
- Draw an 8-room map on paper with two abilities and write it into ROOMS. Check that it can be finished without getting trapped in any room.
Full code
// 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");
// #endregionDesign challenges
- Designing the graph of locks and keys so the player can never get trapped, even when exploring in an unexpected order.
- Guiding the player without making the world linear; showing each lock before handing out its key, so players know where to return.
- Keeping backtracking from getting boring, with shortcuts that open up, teleports or abilities that make familiar ground faster to cross.
- Making each ability useful for both movement and combat, and deciding whether to allow breaking the intended order (sequence breaking).
History
- 1986OriginMetroid
Metroid (Nintendo, 1986) was not the first of its kind, but it is the most influential; the name joins Metroid and Castlevania.
Metroidvania (Wikipedia) - 1987Castlevania II: Simon's Quest
Castlevania II: Simon's Quest tries out an open, non-linear world with role-playing elements, before the series goes back to sequential levels for a while.
Metroidvania (Wikipedia) - 1994Super Metroid
Super Metroid polishes exploration with upgrades that open up the map and an automap; its open structure invites sequence breaking.
Super Metroid (Wikipedia) - 1997Castlevania: Symphony of the Night
Castlevania: Symphony of the Night, considered the genre-defining Metroidvania, adds a large castle to explore, items that open paths and role-playing experience.
Metroidvania (Wikipedia) - 2004Cave Story
Daisuke Amaya releases Cave Story for free, made entirely on his own in his spare time, and helps bring the Metroidvania back as a viable format for independent development.
Cave Story (Wikipedia) - 2009
Shadow Complex, by Chair Entertainment for the Xbox 360, is designed on the premise that Super Metroid is the pinnacle of 2D game design.
Metroidvania (Wikipedia) - 2017Hollow Knight
Hollow Knight, by independent studio Team Cherry, earns wide critical acclaim with a huge interconnected kingdom and goes on to sell more than 15 million copies.
Hollow Knight (Wikipedia)
Reference games
1986
Metroid
Bounty hunter Samus explores the planet Zebes, a maze of areas joined by doors and lifts. Permanent items such as the Morph Ball or bombs open passages that were out of reach, and you often have to retrace your steps to move on.
1987
Castlevania II: Simon's Quest
It leaves behind the first Castlevania's sequential levels for an open world that you roam and revisit freely, with towns, shops, role-playing elements and a day-night cycle that changes the enemies.
1994
Super Metroid
Samus returns to Zebes with an automap that fills in as you explore. Upgrades such as the Grapple Beam, Speed Booster or Space Jump open previously blocked areas, and its open structure lets you break the intended order, which made it hugely popular with speedrunners.
1997
Castlevania: Symphony of the Night
Alucard explores a huge non-linear castle whose areas open up as he gains items and transformations (bat, wolf, mist). It adds experience, levels and equipment from role-playing games, and halfway through reveals a second, inverted castle.
2004
Cave Story
An exploration platformer with shooting that one person programmed, drew, wrote and scored in his spare time over about five years. It was released for free and spread by word of mouth; it showed that a single developer could compete with large studios.
2017
Hollow Knight
A small knight explores the underground kingdom of Hallownest, made up of large interconnected areas that do not force a single path. Movement upgrades such as the dash, wall jump and double jump open new routes. An independent studio partly funded it through Kickstarter.
2018
MetroCUBEvania
A tiny PICO-8 Metroidvania in which you are a cube that keeps gaining abilities, such as a double jump or a dive that breaks weak walls, until you reach a final boss. Its author published it playable in the browser.
2019
Xycore
An exploration and shooting adventure made in under 13 kilobytes for js13kGames 2019, inspired by Metroid and Hero Core. You roam an unknown planet, find hidden upgrades and defeat four bosses to destroy the reactor core.
Exercise
Before touching the demo, draw its five-room map on paper and mark where the lock and the key are. Then add a sixth room to the text map with a second lock (for example, a low corridor that requires crouching) and check that the world can be finished without getting trapped.