Skip to content
GameGenresEspañol
Action

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 screen

A 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

  1. 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);
  2. 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"],
        },
      });
  3. 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));
  4. 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 })]);
    });
  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);
      }
    });
  6. 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 });
          }
        },
      },
    ]);
  7. 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));
  8. 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");
// #endregion

Design 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

  1. 1986
    OriginMetroid

    Metroid (Nintendo, 1986) was not the first of its kind, but it is the most influential; the name joins Metroid and Castlevania.

    Metroidvania (Wikipedia)
  2. 1987
    Castlevania 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)
  3. 1994
    Super Metroid

    Super Metroid polishes exploration with upgrades that open up the map and an automap; its open structure invites sequence breaking.

    Super Metroid (Wikipedia)
  4. 1997
    Castlevania: 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)
  5. 2004
    Cave 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)
  6. 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)
  7. 2017
    Hollow 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

Nintendo R&D1 e Intelligent Systems. Famicom Disk System, NES

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

Konami. Famicom Disk System, NES

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

Nintendo R&D1 e Intelligent Systems. Super NES

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

Konami Computer Entertainment Tokyo. PlayStation, Sega Saturn

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

Daisuke «Pixel» Amaya (Studio Pixel). Windows

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

Team Cherry. Windows, macOS, Linux, Nintendo Switch, PlayStation 4, Xbox One

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

FlytrapStudios. PICO-8, navegador

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

64Mega. navegador

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.

Sources