Saltar al contenido
GameGenresEnglish
Acción

Survival horror

Exploras lugares amenazantes con poca munición, pocos objetos de curación y un inventario limitado; muchas veces conviene huir o esconderte en vez de pelear, y la tensión nace de esa escasez.

También se llama: terror de supervivencia, horror de supervivencia

Mecánicas que lo definen

  • Recursos escasos (munición, curación y a veces hasta las veces que puedes guardar), así que cada disparo es una decisión.
  • Un inventario pequeño que te obliga a elegir qué llevar y qué dejar.
  • Enemigos resistentes, a menudo lentos, a los que huir o esquivar es tan válido como enfrentarlos.
  • Vista limitada por la oscuridad, la niebla, una linterna o cámaras fijas, que esconde la amenaza hasta el último momento.
  • Un lugar cerrado que se explora con llaves, puertas y acertijos, y que obliga a volver sobre tus pasos.

Bucle de juego

Explorar con cuidado, encontrar un recurso o una llave, decidir si pelear o huir, abrir una zona nueva y llegar a un lugar seguro antes de que se acabe lo que tienes.

Demo propia

Controles. Flechas o WASD para moverte; la linterna apunta al ratón o hacia donde caminas. Espacio, J o clic para disparar. En móvil, los botones en pantalla (tocar la pantalla también apunta y dispara).

Abrir en pantalla completa

Un edificio a oscuras visto desde arriba. Solo ves lo que alumbra tu linterna, tienes tres balas y los monstruos aguantan tres disparos cada uno, así que no puedes acabar con todos. Hay que encontrar la llave y volver a la puerta.

Hecha con Kaplay, en unas 188 líneas de JavaScript.

Cómo está hecha

  1. Configuración y controles

    Se crea el juego y se definen botones virtuales; el de disparo responde al espacio y también al clic. Las constantes son el corazón del diseño: tú eres más rápido que los monstruos, pero empiezas con tres balas y cada monstruo resiste tres. Esa diferencia obliga a huir más que a pelear.

    const k = kaplay({
      width: 640,
      height: 360,
      letterbox: true, pixelDensity,
      background: [92, 94, 108],
      buttons: {
        left: { keyboard: ["left", "a"] }, right: { keyboard: ["right", "d"] },
        up: { keyboard: ["up", "w"] }, down: { keyboard: ["down", "s"] },
        shoot: { keyboard: ["space", "j"], mouse: "left" },
      },
    });
    touchControls(k, [{ button: "left", label: "◀" }, { button: "up", label: "▲" }, { button: "down", label: "▼" },
      { button: "right", label: "▶" }, { button: "shoot", label: "•", side: "right" }]);
    
    const TILE = 20;
    const SPEED = 95; // you are faster than the monsters: running away is a real option
    const ENEMY_SPEED = 38; // slow, but they never stop coming
    const ENEMY_HP = 3; // three bullets per monster, and there are few bullets
    const START_AMMO = 3;
    const NOTICE_RANGE = 120; // a monster closer than this, with a clear line, chases you
    const FLASH_RANGE = 170; // how far the flashlight reaches
    const FLASH_FOV = 50; // width of the light cone, in degrees (Kaplay measures angles in degrees)
    const AURA = 34; // the small circle you can always see around you
  2. El edificio como texto

    El mapa es una lista de cadenas y cada carácter es una baldosa (pared, monstruo, balas, botiquín, llave, puerta). La llave está lejos de la puerta a propósito, porque así tienes que cruzar el edificio dos veces.

    // # wall · P player · e monster · a ammo · h first-aid kit · k key · X exit door
    const MAP = [
      "################################",
      "#P....#..........#......a......#",
      "#.....#....e.....#.............#",
      "#.....#..........#....####.....#",
      "#..####...####...####....#.....#",
      "#.........#..............#..e..#",
      "#..h......#....e.........#.....#",
      "####.######.......########.....#",
      "#.........#####..........#.....#",
      "#...a.....#...#...e......###.###",
      "#.........#...#..............#.#",
      "#..............#######.......#k#",
      "#.##########...#.....#...e...#.#",
      "#.#............#..a..#.......#.#",
      "#.#............#.....#.........#",
      "#.#####..####..###.###..########",
      "#X.............................#",
      "################################",
    ];
    const isWall = (p) => (MAP[Math.floor(p.y / TILE)]?.[Math.floor(p.x / TILE)] ?? "#") === "#";
    
    function buildLevel() {
      const item = (shape, c, tag) => () => [shape, color(c), pos(TILE / 2, TILE / 2), anchor("center"), area(), opacity(0), tag];
      return addLevel(MAP, {
        tileWidth: TILE,
        tileHeight: TILE,
        tiles: {
          "#": () => [rect(TILE, TILE), color(170, 174, 192), area(), body({ isStatic: true }), "wall"],
          X: () => [rect(TILE, TILE), color(120, 70, 50), area(), body({ isStatic: true }), "exit"],
          a: item(rect(10, 8), rgb(230, 200, 90), "ammo"),
          h: item(rect(12, 12), rgb(240, 240, 240), "medkit"),
          k: item(circle(5), rgb(255, 215, 60), "key"),
          e: () => [circle(9), color(150, 60, 70), pos(TILE / 2, TILE / 2), anchor("center"), area(), body(), opacity(0),
            { hp: ENEMY_HP, chasing: false, wander: vec2(0), nextTurn: 0, rest: 0 }, "enemy"],
        },
      });
    }
  3. La luz

    Un punto está iluminado si pasa dos pruebas. Primero, debe estar dentro del pequeño círculo que te rodea o dentro del cono de la linterna, que se mide con la distancia y el ángulo. Segundo, ninguna pared debe tapar la línea recta hasta ti, algo que se comprueba avanzando por esa línea en pasos cortos. Es la misma idea que la «línea de visión» de los juegos de sigilo, pero usada para lo que ves tú.

    // A cell is lit if it is inside the aura or the flashlight cone AND nothing blocks
    // the straight line from the player. Everything else stays dark.
    function clearLine(a, b) {
      const steps = Math.ceil(a.dist(b) / 8);
      for (let i = 1; i < steps; i++) if (isWall(a.lerp(b, i / steps))) return false;
      return true;
    }
    const angleDiff = (a, b) => ((a - b + 540) % 360) - 180; // signed, in -180..180
    // Returns how much light reaches point p: 0 (dark) to 1 (fully lit).
    function lightAt(p, from, facing) {
      const d = from.dist(p);
      const inAura = d < AURA;
      const inCone = d < FLASH_RANGE && Math.abs(angleDiff(p.sub(from).angle(), facing)) < FLASH_FOV / 2;
      if (!inAura && !inCone) return 0;
      if (!clearLine(from, p)) return 0;
      return inCone ? 1 - (d / FLASH_RANGE) ** 2 : 1 - d / AURA;
    }
  4. El jugador y la linterna

    Te mueves en ocho direcciones con un paso limitado por fotograma, para que un fotograma lento no te haga atravesar una pared. La linterna apunta al ratón si lo acabas de mover y, si no, hacia donde caminas, así se puede jugar con el teclado solo.

    const row = MAP.findIndex((r) => r.includes("P"));
    const start = vec2(MAP[row].indexOf("P") + 0.5, row + 0.5).scale(TILE);
    // Monsters do not push you (collisionIgnore): touching one only hurts, see enemies.
    const player = add([circle(7), color(120, 200, 255), pos(start), anchor("center"), area({ collisionIgnore: ["enemy"] }), body(), z(3), { facing: 0 }, "player"]);
    let lastMouseMove = -10;
    onMouseMove(() => (lastMouseMove = time()));
    player.onUpdate(() => {
      const axis = (a, b) => (isButtonDown(b) ? 1 : 0) - (isButtonDown(a) ? 1 : 0);
      const dir = vec2(axis("left", "right"), axis("up", "down"));
      // moveBy with a capped step: after a long frame, a big jump could cross a wall.
      if (dir.len() > 0) player.moveBy(dir.unit().scale(SPEED * Math.min(dt(), 1 / 30)));
      // The flashlight points at the mouse if you just moved it; otherwise, where you walk.
      if (time() - lastMouseMove < 2) player.facing = mousePos().sub(player.pos).angle();
      else if (dir.len() > 0) player.facing = dir.angle();
      state.invulnerable = Math.max(0, state.invulnerable - dt());
      state.messageTime -= dt();
    });
  5. La escasez

    Cada disparo gasta una bala, y sin balas solo te queda correr. Las cajas de munición dan dos y el botiquín cura un corazón. Son pocos y están escondidos en la oscuridad, así que cada uno se siente como un alivio. Disparar además delata tu posición, porque el monstruo herido te persigue.

    // Every bullet counts: three to start with and a few boxes hidden in the dark.
    onButtonPress("shoot", () => {
      if (state.ammo <= 0) return say(t({ es: "Sin balas. ¡Corre!", en: "Out of bullets. Run!" }));
      state.ammo--;
      const dir = Vec2.fromAngle(player.facing);
      const bullet = add([rect(6, 3), color(255, 240, 180), pos(player.pos.add(dir.scale(10))), anchor("center"),
        rotate(player.facing), area(), move(dir, 300), offscreen({ destroy: true }), z(4), "bullet"]);
      bullet.onUpdate(() => isWall(bullet.pos) && destroy(bullet));
      bullet.onCollide("enemy", (e) => {
        destroy(bullet);
        e.chasing = true; // a shot gives you away
        if (--e.hp <= 0) destroy(e);
      });
    });
    // Pickups are few and far between: each one is a small relief.
    const PICKUPS = {
      ammo: () => ((state.ammo += 2), t({ es: "+2 balas", en: "+2 bullets" })),
      medkit: () => ((state.hp = Math.min(3, state.hp + 1)), t({ es: "Botiquín: +1 vida", en: "First-aid kit: +1 health" })),
    };
    for (const [tag, use] of Object.entries(PICKUPS)) player.onCollide(tag, (o) => (destroy(o), say(use())));
  6. Monstruos lentos pero tercos

    Cada monstruo deambula hasta que te ve cerca, con una línea libre entre los dos, y entonces camina hacia ti sin parar. Si te alejas mucho, se olvida de ti. Tocarte te quita un corazón, y después él se detiene un segundo y tú quedas un momento a salvo, para que puedas escapar.

    // Slow and stubborn: they wander until they notice you, then walk straight at you.
    // They do not path-find, so a wall between you and them can save your life.
    for (const e of level.get("enemy")) {
      e.onUpdate(() => {
        if ((e.rest -= dt()) > 0) return; // after a hit it stops for a moment
        const toPlayer = player.pos.sub(e.worldPos());
        if (toPlayer.len() < NOTICE_RANGE && clearLine(e.worldPos(), player.pos)) e.chasing = true;
        if (toPlayer.len() > NOTICE_RANGE * 2) e.chasing = false;
        if (e.chasing) e.move(toPlayer.unit().scale(ENEMY_SPEED));
        else {
          if (time() > e.nextTurn) (e.wander = Vec2.fromAngle(rand(0, 360)).scale(ENEMY_SPEED * 0.5)), (e.nextTurn = time() + rand(1, 3));
          e.move(e.wander);
        }
        // A touch costs one heart; then you have a moment to get away.
        if (toPlayer.len() < 16 && state.invulnerable <= 0) {
          state.hp--;
          state.invulnerable = 1.5;
          e.rest = 1;
          shake(6);
          if (state.hp <= 0) go("end", false);
        }
      });
    }
  7. La llave y la salida

    La meta no es matar a todos, sino salir. La puerta está cerrada hasta que recoges la llave, y el mensaje te lo recuerda si llegas antes de tiempo.

    // The goal is not to kill everything but to find the key and get out.
    player.onCollide("key", (key) => {
      destroy(key);
      state.key = true;
      say(t({ es: "Tienes la llave. Vuelve a la puerta.", en: "You have the key. Back to the door." }));
    });
    player.onCollide("exit", () => {
      if (state.key) go("end", true);
      else say(t({ es: "La puerta está cerrada. Falta la llave.", en: "The door is locked. You need the key." }));
    });
  8. La oscuridad

    Sobre el mapa se dibuja una cuadrícula de celdas oscuras, cada una con su propia transparencia. Las iluminadas casi no se ven, las que ya viste quedan en penumbra (recuerdas la forma del lugar) y las demás son negras. Los monstruos y los objetos solo se ven mientras les da la luz: el miedo viene de lo que no ves.

    // A grid of cells drawn over the map. Lit cells are clear, cells you have
    // already seen stay dim (you remember the layout), and the rest is black.
    // Monsters and items are only visible while light falls on them.
    const CELL = 20; // one cell per tile: cheap, and the light reads like a grid of rooms
    const seen = new Set();
    add([z(8), { draw() {
      for (let y = 0; y < height(); y += CELL)
        for (let x = 0; x < width(); x += CELL) {
          const light = lightAt(vec2(x + CELL / 2, y + CELL / 2), player.pos, player.facing);
          if (light > 0) seen.add(x + "," + y);
          const dark = light > 0 ? 0.85 * (1 - light) : seen.has(x + "," + y) ? 0.8 : 0.98;
          drawRect({ pos: vec2(x, y), width: CELL, height: CELL, color: rgb(4, 4, 8), opacity: dark });
        }
    } }]);
    onUpdate(() => {
      for (const o of [...level.get("enemy"), ...level.get("ammo"), ...level.get("medkit"), ...level.get("key")])
        o.opacity = lightAt(o.worldPos(), player.pos, player.facing) > 0 ? 1 : 0;
    });
  9. El marcador

    Arriba están los corazones, las balas y si tienes la llave. El marco rojo late más fuerte cuanto más cerca está el monstruo más próximo, aunque no lo veas. Es la versión visual del sonido que en el género avisa del peligro, como la radio de Silent Hill.

    // The HUD also "hears": the red frame grows as the nearest monster gets closer.
    add([z(10), { draw() {
      const near = Math.min(999, ...level.get("enemy").map((e) => e.worldPos().dist(player.pos)));
      if (near < 120) drawRect({ width: width(), height: height(), fill: false, outline: { width: 10, color: rgb(200, 30, 40) }, opacity: (1 - near / 120) * wave(0.5, 1, time() * 8) });
      const hearts = "♥".repeat(state.hp) + "♡".repeat(3 - state.hp);
      const keyText = state.key ? t({ es: "sí", en: "yes" }) : t({ es: "no", en: "no" });
      drawText({ text: `${hearts}   ${t({ es: "Balas", en: "Bullets" })}: ${state.ammo}   ${t({ es: "Llave", en: "Key" })}: ${keyText}`, pos: vec2(10, 2), size: 14, font: FONT, color: rgb(230, 230, 240) });
      if (state.messageTime > 0) drawText({ text: state.message, pos: vec2(width() / 2, height() - 18), size: 14, font: FONT, anchor: "center", color: rgb(255, 230, 160) });
    } }]);
  10. Escenas

    El juego tiene tres escenas (title, game y end), y go() salta de una a otra. Así cada partida empieza con el edificio, las balas y los monstruos como al principio.

    function screen(title, subtitle, next) {
      add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 40)), anchor("center")]);
      add([text(subtitle, { size: 15, font: FONT, align: "center", width: 540 }), pos(center().add(0, 30)), anchor("center"), color(200, 205, 225)]);
      onKeyPress(["space", "enter"], next);
      onClick(next);
    }
    
    scene("title", () =>
      screen(
        t({ es: "Survival horror", en: "Survival horror" }),
        t({
          es: "Flechas o WASD para moverte; la linterna apunta al ratón o hacia donde caminas.\nEspacio o clic para disparar: tienes muy pocas balas.\nEncuentra la llave y escapa por la puerta.\nPulsa espacio o toca para empezar.",
          en: "Arrows or WASD to move; the flashlight points at the mouse or where you walk.\nSpace or click to shoot: you have very few bullets.\nFind the key and escape through the door.\nPress space or tap to start.",
        }),
        () => go("game"),
      ),
    );
    
    scene("end", (won) =>
      screen(
        won ? t({ es: "Escapaste", en: "You escaped" }) : t({ es: "No sobreviviste", en: "You did not survive" }),
        t({ es: "Pulsa espacio o toca para intentarlo de nuevo.", en: "Press space or tap to try again." }),
        () => go("game"),
      ),
    );
    
    go("title");

Qué simplificamos

  • Todo son formas simples y no hay sonido, que en el género real es la mitad del miedo; el marco rojo que late hace de «oído».
  • La oscuridad es una cuadrícula de celdas del tamaño de una baldosa, no una luz suave.
  • Los monstruos no buscan caminos, van en línea recta hacia ti; una pared entre ustedes basta para frenarlos.
  • No hay inventario que administrar ni puntos de guardado, solo balas, vida y la llave.

Para modificarla

  • Haz que la linterna tenga batería y se apague si la usas mucho; añade pilas como objeto escaso.
  • Agrega un inventario de dos huecos, de modo que tengas que elegir entre llevar el botiquín o la llave.
  • Haz que los disparos hagan ruido y atraigan a los monstruos que estén cerca aunque no te vean.
Código completo
// Survival horror 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: [92, 94, 108],
  buttons: {
    left: { keyboard: ["left", "a"] }, right: { keyboard: ["right", "d"] },
    up: { keyboard: ["up", "w"] }, down: { keyboard: ["down", "s"] },
    shoot: { keyboard: ["space", "j"], mouse: "left" },
  },
});
touchControls(k, [{ button: "left", label: "◀" }, { button: "up", label: "▲" }, { button: "down", label: "▼" },
  { button: "right", label: "▶" }, { button: "shoot", label: "•", side: "right" }]);

const TILE = 20;
const SPEED = 95; // you are faster than the monsters: running away is a real option
const ENEMY_SPEED = 38; // slow, but they never stop coming
const ENEMY_HP = 3; // three bullets per monster, and there are few bullets
const START_AMMO = 3;
const NOTICE_RANGE = 120; // a monster closer than this, with a clear line, chases you
const FLASH_RANGE = 170; // how far the flashlight reaches
const FLASH_FOV = 50; // width of the light cone, in degrees (Kaplay measures angles in degrees)
const AURA = 34; // the small circle you can always see around you
// #endregion

// #region level
// # wall · P player · e monster · a ammo · h first-aid kit · k key · X exit door
const MAP = [
  "################################",
  "#P....#..........#......a......#",
  "#.....#....e.....#.............#",
  "#.....#..........#....####.....#",
  "#..####...####...####....#.....#",
  "#.........#..............#..e..#",
  "#..h......#....e.........#.....#",
  "####.######.......########.....#",
  "#.........#####..........#.....#",
  "#...a.....#...#...e......###.###",
  "#.........#...#..............#.#",
  "#..............#######.......#k#",
  "#.##########...#.....#...e...#.#",
  "#.#............#..a..#.......#.#",
  "#.#............#.....#.........#",
  "#.#####..####..###.###..########",
  "#X.............................#",
  "################################",
];
const isWall = (p) => (MAP[Math.floor(p.y / TILE)]?.[Math.floor(p.x / TILE)] ?? "#") === "#";

function buildLevel() {
  const item = (shape, c, tag) => () => [shape, color(c), pos(TILE / 2, TILE / 2), anchor("center"), area(), opacity(0), tag];
  return addLevel(MAP, {
    tileWidth: TILE,
    tileHeight: TILE,
    tiles: {
      "#": () => [rect(TILE, TILE), color(170, 174, 192), area(), body({ isStatic: true }), "wall"],
      X: () => [rect(TILE, TILE), color(120, 70, 50), area(), body({ isStatic: true }), "exit"],
      a: item(rect(10, 8), rgb(230, 200, 90), "ammo"),
      h: item(rect(12, 12), rgb(240, 240, 240), "medkit"),
      k: item(circle(5), rgb(255, 215, 60), "key"),
      e: () => [circle(9), color(150, 60, 70), pos(TILE / 2, TILE / 2), anchor("center"), area(), body(), opacity(0),
        { hp: ENEMY_HP, chasing: false, wander: vec2(0), nextTurn: 0, rest: 0 }, "enemy"],
    },
  });
}
// #endregion

// #region light
// A cell is lit if it is inside the aura or the flashlight cone AND nothing blocks
// the straight line from the player. Everything else stays dark.
function clearLine(a, b) {
  const steps = Math.ceil(a.dist(b) / 8);
  for (let i = 1; i < steps; i++) if (isWall(a.lerp(b, i / steps))) return false;
  return true;
}
const angleDiff = (a, b) => ((a - b + 540) % 360) - 180; // signed, in -180..180
// Returns how much light reaches point p: 0 (dark) to 1 (fully lit).
function lightAt(p, from, facing) {
  const d = from.dist(p);
  const inAura = d < AURA;
  const inCone = d < FLASH_RANGE && Math.abs(angleDiff(p.sub(from).angle(), facing)) < FLASH_FOV / 2;
  if (!inAura && !inCone) return 0;
  if (!clearLine(from, p)) return 0;
  return inCone ? 1 - (d / FLASH_RANGE) ** 2 : 1 - d / AURA;
}
// #endregion

scene("game", () => {
  const level = buildLevel();
  const state = { hp: 3, ammo: START_AMMO, key: false, invulnerable: 0, message: "", messageTime: 0 };
  const say = (msg) => ((state.message = msg), (state.messageTime = 2.5));

  // #region player
  const row = MAP.findIndex((r) => r.includes("P"));
  const start = vec2(MAP[row].indexOf("P") + 0.5, row + 0.5).scale(TILE);
  // Monsters do not push you (collisionIgnore): touching one only hurts, see enemies.
  const player = add([circle(7), color(120, 200, 255), pos(start), anchor("center"), area({ collisionIgnore: ["enemy"] }), body(), z(3), { facing: 0 }, "player"]);
  let lastMouseMove = -10;
  onMouseMove(() => (lastMouseMove = time()));
  player.onUpdate(() => {
    const axis = (a, b) => (isButtonDown(b) ? 1 : 0) - (isButtonDown(a) ? 1 : 0);
    const dir = vec2(axis("left", "right"), axis("up", "down"));
    // moveBy with a capped step: after a long frame, a big jump could cross a wall.
    if (dir.len() > 0) player.moveBy(dir.unit().scale(SPEED * Math.min(dt(), 1 / 30)));
    // The flashlight points at the mouse if you just moved it; otherwise, where you walk.
    if (time() - lastMouseMove < 2) player.facing = mousePos().sub(player.pos).angle();
    else if (dir.len() > 0) player.facing = dir.angle();
    state.invulnerable = Math.max(0, state.invulnerable - dt());
    state.messageTime -= dt();
  });
  // #endregion

  // #region scarcity
  // Every bullet counts: three to start with and a few boxes hidden in the dark.
  onButtonPress("shoot", () => {
    if (state.ammo <= 0) return say(t({ es: "Sin balas. ¡Corre!", en: "Out of bullets. Run!" }));
    state.ammo--;
    const dir = Vec2.fromAngle(player.facing);
    const bullet = add([rect(6, 3), color(255, 240, 180), pos(player.pos.add(dir.scale(10))), anchor("center"),
      rotate(player.facing), area(), move(dir, 300), offscreen({ destroy: true }), z(4), "bullet"]);
    bullet.onUpdate(() => isWall(bullet.pos) && destroy(bullet));
    bullet.onCollide("enemy", (e) => {
      destroy(bullet);
      e.chasing = true; // a shot gives you away
      if (--e.hp <= 0) destroy(e);
    });
  });
  // Pickups are few and far between: each one is a small relief.
  const PICKUPS = {
    ammo: () => ((state.ammo += 2), t({ es: "+2 balas", en: "+2 bullets" })),
    medkit: () => ((state.hp = Math.min(3, state.hp + 1)), t({ es: "Botiquín: +1 vida", en: "First-aid kit: +1 health" })),
  };
  for (const [tag, use] of Object.entries(PICKUPS)) player.onCollide(tag, (o) => (destroy(o), say(use())));
  // #endregion

  // #region enemies
  // Slow and stubborn: they wander until they notice you, then walk straight at you.
  // They do not path-find, so a wall between you and them can save your life.
  for (const e of level.get("enemy")) {
    e.onUpdate(() => {
      if ((e.rest -= dt()) > 0) return; // after a hit it stops for a moment
      const toPlayer = player.pos.sub(e.worldPos());
      if (toPlayer.len() < NOTICE_RANGE && clearLine(e.worldPos(), player.pos)) e.chasing = true;
      if (toPlayer.len() > NOTICE_RANGE * 2) e.chasing = false;
      if (e.chasing) e.move(toPlayer.unit().scale(ENEMY_SPEED));
      else {
        if (time() > e.nextTurn) (e.wander = Vec2.fromAngle(rand(0, 360)).scale(ENEMY_SPEED * 0.5)), (e.nextTurn = time() + rand(1, 3));
        e.move(e.wander);
      }
      // A touch costs one heart; then you have a moment to get away.
      if (toPlayer.len() < 16 && state.invulnerable <= 0) {
        state.hp--;
        state.invulnerable = 1.5;
        e.rest = 1;
        shake(6);
        if (state.hp <= 0) go("end", false);
      }
    });
  }
  // #endregion

  // #region key-exit
  // The goal is not to kill everything but to find the key and get out.
  player.onCollide("key", (key) => {
    destroy(key);
    state.key = true;
    say(t({ es: "Tienes la llave. Vuelve a la puerta.", en: "You have the key. Back to the door." }));
  });
  player.onCollide("exit", () => {
    if (state.key) go("end", true);
    else say(t({ es: "La puerta está cerrada. Falta la llave.", en: "The door is locked. You need the key." }));
  });
  // #endregion

  // #region darkness
  // A grid of cells drawn over the map. Lit cells are clear, cells you have
  // already seen stay dim (you remember the layout), and the rest is black.
  // Monsters and items are only visible while light falls on them.
  const CELL = 20; // one cell per tile: cheap, and the light reads like a grid of rooms
  const seen = new Set();
  add([z(8), { draw() {
    for (let y = 0; y < height(); y += CELL)
      for (let x = 0; x < width(); x += CELL) {
        const light = lightAt(vec2(x + CELL / 2, y + CELL / 2), player.pos, player.facing);
        if (light > 0) seen.add(x + "," + y);
        const dark = light > 0 ? 0.85 * (1 - light) : seen.has(x + "," + y) ? 0.8 : 0.98;
        drawRect({ pos: vec2(x, y), width: CELL, height: CELL, color: rgb(4, 4, 8), opacity: dark });
      }
  } }]);
  onUpdate(() => {
    for (const o of [...level.get("enemy"), ...level.get("ammo"), ...level.get("medkit"), ...level.get("key")])
      o.opacity = lightAt(o.worldPos(), player.pos, player.facing) > 0 ? 1 : 0;
  });
  // #endregion

  // #region hud
  // The HUD also "hears": the red frame grows as the nearest monster gets closer.
  add([z(10), { draw() {
    const near = Math.min(999, ...level.get("enemy").map((e) => e.worldPos().dist(player.pos)));
    if (near < 120) drawRect({ width: width(), height: height(), fill: false, outline: { width: 10, color: rgb(200, 30, 40) }, opacity: (1 - near / 120) * wave(0.5, 1, time() * 8) });
    const hearts = "♥".repeat(state.hp) + "♡".repeat(3 - state.hp);
    const keyText = state.key ? t({ es: "sí", en: "yes" }) : t({ es: "no", en: "no" });
    drawText({ text: `${hearts}   ${t({ es: "Balas", en: "Bullets" })}: ${state.ammo}   ${t({ es: "Llave", en: "Key" })}: ${keyText}`, pos: vec2(10, 2), size: 14, font: FONT, color: rgb(230, 230, 240) });
    if (state.messageTime > 0) drawText({ text: state.message, pos: vec2(width() / 2, height() - 18), size: 14, font: FONT, anchor: "center", color: rgb(255, 230, 160) });
  } }]);
  // #endregion
});

// #region scenes
function screen(title, subtitle, next) {
  add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 40)), anchor("center")]);
  add([text(subtitle, { size: 15, font: FONT, align: "center", width: 540 }), pos(center().add(0, 30)), anchor("center"), color(200, 205, 225)]);
  onKeyPress(["space", "enter"], next);
  onClick(next);
}

scene("title", () =>
  screen(
    t({ es: "Survival horror", en: "Survival horror" }),
    t({
      es: "Flechas o WASD para moverte; la linterna apunta al ratón o hacia donde caminas.\nEspacio o clic para disparar: tienes muy pocas balas.\nEncuentra la llave y escapa por la puerta.\nPulsa espacio o toca para empezar.",
      en: "Arrows or WASD to move; the flashlight points at the mouse or where you walk.\nSpace or click to shoot: you have very few bullets.\nFind the key and escape through the door.\nPress space or tap to start.",
    }),
    () => go("game"),
  ),
);

scene("end", (won) =>
  screen(
    won ? t({ es: "Escapaste", en: "You escaped" }) : t({ es: "No sobreviviste", en: "You did not survive" }),
    t({ es: "Pulsa espacio o toca para intentarlo de nuevo.", en: "Press space or tap to try again." }),
    () => go("game"),
  ),
);

go("title");
// #endregion

Retos de diseño

  • Calibrar la escasez para que siempre falte un poco sin dejar al jugador sin salida.
  • Crear tensión con luz, sonido y anticipación más que con sustos repetidos, que pierden efecto.
  • Limitar la vista (oscuridad, cámara) sin que el control se sienta injusto.
  • Un mapa que se recorre varias veces y cambia (atajos, puertas que se abren) para que volver no aburra.

Historia

  1. 1981
    3D Monster Maze

    En 3D Monster Maze huyes desarmado de un tiranosaurio por un laberinto; en 2006 la revista Edge lo llamó «el survival horror original».

    3D Monster Maze (Wikipedia)
  2. 1989
    Sweet Home

    Sweet Home (Capcom) reúne una mansión, un inventario muy limitado, curación escasa y personajes que mueren para siempre; fue la inspiración principal de Resident Evil.

    Sweet Home (video game) (Wikipedia)
  3. 1992
    Alone in the Dark

    Alone in the Dark (Infogrames) pone personajes 3D sobre fondos dibujados con cámaras fijas que encuadran como una película de terror; Guinness lo reconoce como el primer survival horror en 3D.

    Alone in the Dark (1992 video game) (Wikipedia)
  4. 1996
    OrigenResident Evil

    El término se usó por primera vez en la versión japonesa de Resident Evil (Capcom, 1996), que se inspiró en Sweet Home (1989) y Alone in the Dark (1992); luego se aplicó a juegos anteriores.

    Survival horror (Wikipedia)
  5. 1996
    Resident Evil

    Resident Evil fija la fórmula: munición tan escasa que no puedes matarlo todo, inventario limitado y cintas de tinta contadas para guardar la partida.

    Resident Evil (1996 video game) (Wikipedia)
  6. 1999
    Silent Hill

    Silent Hill usa escenarios 3D en tiempo real cubiertos de niebla y oscuridad, una linterna y una radio que chisporrotea cuando hay monstruos cerca, y apuesta por el terror psicológico.

    Silent Hill (video game) (Wikipedia)
  7. 2001

    En Fatal Frame (Project Zero en Europa), de Tecmo, la única arma contra los fantasmas es una cámara de fotos.

    Fatal Frame (video game) (Wikipedia)
  8. 2005

    Resident Evil 4 cambia las cámaras fijas por una cámara sobre el hombro y da más peso a la acción; algunos críticos dijeron que la serie había dejado el survival horror.

    Resident Evil 4 (Wikipedia)
  9. 2012
    SCP – Containment Breach

    SCP – Containment Breach, gratuito y hecho por una sola persona, te deja sin armas frente a una criatura que solo se mueve cuando no la miras, y tienes que parpadear.

    SCP – Containment Breach (Wikipedia)
  10. 2014

    En Five Nights at Freddy's no te mueves: vigilas cámaras y cierras puertas con una energía limitada que debe durar toda la noche.

    Five Nights at Freddy's (Wikipedia)
  11. 2017

    Resident Evil 7: Biohazard vuelve a la gestión de recursos y los acertijos de los orígenes del género.

    Survival horror (Wikipedia)

Juegos de referencia

1981

3D Monster Maze

Malcolm Evans (J. K. Greye Software). ZX81

Buscas en primera persona la salida de un laberinto generado al azar mientras te acecha un tiranosaurio. Eres más rápido que él, pero un callejón sin salida puede ser el final.

1989

Sweet Home

Capcom. Famicom

Cinco personajes exploran una mansión embrujada; cada uno tiene un objeto único, el inventario es mínimo y quien muere no vuelve. Solo salió en Japón y fue la inspiración principal de Resident Evil.

1992

Alone in the Dark

Infogrames (Frédérick Raynal). MS-DOS

Investigas una mansión encantada con personajes 3D sobre fondos dibujados, vistos desde cámaras fijas que encuadran como en una película de terror. Guinness lo reconoce como el primer survival horror en 3D.

1996

Resident Evil

Capcom. PlayStation

Recorres una mansión llena de monstruos con poca munición, un inventario pequeño y cintas de tinta contadas para guardar la partida, vista desde cámaras fijas. Fue el primer juego que se vendió como «survival horror».

1999

Silent Hill

Team Silent (Konami). PlayStation

Buscas a tu hija en un pueblo cubierto de niebla y oscuridad, con una linterna y una radio que chisporrotea cuando se acerca un monstruo. Apuesta por el terror psicológico más que por los sustos.

2026

Dark Cone

Faraz The Web Guy. navegador

Prototipo de una game jam, visto desde arriba. Estás encerrado en unas instalaciones con una criatura que solo se mueve cuando no la alumbras, y la batería de la linterna se gasta mientras está encendida. Tienes que encontrar la llave y la salida.

Ejercicio

En la demo, cambia la munición inicial, la vida de los monstruos y su velocidad, y anota en qué punto el juego deja de dar tensión (demasiado fácil) o se vuelve frustrante. Luego ensancha o acorta el cono de la linterna y discute cómo cambia la sensación.

Fuentes