Skip to main content

Cómo Construir un FPS de Terror Atmosférico

v2 Escrito abril 2026 Advanced

Hemos cubierto mucho terreno en la serie de raycasting: sacudida de pantalla y balanceo de vista, niebla e iluminación difuminada, aparición de monstruos y búsqueda de rutas BFS, gestión de escenas y niveles, calcomanías y atrezo, y diseño de niveles con píxeles. Este tutorial reúne todo eso.

Estamos construyendo un FPS de terror en corredor. Despiertas en una instalación oscura. Los pasillos son estrechos, la luz de la antorcha apenas alcanza las paredes de adelante, y algo te está cazando. Tu único objetivo es encontrar la salida antes de que te encuentre a ti. No hay arma — solo movimiento, ingenio, y la esperanza de escuchar los pasos antes de sentir el daño.

Todo lo que necesitamos ya está en nuestra caja de herramientas:

  • Motor de raycasting con un mapa PNG de 24×24
  • Niebla e iluminación difuminada para la atmósfera opresiva
  • Detección LOS y búsqueda de rutas BFS impulsando la IA enemiga
  • Sacudida de pantalla y destello de daño al recibir impactos
  • Parpadeo de antorcha y balanceo de vista para la inmersión física
  • Una máquina de estados gestionando la pantalla de título, el juego y las pantallas de victoria/derrota

Al final tendremos un juego de horror completo y listo para publicar — y una comprensión sólida de cómo conectar múltiples sistemas en una experiencia cohesiva.

Preparando la Escena

Antes de añadir enemigos, salud o atmósfera, necesitamos el mundo. Primero vamos a renderizar los pasillos oscuros — el bucle de raycasting, la niebla y la caída de la antorcha que todo lo demás construye encima.

El Mapa

El nivel es una cuadrícula de 24×24 — 1 para paredes, 0 para espacio abierto. Pasillos estrechos, callejones sin salida y una única salida escondida en la esquina más lejana. Opresivo sin ser un laberinto puro.

const Map = [
  [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
  [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
  [1,0,1,1,0,1,1,1,0,1,1,0,1,0,1,1,1,0,1,1,0,1,0,1],
  // ... (cuadrícula completa de 24×24)
];

Situamos al jugador en { x: 1.5, y: 1.5 } — el centro de la primera celda abierta.

Constantes

Todo lo ajustable vive en un único objeto:

const Constants = {
  Fov: Math.PI / 3,
  MoveSpeed: 0.04,
  RotSpeed: 0.03,
  SpawnX: 1.5,
  SpawnY: 1.5,
  FogStart: 0.2,
  MaxDist: 1.5,
  TorchStrength: 0.65,
  TorchFlicker: 0.08,
};

FogStart y MaxDist son intencionalmente ajustados. La niebla comienza en 0.2 y es total a 1.5. La oscuridad es el objetivo.

El Bucle de Raycasting

Para cada columna vertical de píxeles, lanzamos un rayo y encontramos dónde golpea una pared:

for (let x = 0; x < viewW; x++) {
  const cameraX = (2 * x) / viewW - 1;
  const rdx = dirX + planeX * cameraX;
  const rdy = dirY + planeY * cameraX;

  // DDA — avanza por la cuadrícula hasta golpear una pared
  let side;
  while (true) {
    if (sideDistX < sideDistY) {
      sideDistX += deltaDistX;
      mapX += stepX;
      side = 0;
    } else {
      sideDistY += deltaDistY;
      mapY += stepY;
      side = 1;
    }
    if (Map[mapY]?.[mapX] === 1) break;
  }

  const perpDist = side === 0 ? sideDistX - deltaDistX : sideDistY - deltaDistY;
  const stripeH = Math.floor(viewH / perpDist);
}

perpDist es la distancia de pared corregida. Una distancia euclidiana directa causaría distorsión ojo de pez — perpDist lo evita. stripeH es qué tan alta es la franja de pared a esa profundidad.

Niebla y Caída de la Antorcha

La niebla es una rampa lineal de FogStart a MaxDist:

function fogAmount(dist) {
  return Math.min(1, Math.max(0, (dist - Constants.FogStart) / (Constants.MaxDist - Constants.FogStart)));
}

La distancia bruta no es toda la historia, sin embargo. La antorcha no alcanza los bordes de la pantalla tan bien como el centro — así que reducimos la distancia efectiva según la posición de la columna antes de pasarla por fogAmount:

const colOffset = Math.abs(x - viewW / 2) / (viewW / 2);
const torchReduce = Constants.TorchStrength * (1 - colOffset * colOffset);
const effectiveDist = Math.max(0, perpDist - torchReduce);
const fogFactor = fogAmount(effectiveDist);

colOffset * colOffset nos da una curva cuadrática — caída circular suave en lugar de un borde lineal duro.

Viñeta

Una vez dibujadas las paredes, aplicamos una viñeta radial para oscurecer todo fuera del círculo de la antorcha. En lugar de desvanecerla, usamos la matriz Bayer 4×4 para umbralizarla — el mismo truco de difuminado del tutorial de niebla e iluminación:

for (let sy = 0; sy < viewH; sy++) {
  for (let sx = 0; sx < viewW; sx++) {
    const r2 = cx * cx + cy * cy;
    const vignette = Math.min(1, Math.max(0, (r2 - 0.4) / 0.6));
    if (vignette > Bayer[(sy % 4) * 4 + (sx % 4)]) pset(sx, sy, 0);
  }
}

Difuminar a negro duro en lugar de mezclar le da al borde una sensación granulada y orgánica. Es lo que hace que la antorcha parezca real.

Atmospheric Horror FPS: Setting the Scene
Navega por los oscuros pasillos. Teclas de flecha para moverte y mirar.

Salud del Jugador y Daño

El horror vive y muere por las consecuencias. Sin un sistema de salud, los pasillos son solo un laberinto. Con uno, cada sonido se convierte en una amenaza. Vamos a añadir tres puntos de vida, un HUD para mostrarlos y una respuesta física cuando el jugador recibe daño.

Estado de la Salud

La salud es un array de booleanos — true para lleno, false para perdido:

let health = [true, true, true];
let flashTimer = 0;
let shakeTimer = 0;

Un array hace que el HUD sea trivial de renderizar — iteramos y dibujamos un cuadrado relleno o atenuado por cada ranura. Recibir daño encuentra el último true y lo invierte:

function takeDamage() {
  const idx = health.lastIndexOf(true);
  if (idx === -1) return;
  health[idx] = false;
  flashTimer = 12;
  shakeTimer = 8;
}

lastIndexOf trabaja desde la derecha, así que el HUD se vacía de izquierda a derecha.

Sacudida de Pantalla

La sacudida funciona desplazando toda la escena con camera() durante unos pocos fotogramas. La parte complicada es cuándo llamarla:

function draw() {
  creset();
  cls(0);

  if (shakeTimer > 0) {
    camera((rnd(2) - 1) * 2, (rnd(2) - 1) * 2);
    shakeTimer--;
  }

  // ... raycasting y viñeta ...

  creset(); // reiniciar antes de dibujar el HUD
  // ... dibujo del HUD ...
}

Llamamos a creset() al inicio de draw() para limpiar cualquier desplazamiento sobrante del fotograma anterior. Luego camera() aplica el jitter aleatorio antes de ejecutar el raycasting. Llamamos a creset() de nuevo antes del HUD — así los cuadrados de salud permanecen fijos en la pantalla sin importar cuánto estemos sacudiendo.

Destello de Daño

Una capa roja difuminada hace más por vender un golpe que casi cualquier otra cosa. La intensidad se desvanece a lo largo de 12 fotogramas usando la matriz Bayer como umbral:

if (flashTimer > 0) {
  const intensity = flashTimer / 12;
  for (let fy = 0; fy < viewH; fy++) {
    for (let fx = 0; fx < viewW; fx++) {
      if (intensity > Bayer[(fy % 4) * 4 + (fx % 4)]) pset(fx, fy, 8);
    }
  }
  flashTimer--;
}

En el fotograma 1 de 12, casi todos los píxeles son rojos. En el fotograma 12, es solo un tenue patrón difuminado. El resultado se siente físico — como algo aclarándose ante tus ojos — en lugar de una disolución limpia.

El HUD

Dibujamos el HUD después del segundo creset() para que la sacudida no lo arrastre:

for (let i = 0; i < Constants.PlayerMaxHealth; i++) {
  rectfill(4 + i * 8, 4, 9 + i * 8, 9, health[i] ? 8 : 5);
}

Las ranuras llenas son rojas (color 8), las vacías son gris oscuro (color 5). Presentes pero huecas. Sin iconos necesarios.

Atmospheric Horror FPS: Player Health & Damage
Pulsa Z para recibir daño. Observa el indicador de salud y la respuesta de pantalla.

Monstruos y Terror

Un laberinto vacío es un rompecabezas. Pon algo dentro y se convierte en un juego de terror. Vamos a añadir enemigos — aparecen en posiciones fijas, nos detectan usando línea de visión, nos persiguen con pathfinding BFS, y nos hacen daño al contacto.

Estado del Enemigo

Cada monstruo es un objeto simple. Rastreamos posición, si está persiguiendo, el camino actual, y un tiempo de espera para evitar golpes múltiples instantáneos:

let monsters = EnemySpawns.map(s => ({
  x: s.x, y: s.y,
  chasing: false,
  seenPlayer: false,
  path: [],
  pathAge: 0,
  hitCooldown: 0,
  dead: false,
}));

EnemySpawns es un array de posiciones { x, y } distribuidas por el mapa.

Detección por Línea de Visión

Antes de que un monstruo pueda perseguirnos, necesita vernos. La línea de visión usa el mismo paso DDA que el bucle de raycasting — avanza desde el monstruo hacia el jugador y se detiene en cuanto llega o choca con una pared:

function hasLineOfSight(fromX, fromY, toX, toY) {
  const rdx = dx / dist;
  const rdy = dy / dist;

  // Paso DDA hacia el objetivo
  while (true) {
    if (sdx < sdy) { sdx += ddx; mx += stepX; }
    else { sdy += ddy; my += stepY; }
    if (mx === tx && my === ty) return true;  // llegamos a la celda objetivo
    if (Map[my]?.[mx] === 1) return false;    // chocamos con una pared
  }
}

Si la línea de visión está despejada y el monstruo no nos había visto antes, reproduce un sonido de detección y activa chasing:

if (los) {
  if (!m.seenPlayer) {
    m.seenPlayer = true;
    sfx('clunk');
  }
  m.chasing = true;
}

El sonido en sí es un golpe sintetizado corto registrado en sounds:

const sounds = { clunk: [5, [8, 0.8, 1], [5, 0.5, 1], [0, 0, 0]] };

Pathfinding BFS

Cuando la línea de visión se rompe — doblamos una esquina — el monstruo recurre al BFS:

function bfs(startX, startY, goalX, goalY) {
  const visited = new Set();
  const queue = [{ x: sx, y: sy, path: [] }];

  while (queue.length > 0) {
    const { x, y, path } = queue.shift();
    for (const [dx, dy] of [[0,-1],[1,0],[0,1],[-1,0]]) {
      const nx = x + dx;
      const ny = y + dy;
      if (visited.has(key) || Map[ny]?.[nx] !== 0) continue;
      const newPath = [...path, { x: nx + 0.5, y: ny + 0.5 }];
      if (nx === gx && ny === gy) return newPath;
      queue.push({ x: nx, y: ny, path: newPath });
    }
  }
  return [];
}

No ejecutamos BFS en cada frame — sería costoso. Cada 30 frames es suficiente para que se sienta reactivo:

if (m.path.length === 0 || m.pathAge >= 30) {
  m.path = bfs(m.x, m.y, px, py);
  m.pathAge = 0;
}

El monstruo va sacando waypoints del frente del camino a medida que se acerca a cada uno.

Colisión y Daño

Cuando un monstruo se acerca a menos de 0.4 unidades con línea de visión despejada, hace daño y se elimina:

if (dist < 0.4 && los && m.hitCooldown <= 0) {
  takeDamage(m.x, m.y);
  m.dead = true;
  continue;
}

takeDamage también nos empuja ligeramente lejos del monstruo. Combinado con el temblor de pantalla, el golpe se siente como un impacto real.

Renderizar Monstruos en 3D

Cada monstruo se renderiza como un billboard — un sprite plano de 8×8 que siempre mira a la cámara. Lo proyectamos usando el mismo plano de cámara que el bucle de raycasting:

const transformX = invDet * (dirY * relX - dirX * relY);
const transformY = invDet * (-planeY * relX + planeX * relY);
if (transformY <= 0) continue; // detrás de la cámara

const screenX = Math.floor((viewW / 2) * (1 + transformX / transformY));
const sprH = Math.floor(viewH / transformY);

Para cada columna del sprite, comparamos transformY contra zBuffer[stripe] — la profundidad de pared que guardamos en el pase principal. Si una pared está más cerca, saltamos esa columna. Los monstruos desaparecen correctamente detrás de las esquinas:

if (transformY >= zBuffer[stripe]) continue;

-1 en el array del sprite significa transparente — saltamos esos píxeles. La niebla y la viñeta se aplican a los píxeles de los monstruos igual que a las paredes.

Atmospheric Horror FPS: Monsters & Dread
Los monstruos están mirando. Te cazarán si te ven.

Atmósfera

Los mecánismos funcionan. Ahora hagamos que se sientan como algo. La antorcha parpadea, el movimiento tiene peso físico, y el audio reacciona a lo que hay cerca. Nada de esto cambia las reglas — pero todo cambia cómo se siente el juego.

Parpadeo de la Antorcha

La intensidad de la antorcha solía ser una constante. Ahora varía ligeramente cada frame:

const torchStrength = Constants.TorchStrength + (rnd(2) - 1) * Constants.TorchFlicker;

rnd(2) nos da un float en [0, 2), así que (rnd(2) - 1) está en [-1, 1). Multiplicado por Constants.TorchFlicker (0.08), eso es ±8% por frame. Pasamos torchStrength al cálculo de atenuación en lugar de la constante fija — cada franja de pared y sprite de monstruo respira con él.

Se recalcula de nuevo cada frame, así que no hay ningún patrón que detectar. Eso es lo que hace que se sienta como una llama real en lugar de una animación.

Balanceo de Vista

Caminar debería sentirse como caminar. Rastreamos un bobTimer que se incrementa mientras el jugador se mueve y genera un desplazamiento sinusoidal en la cámara:

if (isMoving) bobTimer += 0.12;
const bobOffset = isMoving ? Math.sin(bobTimer) * 1.5 : 0;

Combinamos el balanceo con el temblor de pantalla en una única llamada a camera():

camera(shakeX, shakeY + bobOffset);

Cuando dejamos de movernos, bobOffset vuelve a cero de golpe. Un juego pulido suavizaría esa transición — pero a esta velocidad de movimiento, el corte brusco en realidad funciona bien.

Latido de Tensión

Cuando un monstruo se acerca a la mitad del radio de detección, reproducimos un sonido de latido cada 60 frames — aproximadamente una vez por segundo:

const tensionDist = Constants.DetectionRadius / 2;
const nearMonster = monsters.some(m => {
  const dx = m.x - px;
  const dy = m.y - py;
  return Math.sqrt(dx * dx + dy * dy) < tensionDist;
});

if (nearMonster && heartbeatTimer >= 60) {
  sfx('heartbeat');
  heartbeatTimer = 0;
}

Es un pulso sintetizado corto:

const sounds = {
  clunk: [5, [8, 0.8, 1], [5, 0.5, 1], [0, 0, 0]],
  heartbeat: [3, [3, 0.6, 2], [1, 0, 0], [0, 0, 0]],
};

El latido suena aunque el monstruo no tenga línea de visión. Podemos oír el peligro antes de verlo. Ese espacio entre el audio y lo visual es donde vive el terror.

Atmospheric Horror FPS: Atmosphere
La atmósfera está viva ahora. Escucha lo que hay cerca.

El Juego Completo

Todos los mecánismos están en su lugar. Ahora los conectamos con una máquina de estados — una pantalla de título, una condición de victoria, una condición de derrota, y un reinicio limpio.

La Máquina de Estados

El estado del juego es una cadena de texto — 'title', 'playing', 'win', o 'lose'. Tanto update() como draw() se ramifican según ella:

let gameState = 'title';

En update():

if (gameState === 'title') {
  if (btn('z')) {
    resetGame();
    gameState = 'playing';
  }
  return;
}

if (gameState === 'win' || gameState === 'lose') {
  if (btn('z')) gameState = 'title';
  return;
}

// ... toda la lógica de juego

En draw(), los estados que no son 'playing' reciben un fondo negro y un par de líneas de texto, y luego regresan anticipadamente — nada se mezcla:

if (gameState === 'title') {
  cls(0);
  caption('ATMOSPHERIC HORROR FPS', 9, 46, 8);
  caption('Press Z to start', 24, 62, 7);
  return;
}

El retorno anticipado mantiene el renderizado de cada estado aislado. El HUD de salud no se filtra a la pantalla de título.

Reiniciando el Juego

Todo el estado mutable vive en resetGame():

function resetGame() {
  px = Constants.SpawnX;
  py = Constants.SpawnY;
  pa = 0;
  health = [true, true, true];
  flashTimer = 0;
  shakeTimer = 0;
  bobTimer = 0;
  isMoving = false;
  heartbeatTimer = 0;
  monsters = EnemySpawns.map(s => ({
    x: s.x, y: s.y,
    chasing: false,
    seenPlayer: false,
    path: [],
    pathAge: 0,
    hitCooldown: 0,
    dead: false,
  }));
}

La llamamos una vez al inicio para que el juego esté listo de inmediato, y de nuevo cada vez que el jugador pulsa Z desde la pantalla de título. Posiciones de los monstruos, estado de persecución, salud — todo se reinicia.

Condiciones de Victoria y Derrota

Las comprobamos al final del update de juego:

if (isDead()) {
  gameState = 'lose';
  return;
}

const edx = px - ExitPos.x;
const edy = py - ExitPos.y;
if (Math.sqrt(edx * edx + edy * edy) < 0.6) {
  gameState = 'win';
  return;
}

ExitPos es una coordenada en el espacio del mundo en el rincón más alejado del mapa. Acércate a menos de 0.6 unidades y ganas — sin pulsar botones, solo llegando allí.

La Puerta de Salida

La salida no está marcada en el minimapa ni etiquetada con texto — tienes que encontrarla. Pero deja una pista: las paredes adyacentes a la posición de salida brillan en verde (color 11) en lugar de la piedra estándar:

const isExitWall = (mapX === exitWallX && mapY === exitWallY - 1)
  || (mapX === exitWallX - 1 && mapY === exitWallY);

let color = isExitWall ? 11 : sprites.wall[ty * 16 + tx];

La niebla sigue aplicándose, así que no verás el brillo hasta que estés cerca. Eso es intencionado. La recompensa por explorar en lugar de entrar en pánico es ese destello de verde en la oscuridad.

Atmospheric Horror FPS: The Complete Game
Usa las teclas de flecha para moverte. Encuentra la salida antes de que te encuentren.