Skip to content
GameGenresEspañol
Role-playing

Roguelike

A role-playing game in which you descend through a dungeon generated anew in every game, moving turn by turn on a grid. If your character dies you lose everything and start again in a different dungeon, so every step calls for thought.

Also known as: rogue-like, traditional roguelike

Defining mechanics

  • Procedural generation of floors, monsters and items; no two games are alike, so memorising the map does not help.
  • Permanent death with no reloading; dying ends the game and gives every decision weight.
  • Turns on a grid, because the world waits for you to decide and the challenge is thinking, not reflexes.
  • Scarce resources (food, potions, scrolls) and unidentified items that force you to experiment and take risks.
  • Nothing carries over between games except what you learn. That is the difference from the roguelite, which keeps some permanent upgrade and often mixes the formula with another genre (action, platforming, cards).

Gameplay loop

Explore the floor, deal with the encounters, manage resources, find the stairs and go down; when you die, learn and start a different game.

Our demo

Controls. Arrows, WASD or HJKL to move; bump into an enemy to attack it; space to wait a turn. With a mouse or on mobile, tap the map in the direction you want to step (on yourself, you wait).

Open full screen

A dungeon of rooms and corridors generated anew on every floor, with enemies that only move when you move, potions and stairs to the next floor. If you die, the game is lost and you start in a new dungeon.

Made with Kaplay, in about 174 lines of JavaScript.

How it's made

  1. Setup and controls

    The game is a 32 × 16 grid. The virtual buttons accept arrows, WASD and also HJKL, the movement keys of the original Rogue. Only the floor number, the player and the seed survive between floors.

    // A grid of 32 × 16 tiles of 20 px, with a strip on top (status) and one at the bottom (messages).
    // No touch buttons: on mobile you tap the map in the direction you want to move.
    kaplay({
      width: 640, height: 360, letterbox: true, pixelDensity, background: [8, 8, 14],
      buttons: {
        up: { keyboard: ["up", "w", "k"] },
        down: { keyboard: ["down", "s", "j"] },
        left: { keyboard: ["left", "a", "h"] },
        right: { keyboard: ["right", "d", "l"] },
        wait: { keyboard: ["space", "."] },
      },
    });
    const TILE = 20, COLS = 32, ROWS = 16, TOP = 20, SIGHT = 6;
    const glyph = (c, x, y, color, opacity = 1) =>
      drawText({ text: c, pos: vec2(x * TILE + TILE / 2, TOP + y * TILE + TILE / 2 + 1), size: 18, font: FONT, anchor: "center", color, opacity });
    let floor, player, seed; // the only things that survive between floors
  2. Procedural generation

    The map starts full of rock. Up to 80 rooms of random size and position are tried, those touching another are discarded and each accepted room is joined to the previous one with an L-shaped corridor, which guarantees the whole floor is connected. The stairs go in the last room, away from the first. If fewer than four rooms come out, it generates again.

    // Rooms and corridors: random rooms are tried, the ones that overlap or touch are discarded,
    // and each new room is joined to the previous one with an L-shaped corridor.
    function generateDungeon() {
      const map = Array.from({ length: ROWS }, () => Array(COLS).fill("#"));
      const rooms = [];
      for (let attempt = 0; attempt < 80 && rooms.length < 7; attempt++) {
        const w = randi(4, 9), h = randi(3, 6);
        const x = randi(1, COLS - w - 1), y = randi(1, ROWS - h - 1);
        const overlaps = rooms.some((r) => x - 2 < r.x + r.w && r.x - 2 < x + w && y - 2 < r.y + r.h && r.y - 2 < y + h);
        if (overlaps) continue;
        for (let j = y; j < y + h; j++) for (let i = x; i < x + w; i++) map[j][i] = ".";
        const center = { x: x + Math.floor(w / 2), y: y + Math.floor(h / 2) };
        if (rooms.length) digCorridor(map, rooms[rooms.length - 1].center, center);
        rooms.push({ x, y, w, h, center });
      }
      if (rooms.length < 4) return generateDungeon(); // too small: try again
      const last = rooms[rooms.length - 1].center;
      map[last.y][last.x] = ">"; // the stairs, in the last room
      return { map, rooms };
    }
    function digCorridor(map, a, b) {
      let { x, y } = a;
      const horizontalFirst = chance(0.5);
      while (x !== b.x || y !== b.y) {
        if ((horizontalFirst && x !== b.x) || y === b.y) x += Math.sign(b.x - x);
        else y += Math.sign(b.y - y);
        if (map[y][x] === "#") map[y][x] = ".";
      }
    }
  3. Enemies and potions

    Enemies and two potions are placed on free tiles in random rooms, never the first one, where you start. Each floor adds an enemy and unlocks stronger types, so going deeper means risking more.

    // Enemies and potions go in random rooms (never the first one, where you start).
    // Each floor brings more enemies and, from the third one on, ogres.
    const MONSTERS = [
      { letter: "r", name: t({ es: "la rata", en: "the rat" }), hp: 3, damage: [1, 2], color: rgb(200, 170, 120) },
      { letter: "g", name: t({ es: "el goblin", en: "the goblin" }), hp: 6, damage: [1, 3], color: rgb(120, 220, 110) },
      { letter: "O", name: t({ es: "el ogro", en: "the ogre" }), hp: 11, damage: [2, 5], color: rgb(240, 110, 90) },
    ];
    function populate(map, rooms) {
      const enemies = [];
      const freeSpot = () => {
        const r = choose(rooms.slice(1));
        const p = { x: randi(r.x, r.x + r.w), y: randi(r.y, r.y + r.h) };
        return map[p.y][p.x] === "." && !enemies.some((e) => e.x === p.x && e.y === p.y) ? p : freeSpot();
      };
      for (let n = 0; n < 2 + floor; n++) enemies.push({ ...choose(MONSTERS.slice(0, Math.min(3, floor + 1))), ...freeSpot() });
      for (let n = 0; n < 2; n++) {
        const p = freeSpot();
        map[p.y][p.x] = "!";
      }
      return enemies;
    }
  4. Fog of war

    You only see nearby tiles, and the map remembers in a dark tone what you have already explored. That way the new dungeon is unknown territory and exploring is part of the challenge.

    // Fog of war: you only see SIGHT tiles away, and the map remembers what you have explored.
    const seen = Array.from({ length: ROWS }, () => Array(COLS).fill(false));
    const visible = (x, y) => Math.hypot(x - player.x, y - player.y) <= SIGHT;
    const reveal = () => map.forEach((row, y) => row.forEach((_, x) => visible(x, y) && (seen[y][x] = true)));
    reveal();
  5. Bump combat

    There is no attack button; moving into an enemy hits it. Damage is a random number within each creature's range. It is the convention of almost every roguelike and keeps the controls minimal.

    // Bumping into an enemy attacks it. Damage is a random number within a range.
    const capitalize = (s) => s[0].toUpperCase() + s.slice(1);
    function attack(attacker, defender) {
      const damage = randi(attacker.damage[0], attacker.damage[1] + 1);
      defender.hp -= damage;
      if (attacker === player) message += t({ es: `Golpeas a ${defender.name} (${damage}). `, en: `You hit ${defender.name} (${damage}). ` });
      else message += t({ es: `${capitalize(attacker.name)} te golpea (${damage}). `, en: `${capitalize(attacker.name)} hits you (${damage}). ` });
      if (defender.hp <= 0 && defender !== player) {
        enemies.splice(enemies.indexOf(defender), 1);
        message += t({ es: `${capitalize(defender.name)} muere. `, en: `${capitalize(defender.name)} dies. ` });
      }
    }
  6. Enemy AI

    If the enemy is adjacent, it attacks; if it sees you, it steps towards you along the axis where you are farther away (or the other one if blocked); if it cannot see you, it waits. Rules this simple already create tactical situations, such as fighting in a corridor to face one enemy at a time.

    // If you are next to it, it attacks; if it sees you, it takes a step towards you; otherwise it waits.
    function enemyTurn(e) {
      const dx = player.x - e.x, dy = player.y - e.y;
      if (Math.abs(dx) + Math.abs(dy) === 1) return attack(e, player);
      if (!visible(e.x, e.y)) return;
      const steps = Math.abs(dx) > Math.abs(dy) ? [[Math.sign(dx), 0], [0, Math.sign(dy)]] : [[0, Math.sign(dy)], [Math.sign(dx), 0]];
      for (const [sx, sy] of steps) {
        const x = e.x + sx, y = e.y + sy;
        if ((sx || sy) && map[y][x] !== "#" && !enemyAt(x, y)) return Object.assign(e, { x, y });
      }
    }
  7. The world moves in turns

    Everything happens inside act, which is only called when the player does something. First it resolves your action (move, attack, drink a potion or go down), then it moves each enemy and finally checks whether you died. Bumping into a wall does not use a turn. Because the game waits, every decision can be considered calmly.

    // The world only moves when you act: a step, a hit or waiting is one turn,
    // and then every enemy moves. If you do nothing, nothing happens.
    function act(dx, dy) {
      const x = player.x + dx, y = player.y + dy;
      const enemy = enemyAt(x, y);
      if (!enemy && map[y][x] === "#") return; // bumping into a wall does not use up the turn
      message = "";
      if (enemy) attack(player, enemy);
      else Object.assign(player, { x, y });
      if (!enemy && map[y][x] === ">") {
        floor++; // the stairs: a new, harder floor is generated
        return go("game");
      }
      if (!enemy && map[y][x] === "!") {
        map[y][x] = ".";
        player.hp = Math.min(player.maxHp, player.hp + 6);
        message += t({ es: "Bebes una poción: +6 de vida. ", en: "You drink a potion: +6 health. " });
      }
      player.turns++;
      for (const e of [...enemies]) enemyTurn(e);
      reveal();
      if (player.hp <= 0) die();
    }
  8. Keyboard, mouse and touch

    Keys call act with a direction. With a mouse or a finger, the tapped point is compared with the player's position and a step is taken along the axis with the larger difference, so the whole game can be played without a keyboard.

    // Keyboard: arrows, WASD or HJKL; space waits one turn.
    // Mouse or touch: a click takes one step towards where you tapped (on yourself, you wait).
    onButtonPress("up", () => act(0, -1));
    onButtonPress("down", () => act(0, 1));
    onButtonPress("left", () => act(-1, 0));
    onButtonPress("right", () => act(1, 0));
    onButtonPress("wait", () => act(0, 0));
    onMousePress(() => {
      const m = mousePos();
      const dx = m.x - (player.x * TILE + TILE / 2), dy = m.y - (TOP + player.y * TILE + TILE / 2);
      if (Math.max(Math.abs(dx), Math.abs(dy)) < TILE / 2) act(0, 0);
      else if (Math.abs(dx) > Math.abs(dy)) act(Math.sign(dx), 0);
      else act(0, Math.sign(dy));
    });
  9. Drawing the dungeon

    Each frame draws the explored tiles; solid rock that touches no floor is skipped so the rooms read clearly. Creatures are letters (@ you, r rat, g goblin, O ogre), a nod to the genre's text presentation. The seed is shown on screen so a dungeon can be repeated.

    // Walls and floor are rectangles; creatures are letters, as in the original Rogue.
    const touchesFloor = (x, y) => [-1, 0, 1].some((j) => [-1, 0, 1].some((i) => (map[y + j]?.[x + i] ?? "#") !== "#"));
    onDraw(() => {
      for (let y = 0; y < ROWS; y++)
        for (let x = 0; x < COLS; x++) {
          const c = map[y][x], near = visible(x, y), op = near ? 1 : 0.4;
          if (!seen[y][x] || (c === "#" && !touchesFloor(x, y))) continue; // solid rock is not drawn
          const color = c === "#" ? (near ? rgb(110, 94, 150) : rgb(50, 44, 70)) : near ? rgb(40, 38, 62) : rgb(20, 20, 32);
          drawRect({ pos: vec2(x * TILE, TOP + y * TILE), width: TILE - 1, height: TILE - 1, color });
          if (c === ">") glyph(">", x, y, rgb(255, 214, 90), op);
          if (c === "!") glyph("!", x, y, rgb(255, 100, 160), op);
        }
      for (const e of enemies) if (visible(e.x, e.y)) glyph(e.letter, e.x, e.y, e.color);
      glyph("@", player.x, player.y, rgb(255, 255, 255));
      const hpLabel = t({ es: "Vida", en: "Health" }), turnLabel = t({ es: "Turno", en: "Turn" });
      drawText({ text: `${t({ es: "Piso", en: "Floor" })} ${floor}   ${hpLabel} ${player.hp}/${player.maxHp}   ${turnLabel} ${player.turns}   ${t({ es: "Semilla", en: "Seed" })} ${seed}`, pos: vec2(8, 2), size: 15, font: FONT });
      drawText({ text: message, pos: vec2(8, TOP + ROWS * TILE + 2), size: 15, font: FONT, color: rgb(200, 210, 255) });
    });
  10. Permadeath

    When you die there is no checkpoint or second life. newGame picks a new seed and resets the floor and the player, so the next dungeon will be different. This is what separates the roguelike from the roguelite, which would keep some upgrade between runs.

    // When you die there is no checkpoint and no second life: the run is lost
    // and the next one starts on floor 1 with a new dungeon (and a new seed).
    function die() {
      go("end", floor, player.turns);
    }
    function newGame() {
      seed = randi(1, 100000);
      randSeed(seed); // the same seed would produce the same dungeon
      floor = 1;
      player = { name: t({ es: "tú", en: "you" }), hp: 20, maxHp: 20, damage: [2, 4], turns: 0 };
      go("game");
    }
  11. Scenes

    Title, game and end. Going down the stairs re-enters the game scene, which generates a new floor without cleaning anything up by hand.

    function screen(title, subtitle) {
      add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 50)), anchor("center")]);
      add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255)]);
      onButtonPress("wait", newGame);
      onKeyPress("enter", newGame);
      onMousePress(newGame);
    }
    scene("title", () =>
      screen("Roguelike", t({
        es: "Eres la @. Baja por las escaleras > tan hondo como puedas. Choca con un enemigo para atacarlo; ! es una poción.\nEl mundo solo se mueve cuando tú te mueves. Flechas o clic hacia donde quieres ir.\nPulsa espacio o haz clic para empezar.",
        en: "You are the @. Go down the stairs > as deep as you can. Bump into an enemy to attack it; ! is a potion.\nThe world only moves when you move. Arrows, or click where you want to go.\nPress space or click to start.",
      })),
    );
    scene("end", (lastFloor, turns) =>
      screen(t({ es: "Has muerto", en: "You died" }), t({
        es: `Caíste en el piso ${lastFloor} tras ${turns} turnos. Muerte permanente: esta mazmorra se pierde para siempre.\nPulsa espacio o haz clic para explorar una nueva.`,
        en: `You fell on floor ${lastFloor} after ${turns} turns. Permadeath: this dungeon is lost forever.\nPress space or click to explore a new one.`,
      })),
    );
    go("title");

What we simplified

  • There is only four-way movement, three enemy types and one item type, with no inventory, experience or unidentified items.
  • Sight is a circle around the player and does not work out what walls block (a real roguelike uses a field-of-view algorithm).
  • L-shaped corridors may cut through other rooms; real generators use BSP trees, cellular automata or other techniques.

To modify it

  • Fix the seed to a number in newGame and check that the same dungeon always comes out. What would that be useful for in a tournament or a class?
  • Turn the demo into a roguelite. When you die, save a permanent upgrade in localStorage (for example, +2 maximum health) and discuss how the feel changes.
  • Add hunger, a counter that drops every turn and stops you lingering too long on a floor.
  • Allow diagonal movement and see how corridors change as a safe place to fight.
Full code
// Roguelike demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, pixelDensity } from "../common.js";

// #region setup
// A grid of 32 × 16 tiles of 20 px, with a strip on top (status) and one at the bottom (messages).
// No touch buttons: on mobile you tap the map in the direction you want to move.
kaplay({
  width: 640, height: 360, letterbox: true, pixelDensity, background: [8, 8, 14],
  buttons: {
    up: { keyboard: ["up", "w", "k"] },
    down: { keyboard: ["down", "s", "j"] },
    left: { keyboard: ["left", "a", "h"] },
    right: { keyboard: ["right", "d", "l"] },
    wait: { keyboard: ["space", "."] },
  },
});
const TILE = 20, COLS = 32, ROWS = 16, TOP = 20, SIGHT = 6;
const glyph = (c, x, y, color, opacity = 1) =>
  drawText({ text: c, pos: vec2(x * TILE + TILE / 2, TOP + y * TILE + TILE / 2 + 1), size: 18, font: FONT, anchor: "center", color, opacity });
let floor, player, seed; // the only things that survive between floors
// #endregion

// #region generation
// Rooms and corridors: random rooms are tried, the ones that overlap or touch are discarded,
// and each new room is joined to the previous one with an L-shaped corridor.
function generateDungeon() {
  const map = Array.from({ length: ROWS }, () => Array(COLS).fill("#"));
  const rooms = [];
  for (let attempt = 0; attempt < 80 && rooms.length < 7; attempt++) {
    const w = randi(4, 9), h = randi(3, 6);
    const x = randi(1, COLS - w - 1), y = randi(1, ROWS - h - 1);
    const overlaps = rooms.some((r) => x - 2 < r.x + r.w && r.x - 2 < x + w && y - 2 < r.y + r.h && r.y - 2 < y + h);
    if (overlaps) continue;
    for (let j = y; j < y + h; j++) for (let i = x; i < x + w; i++) map[j][i] = ".";
    const center = { x: x + Math.floor(w / 2), y: y + Math.floor(h / 2) };
    if (rooms.length) digCorridor(map, rooms[rooms.length - 1].center, center);
    rooms.push({ x, y, w, h, center });
  }
  if (rooms.length < 4) return generateDungeon(); // too small: try again
  const last = rooms[rooms.length - 1].center;
  map[last.y][last.x] = ">"; // the stairs, in the last room
  return { map, rooms };
}
function digCorridor(map, a, b) {
  let { x, y } = a;
  const horizontalFirst = chance(0.5);
  while (x !== b.x || y !== b.y) {
    if ((horizontalFirst && x !== b.x) || y === b.y) x += Math.sign(b.x - x);
    else y += Math.sign(b.y - y);
    if (map[y][x] === "#") map[y][x] = ".";
  }
}
// #endregion

// #region populate
// Enemies and potions go in random rooms (never the first one, where you start).
// Each floor brings more enemies and, from the third one on, ogres.
const MONSTERS = [
  { letter: "r", name: t({ es: "la rata", en: "the rat" }), hp: 3, damage: [1, 2], color: rgb(200, 170, 120) },
  { letter: "g", name: t({ es: "el goblin", en: "the goblin" }), hp: 6, damage: [1, 3], color: rgb(120, 220, 110) },
  { letter: "O", name: t({ es: "el ogro", en: "the ogre" }), hp: 11, damage: [2, 5], color: rgb(240, 110, 90) },
];
function populate(map, rooms) {
  const enemies = [];
  const freeSpot = () => {
    const r = choose(rooms.slice(1));
    const p = { x: randi(r.x, r.x + r.w), y: randi(r.y, r.y + r.h) };
    return map[p.y][p.x] === "." && !enemies.some((e) => e.x === p.x && e.y === p.y) ? p : freeSpot();
  };
  for (let n = 0; n < 2 + floor; n++) enemies.push({ ...choose(MONSTERS.slice(0, Math.min(3, floor + 1))), ...freeSpot() });
  for (let n = 0; n < 2; n++) {
    const p = freeSpot();
    map[p.y][p.x] = "!";
  }
  return enemies;
}
// #endregion

scene("game", () => {
  const { map, rooms } = generateDungeon();
  const enemies = populate(map, rooms);
  Object.assign(player, rooms[0].center);
  let message = t({ es: `Piso ${floor}. Busca la escalera >.`, en: `Floor ${floor}. Find the stairs >.` });
  const enemyAt = (x, y) => enemies.find((e) => e.x === x && e.y === y);

  // #region vision
  // Fog of war: you only see SIGHT tiles away, and the map remembers what you have explored.
  const seen = Array.from({ length: ROWS }, () => Array(COLS).fill(false));
  const visible = (x, y) => Math.hypot(x - player.x, y - player.y) <= SIGHT;
  const reveal = () => map.forEach((row, y) => row.forEach((_, x) => visible(x, y) && (seen[y][x] = true)));
  reveal();
  // #endregion

  // #region combat
  // Bumping into an enemy attacks it. Damage is a random number within a range.
  const capitalize = (s) => s[0].toUpperCase() + s.slice(1);
  function attack(attacker, defender) {
    const damage = randi(attacker.damage[0], attacker.damage[1] + 1);
    defender.hp -= damage;
    if (attacker === player) message += t({ es: `Golpeas a ${defender.name} (${damage}). `, en: `You hit ${defender.name} (${damage}). ` });
    else message += t({ es: `${capitalize(attacker.name)} te golpea (${damage}). `, en: `${capitalize(attacker.name)} hits you (${damage}). ` });
    if (defender.hp <= 0 && defender !== player) {
      enemies.splice(enemies.indexOf(defender), 1);
      message += t({ es: `${capitalize(defender.name)} muere. `, en: `${capitalize(defender.name)} dies. ` });
    }
  }
  // #endregion

  // #region enemy-ai
  // If you are next to it, it attacks; if it sees you, it takes a step towards you; otherwise it waits.
  function enemyTurn(e) {
    const dx = player.x - e.x, dy = player.y - e.y;
    if (Math.abs(dx) + Math.abs(dy) === 1) return attack(e, player);
    if (!visible(e.x, e.y)) return;
    const steps = Math.abs(dx) > Math.abs(dy) ? [[Math.sign(dx), 0], [0, Math.sign(dy)]] : [[0, Math.sign(dy)], [Math.sign(dx), 0]];
    for (const [sx, sy] of steps) {
      const x = e.x + sx, y = e.y + sy;
      if ((sx || sy) && map[y][x] !== "#" && !enemyAt(x, y)) return Object.assign(e, { x, y });
    }
  }
  // #endregion

  // #region turns
  // The world only moves when you act: a step, a hit or waiting is one turn,
  // and then every enemy moves. If you do nothing, nothing happens.
  function act(dx, dy) {
    const x = player.x + dx, y = player.y + dy;
    const enemy = enemyAt(x, y);
    if (!enemy && map[y][x] === "#") return; // bumping into a wall does not use up the turn
    message = "";
    if (enemy) attack(player, enemy);
    else Object.assign(player, { x, y });
    if (!enemy && map[y][x] === ">") {
      floor++; // the stairs: a new, harder floor is generated
      return go("game");
    }
    if (!enemy && map[y][x] === "!") {
      map[y][x] = ".";
      player.hp = Math.min(player.maxHp, player.hp + 6);
      message += t({ es: "Bebes una poción: +6 de vida. ", en: "You drink a potion: +6 health. " });
    }
    player.turns++;
    for (const e of [...enemies]) enemyTurn(e);
    reveal();
    if (player.hp <= 0) die();
  }
  // #endregion

  // #region input
  // Keyboard: arrows, WASD or HJKL; space waits one turn.
  // Mouse or touch: a click takes one step towards where you tapped (on yourself, you wait).
  onButtonPress("up", () => act(0, -1));
  onButtonPress("down", () => act(0, 1));
  onButtonPress("left", () => act(-1, 0));
  onButtonPress("right", () => act(1, 0));
  onButtonPress("wait", () => act(0, 0));
  onMousePress(() => {
    const m = mousePos();
    const dx = m.x - (player.x * TILE + TILE / 2), dy = m.y - (TOP + player.y * TILE + TILE / 2);
    if (Math.max(Math.abs(dx), Math.abs(dy)) < TILE / 2) act(0, 0);
    else if (Math.abs(dx) > Math.abs(dy)) act(Math.sign(dx), 0);
    else act(0, Math.sign(dy));
  });
  // #endregion

  // #region drawing
  // Walls and floor are rectangles; creatures are letters, as in the original Rogue.
  const touchesFloor = (x, y) => [-1, 0, 1].some((j) => [-1, 0, 1].some((i) => (map[y + j]?.[x + i] ?? "#") !== "#"));
  onDraw(() => {
    for (let y = 0; y < ROWS; y++)
      for (let x = 0; x < COLS; x++) {
        const c = map[y][x], near = visible(x, y), op = near ? 1 : 0.4;
        if (!seen[y][x] || (c === "#" && !touchesFloor(x, y))) continue; // solid rock is not drawn
        const color = c === "#" ? (near ? rgb(110, 94, 150) : rgb(50, 44, 70)) : near ? rgb(40, 38, 62) : rgb(20, 20, 32);
        drawRect({ pos: vec2(x * TILE, TOP + y * TILE), width: TILE - 1, height: TILE - 1, color });
        if (c === ">") glyph(">", x, y, rgb(255, 214, 90), op);
        if (c === "!") glyph("!", x, y, rgb(255, 100, 160), op);
      }
    for (const e of enemies) if (visible(e.x, e.y)) glyph(e.letter, e.x, e.y, e.color);
    glyph("@", player.x, player.y, rgb(255, 255, 255));
    const hpLabel = t({ es: "Vida", en: "Health" }), turnLabel = t({ es: "Turno", en: "Turn" });
    drawText({ text: `${t({ es: "Piso", en: "Floor" })} ${floor}   ${hpLabel} ${player.hp}/${player.maxHp}   ${turnLabel} ${player.turns}   ${t({ es: "Semilla", en: "Seed" })} ${seed}`, pos: vec2(8, 2), size: 15, font: FONT });
    drawText({ text: message, pos: vec2(8, TOP + ROWS * TILE + 2), size: 15, font: FONT, color: rgb(200, 210, 255) });
  });
  // #endregion
});

// #region permadeath
// When you die there is no checkpoint and no second life: the run is lost
// and the next one starts on floor 1 with a new dungeon (and a new seed).
function die() {
  go("end", floor, player.turns);
}
function newGame() {
  seed = randi(1, 100000);
  randSeed(seed); // the same seed would produce the same dungeon
  floor = 1;
  player = { name: t({ es: "tú", en: "you" }), hp: 20, maxHp: 20, damage: [2, 4], turns: 0 };
  go("game");
}
// #endregion

// #region scenes
function screen(title, subtitle) {
  add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 50)), anchor("center")]);
  add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255)]);
  onButtonPress("wait", newGame);
  onKeyPress("enter", newGame);
  onMousePress(newGame);
}
scene("title", () =>
  screen("Roguelike", t({
    es: "Eres la @. Baja por las escaleras > tan hondo como puedas. Choca con un enemigo para atacarlo; ! es una poción.\nEl mundo solo se mueve cuando tú te mueves. Flechas o clic hacia donde quieres ir.\nPulsa espacio o haz clic para empezar.",
    en: "You are the @. Go down the stairs > as deep as you can. Bump into an enemy to attack it; ! is a potion.\nThe world only moves when you move. Arrows, or click where you want to go.\nPress space or click to start.",
  })),
);
scene("end", (lastFloor, turns) =>
  screen(t({ es: "Has muerto", en: "You died" }), t({
    es: `Caíste en el piso ${lastFloor} tras ${turns} turnos. Muerte permanente: esta mazmorra se pierde para siempre.\nPulsa espacio o haz clic para explorar una nueva.`,
    en: `You fell on floor ${lastFloor} after ${turns} turns. Permadeath: this dungeon is lost forever.\nPress space or click to explore a new one.`,
  })),
);
go("title");
// #endregion

Design challenges

  • A generator that produces varied levels that are always playable, with every room connected and no unavoidable traps.
  • Making death feel fair, so that danger is visible and there are ways to escape.
  • Balancing chance and skill so that good luck is not enough to win and bad luck does not always doom you.
  • Conveying deep systems with minimal presentation, sometimes with letters alone.

History

  1. 1980
    OriginRogue

    Beneath Apple Manor (1978) already had random dungeons, but Rogue (1980), created by Michael Toy and Glenn Wichman and drawing on Adventure and Dungeons & Dragons, is the one that named the genre.

    Roguelike (Wikipedia)
  2. 1987
    NetHack

    NetHack, an heir to Hack, is developed collaboratively over the internet and becomes famous for how many ways its systems interact.

    NetHack (Wikipedia)
  3. 1993
    Torneko no Daibōken: Fushigi no Dungeon

    Chunsoft's Torneko no Daibōken brings the roguelike to consoles and starts the Mystery Dungeon series.

    Mystery Dungeon (Wikipedia)
  4. 2006
    Dungeon Crawl Stone Soup

    Dungeon Crawl Stone Soup is released, a free roguelike still maintained by its community and also playable in the browser.

    Dungeon Crawl Stone Soup (Wikipedia)
  5. 2008

    At the International Roguelike Development Conference in Berlin, the «Berlin Interpretation» is drafted, a list of traits (procedural generation, permadeath, turns, a grid…) for deciding what a roguelike is; games with only some of those traits are often called roguelites.

    Roguelike (Wikipedia)
  6. 2008
    Spelunky

    Spelunky brings random levels and permadeath to a real-time platformer.

    Spelunky (Wikipedia)
  7. 2011
    The Binding of Isaac

    The Binding of Isaac applies the formula to top-down action, with randomly assembled basements and permadeath; it is a typical roguelite.

    The Binding of Isaac (Wikipedia)

Reference games

1980

Rogue

Michael Toy y Glenn Wichman (luego con Ken Arnold). Unix

You go down an ASCII dungeon in search of the Amulet of Yendor. Floors, monsters and treasure are generated anew in every game, the world only moves when you act and dying erases the game. It gave the genre its name.

1993

Torneko no Daibōken: Fushigi no Dungeon

Chunsoft. Super Famicom

Torneko the merchant, from Dragon Quest IV, explores random dungeons collecting items and fighting turn by turn, with hunger pushing you forward. It brought the roguelike to consoles and started the Mystery Dungeon series.

2006

Dungeon Crawl Stone Soup

Equipo de Dungeon Crawl Stone Soup (comunidad). Windows, macOS, Linux, navegador

A free roguelike maintained by its community since 2006, an heir to Linley's Dungeon Crawl. You choose a species and a background, go down randomly generated dungeon branches, and one death ends the game. It can be played with text characters or with graphical tiles.

2011

The Binding of Isaac

Edmund McMillen y Florian Himsl. Windows

You shoot monsters room by room in basements assembled at random from pre-built rooms. Losing all your hearts ends the run and you start in a new basement; the items you find change how you play each time.

2016

Enter the Gungeon

Dodge Roll. Windows, macOS, Linux, PlayStation 4

A top-down bullet hell with randomly generated rooms. You dodge with a roll that makes you briefly invulnerable, and «blanks» wipe every projectile in the room.

Exercise

In the demo, change how many rooms are generated, their size and the sight radius, and watch how the tension changes. Then fix the seed (randSeed with a number) so other people can play the same dungeon and compare decisions. Finally, think about what you would have to add to turn it into a roguelite.

Subgenres

  • Action roguelikeA roguelike where you fight in real time, relying on reflexes and aim instead of turns. Each run crosses randomly generated levels where you gather upgrades for your character, and dying sends you back to the start.
  • Roguelike deck-builderA roguelike where you fight by playing cards from a deck you build during the run, picking random rewards after each victory. The challenge is forming working combinations without diluting the deck with weak cards, because losing sends you back to the starting deck.
  • RogueliteA game that borrows the roguelike's short runs, randomly generated levels and death that forces a restart, but mixes them with other genres. Some of what you earn also carries over between runs, so every attempt leaves you a little stronger.

Sources