
Stealth
You make progress without being spotted: you hide in shadows, study guards' patrols and lines of sight, and would rather slip past unseen than fight.
Also known as: stealth game, sneak 'em up
Defining mechanics
- Enemies with a field of view limited by distance, angle and obstacles; staying outside it is the main way to make progress.
- Predictable patrol routes that the player watches to find the moment to slip past.
- Gradual alert states (calm, suspicious, alert) that warn before punishing and let the player recover from a mistake.
- Hiding places and cover, such as shadows, crates, corners or disguises, and often light and noise as resources that can be manipulated.
- Deliberately weak or risky combat, so that avoiding conflict is almost always the better option.
Gameplay loop
Watch the guards, plan a route and a moment, move from hiding spot to hiding spot and, if you are seen, hide until the alert dies down and try again.
Our demo
Controls. Arrows or WASD to move. On mobile, the on-screen buttons.
Open full screenA floor plan seen from above with four patrolling guards. Each one draws its vision cone, which walls and crates cut short, and shows «?» when suspicious and «!» when it spots you. Reach the green exit; the gem is optional and sits right on a guard's route.
Made with Kaplay, in about 200 lines of JavaScript.
How it's made
Setup and constants
The virtual movement buttons are defined, along with the constants that weigh most on the design. View range, cone width and the reduced range in shadow decide how much risk the player takes. The player is slower than an alerted guard, so hiding pays off more than running.
const k = kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [58, 64, 84], buttons: { left: { keyboard: ["left", "a"] }, right: { keyboard: ["right", "d"] }, up: { keyboard: ["up", "w"] }, down: { keyboard: ["down", "s"] }, start: { keyboard: ["space", "enter"] }, }, }); touchControls(k, [ { button: "left", label: "◀" }, { button: "up", label: "▲" }, { button: "down", label: "▼" }, { button: "right", label: "▶" }, ]); const SPEED = 110; // the player is slower than an alerted guard: hiding beats running const VIEW_RANGE = 150; // how far a guard sees const SHADOW_RANGE = 45; // how far a guard sees you when you stand in a shadow const FOV = Math.PI / 2.6; // width of the vision cone (about 70°) const RAYS = 40; // rays used to draw the coneWalls, crates and shadows
The level is a list of rectangles. Walls and crates do two jobs: they block movement and they block sight. Shadows block nothing; they only mean you can be seen from much closer. That difference between «taking cover» (behind something) and «hiding» (in darkness) is the basis of stealth level design.
// Walls and crates are plain rectangles [x, y, w, h]: they block movement AND sight. const WALLS = [[0, 0, 640, 12], [0, 348, 640, 12], [0, 0, 12, 360], [628, 0, 12, 360], [130, 12, 14, 120], [130, 210, 14, 138], [300, 90, 14, 180], [460, 12, 14, 150], [460, 230, 14, 118]]; const CRATES = [[240, 100, 30, 30], [380, 60, 30, 30], [390, 280, 30, 30], [540, 170, 30, 30]]; const BLOCKERS = [...WALLS, ...CRATES]; // Shadows do not block anything: they just make you harder to see. const SHADOWS = [[150, 290, 120, 58], [314, 12, 80, 70], [474, 280, 90, 68]]; const EXIT = [580, 20, 40, 40]; const inRect = (p, [x, y, w, h]) => p.x >= x && p.x <= x + w && p.y >= y && p.y <= y + h; const inShadow = (p) => SHADOWS.some((s) => inRect(p, s)); function buildLevel() { for (const [x, y, w, h] of SHADOWS) add([rect(w, h), pos(x, y), color(20, 22, 34), opacity(0.75)]); add([rect(EXIT[2], EXIT[3]), pos(EXIT[0], EXIT[1]), color(80, 200, 120)]); add([text(t({ es: "Salida", en: "Exit" }), { size: 11, font: FONT }), pos(EXIT[0] + 20, EXIT[1] + 20), anchor("center"), color(20, 60, 30)]); for (const [x, y, w, h] of WALLS) add([rect(w, h), pos(x, y), color(150, 156, 180), area(), body({ isStatic: true }), z(2)]); for (const [x, y, w, h] of CRATES) add([rect(w, h), pos(x, y), color(170, 120, 70), area(), body({ isStatic: true }), z(2)]); }Vision cone and line of sight
canSee() runs three tests in order, from cheapest to most expensive. First, distance: if you are farther than the range (shorter in shadow), it cannot see you. Second, angle: it compares the direction towards you with where the guard is facing and, if the difference is more than half the cone, you are outside it. Third, line of sight: it casts a ray towards you and, if the ray hits a wall or crate before reaching you, you are covered. castRay() measures that ray with the slab test, which works out along which stretch of the ray you are inside each rectangle.
// Distance along a ray until it hits the first blocker (slab test against each rectangle). function castRay(origin, dir, maxDist) { let best = maxDist; for (const [x, y, w, h] of BLOCKERS) { let tMin = 0; let tMax = best; let hit = true; for (const [o, d, lo, hi] of [[origin.x, dir.x, x, x + w], [origin.y, dir.y, y, y + h]]) { if (Math.abs(d) < 1e-9) { if (o < lo || o > hi) hit = false; continue; } const t1 = (lo - o) / d; const t2 = (hi - o) / d; tMin = Math.max(tMin, Math.min(t1, t2)); tMax = Math.min(tMax, Math.max(t1, t2)); if (tMin > tMax) hit = false; } if (hit && tMin < best) best = tMin; } return best; } const angleDiff = (a, b) => Math.atan2(Math.sin(a - b), Math.cos(a - b)); // signed, in -π..π // A guard sees you if three tests pass: distance, angle and a clear line between you. function canSee(guard, target) { const toTarget = target.sub(guard.pos); const dist = toTarget.len(); if (dist > (inShadow(target) ? SHADOW_RANGE : VIEW_RANGE)) return false; if (Math.abs(angleDiff(Math.atan2(toTarget.y, toTarget.x), guard.facing)) > FOV / 2) return false; return castRay(guard.pos, toTarget.unit(), dist) >= dist - 1; }The player
It moves in eight directions at a constant speed and collides with walls thanks to body(). When it is in a shadow it turns dark, so you know you are hidden. The gem is an optional objective deliberately placed on a guard's route, so taking it is a risk you choose.
const player = add([circle(9), pos(40, 320), color(120, 200, 255), area(), body(), anchor("center"), z(5)]); player.onUpdate(() => { const axis = (a, b) => (isButtonDown(b) ? 1 : 0) - (isButtonDown(a) ? 1 : 0); const dir = vec2(axis("left", "right"), axis("up", "down")); if (dir.len() > 0) player.move(dir.unit().scale(SPEED)); // In a shadow the player turns dark, so you can tell you are hidden. player.color = inShadow(player.pos) ? rgb(50, 90, 130) : rgb(120, 200, 255); if (inRect(player.pos, EXIT)) go("end", true, gems, spotted); }); // The optional objective: a gem right on a guard's route. add([polygon([vec2(0, -9), vec2(8, 0), vec2(0, 9), vec2(-8, 0)]), pos(400, 200), color(255, 210, 70), area(), "gem"]); player.onCollide("gem", (gem) => { destroy(gem); gems++; updateHud(); });Patrol routes
Each guard walks a list of waypoints and stops for a second at each one while turning to look around. Its facing does not snap; it turns gradually towards where it is walking. Fixed, predictable routes are a design decision, because the player studies them and plans when to slip past.
// Each guard walks a route of waypoints, pausing at each one to look around. const ROUTES = [ [vec2(70, 170), vec2(270, 170)], [vec2(220, 60), vec2(220, 250), vec2(270, 250)], [vec2(380, 140), vec2(380, 240), vec2(430, 240)], [vec2(520, 100), vec2(600, 100), vec2(600, 250), vec2(520, 250)], ]; const guards = ROUTES.map((route) => add([ circle(10), pos(route[0]), color(230, 90, 80), area(), body(), anchor("center"), z(5), { route, next: 1, pause: 0, facing: 0, state: "patrol", meter: 0, lastSeen: null, lostFor: 0 }, ]), ); function walkTo(g, target, speed) { const toTarget = target.sub(g.pos); if (toTarget.len() < 3) return true; turnTowards(g, Math.atan2(toTarget.y, toTarget.x)); g.move(toTarget.unit().scale(speed)); return false; } function turnTowards(g, angle) { const step = 4 * dt(); const diff = angleDiff(angle, g.facing); g.facing += Math.abs(diff) < step ? diff : Math.sign(diff) * step; }State machine: patrol, suspicious, alert
Each guard is always in one of three states, and each state has its own behaviour. While it sees you, a suspicion meter fills, faster the closer you are; when it stops seeing you, the meter drains. On patrol it follows its route. As soon as the meter starts filling it becomes suspicious («?»): it stops, walks slowly to the last place it saw you and looks around. If the meter fills up, it becomes alert («!») and runs after you. After three seconds without seeing you it drops back to suspicious and, once the meter is empty, to patrol. This middle ground is what makes the genre fair, because it gives you time to fix a mistake before you are caught.
// State machine: patrol → suspicious (the meter fills while it sees you) → alert. // Losing sight makes the meter drain, and the guard steps back down. for (const g of guards) { g.onUpdate(() => { const sees = canSee(g, player.pos); // Bumping into a guard always gives you away. if (g.pos.dist(player.pos) < 24) Object.assign(g, { meter: 1, lastSeen: player.pos.clone() }); if (sees) { g.lastSeen = player.pos.clone(); const closeness = 1 - g.pos.dist(player.pos) / VIEW_RANGE; g.meter = Math.min(1, g.meter + dt() * (0.6 + 1.6 * closeness)); } else if (g.state !== "alert") g.meter = Math.max(0, g.meter - dt() * 0.3); if (g.state === "patrol" && g.meter > 0.05) g.state = "suspicious"; else if (g.state === "suspicious" && g.meter >= 1) { g.state = "alert"; spotted++; updateHud(); } else if (g.state === "suspicious" && g.meter === 0) g.state = "patrol"; else if (g.state === "alert") { g.lostFor = sees ? 0 : g.lostFor + dt(); if (g.lostFor > 3) Object.assign(g, { state: "suspicious", meter: 0.6, lostFor: 0 }); } // Each state has its own behaviour. if (g.state === "patrol") { if (g.pause > 0) { g.pause -= dt(); g.facing += dt() * 1.2; // look around while waiting } else if (walkTo(g, g.route[g.next], 60)) { g.next = (g.next + 1) % g.route.length; g.pause = 1; } } else if (g.state === "suspicious") { // Go and check the last place it saw you, then look around there. if (walkTo(g, g.lastSeen, 35)) g.facing += dt() * 1.5; } else { walkTo(g, sees ? player.pos : g.lastSeen, 150); if (g.pos.dist(player.pos) < 24) go("end", false, gems, spotted); } }); }Drawing the cone
The cone is drawn as a fan of 40 rays spread across the field of view, each one cut where it hits the first wall. That way the drawing matches exactly what the guard can see, and the shadows that crates cast show where you are safe. Its colour changes with the state, and the mark and the suspicion meter appear above the guard.
// The cone is a fan of rays, each one cut short by the first wall it hits. const CONE_COLORS = { patrol: rgb(255, 255, 200), suspicious: rgb(255, 200, 60), alert: rgb(255, 70, 60) }; add([ z(3), { draw() { for (const g of guards) { const pts = [g.pos]; for (let i = 0; i <= RAYS; i++) { const a = g.facing - FOV / 2 + (FOV * i) / RAYS; const dir = vec2(Math.cos(a), Math.sin(a)); pts.push(g.pos.add(dir.scale(castRay(g.pos, dir, VIEW_RANGE)))); } drawPolygon({ pts, color: CONE_COLORS[g.state], opacity: 0.25 }); // State indicator: "?" plus the suspicion meter, or "!" when alert. if (g.state !== "patrol") { drawText({ text: g.state === "alert" ? "!" : "?", pos: g.pos.add(-5, -36), size: 18, font: FONT, color: CONE_COLORS[g.state] }); drawRect({ pos: g.pos.add(-12, -16), width: Math.max(1, 24 * g.meter), height: 4, color: CONE_COLORS[g.state] }); } } }, }, ]);Scenes
Three scenes (title, game and end) with go(). The end screen says how many times you were spotted, because in stealth getting through unseen is worth more than just arriving.
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: 560 }), pos(center().add(0, 30)), anchor("center"), color(210, 215, 240)]); onButtonPress("start", next); onClick(next); } scene("title", () => screen( t({ es: "Sigilo", en: "Stealth" }), t({ es: "Flechas o WASD para moverte. Llega a la salida verde sin que te atrapen; la gema es opcional.\nEscóndete en las sombras y detrás de las cajas. «?» = sospecha, «!» = alerta.\nPulsa espacio o toca para empezar.", en: "Arrows or WASD to move. Reach the green exit without being caught; the gem is optional.\nHide in shadows and behind crates. «?» = suspicious, «!» = alert.\nPress space or tap to start.", }), () => go("game"), ), ); scene("end", (won, gems, spotted) => screen( won ? t({ es: "¡Escapaste!", en: "You escaped!" }) : t({ es: "Te atraparon", en: "You were caught" }), t({ es: `Gema: ${gems}/1 · Veces que te vieron: ${spotted}. ${spotted === 0 && won ? "¡Sin ser visto!" : ""}\nPulsa espacio para repetir.`, en: `Gem: ${gems}/1 · Times spotted: ${spotted}. ${spotted === 0 && won ? "Unseen!" : ""}\nPress space to play again.`, }), () => go("game"), ), ); go("title");
What we simplified
- Guards can only see; they do not hear footsteps or noises, which in many games of the genre matter as much as sight.
- Light is binary, either a shadow zone or not; there is no gradual visibility meter and no lights you can switch off.
- You cannot knock out or distract guards; you can only hide and wait for the right moment.
- An alerted guard runs straight at you, without finding paths around walls.
To modify it
- Add noise. If the player runs (hold Shift), nearby guards that cannot see you become suspicious and look towards you.
- Change FOV and VIEW_RANGE and test whether the level can still be beaten. Note which values make it too easy or unfair.
- Make an alerted guard warn the other guards nearby and switch them to suspicious too.
- Replace the fixed shadows with a lamp the player can switch off.
Full code
// Stealth 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: [58, 64, 84],
buttons: {
left: { keyboard: ["left", "a"] },
right: { keyboard: ["right", "d"] },
up: { keyboard: ["up", "w"] },
down: { keyboard: ["down", "s"] },
start: { keyboard: ["space", "enter"] },
},
});
touchControls(k, [
{ button: "left", label: "◀" },
{ button: "up", label: "▲" },
{ button: "down", label: "▼" },
{ button: "right", label: "▶" },
]);
const SPEED = 110; // the player is slower than an alerted guard: hiding beats running
const VIEW_RANGE = 150; // how far a guard sees
const SHADOW_RANGE = 45; // how far a guard sees you when you stand in a shadow
const FOV = Math.PI / 2.6; // width of the vision cone (about 70°)
const RAYS = 40; // rays used to draw the cone
// #endregion
// #region level
// Walls and crates are plain rectangles [x, y, w, h]: they block movement AND sight.
const WALLS = [[0, 0, 640, 12], [0, 348, 640, 12], [0, 0, 12, 360], [628, 0, 12, 360], [130, 12, 14, 120],
[130, 210, 14, 138], [300, 90, 14, 180], [460, 12, 14, 150], [460, 230, 14, 118]];
const CRATES = [[240, 100, 30, 30], [380, 60, 30, 30], [390, 280, 30, 30], [540, 170, 30, 30]];
const BLOCKERS = [...WALLS, ...CRATES];
// Shadows do not block anything: they just make you harder to see.
const SHADOWS = [[150, 290, 120, 58], [314, 12, 80, 70], [474, 280, 90, 68]];
const EXIT = [580, 20, 40, 40];
const inRect = (p, [x, y, w, h]) => p.x >= x && p.x <= x + w && p.y >= y && p.y <= y + h;
const inShadow = (p) => SHADOWS.some((s) => inRect(p, s));
function buildLevel() {
for (const [x, y, w, h] of SHADOWS) add([rect(w, h), pos(x, y), color(20, 22, 34), opacity(0.75)]);
add([rect(EXIT[2], EXIT[3]), pos(EXIT[0], EXIT[1]), color(80, 200, 120)]);
add([text(t({ es: "Salida", en: "Exit" }), { size: 11, font: FONT }), pos(EXIT[0] + 20, EXIT[1] + 20), anchor("center"), color(20, 60, 30)]);
for (const [x, y, w, h] of WALLS) add([rect(w, h), pos(x, y), color(150, 156, 180), area(), body({ isStatic: true }), z(2)]);
for (const [x, y, w, h] of CRATES) add([rect(w, h), pos(x, y), color(170, 120, 70), area(), body({ isStatic: true }), z(2)]);
}
// #endregion
// #region line-of-sight
// Distance along a ray until it hits the first blocker (slab test against each rectangle).
function castRay(origin, dir, maxDist) {
let best = maxDist;
for (const [x, y, w, h] of BLOCKERS) {
let tMin = 0;
let tMax = best;
let hit = true;
for (const [o, d, lo, hi] of [[origin.x, dir.x, x, x + w], [origin.y, dir.y, y, y + h]]) {
if (Math.abs(d) < 1e-9) {
if (o < lo || o > hi) hit = false;
continue;
}
const t1 = (lo - o) / d;
const t2 = (hi - o) / d;
tMin = Math.max(tMin, Math.min(t1, t2));
tMax = Math.min(tMax, Math.max(t1, t2));
if (tMin > tMax) hit = false;
}
if (hit && tMin < best) best = tMin;
}
return best;
}
const angleDiff = (a, b) => Math.atan2(Math.sin(a - b), Math.cos(a - b)); // signed, in -π..π
// A guard sees you if three tests pass: distance, angle and a clear line between you.
function canSee(guard, target) {
const toTarget = target.sub(guard.pos);
const dist = toTarget.len();
if (dist > (inShadow(target) ? SHADOW_RANGE : VIEW_RANGE)) return false;
if (Math.abs(angleDiff(Math.atan2(toTarget.y, toTarget.x), guard.facing)) > FOV / 2) return false;
return castRay(guard.pos, toTarget.unit(), dist) >= dist - 1;
}
// #endregion
scene("game", () => {
buildLevel();
let gems = 0;
let spotted = 0;
const hud = add([text("", { size: 14, font: FONT }), pos(20, 18), z(10)]);
const updateHud = () =>
(hud.text = t({ es: `Gema: ${gems}/1 · Veces que te vieron: ${spotted}`, en: `Gem: ${gems}/1 · Times spotted: ${spotted}` }));
updateHud();
// #region player
const player = add([circle(9), pos(40, 320), color(120, 200, 255), area(), body(), anchor("center"), z(5)]);
player.onUpdate(() => {
const axis = (a, b) => (isButtonDown(b) ? 1 : 0) - (isButtonDown(a) ? 1 : 0);
const dir = vec2(axis("left", "right"), axis("up", "down"));
if (dir.len() > 0) player.move(dir.unit().scale(SPEED));
// In a shadow the player turns dark, so you can tell you are hidden.
player.color = inShadow(player.pos) ? rgb(50, 90, 130) : rgb(120, 200, 255);
if (inRect(player.pos, EXIT)) go("end", true, gems, spotted);
});
// The optional objective: a gem right on a guard's route.
add([polygon([vec2(0, -9), vec2(8, 0), vec2(0, 9), vec2(-8, 0)]), pos(400, 200), color(255, 210, 70), area(), "gem"]);
player.onCollide("gem", (gem) => {
destroy(gem);
gems++;
updateHud();
});
// #endregion
// #region guards
// Each guard walks a route of waypoints, pausing at each one to look around.
const ROUTES = [
[vec2(70, 170), vec2(270, 170)],
[vec2(220, 60), vec2(220, 250), vec2(270, 250)],
[vec2(380, 140), vec2(380, 240), vec2(430, 240)],
[vec2(520, 100), vec2(600, 100), vec2(600, 250), vec2(520, 250)],
];
const guards = ROUTES.map((route) =>
add([
circle(10), pos(route[0]), color(230, 90, 80), area(), body(), anchor("center"), z(5),
{ route, next: 1, pause: 0, facing: 0, state: "patrol", meter: 0, lastSeen: null, lostFor: 0 },
]),
);
function walkTo(g, target, speed) {
const toTarget = target.sub(g.pos);
if (toTarget.len() < 3) return true;
turnTowards(g, Math.atan2(toTarget.y, toTarget.x));
g.move(toTarget.unit().scale(speed));
return false;
}
function turnTowards(g, angle) {
const step = 4 * dt();
const diff = angleDiff(angle, g.facing);
g.facing += Math.abs(diff) < step ? diff : Math.sign(diff) * step;
}
// #endregion
// #region states
// State machine: patrol → suspicious (the meter fills while it sees you) → alert.
// Losing sight makes the meter drain, and the guard steps back down.
for (const g of guards) {
g.onUpdate(() => {
const sees = canSee(g, player.pos);
// Bumping into a guard always gives you away.
if (g.pos.dist(player.pos) < 24) Object.assign(g, { meter: 1, lastSeen: player.pos.clone() });
if (sees) {
g.lastSeen = player.pos.clone();
const closeness = 1 - g.pos.dist(player.pos) / VIEW_RANGE;
g.meter = Math.min(1, g.meter + dt() * (0.6 + 1.6 * closeness));
} else if (g.state !== "alert") g.meter = Math.max(0, g.meter - dt() * 0.3);
if (g.state === "patrol" && g.meter > 0.05) g.state = "suspicious";
else if (g.state === "suspicious" && g.meter >= 1) {
g.state = "alert";
spotted++;
updateHud();
} else if (g.state === "suspicious" && g.meter === 0) g.state = "patrol";
else if (g.state === "alert") {
g.lostFor = sees ? 0 : g.lostFor + dt();
if (g.lostFor > 3) Object.assign(g, { state: "suspicious", meter: 0.6, lostFor: 0 });
}
// Each state has its own behaviour.
if (g.state === "patrol") {
if (g.pause > 0) {
g.pause -= dt();
g.facing += dt() * 1.2; // look around while waiting
} else if (walkTo(g, g.route[g.next], 60)) {
g.next = (g.next + 1) % g.route.length;
g.pause = 1;
}
} else if (g.state === "suspicious") {
// Go and check the last place it saw you, then look around there.
if (walkTo(g, g.lastSeen, 35)) g.facing += dt() * 1.5;
} else {
walkTo(g, sees ? player.pos : g.lastSeen, 150);
if (g.pos.dist(player.pos) < 24) go("end", false, gems, spotted);
}
});
}
// #endregion
// #region cone
// The cone is a fan of rays, each one cut short by the first wall it hits.
const CONE_COLORS = { patrol: rgb(255, 255, 200), suspicious: rgb(255, 200, 60), alert: rgb(255, 70, 60) };
add([
z(3),
{
draw() {
for (const g of guards) {
const pts = [g.pos];
for (let i = 0; i <= RAYS; i++) {
const a = g.facing - FOV / 2 + (FOV * i) / RAYS;
const dir = vec2(Math.cos(a), Math.sin(a));
pts.push(g.pos.add(dir.scale(castRay(g.pos, dir, VIEW_RANGE))));
}
drawPolygon({ pts, color: CONE_COLORS[g.state], opacity: 0.25 });
// State indicator: "?" plus the suspicion meter, or "!" when alert.
if (g.state !== "patrol") {
drawText({ text: g.state === "alert" ? "!" : "?", pos: g.pos.add(-5, -36), size: 18, font: FONT, color: CONE_COLORS[g.state] });
drawRect({ pos: g.pos.add(-12, -16), width: Math.max(1, 24 * g.meter), height: 4, color: CONE_COLORS[g.state] });
}
}
},
},
]);
// #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: 560 }), pos(center().add(0, 30)), anchor("center"), color(210, 215, 240)]);
onButtonPress("start", next);
onClick(next);
}
scene("title", () =>
screen(
t({ es: "Sigilo", en: "Stealth" }),
t({
es: "Flechas o WASD para moverte. Llega a la salida verde sin que te atrapen; la gema es opcional.\nEscóndete en las sombras y detrás de las cajas. «?» = sospecha, «!» = alerta.\nPulsa espacio o toca para empezar.",
en: "Arrows or WASD to move. Reach the green exit without being caught; the gem is optional.\nHide in shadows and behind crates. «?» = suspicious, «!» = alert.\nPress space or tap to start.",
}),
() => go("game"),
),
);
scene("end", (won, gems, spotted) =>
screen(
won ? t({ es: "¡Escapaste!", en: "You escaped!" }) : t({ es: "Te atraparon", en: "You were caught" }),
t({
es: `Gema: ${gems}/1 · Veces que te vieron: ${spotted}. ${spotted === 0 && won ? "¡Sin ser visto!" : ""}\nPulsa espacio para repetir.`,
en: `Gem: ${gems}/1 · Times spotted: ${spotted}. ${spotted === 0 && won ? "Unseen!" : ""}\nPress space to play again.`,
}),
() => go("game"),
),
);
go("title");
// #endregionDesign challenges
- Making sure players always understand why they were seen. Cones, light meters and state indicators make visible what the guard perceives.
- Tuning detection to be gradual and fair, with a margin of suspicion instead of an instant loss at the first glimpse.
- Designing levels with several routes, cover and vantage points from which to watch before taking a risk.
- Readable, consistent guard AI that the player can predict and fool.
- Making sure failing does not force too much repetition; frequent checkpoints or recoverable alerts keep the tension without frustration.
History
- 1981Origin005
005 (Sega, 1981), where you carry secret documents while avoiding enemy flashlights, holds the Guinness record as the first stealth game and was the first to show the enemy's «vision cone». Retro Gamer points to Manbiki Shounen (1979) as a forerunner.
Stealth game (Wikipedia) - 1981Castle Wolfenstein
Castle Wolfenstein is one of the earliest games built on stealth; you can disguise yourself in enemy uniforms to walk past guards.
Castle Wolfenstein (Wikipedia) - 1987Metal Gear
Hideo Kojima's Metal Gear is the first mainstream stealth game on consoles: you start unarmed and must avoid combat.
Stealth game (Wikipedia) - 1998Tenchu: Stealth Assassins
Tenchu is considered the first 3D stealth game, with an indicator that shows the distance and alert level of nearby enemies.
Stealth game (Wikipedia) - 1998Metal Gear Solid
Metal Gear Solid popularises the genre. Its radar shows the guards' fields of view, and play moves through alert, evasion and infiltration modes.
Metal Gear Solid (Wikipedia) - 1998Thief: The Dark Project
Thief: The Dark Project brings stealth into first person. A gem-shaped indicator shows how visible you are, and the noise of your footsteps depends on the floor.
Thief: The Dark Project (Wikipedia) - 2002Tom Clancy's Splinter Cell
Splinter Cell puts light at the centre of play, with a visibility meter and the option of shooting out lights to create shadows.
Tom Clancy's Splinter Cell (Wikipedia) - 2012Mark of the Ninja
Mark of the Ninja brings stealth to 2D side-scrolling and draws on screen what guards can see and how far noise travels.
Stealth game (Wikipedia)
Reference games
1981
005
You carry a briefcase of secret documents to a helicopter through buildings and warehouses, staying out of the enemies' flashlight beams and hiding behind boxes. It is one of the first games to show the enemy's field of view as a cone.
1981
Castle Wolfenstein
You escape from a castle room by room in search of secret plans. You can fight, but you can also disguise yourself in an enemy uniform to walk past the guards without being stopped.
1987
Metal Gear
You infiltrate an enemy base, seen from above, alone and unarmed. Guards follow set patrols and the goal is to stay out of sight and avoid fighting; if you are spotted, they go on alert and reinforcements may arrive.
1998
Metal Gear Solid
You infiltrate a guarded facility, dodging cameras and patrols, pressed against walls and using a radar that shows the guards' fields of view. It took the series' stealth into 3D with a story told like a film.
1998
Tenchu: Stealth Assassins
You guide a ninja through missions in 3D settings with the aim of staying undetected. An indicator shows how far away enemies are and how alert they are, and if you sneak up on one unseen you take it out instantly.
1998
Thief: The Dark Project
You are a thief robbing a dark city, seeing the world in first person. An indicator shows how much light falls on you and guards hear your footsteps depending on the floor you walk on, so light and sound are your main tools.
2002
Tom Clancy's Splinter Cell
An agent moves through the shadows with night-vision goggles and can shoot out lights to darken the way. A meter shows how much light falls on him, and being spotted often sets off alarms that make the mission harder.
2012
Mark of the Ninja
Side-scrolling 2D stealth. It draws on screen what is usually invisible, such as how far the noise of your footsteps travels and which areas guards can see, so you plan with full information.
2017
The Rescue
A short stealth game in which an agent infiltrates an enemy base to find a missing colleague. It was made for js13kGames 2017, so the whole game fits in 13 kilobytes, and its author published it free and playable in the browser.
Exercise
Play the demo twice, once trying never to be seen and once taking the gem. Then change the cone width (FOV) and the range in shadow, and discuss when the level becomes unfair. Finally, draw a new level on paper with at least two possible routes and mark the spots from which the player can watch safely.