
Survival horror
You explore threatening places with scarce ammunition, few healing items and a limited inventory; running or hiding is often wiser than fighting, and the tension comes from that scarcity.
Also known as: survival horror game
Defining mechanics
- Scarce resources (ammunition, healing and sometimes even the number of saves), so every shot is a decision.
- A small inventory that forces you to choose what to carry and what to leave behind.
- Tough enemies, often slow, that are as reasonable to flee or avoid as to fight.
- Vision limited by darkness, fog, a flashlight or fixed cameras, which hides the threat until the last moment.
- An enclosed place explored through keys, doors and puzzles, which makes you retrace your steps.
Gameplay loop
Explore carefully, find a resource or a key, decide whether to fight or flee, open a new area and reach a safe place before what you carry runs out.
Our demo
Controls. Arrows or WASD to move; the flashlight points at the mouse or where you walk. Space, J or click to shoot. On mobile, the on-screen buttons (tapping the screen also aims and shoots).
Open full screenA dark building seen from above. You only see what your flashlight lights up, you have three bullets and each monster takes three shots, so you cannot kill them all. You have to find the key and get back to the door.
Made with Kaplay, in about 188 lines of JavaScript.
How it's made
Setup and controls
The game is created and virtual buttons are defined; shooting answers to space and also to a click. The constants are the heart of the design. You are faster than the monsters, but you start with three bullets and each monster takes three. That gap makes you run more than fight.
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 youThe building as text
The map is a list of strings and each character is a tile (wall, monster, bullets, first-aid kit, key, door). The key is far from the door on purpose, so that you have to cross the building twice.
// # 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"], }, }); }The light
A point is lit if it passes two tests. First, it must be inside the small circle around you or inside the flashlight cone, which is measured by distance and angle. Second, no wall may block the straight line to you, which is checked by stepping along that line. It is the same idea as «line of sight» in stealth games, but used for what you see.
// 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; }The player and the flashlight
You move in eight directions with a step capped per frame, so a slow frame cannot carry you through a wall. The flashlight points at the mouse if you just moved it and otherwise where you walk, so the game can be played with the keyboard alone.
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(); });Scarcity
Every shot uses a bullet, and with no bullets all you can do is run. Ammo boxes give two and the first-aid kit heals one heart. They are few and hidden in the dark, so each one feels like a relief. Shooting also gives you away, because the wounded monster comes after you.
// 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())));Slow but stubborn monsters
Each monster wanders until it sees you nearby, with a clear line between you, and then walks towards you without stopping. If you get far enough away, it forgets about you. Touching you costs one heart, and then it stops for a second and you are safe for a moment, so you can get away.
// 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); } }); }The key and the exit
The goal is not to kill everything but to get out. The door stays locked until you pick up the key, and a message reminds you if you arrive too early.
// 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." })); });The darkness
A grid of dark cells, each with its own transparency, is drawn over the map. Lit cells are almost invisible, cells you have already seen stay dim (you remember the layout) and the rest are black. Monsters and items are only visible while light falls on them: the fear comes from what you cannot see.
// 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; });The HUD
At the top are your hearts, bullets and whether you have the key. The red frame pulses harder the closer the nearest monster is, even if you cannot see it. It is the visual version of the sound that warns of danger in the genre, like the radio in 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) }); } }]);Scenes
The game has three scenes, title, game and end, and go() jumps between them. That way every run starts with the building, the bullets and the monsters as they were at the beginning.
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");
What we simplified
- Everything is simple shapes and there is no sound, which in the real genre is half the fear; the pulsing red frame stands in for «hearing».
- The darkness is a grid of tile-sized cells, not soft lighting.
- The monsters do not path-find, they walk straight at you; a wall between you is enough to stop them.
- There is no inventory to manage and no save points, only bullets, health and the key.
To modify it
- Give the flashlight a battery that runs out if you use it a lot; add batteries as a scarce item.
- Add a two-slot inventory, so you have to choose between carrying the first-aid kit or the key.
- Make shots noisy so they attract nearby monsters even if they cannot see you.
Full code
// 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");
// #endregionDesign challenges
- Tuning scarcity so that something is always a little short without leaving the player stuck.
- Building tension with light, sound and anticipation rather than repeated jump scares, which wear off.
- Limiting what the player sees (darkness, camera) without making the controls feel unfair.
- A map you cross several times that changes (shortcuts, doors that open) so that backtracking stays interesting.
History
- 19813D Monster Maze
In 3D Monster Maze you flee, unarmed, from a Tyrannosaurus through a maze; in 2006 Edge magazine called it «the original survival horror game».
3D Monster Maze (Wikipedia) - 1989Sweet Home
Sweet Home (Capcom) brings together a mansion, a very limited inventory, scarce healing and characters who die for good; it was the main inspiration for Resident Evil.
Sweet Home (video game) (Wikipedia) - 1992Alone in the Dark
Alone in the Dark (Infogrames) places 3D characters over hand-drawn backgrounds with fixed cameras framed like a horror film; Guinness recognises it as the first 3D survival horror game.
Alone in the Dark (1992 video game) (Wikipedia) - 1996OriginResident Evil
The term was first used for the Japanese release of Resident Evil (Capcom, 1996), which drew on Sweet Home (1989) and Alone in the Dark (1992); it was later applied to earlier games.
Survival horror (Wikipedia) - 1996Resident Evil
Resident Evil sets the formula: ammunition so scarce you cannot kill everything, a limited inventory and a counted supply of ink ribbons to save the game.
Resident Evil (1996 video game) (Wikipedia) - 1999Silent Hill
Silent Hill uses real-time 3D settings shrouded in fog and darkness, a flashlight and a radio that crackles when monsters are near, and goes for psychological horror.
Silent Hill (video game) (Wikipedia) - 2001
In Tecmo's Fatal Frame (Project Zero in Europe), the only weapon against ghosts is a photo camera.
Fatal Frame (video game) (Wikipedia) - 2005
Resident Evil 4 swaps fixed cameras for an over-the-shoulder camera and puts more weight on action; some critics said the series had left survival horror behind.
Resident Evil 4 (Wikipedia) - 2012SCP – Containment Breach
SCP – Containment Breach, free and made by a single person, leaves you unarmed against a creature that only moves when you are not looking at it, and you have to blink.
SCP – Containment Breach (Wikipedia) - 2014
In Five Nights at Freddy's you do not move: you watch cameras and shut doors with limited power that has to last the whole night.
Five Nights at Freddy's (Wikipedia) - 2017
Resident Evil 7: Biohazard returns to the resource management and puzzles of the genre's beginnings.
Survival horror (Wikipedia)
Reference games
1981
3D Monster Maze
In first person, you look for the exit of a randomly generated maze while a Tyrannosaurus stalks you. You are faster than it, but a dead end can be the end of you.
1989
Sweet Home
Five characters explore a haunted mansion; each one has a unique item, the inventory is tiny and whoever dies stays dead. It was only released in Japan and was the main inspiration for Resident Evil.
1992
Alone in the Dark
You investigate a haunted mansion with 3D characters over hand-drawn backgrounds, seen through fixed cameras framed like a horror film. Guinness recognises it as the first 3D survival horror game.
1996
Resident Evil
You explore a monster-filled mansion with little ammunition, a small inventory and a counted supply of ink ribbons to save the game, seen through fixed cameras. It was the first game marketed as «survival horror».
1999
Silent Hill
You search for your daughter in a town shrouded in fog and darkness, with a flashlight and a radio that crackles when a monster comes near. It goes for psychological horror rather than scares.
2012
SCP – Containment Breach
You escape, unarmed, from a randomly generated facility, chased by a statue that only moves when you are not looking at it; a meter forces you to blink. It is free and based on the SCP collaborative writing project.
2026
Dark Cone
A top-down game jam prototype. You are locked in a facility with a creature that only moves when your light is not on it, and the flashlight battery drains while it is on. You have to find the key and the exit.
Exercise
In the demo, change the starting ammunition, the monsters' health and their speed, and note at which point the game stops being tense (too easy) or becomes frustrating. Then widen or shorten the flashlight cone and discuss how the feeling changes.