Lethal ape: redux updated but cool

This is a 2D top-down horror facility escape game where you navigate a character through monster-infested lab rooms, avoiding enemies and collecting items. You control movement with a joystick or keyboard, interact with machines and lockers, and experience tension-building jumpscares when caught by the procedurally-animated creatures called Lethal Apes.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make the player walk faster — Increase the player's movement speed so they can escape monsters more easily - try changing PLAYER_SPEED to 5 to see the difference
  2. Make monsters WAY more aggressive — Triple the monster's base speed to see them zoom around the rooms aggressively
  3. Make monsters spot you from farther away — Increase the monster's detection range from 200 pixels to 400 so they hunt you from across the room
  4. Change the player to a different color — Edit the player's body color to blue instead of red - changes the visual style of your character
  5. Make the player HUGE — Double the player's width and height so they take up much more space on screen
  6. Make the camera follow more smoothly — Change the lerp smoothing factor from 0.1 to 0.05 so the camera lags even more behind the player
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch creates an immersive 2D top-down horror experience set in a facility where you guide a character through networked lab rooms while intelligent monsters hunt you. The visual style uses procedural shapes for all characters, objects, and UI, avoiding external image assets. The code powers this experience through several advanced p5.js techniques: the draw loop manages six different game states (loading, startScreen, game, jumpscare, modMenu, and UI menus), collision detection prevents monsters from entering safe zones and triggers jumpscares, camera translation creates a smooth viewport following the player, and procedural p5.sound oscillators and envelopes generate all audio effects from drones to screams to footsteps.

The sketch is organized into logical sections: game state variables, setup and draw loops, player physics and movement, monster AI with room-aware pathfinding, collectible item systems, interactive UI elements (joystick, buttons, menus), camera control with device orientation support for mobile VR, procedural sound generation, and utility functions for collision detection. By reading this code, you will learn how to structure a multi-state game, implement touch controls on mobile with multi-touch tracking, create intelligent enemy AI that pathfinds between rooms, manage dynamic camera systems, generate sound procedurally, and build interactive menus that pause gameplay.

⚙️ How It Works

  1. When the sketch loads, setup() initializes a full-screen canvas, defines five interconnected floor rooms plus a safe main room and monster display room, spawns up to 5 monsters in the floor rooms, and places a player character in the main room. It also sets up procedural oscillators and noise generators for all sound effects, ready to play on demand.
  2. On every frame, draw() checks the current gameState: if loading, it shows a title screen with procedural sound; if startScreen, it waits for a touch; if game, it updates player position from keyboard/joystick input, updates all monsters with AI behavior, renders the game world with a camera translation offset, and displays UI on top.
  3. Player movement is controlled by a virtual joystick on mobile (left side) or WASD/arrow keys on desktop. Movement values (vx, vy between -1 and 1) are multiplied by either PLAYER_SPEED or PLAYER_RUN_SPEED depending on whether the run button is pressed, updating the player's x and y position each frame.
  4. Monsters use state machines: in 'roaming' state they move randomly within their room; when the player enters the same room and comes within MONSTER_CHASE_RANGE (200 pixels), they switch to 'chasing' and pursue directly, or if the player is on a different floor, they intelligently navigate toward staircases that lead toward the player's floor.
  5. When the player collides with a collectible (key or scrap), it's removed from the world and added to inventory or scrap count, playing a procedural chime sound. When a monster collides with the player, triggerJumpscare() is called, which switches gameState to 'jumpscare', plays a screech oscillator sweep, and displays an animated monster head that grows on screen before fading to black and resetting the game.
  6. The camera system (updateCamera) calculates a target position centered on the player, then optionally adds offsets from device tilt (gamma and beta) if VR mode is enabled, smoothly interpolates to that target using lerp, and is clamped to the game world bounds so the viewport never shows empty space.

🎓 Concepts You'll Learn

Game State ManagementTop-down Camera TranslationMulti-touch Input HandlingAABB Collision DetectionProcedural Sound SynthesisProcedural AnimationRoom-based AI PathfindingUI Button InteractionMobile Device Orientation (VR)Inventory and Resource Management

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. It initializes the entire game world: canvas, rooms, the player, all monsters, all collectibles, all UI buttons, and audio oscillators. Everything you see and interact with is defined here. Understanding setup() is key to understanding the game's structure.

function setup() {
  createCanvas(windowWidth, windowHeight);
  noStroke();
  textAlign(CENTER, CENTER);

  // Define rooms (x, y, width, height) relative to the entire game world
  rooms = {
    mainRoom: { x: 0, y: 0, w: 600, h: 400, color: '#333333', floor: -1,
      scrapMachine: { x: 500, y: 150, w: 80, h: 100 },
      lockers: [
        { x: 100, y: 300, w: 40, h: 60 },
        { x: 150, y: 300, w: 40, h: 60 },
        { x: 200, y: 300, w: 40, h: 60 }
      ]
    },
    monsterRoom: { x: 600, y: 0, w: 400, h: 400, color: '#222222', floor: -1 },
    floor0: { x: 0, y: 400, w: 800, h: 600, color: '#554444', floor: 0 },
    floor1: { x: 800, y: 400, w: 800, h: 600, color: '#445544', floor: 1 },
    floor2: { x: 1600, y: 400, w: 800, h: 600, color: '#665555', floor: 2 },
    floor3: { x: 2400, y: 400, w: 800, h: 600, color: '#556655', floor: 3 },
    floor4: { x: 3200, y: 400, w: 800, h: 600, color: '#776666', floor: 4 }
  };

  player = {
    x: rooms.mainRoom.x + 100,
    y: rooms.mainRoom.y + 100,
    w: 30,
    h: 30,
    vx: 0,
    vy: 0,
    isMoving: false,
    isRunning: false
  };

  boombox.x = rooms.mainRoom.x + rooms.mainRoom.w / 2;
  boombox.y = rooms.mainRoom.y + rooms.mainRoom.h / 2;
  boombox.w = 80;
  boombox.h = 60;

  itemsForSale = [
    { name: "Speed Boost", cost: 10, type: "speedBoost", effect: () => { player.isRunning = true; }, description: "Run faster for a limited time." },
    { name: "Health Pack", cost: 5, type: "healthPack", description: "Restores health (not implemented yet)." }
  ];

  staircases = [
    { x: rooms.mainRoom.x + 200, y: rooms.mainRoom.y + rooms.mainRoom.h - 50, w: 200, h: 50, targetRoom: "floor0", targetPlayerX: rooms.floor0.x + 300, targetPlayerY: rooms.floor0.y + 50 },
    { x: rooms.floor0.x + 300, y: rooms.floor0.y, w: 200, h: 50, targetRoom: "mainRoom", targetPlayerX: rooms.mainRoom.x + 300, targetPlayerY: rooms.mainRoom.y + rooms.mainRoom.h - player.h },
    { x: rooms.mainRoom.x + rooms.mainRoom.w - 50, y: rooms.mainRoom.y + 150, w: 50, h: 100, targetRoom: "monsterRoom", targetPlayerX: rooms.monsterRoom.x + 50, targetPlayerY: rooms.monsterRoom.y + 200 },
    { x: rooms.monsterRoom.x, y: rooms.monsterRoom.y + 150, w: 50, h: 100, targetRoom: "mainRoom", targetPlayerX: rooms.mainRoom.x + rooms.mainRoom.w - 50, targetPlayerY: rooms.mainRoom.y + 200 },
    { x: rooms.floor0.x + rooms.floor0.w - 50, y: rooms.floor0.y + 200, w: 50, h: 200, targetRoom: "floor1", targetPlayerX: rooms.floor1.x + 50, targetPlayerY: rooms.floor1.y + 300 },
    { x: rooms.floor1.x, y: rooms.floor1.y + 200, w: 50, h: 200, targetRoom: "floor0", targetPlayerX: rooms.floor0.x + rooms.floor0.w - 50, targetPlayerY: rooms.floor0.y + 300 },
    { x: rooms.floor1.x + rooms.floor1.w - 50, y: rooms.floor1.y + 200, w: 50, h: 200, targetRoom: "floor2", targetPlayerX: rooms.floor2.x + 50, targetPlayerY: rooms.floor2.y + 300 },
    { x: rooms.floor2.x, y: rooms.floor2.y + 200, w: 50, h: 200, targetRoom: "floor1", targetPlayerX: rooms.floor1.x + rooms.floor1.w - 50, targetPlayerY: rooms.floor1.y + 300 },
    { x: rooms.floor2.x + rooms.floor2.w - 50, y: rooms.floor2.y + 200, w: 50, h: 200, targetRoom: "floor3", targetPlayerX: rooms.floor3.x + 50, targetPlayerY: rooms.floor3.y + 300 },
    { x: rooms.floor3.x, y: rooms.floor3.y + 200, w: 50, h: 200, targetRoom: "floor2", targetPlayerX: rooms.floor2.x + rooms.floor2.w - 50, targetPlayerY: rooms.floor2.y + 300 },
    { x: rooms.floor3.x + rooms.floor3.w - 50, y: rooms.floor3.y + 200, w: 50, h: 200, targetRoom: "floor4", targetPlayerX: rooms.floor4.x + 50, targetPlayerY: rooms.floor4.y + 300 },
    { x: rooms.floor4.x, y: rooms.floor4.y + 200, w: 50, h: 200, targetRoom: "floor3", targetPlayerX: rooms.floor3.x + rooms.floor3.w - 50, targetPlayerY: rooms.floor0.y + 300 },
  ];

  joystick = {
    baseX: width * 0.2,
    baseY: height * 0.8,
    radius: 60,
    knobX: width * 0.2,
    knobY: height * 0.8,
    active: false,
    touchId: -1,
    maxX: width * 0.4
  };

  runButton = {
    x: width * 0.8,
    y: height * 0.8,
    w: 80,
    h: 80,
    active: false,
    touchId: -1
  };

  modButton = {
    x: width * 0.9,
    y: height * 0.1,
    w: 80,
    h: 40,
    active: false,
    touchId: -1
  };

  scrapMachineButton = {
    x: width / 2,
    y: height - 50,
    w: 200,
    h: 60,
    active: false,
    touchId: -1,
    visible: false
  };

  lockerButton = {
    x: width / 2,
    y: height - 50,
    w: 200,
    h: 60,
    active: false,
    touchId: -1,
    visible: false
  };

  for (let i = 0; i < MAX_MONSTERS; i++) {
    spawnMonster();
  }

  spawnCollectibles();

  if (typeof DeviceOrientationEvent !== 'undefined' && typeof DeviceOrientationEvent.requestPermission === 'function') {
    // Permission will be requested in touchStarted()
  } else {
    window.addEventListener('deviceorientation', handleDeviceOrientation);
    deviceOrientationGranted = true;
  }

  gameState = "loading";
  loadingTimer = 0;
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

assignment Room Map Layout rooms = { mainRoom: {...}, floor0: {...}, floor1: {...}, ... };

Defines the 2D positions and sizes of all five facility floors, the safe main room, and the monster gallery room - these are the game world boundaries

assignment Player Object Creation player = { x: rooms.mainRoom.x + 100, y: rooms.mainRoom.y + 100, w: 30, h: 30, vx: 0, vy: 0, isMoving: false, isRunning: false };

Creates the player object with position, size, velocity, and state flags that will be updated every frame

assignment Staircase Connections staircases = [ { x: rooms.mainRoom.x + 200, y: ..., targetRoom: "floor0", ... }, ... ];

Defines rectangular zones that teleport the player between rooms when touched - each staircase knows its target room and player spawn position

assignment UI Button Initialization joystick = {...}; runButton = {...}; modButton = {...};

Creates button objects that track position, size, and which touch ID is controlling them for multi-touch support

createCanvas(windowWidth, windowHeight);
Creates a full-screen canvas that fills the entire browser window, making the game responsive to screen size
noStroke(); textAlign(CENTER, CENTER);
Disables outlines on shapes and centers all text - keeps the visual style clean and consistent
rooms = { mainRoom: { x: 0, y: 0, w: 600, h: 400, ... }, floor0: { x: 0, y: 400, ... } };
Maps out the entire game world by defining bounding boxes for each room - mainRoom starts at (0,0), floors are laid out horizontally below it at y=400
player = { x: rooms.mainRoom.x + 100, y: rooms.mainRoom.y + 100, w: 30, h: 30, vx: 0, vy: 0, isMoving: false, isRunning: false };
Creates the player character with initial position (100 pixels into mainRoom), fixed 30×30 size, zero velocity, and state tracking for movement and running
staircases = [ { x: rooms.mainRoom.x + 200, y: rooms.mainRoom.y + rooms.mainRoom.h - 50, w: 200, h: 50, targetRoom: "floor0", targetPlayerX: rooms.floor0.x + 300, targetPlayerY: rooms.floor0.y + 50 }, ... ];
Creates a list of invisible zones that trigger room transitions - each staircase has an entrance position and exit position in the target room
joystick = { baseX: width * 0.2, baseY: height * 0.8, radius: 60, knobX: width * 0.2, knobY: height * 0.8, active: false, touchId: -1, maxX: width * 0.4 };
Creates the virtual joystick positioned at the bottom-left 20% of screen, with a 60-pixel radius - tracking which touch controls it enables multi-touch on mobile
for (let i = 0; i < MAX_MONSTERS; i++) { spawnMonster(); }
Loops MAX_MONSTERS times (5) calling spawnMonster to populate the facility with enemies scattered across the floor rooms
spawnCollectibles();
Populates the floors with keys (one per floor) and scrap pieces (5 per floor) that the player can collect
gameState = "loading"; loadingTimer = 0;
Sets the game to display a loading screen for 3 seconds before transitioning to the start screen

draw()

The draw() function is the main game loop that runs 60 times per second. It's structured as a state machine using switch: each gameState has its own rendering path. The clever camera system uses push/pop with translate to separate world rendering (that moves) from UI rendering (that stays fixed). This is a core pattern in game development called the 'render pipeline'.

🔬 This condition hides the boombox in other rooms. What happens if you remove the condition so the boombox is always drawn everywhere, even on the floors?

      if (currentRoom === "mainRoom") {
        drawBoombox(boombox.x, boombox.y, boombox.w, boombox.h);
      }
function draw() {
  background(0); // Dark background for horror theme

  switch (gameState) {
    case "loading":
      drawLoadingScreen();
      break;
    case "startScreen":
      drawStartScreen();
      break;
    case "game":
      // --- Update Game State ---
      updatePlayer();
      updateMonsters();
      updateCamera();

      // --- Render Game World (with camera translation) ---
      push();
      translate(-cameraX, -cameraY); // Apply camera offset for top-down

      // Draw rooms (procedural colors)
      drawRooms();

      // Draw staircases (procedural)
      drawStaircases();

      // Draw Boombox (only if in mainRoom)
      if (currentRoom === "mainRoom") {
        drawBoombox(boombox.x, boombox.y, boombox.w, boombox.h);
      }

      // Draw Scrap Machine (only if in mainRoom)
      if (currentRoom === "mainRoom") {
        drawScrapMachine(rooms.mainRoom.scrapMachine.x, rooms.mainRoom.scrapMachine.y, rooms.mainRoom.scrapMachine.w, rooms.mainRoom.scrapMachine.h);
      }

      // Draw Lockers (only if in mainRoom)
      if (currentRoom === "mainRoom") {
        for (let locker of rooms.mainRoom.lockers) {
          drawLocker(locker.x, locker.y, locker.w, locker.h);
        }
      }

      // Draw player (procedural)
      drawPlayer();

      // Draw monsters (procedural)
      drawMonsters();

      // Draw collectibles (keys and scrap)
      drawCollectibles();

      pop(); // End camera translation

      // --- Render UI (always on top of camera) ---
      drawJoystick();
      drawMonsterTracker();
      drawInventory(); // Draw player's inventory
      drawVRToggleButton(); // Draw the VR toggle button
      drawRunButton(); // New: Draw the run button
      drawModMenuButton(); // New: Draw the Mod Menu button
      drawScrapMachineButton(); // New: Draw interaction button for scrap machine
      drawLockerButton(); // New: Draw interaction button for lockers
      break;
    case "jumpscare":
      drawJumpscare();
      break;
    case "modMenu":
      drawModMenu();
      break;
    case "scrapMachine":
      drawScrapMachineUI();
      break;
    case "lockerMenu":
      drawLockerMenuUI();
      break;
    case "monsterRoomView":
      drawMonsterRoomView();
      break;
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

switch-case Game State Dispatcher switch (gameState) { case "loading": ... case "game": ... case "jumpscare": ... }

Routes all rendering and logic to the correct function based on the current game state - loads show title screen, game renders world, jumpscare displays dying sequence

conditional Game Loop with Camera case "game": updatePlayer(); updateMonsters(); updateCamera(); push(); translate(-cameraX, -cameraY);

Updates player and monster positions, recalculates camera, then applies camera translation so the viewport follows the player

conditional Room-specific Rendering if (currentRoom === "mainRoom") { drawBoombox(...); drawScrapMachine(...); }

Only draws boombox, scrap machine, and lockers when the player is in mainRoom, avoiding clutter in other rooms

sequential UI Rendering Layer drawJoystick(); drawMonsterTracker(); drawInventory(); drawVRToggleButton(); drawRunButton();

After pop() ends camera translation, renders UI elements in screen space (not world space) so they stay visible and clickable

background(0);
Fills the entire canvas with black every frame - erases the previous frame and sets the dark, horror-themed tone
switch (gameState) { case "loading": drawLoadingScreen(); break; ... }
Routes logic based on gameState variable - if loading, draws title; if game, updates and renders the world; if jumpscare, plays death animation
updatePlayer(); updateMonsters(); updateCamera();
Every game frame, recalculates player position from input, updates all monsters' AI and positions, and recalculates camera target
push(); translate(-cameraX, -cameraY);
Saves the current transformation state, then applies a negative translation - shifts everything by the camera offset so the player stays centered
drawRooms(); drawStaircases(); drawBoombox(...); drawScrapMachine(...); drawPlayer(); drawMonsters(); drawCollectibles();
Draws all world elements in order: room backgrounds, staircases, interactive objects, player, enemies, and items - all are translated by camera offset
if (currentRoom === "mainRoom") { drawBoombox(boombox.x, boombox.y, boombox.w, boombox.h); }
Only renders the boombox when player is in mainRoom - other rooms don't have it so don't draw it
pop();
Restores the transformation state, undoing the camera translation - all subsequent draws are in screen space, not world space
drawJoystick(); drawMonsterTracker(); drawInventory(); drawVRToggleButton(); drawRunButton(); drawModMenuButton();
Renders all UI elements in screen space at fixed positions - they stay visible regardless of camera movement because they're drawn after pop()

updatePlayer()

updatePlayer() is called every frame and handles all player logic: movement, collision detection with items and staircases, room transitions, footstep sounds, and interaction button visibility. The backwards loop pattern for collectible collection is important - when you remove an item with splice(), looping forward would skip the next item. Understanding collision detection and state updates here teaches the foundations of game physics.

function updatePlayer() {
  let currentSpeed = player.isRunning ? PLAYER_RUN_SPEED : PLAYER_SPEED;

  // Apply movement from joystick or keyboard
  player.x += player.vx * currentSpeed;
  player.y += player.vy * currentSpeed;

  // Check if player is moving
  player.isMoving = (abs(player.vx) > 0.1 || abs(player.vy) > 0.1);

  // --- Footstep Sounds ---
  if (player.isMoving) {
    footstepCounter++;
    if (footstepCounter >= FOOTSTEP_INTERVAL) {
      footstepOsc.freq(random(100, 150)); // Vary pitch slightly
      footstepEnvelope.play(footstepOsc);
      footstepCounter = 0;
    }
  } else {
    footstepCounter = 0; // Reset counter if not moving
  }

  // --- Collectible Items Collision ---
  for (let i = collectibles.length - 1; i >= 0; i--) {
    let item = collectibles[i];
    if (item.room === currentRoom && rectCollision(player.x, player.y, player.w, player.h, item.x, item.y, item.w, item.h)) {
      if (item.type === "key") {
        playerInventory.push(item);
        collectOsc.freq(random(600, 800)); // Play collect sound for key
        collectEnvelope.play(collectOsc);
        console.log(`Grabbed a ${item.type}! Inventory:`, playerInventory);
      } else if (item.type === "scrap") {
        playerScrap += 1; // Increment scrap count
        scrapCollectOsc.freq(random(200, 300)); // Play collect sound for scrap
        scrapCollectEnv.play(scrapCollectOsc);
        console.log(`Grabbed some scrap! Scrap: ${playerScrap}`);
      }
      collectibles.splice(i, 1); // Remove item from game world
    }
  }

  // --- Handle Room Transitions (Staircases) ---
  if (canTransition) {
    let hasTransitioned = false;
    for (let staircase of staircases) {
      if (rectCollision(player.x, player.y, player.w, player.h, staircase.x, staircase.y, staircase.w, staircase.h)) {
        // Transition to new room if the staircase leads to a different room
        if (currentRoom !== staircase.targetRoom) {
          currentRoom = staircase.targetRoom;
          player.x = staircase.targetPlayerX;
          player.y = staircase.targetPlayerY;
          player.vx = 0;
          player.vy = 0;
          player.isRunning = false; // Stop running after transition
          hasTransitioned = true;
          canTransition = false; // Disable transitions
          setTimeout(() => { canTransition = true; }, TRANSITION_COOLDOWN); // Re-enable after cooldown
          console.log(`Transitioned to ${currentRoom}`);
          break; // Only transition once per frame
        }
      }
    }

    // If we transitioned rooms, skip further movement/clamping for this frame
    if (hasTransitioned) {
      // If entering monsterRoom, switch to its view state
      if (currentRoom === "monsterRoom") {
        gameState = "monsterRoomView";
      }
      return;
    }
  }

  // Clamp player position to current room boundaries
  let currentRoomData = rooms[currentRoom];
  player.x = constrain(player.x, currentRoomData.x, currentRoomData.x + currentRoomData.w - player.w);
  player.y = constrain(player.y, currentRoomData.y, currentRoomData.y + currentRoomData.h - player.h);

  // Stop movement if joystick is inactive (desktop keyboard will handle its own release)
  if (!joystick.active && touches.length === 0) {
    player.vx = 0;
    player.vy = 0;
  }

  // --- Boombox Echo Music Control ---
  if (currentRoom === "mainRoom") {
    let d = dist(player.x, player.y, boombox.x, boombox.y);
    let targetAmp = 0;
    let targetDelayAmp = 0;

    if (d < boombox.nearbyThreshold) {
      // Player is nearby or entering fade zone, fade in
      targetAmp = map(d, boombox.nearbyThreshold - boombox.fadeZone, boombox.nearbyThreshold, 0.5, 0, true); // Increased max volume
      targetDelayAmp = map(d, boombox.nearbyThreshold - boombox.fadeZone, boombox.nearbyThreshold, 0.8, 0, true); // Increased max volume
    } else {
      // Player is far away, fade out
      targetAmp = 0;
      targetDelayAmp = 0;
    }

    boombox.osc.amp(targetAmp, 0.5); // Fade oscillator amp over 0.5 seconds
    boombox.delay.amp(targetDelayAmp, 0.5); // Fade delay amp over 0.5 seconds
  } else {
    // Player is not in main room, silence boombox
    boombox.osc.amp(0, 0.5);
    boombox.delay.amp(0, 0.5);
  }

  // --- Interaction Button Visibility ---
  scrapMachineButton.visible = false;
  lockerButton.visible = false;

  // Check for scrap machine interaction (only in mainRoom)
  if (currentRoom === "mainRoom") {
    let scrapMachineData = rooms.mainRoom.scrapMachine;
    if (rectCollision(player.x, player.y, player.w, player.h, scrapMachineData.x, scrapMachineData.y, scrapMachineData.w, scrapMachineData.h)) {
      scrapMachineButton.visible = true;
    }

    // Check for locker interaction (only in mainRoom)
    for (let locker of rooms.mainRoom.lockers) {
      if (rectCollision(player.x, player.y, player.w, player.h, locker.x, locker.y, locker.w, locker.h)) {
        lockerButton.visible = true;
        break; // Only show one locker button if near any locker
      }
    }
  }
}
Line-by-line explanation (14 lines)

🔧 Subcomponents:

conditional Speed Selection let currentSpeed = player.isRunning ? PLAYER_RUN_SPEED : PLAYER_SPEED;

Chooses between normal walk speed or faster run speed based on whether the run button is pressed

calculation Position Update player.x += player.vx * currentSpeed; player.y += player.vy * currentSpeed;

Moves the player by multiplying velocity (-1 to 1) by the current speed, allowing smooth movement in any direction

conditional Footstep Audio Loop if (player.isMoving) { footstepCounter++; if (footstepCounter >= FOOTSTEP_INTERVAL) { footstepOsc.freq(...); footstepEnvelope.play(...); footstepCounter = 0; } }

Plays a footstep sound every 25 frames while the player is moving, creating rhythmic walking audio

for-loop Item Collection Loop for (let i = collectibles.length - 1; i >= 0; i--) { if (rectCollision(...)) { ... collectibles.splice(i, 1); } }

Backwards loop checking each collectible for collision - if hit, increments inventory or scrap and removes the item

for-loop Room Transition Detection for (let staircase of staircases) { if (rectCollision(...)) { currentRoom = staircase.targetRoom; ... canTransition = false; } }

Tests if player overlaps any staircase zone - if so, teleports player to the target room's spawn position and disables further transitions for 500ms

calculation Room Boundary Clamping player.x = constrain(player.x, currentRoomData.x, currentRoomData.x + currentRoomData.w - player.w);

Forces the player to stay within the current room by clamping position to room bounds

conditional Boombox Distance-based Audio if (currentRoom === "mainRoom") { let d = dist(...); if (d < boombox.nearbyThreshold) { targetAmp = map(...); } }

Calculates distance from player to boombox, then fades the music in/out based on proximity - closer = louder

conditional Button Visibility Check if (rectCollision(player.x, player.y, player.w, player.h, scrapMachineData.x, ...)) { scrapMachineButton.visible = true; }

Shows the scrap machine interact button only when player is touching the machine's bounding box

let currentSpeed = player.isRunning ? PLAYER_RUN_SPEED : PLAYER_SPEED;
Picks the appropriate speed: if player.isRunning is true, use 5 pixels/frame; otherwise use 3 pixels/frame
player.x += player.vx * currentSpeed;
Updates x position by adding velocity (which is -1, 0, or 1 from the joystick) multiplied by speed - moves the player horizontally
player.isMoving = (abs(player.vx) > 0.1 || abs(player.vy) > 0.1);
Sets isMoving to true if either velocity component is greater than 0.1, triggering footstep sounds only during actual movement
if (footstepCounter >= FOOTSTEP_INTERVAL) { footstepOsc.freq(random(100, 150)); footstepEnvelope.play(footstepOsc); footstepCounter = 0; }
Every 25 frames when moving, plays a footstep sound with a slightly randomized pitch between 100-150 Hz, then resets the counter
for (let i = collectibles.length - 1; i >= 0; i--) {
Loops backwards through the collectibles array - backwards because we're removing items, which prevents skipping items
if (item.room === currentRoom && rectCollision(player.x, player.y, player.w, player.h, item.x, item.y, item.w, item.h)) {
First checks if the item is in the same room as the player, then checks if their rectangles overlap - both must be true
playerInventory.push(item); collectOsc.freq(random(600, 800)); collectEnvelope.play(collectOsc);
Adds the key to inventory and plays a chime sound with a pitch between 600-800 Hz
collectibles.splice(i, 1);
Removes the collected item from the array by splicing it out at position i
if (rectCollision(player.x, player.y, player.w, player.h, staircase.x, staircase.y, staircase.w, staircase.h)) {
Tests if the player's box overlaps a staircase zone - if true, a room transition occurs
currentRoom = staircase.targetRoom; player.x = staircase.targetPlayerX; player.y = staircase.targetPlayerY;
Moves the player to a new room by changing currentRoom and teleporting the player to the target room's spawn coordinates
canTransition = false; setTimeout(() => { canTransition = true; }, TRANSITION_COOLDOWN);
Prevents the player from immediately re-triggering the staircase - re-enables transitions after 500ms so the player can't get stuck bouncing between rooms
let d = dist(player.x, player.y, boombox.x, boombox.y);
Calculates the Euclidean distance in pixels from the player to the boombox using the dist() function
if (d < boombox.nearbyThreshold) { targetAmp = map(d, boombox.nearbyThreshold - boombox.fadeZone, boombox.nearbyThreshold, 0.5, 0, true); }
If player is close enough, maps the distance to an amplitude: farthest in fade zone = 0.5, closest = 0, smoothly in between
if (rectCollision(player.x, player.y, player.w, player.h, scrapMachineData.x, scrapMachineData.y, scrapMachineData.w, scrapMachineData.h)) { scrapMachineButton.visible = true; }
Shows the scrap machine button only when the player is inside the machine's collision box

updateMonsters()

updateMonsters() is a simple wrapper that updates all monsters in the game. The real logic is in the Monster class's update() method. This separation keeps the code organized - draw() calls updateMonsters(), which then delegates to each monster.

function updateMonsters() {
  for (let monster of monsters) {
    monster.update();
  }
}
Line-by-line explanation (2 lines)
for (let monster of monsters) {
Iterates through every monster in the monsters array using a for-of loop
monster.update();
Calls the update() method on each monster, which handles its movement, AI behavior, collision with player, and room transitions

Monster class

The Monster class is the most complex part of the sketch because it combines multiple AI behaviors: roaming (random movement), chase detection (distance-based), direct pursuit (same floor), and intelligent pathfinding (different floors). The state machine pattern (roaming vs. chasing) is a powerful technique used in all game AI - understanding this Monster class teaches you how to structure intelligent enemy behavior.

class Monster {
  constructor(x, y, roomName) {
    this.x = x;
    this.y = y;
    this.w = 40; // Fixed width for procedural monster
    this.h = 40; // Fixed height for procedural monster (top-down, square is fine)
    this.vx = random([-MONSTER_SPEED, MONSTER_SPEED]); // Start roaming
    this.vy = random([-MONSTER_SPEED, MONSTER_SPEED]); // Start roaming in Y
    this.room = roomName;
    this.state = "roaming"; // "roaming" or "chasing"
    this.chaseTimer = 0;
    this.chaseDuration = 120; // How long to chase after seeing player
    this.roamTimer = 0;
    this.roamDuration = random(60, 240); // How long to roam before changing direction
    this.monsterCanTransition = true; // New: Cooldown flag for monster staircase transitions
  }

  update() {
    let roomData = rooms[this.room];
    let speedModifier = monsterSpeedFast ? (MONSTER_CHASE_SPEED / MONSTER_SPEED) : 1; // 2.5/1.5 if fast, 1 otherwise
    let currentMonsterSpeed = MONSTER_SPEED * speedModifier;

    if (this.state === "chasing") {
      currentMonsterSpeed = MONSTER_CHASE_SPEED * speedModifier; // Apply modifier to chase speed too
    }

    // Apply movement
    this.x += this.vx * currentMonsterSpeed;
    this.y += this.vy * currentMonsterSpeed;

    // Wall collision (room boundaries - turn around)
    if (this.x < roomData.x || this.x + this.w > roomData.x + roomData.w) {
      this.vx *= -1;
      this.x = constrain(this.x, roomData.x, roomData.x + roomData.w - this.w);
    }
    if (this.y < roomData.y || this.y + this.h > roomData.y + roomData.h) {
      this.vy *= -1;
      this.y = constrain(this.y, roomData.y, roomData.y + roomData.h - this.h);
    }

    // Behavior: Roam or Chase
    // Player.currentRoom is the room the player is visually in.
    // Monster.room is the room the monster is logically in.
    if (this.room === currentRoom && currentRoom !== "mainRoom" && currentRoom !== "monsterRoomView") { // Monster tracker is active in Floor 0-4
      let d = dist(this.x, this.y, player.x, player.y);
      if (d < MONSTER_CHASE_RANGE) {
        this.state = "chasing";
        this.chaseTimer = this.chaseDuration; // Reset chase timer
      } else if (this.state === "chasing") {
        this.chaseTimer--;
        if (this.chaseTimer <= 0) {
          this.state = "roaming";
          // Roaming direction update logic
          let baseRoamSpeed = monsterSpeedFast ? MONSTER_CHASE_SPEED : MONSTER_SPEED;
          this.vx = random([-baseRoamSpeed, baseRoamSpeed]);
          this.vy = random([-baseRoamSpeed, baseRoamSpeed]);
          this.roamTimer = 0;
        }
      }
    } else {
      this.state = "roaming";
      // Roaming direction update logic
      let baseRoamSpeed = monsterSpeedFast ? MONSTER_CHASE_SPEED : MONSTER_SPEED;
      this.vx = random([-baseRoamSpeed, baseRoamSpeed]);
      this.vy = random([-baseRoamSpeed, baseRoamSpeed]);
      this.roamTimer = 0;
    }

    if (this.state === "roaming") {
      this.roamTimer++;
      if (this.roamTimer > this.roamDuration) {
        this.vx *= -1; // Change direction
        this.vy *= -1; // Change direction in Y
        this.roamTimer = 0;
        this.roamDuration = random(60, 240);
      }
    } else if (this.state === "chasing") {
      // New: Room-aware AI
      let playerFloor = rooms[currentRoom].floor;
      let monsterFloor = rooms[this.room].floor;

      if (playerFloor !== monsterFloor && monsterFloor !== -1 && this.monsterCanTransition) { // If player is on a different floor (and monster is not in main room or monsterRoom)
        let targetStaircase = null;
        let minStaircaseDist = Infinity;

        // Find the closest staircase that leads towards the player's floor
        for (let staircase of staircases) {
          if (rectCollision(this.x, this.y, this.w, this.h, staircase.x, staircase.y, staircase.w, staircase.h)) {
            // Already on a staircase, attempt transition
            let targetStaircaseFloor = rooms[staircase.targetRoom].floor;
            // Only transition if it moves towards the player's floor
            if ((playerFloor > monsterFloor && targetStaircaseFloor > monsterFloor) ||
                (playerFloor < monsterFloor && targetStaircaseFloor < monsterFloor) ||
                (playerFloor === targetStaircaseFloor)) { // Direct path
              this.room = staircase.targetRoom;
              this.x = staircase.targetPlayerX; // Monster teleports to player-like position
              this.y = staircase.targetPlayerY;
              this.vx = 0;
              this.vy = 0;
              this.monsterCanTransition = false; // Disable monster transitions
              setTimeout(() => { this.monsterCanTransition = true; }, TRANSITION_COOLDOWN); // Re-enable after cooldown
              return; // Monster successfully transitioned, skip further movement for this frame
            }
          }

          if (staircase.targetRoom !== this.room && staircase.targetRoom !== "mainRoom" && staircase.targetRoom !== "monsterRoom") { // Consider staircases not in current room, and not leading to safe zones
            let targetStaircaseFloor = rooms[staircase.targetRoom].floor;
            let currentStaircaseFloor = rooms[this.room].floor;

            // Only consider staircases that lead to an adjacent floor or the player's floor
            if (abs(targetStaircaseFloor - playerFloor) < abs(currentStaircaseFloor - playerFloor) || targetStaircaseFloor === playerFloor) {
              let d = dist(this.x, this.y, staircase.x + staircase.w / 2, staircase.y + staircase.h / 2);
              if (d < minStaircaseDist) {
                minStaircaseDist = d;
                targetStaircase = staircase;
              }
            }
          }
        }

        if (targetStaircase) {
          // Move towards the center of the target staircase
          let targetX = targetStaircase.x + targetStaircase.w / 2;
          let targetY = targetStaircase.y + targetStaircase.h / 2;
          let dx = targetX - this.x;
          let dy = targetY - this.y;
          let angle = atan2(dy, dx);
          this.vx = cos(angle) * currentMonsterSpeed; // Use currentMonsterSpeed
          this.vy = sin(angle) * currentMonsterSpeed; // Use currentMonsterSpeed
        } else {
          // If no suitable staircase found, just roam (or chase within current room if player enters)
          let baseRoamSpeed = monsterSpeedFast ? MONSTER_CHASE_SPEED : MONSTER_SPEED;
          this.vx = random([-baseRoamSpeed, baseRoamSpeed]);
          this.vy = random([-baseRoamSpeed, baseRoamSpeed]);
        }
      } else {
        // Player is on the same floor or monster is in main room/monsterRoom, chase directly
        this.vx = (player.x > this.x ? 1 : -1) * currentMonsterSpeed; // Use currentMonsterSpeed
        this.vy = (player.y > this.y ? 1 : -1) * currentMonsterSpeed; // Use currentMonsterSpeed
      }
    }

    // Main Room (safe zone) interaction: Monsters cannot enter the main room or monsterRoom
    let safeRooms = ["mainRoom", "monsterRoom"];
    for (let safeRoomName of safeRooms) {
      if (this.room !== safeRoomName) {
        let safeRoom = rooms[safeRoomName];
        if (rectCollision(this.x, this.y, this.w, this.h, safeRoom.x, safeRoom.y, safeRoom.w, safeRoom.h)) {
          // If monster tries to enter safe room, push it back and turn it around
          // Determine which edge was crossed
          if (this.vx > 0 && this.x < safeRoom.x + safeRoom.w && this.x + this.w > safeRoom.x + safeRoom.w) { // Crossed from left into right
            this.x = safeRoom.x + safeRoom.w;
            this.vx *= -1;
          } else if (this.vx < 0 && this.x + this.w > safeRoom.x && this.x < safeRoom.x) { // Crossed from right into left
            this.x = safeRoom.x - this.w;
            this.vx *= -1;
          }
          if (this.vy > 0 && this.y < safeRoom.y + safeRoom.h && this.y + this.h > safeRoom.y + safeRoom.h) { // Crossed from top into bottom
            this.y = safeRoom.y + safeRoom.h;
            this.vy *= -1;
          } else if (this.vy < 0 && this.y + this.h > safeRoom.y && this.y < safeRoom.y) { // Crossed from bottom into top
            this.y = safeRoom.y - this.h;
            this.vy *= -1;
          }
          this.state = "roaming"; // Stop chasing when hitting safe zone
          this.roamTimer = 0;
        }
      }
    }

    // Player collision
    if (currentRoom === this.room && rectCollision(player.x, player.y, player.w, player.h, this.x, this.y, this.w, this.h)) {
      triggerJumpscare();
    }
  }

  draw() {
    // Draw procedural monster (a yellow rectangle with a simple "eye")
    // More terrifying procedural monster model
    push();
    translate(this.x + this.w / 2, this.y + this.h / 2); // Center monster for drawing

    let rotationAngle = atan2(this.vy, this.vx); // Make monster face direction of movement
    rotate(rotationAngle);

    // Body (dark green/brown, irregular shape)
    fill(20, 60, 20); // Dark green
    beginShape();
    vertex(-this.w * 0.7, -this.h * 0.3);
    vertex(this.w * 0.7, -this.h * 0.5);
    vertex(this.w * 0.5, this.h * 0.5);
    vertex(-this.w * 0.5, this.h * 0.7);
    endShape(CLOSE);

    // Head (more irregular, darker green)
    fill(10, 40, 10);
    ellipse(this.w * 0.6, -this.h * 0.4, this.w * 0.8, this.h * 0.6);

    // Eyes (glowing red)
    fill(255, 0, 0);
    ellipse(this.w * 0.7, -this.h * 0.5, this.w * 0.2, this.h * 0.2);
    ellipse(this.w * 0.5, -this.h * 0.3, this.w * 0.2, this.h * 0.2);

    // Mouth (jagged, black with red lining)
    fill(0);
    beginShape();
    vertex(this.w * 0.6, -this.h * 0.1);
    vertex(this.w * 0.8, this.h * 0.1);
    vertex(this.w * 0.5, this.h * 0.3);
    vertex(this.w * 0.3, this.h * 0.1);
    vertex(this.w * 0.6, -this.h * 0.1);
    endShape(CLOSE);
    stroke(255, 0, 0);
    strokeWeight(1);
    beginShape();
    vertex(this.w * 0.6, -this.h * 0.1);
    vertex(this.w * 0.8, this.h * 0.1);
    vertex(this.w * 0.5, this.h * 0.3);
    vertex(this.w * 0.3, this.h * 0.1);
    vertex(this.w * 0.6, -this.h * 0.1);
    endShape(CLOSE);
    noStroke();

    pop();
  }
}
Line-by-line explanation (23 lines)

🔧 Subcomponents:

constructor Monster Constructor constructor(x, y, roomName) { this.x = x; this.y = y; this.w = 40; this.h = 40; this.vx = random(...); this.state = "roaming"; ... }

Initializes a new monster with position, size, random velocity, and state variables for roaming and chasing behavior

conditional Speed Modifier Check let speedModifier = monsterSpeedFast ? (MONSTER_CHASE_SPEED / MONSTER_SPEED) : 1;

If the monsterSpeedFast toggle is on, multiplies monster speeds by ~1.67× (2.5/1.5) to make them faster

conditional Wall Bounce Logic if (this.x < roomData.x || this.x + this.w > roomData.x + roomData.w) { this.vx *= -1; ... }

If the monster hits a room edge, reverses its horizontal velocity so it bounces back into the room

conditional Chase State Trigger if (this.room === currentRoom && currentRoom !== "mainRoom" && currentRoom !== "monsterRoomView") { let d = dist(...); if (d < MONSTER_CHASE_RANGE) { this.state = "chasing"; } }

If the player is in the same room and within 200 pixels, switches the monster to chase state

conditional Floor-aware Pathfinding if (playerFloor !== monsterFloor && monsterFloor !== -1 && this.monsterCanTransition) { ... find staircase ... this.room = staircase.targetRoom; }

When the player is on a different floor, searches for the best staircase to move toward and teleports through it

for-loop Safe Zone Barrier for (let safeRoomName of safeRooms) { if (rectCollision(...)) { ... this.vx *= -1; this.state = "roaming"; } }

Prevents monsters from entering mainRoom or monsterRoom - if they try, bounces them back and stops chasing

conditional Jumpscare Trigger if (currentRoom === this.room && rectCollision(player.x, player.y, player.w, player.h, this.x, this.y, this.w, this.h)) { triggerJumpscare(); }

If the monster touches the player in the same room, immediately triggers the death sequence

constructor(x, y, roomName) {
Defines the constructor function that runs when a new Monster is created with specific position and room
this.x = x; this.y = y; this.w = 40; this.h = 40;
Sets the monster's position and size (40×40 pixels) - all monsters are the same size
this.vx = random([-MONSTER_SPEED, MONSTER_SPEED]);
Picks a random starting velocity of either -1.5 or +1.5 pixels/frame, making it roam immediately
this.state = "roaming";
Initializes the monster in roaming state (not chasing), so it wanders randomly until it sees the player
let speedModifier = monsterSpeedFast ? (MONSTER_CHASE_SPEED / MONSTER_SPEED) : 1;
If the monsterSpeedFast mod is enabled, calculates a speed multiplier of 1.67× (2.5÷1.5), otherwise 1× (normal)
this.x += this.vx * currentMonsterSpeed;
Moves the monster horizontally each frame by its velocity × speed - determines how quickly it roams or chases
if (this.x < roomData.x || this.x + this.w > roomData.x + roomData.w) { this.vx *= -1; }
If the monster's left edge goes past the left room edge or right edge goes past the right room edge, flips its horizontal velocity
if (this.room === currentRoom && currentRoom !== "mainRoom" && currentRoom !== "monsterRoomView") {
Only checks for the player if the monster is in the same room as the player, excluding safe rooms where monsters don't hunt
let d = dist(this.x, this.y, player.x, player.y);
Calculates the distance in pixels from the monster to the player
if (d < MONSTER_CHASE_RANGE) { this.state = "chasing"; this.chaseTimer = this.chaseDuration; }
If the player is closer than 200 pixels, switches to chasing state and resets the chase timer to 120 frames (2 seconds)
if (playerFloor !== monsterFloor && monsterFloor !== -1 && this.monsterCanTransition) {
Enters pathfinding logic if the player is on a different floor (not -1, which is main room), and the monster can transition (not in cooldown)
let targetStaircase = null; let minStaircaseDist = Infinity;
Initializes variables to track the best staircase: starting with no target and infinite distance (worst possible)
for (let staircase of staircases) {
Loops through all staircases in the game to find one that leads toward the player's floor
if (rectCollision(this.x, this.y, this.w, this.h, staircase.x, staircase.y, staircase.w, staircase.h)) {
Checks if the monster is already standing on a staircase zone
let targetStaircaseFloor = rooms[staircase.targetRoom].floor;
Gets the floor number of the room the staircase leads to (0-4, or -1 for main room)
if ((playerFloor > monsterFloor && targetStaircaseFloor > monsterFloor) || (playerFloor < monsterFloor && targetStaircaseFloor < monsterFloor) || (playerFloor === targetStaircaseFloor)) {
Checks three conditions: if the target floor is between monster and player, OR directly reaches the player's floor, the monster can use this staircase
this.room = staircase.targetRoom; this.x = staircase.targetPlayerX; this.y = staircase.targetPlayerY;
Teleports the monster through the staircase to the target room's spawn position
this.monsterCanTransition = false; setTimeout(() => { this.monsterCanTransition = true; }, TRANSITION_COOLDOWN);
Disables further transitions for 500ms to prevent the monster from constantly teleporting back and forth
let targetX = targetStaircase.x + targetStaircase.w / 2; let angle = atan2(dy, dx);
Calculates the center of the target staircase and determines the angle toward it using atan2
this.vx = cos(angle) * currentMonsterSpeed; this.vy = sin(angle) * currentMonsterSpeed;
Sets velocity to move toward the staircase at the appropriate angle - velocity = unit direction × speed
if (rectCollision(this.x, this.y, this.w, this.h, safeRoom.x, safeRoom.y, safeRoom.w, safeRoom.h)) {
Checks if the monster has collided with the safe zone boundary while chasing from another room
if (this.vx > 0 && this.x < safeRoom.x + safeRoom.w && this.x + this.w > safeRoom.x + safeRoom.w) { this.x = safeRoom.x + safeRoom.w; this.vx *= -1; }
If the monster was moving right into the safe zone, pushes it back to the edge and reverses its velocity
if (currentRoom === this.room && rectCollision(player.x, player.y, player.w, player.h, this.x, this.y, this.w, this.h)) { triggerJumpscare(); }
If the monster's box touches the player's box in the same room, immediately calls triggerJumpscare() - game over

triggerJumpscare()

triggerJumpscare() is the game-over function that gets called when a monster touches the player. It demonstrates p5.sound's Web Audio API integration: scheduling frequency and amplitude changes precisely on an oscillator using currentTime. The rapid state transition from 'game' to 'jumpscare' and the audio effects create the horror climax. This teaches how to orchestrate a complex audio-visual sequence in p5.js.

function triggerJumpscare() {
  if (jumpscare.isActive) return;

  jumpscare.isActive = true;
  jumpscare.timer = jumpscare.duration;
  jumpscare.animationFrame = 0;
  jumpscare.phase = "monster"; // Start with monster phase
  gameState = "jumpscare"; // Set game state to jumpscare

  // Silence ambient sounds and boombox music
  ambientSoundOsc.amp(0, 0.1); // Fade out drone quickly
  ambientSoundNoise.amp(0, 0.1); // Fade out hiss quickly
  boombox.osc.amp(0, 0.1); // Silence boombox
  boombox.delay.amp(0, 0.1); // Silence boombox delay

  // Play procedural jumpscare noise burst controlled by envelope
  jumpscareEnvelope.play(jumpscareNoise);

  let currentTime = getAudioContext().currentTime; // Get current Web Audio time

  // Fast frequency sweep oscillator (sawtooth) for a screech
  if (isFinite(2000) && isFinite(0.2) && isFinite(500) && isFinite(0.5) && isFinite(0.8) && isFinite(0.05)) {
      jumpscareOsc.amp(0.8, 0.05, currentTime); // Fade in screech quickly from currentTime
      jumpscareOsc.freq(2000, 0.2, currentTime); // Sweep screech to 2000 Hz from currentTime
      jumpscareOsc.freq(500, 0.5, currentTime + 0.2); // Sweep screech down to 500 Hz after 0.2 seconds
      jumpscareOsc.amp(0, 0.5, currentTime + 0.2); // Fade out screech after 0.2 seconds
      jumpscareOsc.amp(0, 0.1, currentTime + 0.2 + 0.5); // Ensure it's off after the fade-out
  } else {
      console.error("Jumpscare Oscillator parameters are not finite. Skipping screech effect.");
      jumpscareOsc.amp(0);
  }
}
Line-by-line explanation (13 lines)

🔧 Subcomponents:

conditional Guard Clause if (jumpscare.isActive) return;

Prevents triggering multiple jumpscares at once - if one is already active, this function exits immediately

assignment Jumpscare State Setup jumpscare.isActive = true; jumpscare.phase = "monster"; gameState = "jumpscare";

Activates the jumpscare system, sets it to start with the monster phase, and switches the game to jumpscare rendering mode

sequential Ambient Audio Cutoff ambientSoundOsc.amp(0, 0.1); ambientSoundNoise.amp(0, 0.1); boombox.osc.amp(0, 0.1);

Fades out all background music and ambient sounds quickly (0.1 seconds) to silence before the scream starts

sequential Procedural Screech Synthesis jumpscareOsc.amp(0.8, 0.05, currentTime); jumpscareOsc.freq(2000, 0.2, currentTime); jumpscareOsc.freq(500, 0.5, currentTime + 0.2);

Creates a terrifying sound effect by sweeping a sawtooth oscillator from 2000 Hz to 500 Hz over 0.7 seconds, fading in and out

if (jumpscare.isActive) return;
Checks if a jumpscare is already playing - if so, exits immediately to prevent stacking multiple jumpscares
jumpscare.isActive = true;
Marks the jumpscare as active so future collision calls are ignored
jumpscare.animationFrame = 0;
Resets the animation frame counter to 0 so the monster growth animation starts from the beginning
jumpscare.phase = "monster";
Sets the jumpscare phase to 'monster' so drawJumpscare() will render the monster-growth animation first
gameState = "jumpscare";
Switches the game state to 'jumpscare' so draw() will call drawJumpscare() instead of the normal game rendering
ambientSoundOsc.amp(0, 0.1);
Fades the ambient drone oscillator to silence over 0.1 seconds (100 milliseconds) - very fast fade-out
boombox.osc.amp(0, 0.1);
Fades the boombox music to silence over 0.1 seconds - cuts the music abruptly
jumpscareEnvelope.play(jumpscareNoise);
Plays the jumpscare envelope on the noise oscillator - this triggers a white noise burst controlled by the envelope's ADSR shape
let currentTime = getAudioContext().currentTime;
Gets the current Web Audio API time in seconds - used as the reference point for all scheduled oscillator changes
jumpscareOsc.amp(0.8, 0.05, currentTime);
Schedules the screech oscillator to fade in to amplitude 0.8 over 0.05 seconds starting now
jumpscareOsc.freq(2000, 0.2, currentTime);
Schedules the screech oscillator to sweep its frequency to 2000 Hz over 0.2 seconds - the high-pitched beginning of the scream
jumpscareOsc.freq(500, 0.5, currentTime + 0.2);
After 0.2 seconds have passed, schedules a frequency sweep down to 500 Hz over 0.5 seconds - the falling pitch
jumpscareOsc.amp(0, 0.5, currentTime + 0.2);
After 0.2 seconds, schedules the amplitude to fade to 0 over 0.5 seconds - ending the scream sound

updateCamera()

updateCamera() demonstrates a sophisticated camera system combining three techniques: centering on the player, mobile device orientation tracking for VR, and smoothing via lerp. The dead zone prevents jitter from sensor noise. The clamping prevents showing outside the game world. This is the foundation of third-person game cameras used in professional games.

function updateCamera() {
  // Calculate base camera position centered on player
  targetCameraX = player.x - width / 2;
  targetCameraY = player.y - height / 2; // New: Y camera target

  // Apply device orientation (gamma for X, beta for Y) to offset camera horizontally ONLY if VR mode is active
  if (vrMode && deviceOrientationGranted) {
    let gammaOffset = 0;
    if (abs(deviceGamma) > GAMMA_DEAD_ZONE) {
      gammaOffset = map(deviceGamma, -90, 90, -GAMMA_SENSITIVITY * width, GAMMA_SENSITIVITY * width);
    }
    targetCameraX += gammaOffset;

    let betaOffset = 0; // New: Beta offset
    if (abs(deviceBeta) > BETA_DEAD_ZONE) {
      betaOffset = map(deviceBeta, -180, 180, -BETA_SENSITIVITY * height, BETA_SENSITIVITY * height);
    }
    targetCameraY += betaOffset;
  }

  // Determine total game world width and height for clamping
  let minWorldX = rooms.mainRoom.x;
  let maxWorldX = rooms.floor4.x + rooms.floor4.w; // Max X is right edge of floor 4
  let minWorldY = rooms.mainRoom.y;
  let maxWorldY = rooms.floor4.y + rooms.floor4.h; // Max Y is bottom edge of floor 4

  // Clamp the target camera position to the total game world boundaries
  targetCameraX = constrain(targetCameraX, minWorldX, maxWorldX - width);
  targetCameraY = constrain(targetCameraY, minWorldY, maxWorldY - height);

  // Smoothly interpolate the camera to the target position
  smoothedCameraX = lerp(smoothedCameraX, targetCameraX, 0.1); // Adjust 0.1 for desired smoothing
  smoothedCameraY = lerp(smoothedCameraY, targetCameraY, 0.1); // New: Smooth Y camera
  cameraX = smoothedCameraX;
  cameraY = smoothedCameraY;
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

calculation Center Camera on Player targetCameraX = player.x - width / 2; targetCameraY = player.y - height / 2;

Calculates where the camera should point so the player is centered in the middle of the screen

conditional VR Device Orientation Offset if (vrMode && deviceOrientationGranted) { let gammaOffset = 0; if (abs(deviceGamma) > GAMMA_DEAD_ZONE) { gammaOffset = map(deviceGamma, -90, 90, -GAMMA_SENSITIVITY * width, GAMMA_SENSITIVITY * width); } targetCameraX += gammaOffset; }

If VR mode is on, adds device tilt (gamma) to the camera offset so tilting your phone left/right pans the view

calculation Clamp to World Bounds targetCameraX = constrain(targetCameraX, minWorldX, maxWorldX - width);

Prevents the camera from scrolling outside the game world - the viewport never shows empty space

calculation Smooth Camera Following smoothedCameraX = lerp(smoothedCameraX, targetCameraX, 0.1);

Interpolates the camera 10% toward the target position each frame, creating a smooth follow effect instead of snapping

targetCameraX = player.x - width / 2;
Centers the camera on the player by subtracting half the screen width from the player's x position
targetCameraY = player.y - height / 2;
Centers the camera on the player vertically by subtracting half the screen height from the player's y position
if (vrMode && deviceOrientationGranted) {
Only applies device orientation if VR mode is enabled AND permission has been granted
if (abs(deviceGamma) > GAMMA_DEAD_ZONE) {
Checks if device tilt exceeds the dead zone (3 degrees) to avoid camera jitter from slight vibrations
gammaOffset = map(deviceGamma, -90, 90, -GAMMA_SENSITIVITY * width, GAMMA_SENSITIVITY * width);
Maps device gamma (-90° to 90°) to camera offset range - tilt left maps to negative offset (pan left), tilt right maps to positive (pan right)
targetCameraX += gammaOffset;
Adds the tilt-based offset to the target camera position, allowing head movement to control the view
let minWorldX = rooms.mainRoom.x; let maxWorldX = rooms.floor4.x + rooms.floor4.w;
Determines the boundaries of the entire game world from the leftmost room to the rightmost edge of floor 4
targetCameraX = constrain(targetCameraX, minWorldX, maxWorldX - width);
Clamps the target camera position between the world bounds, ensuring the viewport never scrolls past the edge
smoothedCameraX = lerp(smoothedCameraX, targetCameraX, 0.1);
Smoothly interpolates: moves the smoothed camera 10% of the distance toward the target each frame (lerp stands for linear interpolation)
cameraX = smoothedCameraX;
Assigns the smoothed position to the actual cameraX variable used in draw() for the translate() offset

drawPlayer()

drawPlayer() demonstrates procedural character design - building a recognizable character from simple shapes (ellipses and lines) layered together. This is faster than loading image files and works great for stylized games. The translate-centered approach is a common pattern for drawing complex objects.

function drawPlayer() {
  // Draw procedural player (a red rectangle)
  // More detailed procedural player model
  push();
  translate(player.x + player.w / 2, player.y + player.h / 2); // Center player for drawing

  // Body
  fill(200, 0, 0); // Dark red body
  ellipse(0, 0, player.w * 1.2, player.h * 1.5);

  // Head
  fill(150, 0, 0); // Even darker red head
  ellipse(0, -player.h * 0.7, player.w * 0.8, player.h * 0.8);

  // Eyes (white with red pupils)
  fill(255);
  ellipse(-player.w * 0.2, -player.h * 0.8, player.w * 0.2, player.h * 0.2);
  ellipse(player.w * 0.2, -player.h * 0.8, player.w * 0.2, player.h * 0.2);
  fill(255, 0, 0);
  ellipse(-player.w * 0.2, -player.h * 0.8, player.w * 0.1, player.h * 0.1);
  ellipse(player.w * 0.2, -player.h * 0.8, player.w * 0.1, player.h * 0.1);

  // Mouth
  stroke(255, 0, 0);
  strokeWeight(2);
  line(-player.w * 0.2, -player.h * 0.6, player.w * 0.2, -player.h * 0.6); // Simple line mouth
  noStroke();

  // Arms (simple lines)
  stroke(200, 0, 0);
  strokeWeight(4);
  line(-player.w * 0.6, -player.h * 0.3, -player.w * 0.2, 0);
  line(player.w * 0.6, -player.h * 0.3, player.w * 0.2, 0);
  noStroke();

  pop();
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

transformation Center and Translate push(); translate(player.x + player.w / 2, player.y + player.h / 2);

Saves the transformation state and centers the coordinate system at the player's center for easier drawing of body parts

sequential Body Part Drawing ellipse(0, 0, player.w * 1.2, player.h * 1.5); // Body ellipse(0, -player.h * 0.7, player.w * 0.8, player.h * 0.8); // Head

Draws the player as a layered composition: body ellipse, head ellipse, eyes with pupils, mouth line, and arms

push();
Saves the current transformation state so changes here don't affect other drawings
translate(player.x + player.w / 2, player.y + player.h / 2);
Moves the origin to the center of the player's bounding box, making it easier to position body parts relative to center
fill(200, 0, 0); ellipse(0, 0, player.w * 1.2, player.h * 1.5);
Draws a dark red body ellipse centered at origin, scaled to 1.2× width and 1.5× height
fill(150, 0, 0); ellipse(0, -player.h * 0.7, player.w * 0.8, player.h * 0.8);
Draws an even darker red head 0.7× height units above the center
fill(255); ellipse(-player.w * 0.2, -player.h * 0.8, player.w * 0.2, player.h * 0.2);
Draws white circles for eyes, positioned at the left and right of the head
fill(255, 0, 0); ellipse(-player.w * 0.2, -player.h * 0.8, player.w * 0.1, player.h * 0.1);
Draws red pupils inside the white eyes at half the size - gives the player a scary red-eyed appearance
stroke(255, 0, 0); strokeWeight(2); line(-player.w * 0.2, -player.h * 0.6, player.w * 0.2, -player.h * 0.6);
Draws a red horizontal line for the mouth, connecting left to right across the face
stroke(200, 0, 0); strokeWeight(4); line(-player.w * 0.6, -player.h * 0.3, -player.w * 0.2, 0);
Draws a thick dark red line for the left arm from upper-left to center
pop();
Restores the transformation state, undoing the translate so subsequent drawings aren't affected

📦 Key Variables

player object

The main character object storing position (x, y), size (w, h), velocity (vx, vy), and state flags (isMoving, isRunning)

let player = { x: 100, y: 100, w: 30, h: 30, vx: 0, vy: 0, isMoving: false, isRunning: false };
monsters array

Array of Monster objects that populate the facility floors - each has its own position, room, AI state, and chase behavior

let monsters = []; // Filled by spawnMonster() during setup
currentRoom string

Tracks which room the player is currently in (e.g., 'mainRoom', 'floor0', 'floor1') - used for collision, rendering, and AI behavior

let currentRoom = "mainRoom";
rooms object

A dictionary mapping room names to room data (position, size, color, floor number) - defines the entire game world layout

let rooms = { mainRoom: { x: 0, y: 0, w: 600, h: 400, color: '#333333', floor: -1 }, floor0: { x: 0, y: 400, w: 800, h: 600, color: '#554444', floor: 0 }, ... };
cameraX number

The horizontal offset applied to the world rendering via translate() - shifts all world elements so the player stays centered

let cameraX = 0;
cameraY number

The vertical offset applied to the world rendering via translate() - enables vertical camera panning

let cameraY = 0;
gameState string

Controls the main game loop dispatch - can be 'loading', 'startScreen', 'game', 'jumpscare', 'modMenu', 'scrapMachine', 'lockerMenu', or 'monsterRoomView'

let gameState = "loading";
collectibles array

Array of item objects (keys and scrap) scattered throughout the floors - removed when the player collects them

let collectibles = [];
playerInventory array

Stores collected items (like keys) - displayed in the top-left corner as a list

let playerInventory = [];
playerScrap number

Tracks the player's scrap count - currency for buying items at the scrap machine

let playerScrap = 0;
joystick object

The mobile virtual joystick object storing base position, knob position, active state, and touch ID for multi-touch support

let joystick = { baseX: width * 0.2, baseY: height * 0.8, radius: 60, knobX: width * 0.2, knobY: height * 0.8, active: false, touchId: -1, maxX: width * 0.4 };
vrMode boolean

Toggle for VR camera mode - when true, device orientation (tilt) controls camera offset for head-tracking effect

let vrMode = false;
deviceGamma number

The device's left-right tilt angle in degrees from the deviceorientation event - used to pan the camera horizontally

let deviceGamma = 0;
deviceBeta number

The device's front-back tilt angle in degrees from the deviceorientation event - used to pan the camera vertically

let deviceBeta = 0;
jumpscare object

Stores jumpscare animation state: isActive (bool), timer, phase ('monster', 'blood', 'fadeToBlack'), animation frame counter

let jumpscare = { isActive: false, timer: 0, duration: 120, animationFrame: 0, phase: "monster", bloodFadeTimer: 0, bloodFadeDuration: 30 };
ambientSoundOsc p5.Oscillator

A sine wave oscillator generating a low 50 Hz drone for ambient horror atmosphere - fades in/out with game state

let ambientSoundOsc = new p5.Oscillator('sine');
ambientSoundNoise p5.Noise

White noise generating a subtle hiss/static sound - filtered through a low-pass filter for harsh tone softening

let ambientSoundNoise = new p5.Noise('white');
boombox object

A distance-based audio object in the main room - plays a triangular wave melody that fades in/out based on player proximity

let boombox = { osc: new p5.Oscillator('triangle'), delay: new p5.Delay(), nearbyThreshold: 200, fadeZone: 100, x: 0, y: 0, w: 80, h: 60 };
monsterSpeedFast boolean

Mod menu toggle - when true, multiplies all monster speeds by ~1.67× to make the game harder

let monsterSpeedFast = false;
keysSpawnEnabled boolean

Mod menu toggle - when true, spawns keys on each floor; when false, no keys appear

let keysSpawnEnabled = true;
scrapSpawnEnabled boolean

Mod menu toggle - when true, spawns 5 scrap pieces per floor; when false, no scrap appears

let scrapSpawnEnabled = true;

🔧 Potential Improvements (7)

Here are some ways this code could be enhanced:

PERFORMANCE updateMonsters() and Monster.update()

Every monster searches through the entire staircases array every frame when chasing on a different floor, even when already pathfinding - this is O(n*m) complexity and could be slow with many monsters or staircases

💡 Cache the result of staircase pathfinding for 1-2 seconds per monster, or pre-process staircases by floor to avoid redundant searches

BUG updatePlayer() - staircase transition

If the player stands on a staircase zone for longer than 500ms (TRANSITION_COOLDOWN), they can trigger multiple room transitions - the cooldown only prevents immediate re-entry, not longer stays

💡 Add a check to only transition if the player wasn't in a staircase in the previous frame, or track which staircase was last used

BUG Monster pathfinding

Monsters don't pathfind to main room when chasing a player there - they treat main/monsterRoom as safe zones they can't enter, which is correct, but if a player somehow enters monsterRoom, monsters can't follow

💡 Add special logic for monsterRoom: monsters should be able to enter it but slowly, or add a second pathfinding mode for chase sequences

STYLE triggerJumpscare()

The code has multiple identical isFinite() checks that don't serve a clear purpose - they check if numbers are finite but never use the result

💡 Remove the isFinite() checks or add meaningful error handling if the values are actually expected to be invalid

FEATURE Inventory system

Keys are collected but never used - the lockerMenu just says 'this locker is empty' without checking if the player has a key

💡 Implement key consumption: check playerInventory for keys in handleLockerUIInteraction(), unlock the locker, and display 'key used' or spawn a reward

FEATURE Player death

When a jumpscare is triggered, the game resets to startScreen but there's no 'game over' screen showing the player's scrap/inventory score

💡 Add a game-over screen that displays final stats (scrap collected, rooms visited, time survived) before returning to start screen

PERFORMANCE collectibles rendering and collision

Every collectible in the game is checked for collision every frame, even items in rooms the player can't reach - this is wasteful for large lists

💡 Only check collision for collectibles in currentRoom, or use spatial partitioning (grid-based regions) for faster lookups

🔄 Code Flow

Code flow showing setup, draw, updateplayer, updatemonsters, monster, triggerjumpscare, updatecamera, drawplayer

💡 Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] click setup href "#fn-setup" click draw href "#fn-draw" draw --> state-switch[Game State Dispatcher] click state-switch href "#sub-state-switch" state-switch --> game-state-block[Game Loop with Camera] click game-state-block href "#sub-game-state-block" game-state-block --> updateplayer[updatePlayer] click updateplayer href "#fn-updateplayer" updateplayer --> speed-calc[Speed Selection] click speed-calc href "#sub-speed-calc" speed-calc --> position-update[Position Update] click position-update href "#sub-position-update" position-update --> footstep-sound[Footstep Audio Loop] click footstep-sound href "#sub-footstep-sound" footstep-sound --> collectible-loop[Item Collection Loop] click collectible-loop href "#sub-collectible-loop" collectible-loop --> staircase-transition[Room Transition Detection] click staircase-transition href "#sub-staircase-transition" staircase-transition --> boundary-clamp[Room Boundary Clamping] click boundary-clamp href "#sub-boundary-clamp" game-state-block --> updatemonsters[updateMonsters] click updatemonsters href "#fn-updatemonsters" updatemonsters --> constructor[Monster Constructor] click constructor href "#sub-constructor" updatemonsters --> speed-calc-monster[Speed Modifier Check] click speed-calc-monster href "#sub-speed-calc-monster" updatemonsters --> room-boundary-collision[Wall Bounce Logic] click room-boundary-collision href "#sub-room-boundary-collision" updatemonsters --> chase-detection[Chase State Trigger] click chase-detection href "#sub-chase-detection" chase-detection --> pathfinding[Floor-aware Pathfinding] click pathfinding href "#sub-pathfinding" updatemonsters --> safe-room-barrier[Safe Zone Barrier] click safe-room-barrier href "#sub-safe-room-barrier" updatemonsters --> player-collision[Jumpscare Trigger] click player-collision href "#sub-player-collision" player-collision --> guard-clause[Guard Clause] click guard-clause href "#sub-guard-clause" player-collision --> triggerjumpscare[triggerJumpscare] click triggerjumpscare href "#fn-triggerjumpscare" triggerjumpscare --> state-initialization[Jumpscare State Setup] click state-initialization href "#sub-state-initialization" state-initialization --> audio-silence[Ambient Audio Cutoff] click audio-silence href "#sub-audio-silence" audio-silence --> screech-effect[Procedural Screech Synthesis] click screech-effect href "#sub-screech-effect" draw --> updatecamera[updateCamera] click updatecamera href "#fn-updatecamera" updatecamera --> base-position[Center Camera on Player] click base-position href "#sub-base-position" base-position --> vr-offset[VR Device Orientation Offset] click vr-offset href "#sub-vr-offset" vr-offset --> boundary-clamp-camera[Clamp to World Bounds] click boundary-clamp-camera href "#sub-boundary-clamp-camera" boundary-clamp-camera --> smooth-interpolation[Smooth Camera Following] click smooth-interpolation href "#sub-smooth-interpolation" draw --> drawplayer[drawPlayer] click drawplayer href "#fn-drawplayer" drawplayer --> push-translate[Center and Translate] click push-translate href "#sub-push-translate" push-translate --> body-parts[Body Part Drawing] click body-parts href "#sub-body-parts" setup --> room-definitions[Room Map Layout] click room-definitions href "#sub-room-definitions" setup --> player-init[Player Object Creation] click player-init href "#sub-player-init" setup --> staircase-definitions[Staircase Connections] click staircase-definitions href "#sub-staircase-definitions" setup --> ui-buttons[UI Button Initialization] click ui-buttons href "#sub-ui-buttons"

❓ Frequently Asked Questions

What kind of visuals does the 'Lethal ape: redux updated but cool' sketch create?

The sketch features a 2D top-down horror facility with stylized shapes to represent different lab rooms, characters, and lurking monsters.

How can players interact with the 'Lethal ape: redux updated but cool' sketch?

Users can navigate through the facility using on-screen controls or device tilt on mobile, allowing them to move, look around, and interact with elements like lockers and machines.

What creative coding techniques are showcased in this p5.js sketch?

This sketch demonstrates procedural drawing techniques and game state management to create an engaging horror experience without relying on image assets.

Preview

Lethal ape: redux updated but cool - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Lethal ape: redux updated but cool - Code flow showing setup, draw, updateplayer, updatemonsters, monster, triggerjumpscare, updatecamera, drawplayer
Code Flow Diagram