Lethal ape redux

This is a spooky top-down maze game where you navigate procedurally-lit rooms, avoid intelligent monsters that hunt across multiple floors, collect keys, and face sudden jumpscares with eerie procedural audio. You control movement with a joystick, toggle VR head-tracking mode to look around, and run to escape danger.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make monsters faster — Increase the monster chase speed so they're more dangerous when hunting
  2. Change the safe zone color — The main room (safe zone) is dark gray by default—switch it to a bright color to see it stand out
  3. Spawn more monsters — Increase MAX_MONSTERS to populate the world with more threats
  4. Make footsteps more frequent — Lower FOOTSTEP_INTERVAL so the player hears more rapid footstep sounds when moving
  5. Change the jumpscare message — Customize the text that appears during the monster attack
  6. Make the player bigger
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch creates a terrifying top-down maze explorer where you roam dim rooms, hunt for collectible keys, and evade procedurally-drawn monsters who chase you across multiple floors. The horror unfolds through several p5.js techniques: a world camera that follows the player, collision detection for enemies and items, state machines for monster AI and jumpscare sequences, and procedural audio synthesis using p5.sound oscillators and envelopes to generate ambient drones, footsteps, and screams.

The code is organized into game state management (player, monsters, rooms, collectibles), update functions that drive physics and AI each frame, render functions that draw the world relative to the camera, mobile touch input handlers for joystick and run button, and device orientation listeners for VR look-around. By reading it, you will learn how to architect a multi-room exploration game, implement pathfinding AI that hunts between floors, compose complex audio events with p5.sound, and handle the camera translation that powers all camera-relative games.

⚙️ How It Works

  1. When the game loads, setup() builds a procedural world with six rooms (a safe main room plus five dungeon floors laid out horizontally), initializes the player in the safe zone, spawns five monsters in random floor rooms, places keys to collect, and sets up p5.sound oscillators for ambient audio and effects.
  2. A start screen waits for a touch or key press to begin audio context and the game loop. Once started, the draw() loop every frame updates player position based on joystick input, moves and updates monster AI, checks collisions for items and jumpscare triggers, applies a camera offset based on device orientation (if VR mode is on), then renders the entire game world translated by the camera so it stays centered on the player.
  3. The joystick on the left side of the screen controls player movement direction; the run button on the right makes the player faster. Staircases (light gray rectangles) detect when the player overlaps them and teleport the player to the target room, with a cooldown to prevent rapid re-triggering.
  4. Monsters have a simple AI: in roaming state they reverse direction periodically; when they see the player in their room and within chase range, they switch to chasing state and move toward the player. If the player is on a different floor, monsters intelligently seek out staircases to climb toward the player.
  5. If a monster catches the player, triggerJumpscare() activates a multi-phase scare sequence: first the monster scales up and fills the screen with screaming procedural audio, then the screen flashes blood-red, finally fading to black before the game resets.
  6. Throughout, procedural audio reacts to gameplay: footsteps sound when moving, keys chime when collected, ambient drones and hiss provide eerie background texture, and device orientation (gamma for horizontal look, beta for vertical look) shifts the camera viewport if VR mode is toggled on.

🎓 Concepts You'll Learn

Game state managementCamera offset and translationCollision detectionMonster AI and pathfindingProcedural audio synthesisDevice orientation trackingTouch input and joystick controlRoom transitions and portals

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. It defines the game world layout (rooms), initializes the player, spawns enemies and items, and sets up input listeners. This is where you design your level—by moving room coordinates and changing colors, you reshape the entire game.

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

  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 },
    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
  };

  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.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.floor3.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
  };

  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;
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

function-call Canvas and text setup createCanvas(windowWidth, windowHeight);

Creates a canvas that fills the entire window and prepares text rendering

object-literal Room definitions rooms = { mainRoom: {...}, floor0: {...}, floor1: {...} };

Defines the layout of six rooms with positions, sizes, colors, and floor numbers for AI pathfinding

object-literal Player object initialization player = { x: rooms.mainRoom.x + 100, y: rooms.mainRoom.y + 100, ... };

Creates the player object with position, velocity, and state flags

array-literal Staircase portals staircases = [ { x: rooms.mainRoom.x + 200, y: ... }, ... ];

Defines transition points between rooms so players can move between floors

object-literal Joystick UI object joystick = { baseX: width * 0.2, baseY: height * 0.8, ... };

Sets up the left-side joystick with position, radius, and touch tracking

for-loop Monster spawning for (let i = 0; i < MAX_MONSTERS; i++) { spawnMonster(); }

Populates the world with five monsters in random floor rooms

conditional Device orientation setup if (typeof DeviceOrientationEvent !== 'undefined' && typeof DeviceOrientationEvent.requestPermission === 'function')

Checks if the device supports iOS-style permission-based orientation events

createCanvas(windowWidth, windowHeight);
Creates a canvas that fills the entire browser window—essential for a full-screen game
noStroke();
Removes outlines from all shapes drawn after this, making the procedural graphics cleaner
textAlign(CENTER, CENTER);
Centers all text around its x,y coordinates rather than using top-left as origin
rooms = { mainRoom: {...}, floor0: {...} };
Defines six rooms with position, size, color, and floor number—rooms are the building blocks of the game world
player = { x: rooms.mainRoom.x + 100, y: rooms.mainRoom.y + 100, ... };
Initializes the player as an object with position (100 pixels into the safe zone), velocity (initially zero), width/height, and state flags
for (let i = 0; i < MAX_MONSTERS; i++) { spawnMonster(); }
Loops five times to spawn five monsters at random locations in the floor rooms
spawnCollectibles();
Populates the world with five keys—one in each floor room for the player to collect

draw()

draw() is the heart of every p5.js sketch—it runs 60 times per second. This draw() uses early returns (with `!gameStart` and `jumpscare.isActive`) to gate different game states, then uses push/pop and translate to create a camera system that keeps the player centered. The pattern of updating logic, then rendering with transforms, is fundamental to all games.

function draw() {
  background(0);

  if (!gameStart) {
    drawStartScreen();
    return;
  }

  if (jumpscare.isActive) {
    drawJumpscare();
    return;
  }

  updatePlayer();
  updateMonsters();
  updateCamera();

  push();
  translate(-cameraX, -cameraY);

  drawRooms();
  drawStaircases();
  drawPlayer();
  drawMonsters();
  drawCollectibles();

  pop();

  drawJoystick();
  drawMonsterTracker();
  drawInventory();
  drawVRToggleButton();
  drawRunButton();
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

conditional Start screen gate if (!gameStart) { drawStartScreen(); return; }

Shows the start screen until the player touches to begin the game

conditional Jumpscare interrupt if (jumpscare.isActive) { drawJumpscare(); return; }

Pauses normal gameplay to show the monster attack sequence when caught

function-call Update game logic updatePlayer(); updateMonsters(); updateCamera();

Moves the player, updates monster AI, and recalculates camera position for this frame

transform Camera offset rendering push(); translate(-cameraX, -cameraY); ... pop();

Renders the entire game world shifted by camera offset so it stays centered on the player

function-call UI layer drawing drawJoystick(); drawMonsterTracker(); drawInventory(); drawVRToggleButton(); drawRunButton();

Draws UI elements on top of the world (always visible, not affected by camera)

background(0);
Fills the screen with black every frame—clears the previous frame so motion is visible
if (!gameStart) { drawStartScreen(); return; }
If the game hasn't started (player hasn't touched yet), show the start screen and exit draw early—skip all game logic
if (jumpscare.isActive) { drawJumpscare(); return; }
If a jumpscare is playing, render only the jumpscare and exit draw early—pause normal gameplay
updatePlayer();
Moves the player based on joystick input, checks staircase collisions, and handles collectible pickups
updateMonsters();
Moves each monster, updates their AI state (roaming or chasing), and checks if they caught the player
updateCamera();
Recalculates camera position centered on the player, adjusted for device orientation if VR mode is on
push();
Saves the current drawing state (transformations, styles) so the camera translation only affects game world drawing
translate(-cameraX, -cameraY);
Shifts the entire game world by negative camera offset—this centers the world around the player on screen
drawRooms(); drawStaircases(); drawPlayer(); drawMonsters(); drawCollectibles();
Draws all game world elements—they are all translated by camera offset so they move with the player
pop();
Restores the drawing state, ending camera translation—subsequent draws (UI) are not affected by camera
drawJoystick(); drawMonsterTracker(); drawInventory(); drawVRToggleButton(); drawRunButton();
Draws UI elements after pop() so they stay fixed on screen—the joystick, tracker, inventory, and buttons don't move with the camera

updatePlayer()

updatePlayer() is called every frame to move the player, handle collisions, play footstep sounds, and detect room transitions. It's where physics, input response, and game logic converge. The backward loop pattern for collectibles is a professional technique—removing items while iterating forward skips elements, but iterating backward avoids this.

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

  player.x += player.vx * currentSpeed;
  player.y += player.vy * currentSpeed;

  player.isMoving = (abs(player.vx) > 0.1 || abs(player.vy) > 0.1);

  if (player.isMoving) {
    footstepCounter++;
    if (footstepCounter >= FOOTSTEP_INTERVAL) {
      footstepOsc.freq(random(100, 150));
      footstepEnvelope.play(footstepOsc);
      footstepCounter = 0;
    }
  } else {
    footstepCounter = 0;
  }

  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)) {
      playerInventory.push(item);
      collectibles.splice(i, 1);
      collectOsc.freq(random(600, 800));
      collectEnvelope.play(collectOsc);
      console.log(`Grabbed a ${item.type}! Inventory:`, playerInventory);
    }
  }

  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)) {
        if (currentRoom !== staircase.targetRoom) {
          currentRoom = staircase.targetRoom;
          player.x = staircase.targetPlayerX;
          player.y = staircase.targetPlayerY;
          player.vx = 0;
          player.vy = 0;
          player.isRunning = false;
          hasTransitioned = true;
          canTransition = false;
          setTimeout(() => { canTransition = true; }, TRANSITION_COOLDOWN);
          break;
        }
      }
    }

    if (hasTransitioned) {
      return;
    }
  }

  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);

  if (!joystick.active) {
    player.vx = 0;
    player.vy = 0;
  }
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

ternary Speed mode selection let currentSpeed = player.isRunning ? PLAYER_RUN_SPEED : PLAYER_SPEED;

Chooses between normal or fast movement speed based on run button state

arithmetic Position update player.x += player.vx * currentSpeed; player.y += player.vy * currentSpeed;

Moves the player by adding velocity multiplied by speed

conditional Movement state detection player.isMoving = (abs(player.vx) > 0.1 || abs(player.vy) > 0.1);

Determines if the player is moving to trigger footstep sounds

conditional Footstep audio loop if (player.isMoving) { footstepCounter++; if (footstepCounter >= FOOTSTEP_INTERVAL) {...} }

Plays a footstep sound every 25 frames when the player is moving

for-loop Item pickup detection for (let i = collectibles.length - 1; i >= 0; i--) { if (rectCollision(...)) { ... } }

Loops backward through items to detect collisions and remove collected items

for-loop Room transition check for (let staircase of staircases) { if (rectCollision(...)) { ... } }

Detects when the player overlaps a staircase and teleports them to the target room

function-call Boundary clamping player.x = constrain(player.x, currentRoomData.x, currentRoomData.x + currentRoomData.w - player.w);

Prevents the player from walking outside the current room's edges

let currentSpeed = player.isRunning ? PLAYER_RUN_SPEED : PLAYER_SPEED;
Sets currentSpeed to 5 if the player is running, otherwise 3—controls how far the player moves this frame
player.x += player.vx * currentSpeed;
Updates the player's x position by multiplying the velocity direction (vx is -1, 0, or 1) by the speed—this is how movement happens
player.y += player.vy * currentSpeed;
Updates the player's y position the same way—both x and y update each frame to create smooth motion
player.isMoving = (abs(player.vx) > 0.1 || abs(player.vy) > 0.1);
Sets isMoving to true only if at least one velocity component is greater than 0.1, triggering footstep sounds
if (footstepCounter >= FOOTSTEP_INTERVAL) {
Every FOOTSTEP_INTERVAL (25) frames, trigger a footstep sound—this creates the illusion of rhythmic walking
for (let i = collectibles.length - 1; i >= 0; i--) {
Loops backward through the collectibles array so removing items doesn't skip any—a best practice for deletion loops
if (item.room === currentRoom && rectCollision(...)) {
Only checks collision if the item is in the current room, saving computation
collectibles.splice(i, 1);
Removes the collected item from the array—it disappears from the game world
if (canTransition) {
Only allows room transitions if the cooldown flag is true—prevents rapid re-triggering of the same staircase
let currentRoomData = rooms[currentRoom];
Looks up the current room object to get its boundaries for collision clamping
player.x = constrain(player.x, currentRoomData.x, currentRoomData.x + currentRoomData.w - player.w);
Clamps the player's x position between the left and right edges of the room—keeps them from walking through walls

updateMonsters()

updateMonsters() is a thin wrapper that calls update() on every monster each frame. In a larger game, you might also check for monster-to-monster collisions or remove dead monsters here. This function shows the power of object-oriented design—a single line of code does complex work.

function updateMonsters() {
  for (let monster of monsters) {
    monster.update();
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

for-loop Monster update loop for (let monster of monsters) { monster.update(); }

Calls update() on each monster to move and update AI every frame

for (let monster of monsters) {
Iterates through the monsters array using a for-of loop—a clean way to process each monster
monster.update();
Calls the update() method on each Monster object, which moves it, checks AI logic, and tests collisions with the player

Monster (class)

The Monster class is the heart of the game's AI. It combines simple roaming (periodic direction changes) with distance-based chase detection, and when the player is on a different floor, it uses pathfinding to climb staircases. The draw() method rotates the monster to face its movement direction using atan2 and rotate, creating the illusion of purposeful movement. This is a great example of separating logic (update) from rendering (draw).

🔬 When a monster is chasing and the player leaves its range, this code decrements chaseTimer until it hits zero, then switches back to roaming. What happens if you change `this.chaseTimer <= 0` to `this.chaseTimer <= -300`? Does the monster stick to chase mode longer after the player escapes?

      } else if (this.state === "chasing") {
        this.chaseTimer--;
        if (this.chaseTimer <= 0) {
          this.state = "roaming";
class Monster {
  constructor(x, y, roomName) {
    this.x = x;
    this.y = y;
    this.w = 40;
    this.h = 40;
    this.vx = random([-MONSTER_SPEED, MONSTER_SPEED]);
    this.vy = random([-MONSTER_SPEED, MONSTER_SPEED]);
    this.room = roomName;
    this.state = "roaming";
    this.chaseTimer = 0;
    this.chaseDuration = 120;
    this.roamTimer = 0;
    this.roamDuration = random(60, 240);
    this.monsterCanTransition = true;
  }

  update() {
    let roomData = rooms[this.room];
    let currentMonsterSpeed = MONSTER_SPEED;

    this.x += this.vx * currentMonsterSpeed;
    this.y += this.vy * currentMonsterSpeed;

    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);
    }

    if (this.room === currentRoom && currentRoom !== "mainRoom") {
      let d = dist(this.x, this.y, player.x, player.y);
      if (d < MONSTER_CHASE_RANGE) {
        this.state = "chasing";
        this.chaseTimer = this.chaseDuration;
        currentMonsterSpeed = MONSTER_CHASE_SPEED;
      } else if (this.state === "chasing") {
        this.chaseTimer--;
        if (this.chaseTimer <= 0) {
          this.state = "roaming";
          this.vx = random([-MONSTER_SPEED, MONSTER_SPEED]);
          this.vy = random([-MONSTER_SPEED, MONSTER_SPEED]);
          this.roamTimer = 0;
        } else {
          currentMonsterSpeed = MONSTER_CHASE_SPEED;
        }
      }
    } else {
      this.state = "roaming";
      this.vx = random([-MONSTER_SPEED, MONSTER_SPEED]);
      this.vy = random([-MONSTER_SPEED, MONSTER_SPEED]);
      this.roamTimer = 0;
    }

    if (this.state === "roaming") {
      this.roamTimer++;
      if (this.roamTimer > this.roamDuration) {
        this.vx *= -1;
        this.vy *= -1;
        this.roamTimer = 0;
        this.roamDuration = random(60, 240);
      }
    } else if (this.state === "chasing") {
      let playerFloor = rooms[currentRoom].floor;
      let monsterFloor = rooms[this.room].floor;

      if (playerFloor !== monsterFloor && monsterFloor !== -1 && this.monsterCanTransition) {
        let targetStaircase = null;
        let minStaircaseDist = Infinity;

        for (let staircase of staircases) {
          if (rectCollision(this.x, this.y, this.w, this.h, staircase.x, staircase.y, staircase.w, staircase.h)) {
            let targetStaircaseFloor = rooms[staircase.targetRoom].floor;
            if ((playerFloor > monsterFloor && targetStaircaseFloor > monsterFloor) ||
                (playerFloor < monsterFloor && targetStaircaseFloor < monsterFloor) ||
                (playerFloor === targetStaircaseFloor)) {
              this.room = staircase.targetRoom;
              this.x = staircase.targetPlayerX;
              this.y = staircase.targetPlayerY;
              this.vx = 0;
              this.vy = 0;
              this.monsterCanTransition = false;
              setTimeout(() => { this.monsterCanTransition = true; }, TRANSITION_COOLDOWN);
              return;
            }
          }

          if (staircase.targetRoom !== this.room) {
            let targetStaircaseFloor = rooms[staircase.targetRoom].floor;
            let currentStaircaseFloor = rooms[this.room].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) {
          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;
          this.vy = sin(angle) * currentMonsterSpeed;
        } else {
          this.vx = random([-MONSTER_SPEED, MONSTER_SPEED]);
          this.vy = random([-MONSTER_SPEED, MONSTER_SPEED]);
        }
      } else {
        this.vx = (player.x > this.x ? 1 : -1);
        this.vy = (player.y > this.y ? 1 : -1);
      }
    }

    let mainRoom = rooms.mainRoom;
    if (this.room !== "mainRoom" && rectCollision(this.x, this.y, this.w, this.h, mainRoom.x, mainRoom.y, mainRoom.w, mainRoom.h)) {
      if (this.vx > 0 && this.x < mainRoom.x + mainRoom.w && this.x + this.w > mainRoom.x + mainRoom.w) {
        this.x = mainRoom.x + mainRoom.w;
        this.vx *= -1;
      } else if (this.vx < 0 && this.x + this.w > mainRoom.x && this.x < mainRoom.x) {
        this.x = mainRoom.x - this.w;
        this.vx *= -1;
      }
      if (this.vy > 0 && this.y < mainRoom.y + mainRoom.h && this.y + this.h > mainRoom.y + mainRoom.h) {
        this.y = mainRoom.y + mainRoom.h;
        this.vy *= -1;
      } else if (this.vy < 0 && this.y + this.h > mainRoom.y && this.y < mainRoom.y) {
        this.y = mainRoom.y - this.h;
        this.vy *= -1;
      }
      this.state = "roaming";
      this.roamTimer = 0;
    }

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

  draw() {
    push();
    translate(this.x + this.w / 2, this.y + this.h / 2);

    let rotationAngle = atan2(this.vy, this.vx);
    rotate(rotationAngle);

    fill(20, 60, 20);
    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);

    fill(10, 40, 10);
    ellipse(this.w * 0.6, -this.h * 0.4, this.w * 0.8, this.h * 0.6);

    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);

    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 (11 lines)

🔧 Subcomponents:

constructor Monster creation constructor(x, y, roomName) { this.x = x; this.y = y; ... }

Initializes a monster with position, velocity, room, and AI state variables

arithmetic Movement this.x += this.vx * currentMonsterSpeed; this.y += this.vy * currentMonsterSpeed;

Moves the monster each frame

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

Bounces the monster off room edges by reversing velocity

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

Detects when the player is near and switches monster to chase mode

conditional Roaming behavior if (this.state === "roaming") { this.roamTimer++; if (this.roamTimer > this.roamDuration) { this.vx *= -1; this.vy *= -1; } }

Makes the monster wander by reversing direction periodically

conditional Chasing behavior else if (this.state === "chasing") { ... atan2/cos/sin for pathfinding ... }

Makes the monster seek the player or pathfind to a staircase if on a different floor

conditional Main room barrier if (this.room !== "mainRoom" && rectCollision(..., mainRoom.x, mainRoom.y, mainRoom.w, mainRoom.h)) { ... push back ... }

Prevents monsters from entering the safe zone

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(); }

Detects when a monster catches the player

method Monster rendering draw() { ... beginShape/vertex for body, ellipse for head, eyes, mouth ... }

Draws a procedurally-generated monster facing its movement direction

constructor(x, y, roomName) {
Creates a new Monster with starting position and room name
this.vx = random([-MONSTER_SPEED, MONSTER_SPEED]);
Sets an initial random velocity—either -1.5 or 1.5—so monsters start moving in a random direction
this.state = "roaming";
Initializes monsters in roaming state—they will wander until they see the player
this.x += this.vx * currentMonsterSpeed;
Updates the monster's x position each frame, just like the player
if (this.x < roomData.x || this.x + this.w > roomData.x + roomData.w) { this.vx *= -1; }
If the monster hits the left or right wall, flip its horizontal velocity to bounce backward
let d = dist(this.x, this.y, player.x, player.y);
Calculates the distance from the monster to the player
if (d < MONSTER_CHASE_RANGE) { this.state = "chasing"; currentMonsterSpeed = MONSTER_CHASE_SPEED; }
If the player is within 200 pixels, switch to chase state and run faster
if (this.roamTimer > this.roamDuration) { this.vx *= -1; this.vy *= -1; }
Every roam duration (60-240 frames), flip both velocities so the monster changes direction
let angle = atan2(dy, dx);
Calculates the angle from the monster toward the target (player or staircase)
this.vx = cos(angle) * currentMonsterSpeed; this.vy = sin(angle) * currentMonsterSpeed;
Sets velocity as unit vector (cos/sin) scaled by speed, so the monster moves directly toward the target
if (currentRoom === this.room && rectCollision(player.x, player.y, ...)) { triggerJumpscare(); }
If the monster and player collide in the same room, trigger a jumpscare

drawPlayer()

drawPlayer() uses translate() and a push/pop sandwich to draw the player centered at its origin. All the coordinates are relative to (0,0), the player's center—this makes the drawing cleaner and ensures the player rotates around its center if you later add rotation. Procedural drawing (using circles and lines) instead of images saves file size and loads instantly.

🔬 These lines draw two eye pairs: first white circles, then red pupils inside them. What happens if you change the pupil size from `player.w * 0.1` to `player.w * 0.05` (smaller) or `player.w * 0.2` (bigger)? Does the player look more or less terrified?

  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);
function drawPlayer() {
  push();
  translate(player.x + player.w / 2, player.y + player.h / 2);

  fill(200, 0, 0);
  ellipse(0, 0, player.w * 1.2, player.h * 1.5);

  fill(150, 0, 0);
  ellipse(0, -player.h * 0.7, player.w * 0.8, player.h * 0.8);

  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);

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

  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 (8 lines)

🔧 Subcomponents:

transform Center player for drawing translate(player.x + player.w / 2, player.y + player.h / 2);

Moves the drawing origin to the player's center so shapes are symmetrical

shape Player body fill(200, 0, 0); ellipse(0, 0, player.w * 1.2, player.h * 1.5);

Draws a dark red ellipse for the body

shape Player head fill(150, 0, 0); ellipse(0, -player.h * 0.7, player.w * 0.8, player.h * 0.8);

Draws a darker red ellipse for the head above the body

shape Eyes fill(255); ellipse(...); fill(255, 0, 0); ellipse(...);

Draws white circles for eye whites and red circles for pupils

shape Mouth line 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 horizontal red line for a simple mouth

shape Arms 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, ...);

Draws two diagonal lines for arms

push();
Saves the current transformation state so the translation only affects this drawing
translate(player.x + player.w / 2, player.y + player.h / 2);
Moves the drawing origin to the player's center—now (0,0) is the player's middle
fill(200, 0, 0); ellipse(0, 0, player.w * 1.2, player.h * 1.5);
Draws a red ellipse centered at (0,0), slightly wider and taller than the player—the body
fill(150, 0, 0); ellipse(0, -player.h * 0.7, ...);
Draws a darker red ellipse above the body (at y = -player.h * 0.7)—the head
fill(255); ellipse(-player.w * 0.2, -player.h * 0.8, ...);
Draws a white circle on the left for the left eye white
fill(255, 0, 0); ellipse(-player.w * 0.2, -player.h * 0.8, ...);
Draws a red circle inside the white—the pupil looking straight ahead
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 thin red line from left to right for a simple straight mouth
pop();
Restores the transformation state so the rest of the sketch is unaffected

updateCamera()

updateCamera() is where all camera logic lives. It calculates a target position (centered on player), applies device orientation offsets if VR mode is active, clamps to world boundaries, then uses lerp() for smooth interpolation. The lerp is key—without it, the camera would snap each frame and feel jerky. This function is called every frame in draw(), and its output (cameraX, cameraY) is used in the translate() call to shift the entire game world.

function updateCamera() {
  targetCameraX = player.x - width / 2;
  targetCameraY = player.y - height / 2;

  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;
    if (abs(deviceBeta) > BETA_DEAD_ZONE) {
      betaOffset = map(deviceBeta, -180, 180, -BETA_SENSITIVITY * height, BETA_SENSITIVITY * height);
    }
    targetCameraY += betaOffset;
  }

  let minWorldX = rooms.mainRoom.x;
  let maxWorldX = rooms.floor4.x + rooms.floor4.w;
  let minWorldY = rooms.mainRoom.y;
  let maxWorldY = rooms.floor4.y + rooms.floor4.h;

  targetCameraX = constrain(targetCameraX, minWorldX, maxWorldX - width);
  targetCameraY = constrain(targetCameraY, minWorldY, maxWorldY - height);

  smoothedCameraX = lerp(smoothedCameraX, targetCameraX, 0.1);
  smoothedCameraY = lerp(smoothedCameraY, targetCameraY, 0.1);
  cameraX = smoothedCameraX;
  cameraY = smoothedCameraY;
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

arithmetic Center on player targetCameraX = player.x - width / 2; targetCameraY = player.y - height / 2;

Calculates camera position to keep the player centered on screen

conditional VR look-around offset if (vrMode && deviceOrientationGranted) { gammaOffset = map(...); targetCameraX += gammaOffset; }

Tilts the camera based on device orientation when VR mode is on

function-call Gamma to screen offset gammaOffset = map(deviceGamma, -90, 90, -GAMMA_SENSITIVITY * width, GAMMA_SENSITIVITY * width);

Converts device tilt (-90 to 90 degrees) to camera pan (pixels)

function-call Camera boundary clamping targetCameraX = constrain(targetCameraX, minWorldX, maxWorldX - width);

Prevents camera from showing outside the game world

function-call Camera smoothing smoothedCameraX = lerp(smoothedCameraX, targetCameraX, 0.1);

Gradually interpolates camera to target position for smooth motion

targetCameraX = player.x - width / 2;
Calculates camera X so the player is horizontally centered—player.x is moved to the screen center (width / 2)
targetCameraY = player.y - height / 2;
Calculates camera Y so the player is vertically centered
if (vrMode && deviceOrientationGranted) {
Only apply device orientation if VR mode is on AND the browser has permission to read the device sensors
if (abs(deviceGamma) > GAMMA_DEAD_ZONE) {
Only map gamma if it's bigger than 3 degrees—a dead zone prevents jitter when the device is still
gammaOffset = map(deviceGamma, -90, 90, -GAMMA_SENSITIVITY * width, GAMMA_SENSITIVITY * width);
Remaps device tilt (-90 to 90 degrees left-right) to camera pan (-width to +width pixels)—tilt left pans the camera left
targetCameraX += gammaOffset;
Adds the gamma offset to the base camera position—the camera pans in the direction of device tilt
targetCameraX = constrain(targetCameraX, minWorldX, maxWorldX - width);
Clamps the camera so it never scrolls past the world boundaries—prevents black void on screen edges
smoothedCameraX = lerp(smoothedCameraX, targetCameraX, 0.1);
Interpolates 10% from current to target position each frame—camera follows smoothly instead of snapping

triggerJumpscare()

triggerJumpscare() is the entry point for the horror sequence. It sets flags that pause draw() and activates the visual and audio jumpscare. The audio uses p5.sound envelopes and frequency sweeps—advanced techniques that create scary procedural effects without needing pre-recorded sounds. The guard clause at the top is crucial; without it, if a monster touched the player multiple times in one frame, multiple jumpscares would trigger simultaneously.

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

  jumpscare.isActive = true;
  jumpscare.timer = jumpscare.duration;
  jumpscare.animationFrame = 0;
  jumpscare.phase = "monster";

  ambientSoundOsc.amp(0, 0.1);
  ambientSoundNoise.amp(0, 0.1);

  if (isFinite(jumpscareEnvelope.a) && isFinite(jumpscareEnvelope.d) && isFinite(jumpscareEnvelope.s) && isFinite(jumpscareEnvelope.r)) {
      jumpscareEnvelope.play(jumpscareNoise);
  } else {
      console.error("Jumpscare Envelope parameters are not finite. Skipping noise burst.");
      jumpscareNoise.amp(0);
  }

  if (isFinite(2000) && isFinite(0.2) && isFinite(500) && isFinite(0.5) && isFinite(0.8) && isFinite(0.05)) {
      jumpscareOsc.amp(0.8, 0.05);
      jumpscareOsc.freq(2000, 0.2);
      jumpscareOsc.freq(500, 0.5, '+0.2');
      jumpscareOsc.amp(0, 0.5, '+0.2');
  } else {
      console.error("Jumpscare Oscillator parameters are not finite. Skipping screech effect.");
      jumpscareOsc.amp(0);
  }

  setTimeout(() => jumpscareOsc.stop(), 950);
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

conditional Prevent double trigger if (jumpscare.isActive) return;

Exits if a jumpscare is already playing—prevents overlapping scares

assignment Activate jumpscare state jumpscare.isActive = true; jumpscare.timer = jumpscare.duration; jumpscare.phase = "monster";

Sets jumpscare flags to true and initializes animation timer and phase

function-call Fade out ambient audio ambientSoundOsc.amp(0, 0.1); ambientSoundNoise.amp(0, 0.1);

Quickly silences the background drone and hiss

function-call Noise envelope trigger jumpscareEnvelope.play(jumpscareNoise);

Plays the jumpscare noise burst with attack/decay envelope

function-call Screech oscillator jumpscareOsc.amp(0.8, 0.05); jumpscareOsc.freq(2000, 0.2); jumpscareOsc.freq(500, 0.5, '+0.2');

Triggers a sawtooth oscillator that sweeps from 2000 Hz down to 500 Hz for a terrifying screech

timeout Stop screech after delay setTimeout(() => jumpscareOsc.stop(), 950);

Stops the oscillator after 0.95 seconds so it doesn't play forever

if (jumpscare.isActive) return;
If a jumpscare is already playing, exit immediately—prevents multiple overlapping jumpscares
jumpscare.isActive = true;
Sets the flag that pauses normal gameplay in draw()
jumpscare.phase = "monster";
Sets the phase to 'monster'—the drawJumpscare() function will check this and render the monster phase first
ambientSoundOsc.amp(0, 0.1);
Fades out the ambient drone to silence in 0.1 seconds—the jumpscare needs silence for maximum impact
jumpscareEnvelope.play(jumpscareNoise);
Plays the noise burst (white noise) through its envelope, which shapes its attack and decay
jumpscareOsc.amp(0.8, 0.05);
Starts the screech oscillator with volume 0.8, ramping up over 0.05 seconds (fast attack)
jumpscareOsc.freq(2000, 0.2);
Sweeps the screech frequency to 2000 Hz over 0.2 seconds—a rising pitch that's terrifying
jumpscareOsc.freq(500, 0.5, '+0.2');
After the first sweep (after 0.2 seconds, hence '+0.2'), sweeps down to 500 Hz over 0.5 seconds—a falling wail
setTimeout(() => jumpscareOsc.stop(), 950);
Stops the oscillator completely after 950 milliseconds so the sound doesn't loop infinitely

drawJumpscare()

drawJumpscare() orchestrates a multi-phase horror sequence using switch/case. Each phase lasts a specific duration, then the code transitions to the next. The monster phase uses scale() and map() for animation, the blood phase just shows a color, and the fadeToBlack phase uses alpha blending to darken gradually. Together, they create a cinematic jump-scare experience. Notice how it ends with resetGame()—this is how the game recovers from a death.

🔬 The `sin(jumpscare.animationFrame * 0.2)` creates a pulsing red text by oscillating the red value. What happens if you change 0.2 to 0.05 (slower) or 0.5 (faster)? Does the text flash more or less rapidly?

      let redFlash = map(sin(jumpscare.animationFrame * 0.2), -1, 1, 100, 255);
      fill(redFlash, 0, 0);
      textSize(64);
      text("YOU DIED!", width / 2, height / 2 + height * 0.2);
function drawJumpscare() {
  switch (jumpscare.phase) {
    case "monster":
      background(0);
      push();
      translate(width / 2, height / 2);

      let monsterScale = map(jumpscare.animationFrame, 0, jumpscare.duration * 0.5, 0.1, 1.2, true);
      let headSize = width * monsterScale * 0.6;
      let bodySize = width * monsterScale * 0.3;

      push();
      scale(2.5);
      drawJumpscareMonster(0, 0, headSize, bodySize);
      pop();

      pop();

      let redFlash = map(sin(jumpscare.animationFrame * 0.2), -1, 1, 100, 255);
      fill(redFlash, 0, 0);
      textSize(64);
      text("YOU DIED!", width / 2, height / 2 + height * 0.2);
      textSize(24);
      fill(255);
      text("Beware the shadows...", width / 2, height / 2 + height * 0.2 + 70);

      jumpscare.animationFrame++;
      if (jumpscare.animationFrame >= jumpscare.duration * 0.5) {
        jumpscare.phase = "blood";
        jumpscare.bloodFadeTimer = 0;
      }
      break;

    case "blood":
      background(136, 0, 0);
      fill(255);
      textSize(64);
      text("YOU DIED!", width / 2, height / 2);
      textSize(24);
      text("Beware the shadows...", width / 2, height / 2 + 70);

      jumpscare.bloodFadeTimer++;
      if (jumpscare.bloodFadeTimer >= jumpscare.bloodFadeDuration) {
        jumpscare.phase = "fadeToBlack";
        jumpscare.bloodFadeTimer = 0;
      }
      break;

    case "fadeToBlack":
      let fadeProgress = jumpscare.bloodFadeTimer / jumpscare.bloodFadeDuration;
      let alpha = lerp(0, 255, fadeProgress);
      fill(0, alpha);
      rect(0, 0, width, height);

      jumpscare.bloodFadeTimer++;
      if (jumpscare.bloodFadeTimer >= jumpscare.bloodFadeDuration) {
        jumpscare.isActive = false;
        jumpscareNoise.amp(0);
        jumpscareOsc.amp(0);
        resetGame();
      }
      break;
  }
}
Line-by-line explanation (13 lines)

🔧 Subcomponents:

switch-case Jumpscare phase management switch (jumpscare.phase) { case "monster": ... case "blood": ... case "fadeToBlack": ... }

Renders different visual and audio effects based on the current jumpscare phase

case Monster attack phase case "monster": ... drawJumpscareMonster(...); redFlash = map(sin(...)); ... if (jumpscare.animationFrame >= jumpscare.duration * 0.5) { jumpscare.phase = "blood"; }

Scales up a terrifying monster and flashes red text, then advances to blood phase

case Blood screen phase case "blood": background(136, 0, 0); text("YOU DIED!", ...); if (jumpscare.bloodFadeTimer >= jumpscare.bloodFadeDuration) { jumpscare.phase = "fadeToBlack"; }

Shows a deep red screen with death message, then transitions to fade out

case Fade to black phase case "fadeToBlack": ... fill(0, alpha); rect(...); if (jumpscare.bloodFadeTimer >= jumpscare.bloodFadeDuration) { jumpscare.isActive = false; resetGame(); }

Gradually darkens the screen, then resets the game

arithmetic Monster animation let monsterScale = map(jumpscare.animationFrame, 0, jumpscare.duration * 0.5, 0.1, 1.2, true);

Scales the monster from small (0.1) to large (1.2) over the first half of the jumpscare duration

arithmetic Red text flashing let redFlash = map(sin(jumpscare.animationFrame * 0.2), -1, 1, 100, 255);

Uses sin() to make the red channel oscillate, creating a pulsing red text effect

switch (jumpscare.phase) {
Routes the jumpscare rendering based on the current phase (monster, blood, or fadeToBlack)
background(0);
Clears the screen to black for the monster phase
translate(width / 2, height / 2);
Moves the drawing origin to the center of the screen so the monster scales from the center
let monsterScale = map(jumpscare.animationFrame, 0, jumpscare.duration * 0.5, 0.1, 1.2, true);
Remaps animation frame (0 to 60) to scale (0.1 to 1.2), with clamping—the monster grows bigger over time
scale(2.5);
Scales everything drawn inside this push/pop by 2.5x—makes the monster HUGE for maximum terror
let redFlash = map(sin(jumpscare.animationFrame * 0.2), -1, 1, 100, 255);
Uses sin() (oscillates -1 to 1) to map to red values (100 to 255)—creates a pulsing red flash effect
jumpscare.animationFrame++;
Increments the animation counter each frame so the monster grows
if (jumpscare.animationFrame >= jumpscare.duration * 0.5) { jumpscare.phase = "blood"; }
After the monster phase lasts 60 frames (half of 120), switch to the blood phase
background(136, 0, 0);
The blood phase fills the screen with deep red (RGB: 136, 0, 0)
let fadeProgress = jumpscare.bloodFadeTimer / jumpscare.bloodFadeDuration;
Calculates how far through the fade we are (0 to 1)
let alpha = lerp(0, 255, fadeProgress);
Interpolates from 0 (transparent) to 255 (opaque) based on progress
fill(0, alpha); rect(0, 0, width, height);
Draws a black rectangle with increasing opacity, gradually darkening the screen
resetGame();
Once the fade is complete, resets the game—player returns to the safe zone, monsters respawn, the game continues

touchStarted()

touchStarted() handles all touch input on the first frame a touch is registered. It checks for button collisions (VR toggle, run button), requests permissions for device orientation, and activates the joystick if touched on the left. The newTouch pattern is more robust than checking mouseX/mouseY because multiple touches can be active simultaneously. The permission request is iOS-specific—Android doesn't require this, but the code handles both gracefully.

function touchStarted() {
  let vrButtonX = width * 0.1;
  let vrButtonY = height * 0.1;
  let vrButtonW = 120;
  let vrButtonH = 60;

  let newTouch = touches[touches.length - 1];

  if (gameStart && rectCollision(newTouch.x, newTouch.y, 1, 1, vrButtonX - vrButtonW / 2, vrButtonY - vrButtonH / 2, vrButtonW, vrButtonH)) {
    vrMode = !vrMode;
    console.log(`VR Mode: ${vrMode ? "ON" : "OFF"}`);
    if (vrMode && !deviceOrientationGranted) {
      if (typeof DeviceOrientationEvent !== 'undefined' && typeof DeviceOrientationEvent.requestPermission === 'function') {
        DeviceOrientationEvent.requestPermission()
          .then(permissionState => {
            if (permissionState === 'granted') {
              deviceOrientationGranted = true;
              window.addEventListener('deviceorientation', handleDeviceOrientation);
              console.log('Device orientation permission granted.');
            } else {
              console.warn('Device orientation permission denied.');
              deviceOrientationGranted = false;
              vrMode = false;
            }
          })
          .catch(console.error);
      } else {
        window.addEventListener('deviceorientation', handleDeviceOrientation);
        deviceOrientationGranted = true;
      }
    }
    return false;
  }

  if (gameStart && rectCollision(newTouch.x, newTouch.y, 1, 1, runButton.x - runButton.w / 2, runButton.y - runButton.h / 2, runButton.w, runButton.h)) {
    if (!runButton.active) {
      player.isRunning = true;
      runButton.active = true;
      runButton.touchId = newTouch.id;
      console.log(`Running: ${player.isRunning}`);
    }
    return false;
  }

  if (!gameStart) {
    gameStart = true;
    userStartAudio();

    if (vrMode && typeof DeviceOrientationEvent !== 'undefined' && typeof DeviceOrientationEvent.requestPermission === 'function') {
      DeviceOrientationEvent.requestPermission()
        .then(permissionState => {
          if (permissionState === 'granted') {
            deviceOrientationGranted = true;
            window.addEventListener('deviceorientation', handleDeviceOrientation);
            console.log('Device orientation permission granted.');
          } else {
            console.warn('Device orientation permission denied.');
            deviceOrientationGranted = false;
            vrMode = false;
          }
        })
        .catch(console.error);
    } else if (vrMode) {
      window.addEventListener('deviceorientation', handleDeviceOrientation);
      deviceOrientationGranted = true;
    }

    ambientSoundOsc.amp(0.5, 1);
    ambientSoundNoise.amp(0.2, 1);

    return false;
  }

  if (newTouch.x < joystick.maxX) {
    if (!joystick.active) {
      joystick.active = true;
      joystick.touchId = newTouch.id;
      joystick.knobX = newTouch.x;
      joystick.knobY = newTouch.y;
    }
  }

  return false;
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

conditional VR toggle detection if (gameStart && rectCollision(newTouch.x, newTouch.y, 1, 1, vrButtonX - vrButtonW / 2, vrButtonY - vrButtonH / 2, vrButtonW, vrButtonH))

Checks if the touch hits the VR mode button and toggles it

promise Device orientation permission DeviceOrientationEvent.requestPermission() .then(permissionState => { ... });

Requests iOS permission to access device orientation when VR is enabled

conditional Run button detection if (gameStart && rectCollision(newTouch.x, newTouch.y, 1, 1, runButton.x - runButton.w / 2, runButton.y - runButton.h / 2, runButton.w, runButton.h))

Checks if touch hits the run button and activates running

conditional Game start gate if (!gameStart) { gameStart = true; userStartAudio(); ... }

On the first touch, starts the game and audio context

conditional Joystick touch detection if (newTouch.x < joystick.maxX) { joystick.active = true; joystick.touchId = newTouch.id; ... }

If touch is on the left side, activate the joystick

let newTouch = touches[touches.length - 1];
Gets the most recent touch that just started—p5.js's touches array includes all active touches
if (gameStart && rectCollision(...vrButtonX..., ...vrButtonW...)) {
Checks if this touch overlaps the VR toggle button (only after game has started)
vrMode = !vrMode;
Toggles VR mode on/off
if (vrMode && !deviceOrientationGranted) {
If VR is now on but we don't have permission yet, request it
DeviceOrientationEvent.requestPermission()
iOS-specific API that asks the user for permission to access device orientation
if (permissionState === 'granted') { deviceOrientationGranted = true; window.addEventListener('deviceorientation', handleDeviceOrientation); }
If the user grants permission, set the flag and add the listener to receive orientation events
if (!gameStart) { gameStart = true; userStartAudio(); ... }
On the very first touch (before gameStart), initialize the audio context and start the game loop
ambientSoundOsc.amp(0.5, 1); ambientSoundNoise.amp(0.2, 1);
Fades in the ambient audio over 1 second—creates the horror atmosphere as the game begins
if (newTouch.x < joystick.maxX) { joystick.active = true; joystick.touchId = newTouch.id; }
If the touch is on the left side of the screen (< 40% width), it activates the joystick

📦 Key Variables

gameStart boolean

Tracks whether the player has touched to begin the game—gates the start screen until true

let gameStart = false;
vrMode boolean

Toggles whether device orientation head-tracking is active—controls camera panning

let vrMode = false;
currentRoom string

Stores the name of the room the player is currently in (e.g., 'floor0', 'mainRoom')—controls which monsters and items are visible

let currentRoom = "mainRoom";
rooms object

A dictionary of room objects, each with position (x, y), size (w, h), color, and floor number—defines the game world layout

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

The player object with 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 patrol the game world and hunt the player

let monsters = [];
collectibles array

Array of item objects (keys) scattered in rooms that the player can pick up

let collectibles = [];
playerInventory array

Array of items the player has collected—displayed in the UI

let playerInventory = [];
joystick object

Mobile input joystick with base position (baseX, baseY), knob position (knobX, knobY), and active state

let joystick = { baseX: 0, baseY: 0, radius: 60, knobX: 0, knobY: 0, active: false, touchId: -1, maxX: 0 };
cameraX number

The horizontal camera offset—used in translate(-cameraX, -cameraY) to center the game world on the player

let cameraX = 0;
cameraY number

The vertical camera offset for top-down camera control

let cameraY = 0;
jumpscare object

Tracks jumpscare state: isActive (whether scare is playing), phase (monster/blood/fadeToBlack), timer and animation frame

let jumpscare = { isActive: false, timer: 0, duration: 120, animationFrame: 0, phase: "monster", bloodFadeTimer: 0, bloodFadeDuration: 30 };
deviceGamma number

Device left-right tilt in degrees (-90 to 90), read from deviceorientation event—used for VR horizontal look

let deviceGamma = 0;
deviceBeta number

Device front-back tilt in degrees (-180 to 180)—used for VR vertical look

let deviceBeta = 0;
ambientSoundOsc p5.Oscillator

A sine wave oscillator (50 Hz) that plays the low drone background audio

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

White noise that passes through a low-pass filter to create a hiss/static texture

let ambientSoundNoise = new p5.Noise('white');
footstepOsc p5.Oscillator

A sine wave oscillator that plays footstep sounds when the player moves

let footstepOsc = new p5.Oscillator('sine');
jumpscareOsc p5.Oscillator

A sawtooth wave oscillator that creates the terrifying screech during jumpscare

let jumpscareOsc = new p5.Oscillator('sawtooth');
collectOsc p5.Oscillator

A triangle wave oscillator that plays a chime when collecting items

let collectOsc = new p5.Oscillator('triangle');

🔧 Potential Improvements (7)

Here are some ways this code could be enhanced:

BUG Monster.update() / pathfinding logic

Monsters sometimes get stuck at staircase edges when attempting to transition between floors if the staircase boundary is tight

💡 Add a buffer zone around staircase positions before attempting transition. For example, check `dist(this.x, this.y, staircase.x + staircase.w / 2, staircase.y + staircase.h / 2) < 10` instead of relying solely on rectCollision, which may be too precise.

PERFORMANCE Monster.update() / chase logic

Every monster searches through all staircases every frame during chase, even when on the same floor as the player—wasteful CPU usage

💡 Add a guard before the staircase loop: `if (playerFloor === monsterFloor) { ... direct chase instead ...; return; }` so cross-floor pathfinding is skipped when it's not needed.

BUG drawMonsterTracker()

The monster tracker calculates distance as `let d = dist(this.x, this.y, ...)` but `this` is undefined in that context—should be `dist(nearestMonster.x, nearestMonster.y, ...)`

💡 Replace `dist(this.x, this.y, player.x, player.y)` with `dist(nearestMonster.x, nearestMonster.y, player.x, player.y)` so the correct distance is calculated.

STYLE preload() / audio initialization

Oscillators and noise generators are all started with .start() but amp() is set to 0 initially—starting silent oscillators wastes CPU

💡 Remove the `.start()` calls from preload and instead call them only when audio is needed (in triggerJumpscare, updatePlayer footsteps, etc.). This is more efficient and is how p5.sound is meant to be used.

FEATURE collectibles

Keys are collected but have no gameplay purpose—inventory is displayed but items can't be used

💡 Add locked doors that require specific keys, or treasure chests that spawn keys when opened. This gives the collectibles meaning and encourages exploration.

BUG jumpscare phase transition

If the game is reset during a jumpscare (edge case), the jumpscareOsc.stop() timeout may still fire and cause errors

💡 Store the timeout ID and clear it in resetGame(): `let jumpscareTimeout = null;` then `jumpscareTimeout = setTimeout(...);` and `clearTimeout(jumpscareTimeout);` in resetGame().

PERFORMANCE draw() / collision detection

Collectible and staircase collision checks happen every frame even if the player isn't in the right room, wasting computation

💡 Filter collectibles and staircases by currentRoom before looping, or add early-exit conditions: `if (item.room !== currentRoom) continue;`

🔄 Code Flow

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

💡 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" setup --> canvas-creation[Canvas Creation] setup --> rooms-definition[Room Definitions] setup --> player-initialization[Player Initialization] setup --> staircases-setup[Staircase Setup] setup --> joystick-init[Joystick Initialization] setup --> spawn-monsters[Spawn Monsters] setup --> device-orientation-listener[Device Orientation Listener] click canvas-creation href "#sub-canvas-creation" click rooms-definition href "#sub-rooms-definition" click player-initialization href "#sub-player-initialization" click staircases-setup href "#sub-staircases-setup" click joystick-init href "#sub-joystick-init" click spawn-monsters href "#sub-spawn-monsters" click device-orientation-listener href "#sub-device-orientation-listener" draw --> start-screen-check[Start Screen Check] draw --> jumpscare-check[Jumpscare Check] draw --> game-update[Game Update] draw --> camera-translation[Camera Translation] draw --> ui-render[UI Render] click start-screen-check href "#sub-start-screen-check" click jumpscare-check href "#sub-jumpscare-check" click game-update href "#sub-game-update" click camera-translation href "#sub-camera-translation" click ui-render href "#sub-ui-render" game-update --> updateplayer[Update Player] game-update --> updatemonsters[Update Monsters] click updateplayer href "#fn-updateplayer" click updatemonsters href "#fn-updatemonsters" updateplayer --> speed-selection[Speed Selection] updateplayer --> position-update[Position Update] updateplayer --> movement-check[Movement Check] updateplayer --> footstep-sound[Footstep Sound] updateplayer --> collectible-collision[Collectible Collision] updateplayer --> staircase-transition[Staircase Transition] updateplayer --> room-boundaries[Room Boundaries] click speed-selection href "#sub-speed-selection" click position-update href "#sub-position-update" click movement-check href "#sub-movement-check" click footstep-sound href "#sub-footstep-sound" click collectible-collision href "#sub-collectible-collision" click staircase-transition href "#sub-staircase-transition" click room-boundaries href "#sub-room-boundaries" updatemonsters --> monster-loop[Monster Loop] click monster-loop href "#sub-monster-loop" monster-loop --> constructor[Constructor] click constructor href "#sub-constructor" constructor --> position-update-1[Position Update] constructor --> wall-collision[Wall Collision] constructor --> chase-detection[Chase Detection] constructor --> roam-logic[Roam Logic] constructor --> chase-logic[Chase Logic] constructor --> safe-zone-barrier[Safe Zone Barrier] constructor --> player-collision[Player Collision] constructor --> draw-method[Draw Method] click position-update-1 href "#sub-position-update-1" click wall-collision href "#sub-wall-collision" click chase-detection href "#sub-chase-detection" click roam-logic href "#sub-roam-logic" click chase-logic href "#sub-chase-logic" click safe-zone-barrier href "#sub-safe-zone-barrier" click player-collision href "#sub-player-collision" click draw-method href "#sub-draw-method" draw-method --> translate-center[Translate Center] translate-center --> body[Body] translate-center --> head[Head] translate-center --> eyes[Eyes] translate-center --> mouth[Mouth] translate-center --> arms[Arms] click translate-center href "#sub-translate-center" click body href "#sub-body" click head href "#sub-head" click eyes href "#sub-eyes" click mouth href "#sub-mouth" click arms href "#sub-arms" draw --> updatecamera[Update Camera] updatecamera --> base-camera[Base Camera] updatecamera --> vr-offset[VR Offset] updatecamera --> gamma-mapping[Gamma Mapping] updatecamera --> world-clamping[World Clamping] updatecamera --> smoothing[Smoothing] click updatecamera href "#fn-updatecamera" click base-camera href "#sub-base-camera" click vr-offset href "#sub-vr-offset" click gamma-mapping href "#sub-gamma-mapping" click world-clamping href "#sub-world-clamping" click smoothing href "#sub-smoothing" jumpscare-check --> guard-clause[Guard Clause] jumpscare-check --> triggerjumpscare[Trigger Jumpscare] click guard-clause href "#sub-guard-clause" click triggerjumpscare href "#fn-triggerjumpscare" triggerjumpscare --> state-activation[State Activation] triggerjumpscare --> silence-ambient[Silence Ambient] triggerjumpscare --> noise-burst[Noise Burst] triggerjumpscare --> screech-effect[Screech Effect] click state-activation href "#sub-state-activation" click silence-ambient href "#sub-silence-ambient" click noise-burst href "#sub-noise-burst" click screech-effect href "#sub-screech-effect" screech-effect --> screech-stop[Screech Stop] click screech-stop href "#sub-screech-stop" drawjumpscare[Draw Jumpscare] --> phase-switch[Phase Switch] click drawjumpscare href "#fn-drawjumpscare" click phase-switch href "#sub-phase-switch" phase-switch --> monster-phase[Monster Phase] phase-switch --> blood-phase[Blood Phase] phase-switch --> fade-phase[Fade Phase] click monster-phase href "#sub-monster-phase" click blood-phase href "#sub-blood-phase" click fade-phase href "#sub-fade-phase" monster-phase --> monster-scaling[Monster Scaling] monster-phase --> red-flash[Red Flash] click monster-scaling href "#sub-monster-scaling" click red-flash href "#sub-red-flash" fade-phase --> resetGame[Reset Game] click resetGame href "#fn-resetGame" touchstarted[Touch Started] --> vr-button-check[VR Button Check] touchstarted --> run-button-check[Run Button Check] touchstarted --> joystick-activation[Joystick Activation] touchstarted --> game-start[Game Start] click touchstarted href "#fn-touchstarted" click vr-button-check href "#sub-vr-button-check" click run-button-check href "#sub-run-button-check" click joystick-activation href "#sub-joystick-activation" click game-start href "#sub-game-start"

❓ Frequently Asked Questions

What visual experience does the Lethal Ape Redux sketch provide?

The sketch creates a spooky top-down maze with dimly lit atmospheric rooms, where players navigate through eerie environments filled with monsters and collectibles.

How can users interact with the Lethal Ape Redux sketch?

Users can tilt their devices or use a joystick to control their character's movement and camera angle while exploring the maze.

What creative coding concepts are demonstrated in the Lethal Ape Redux sketch?

The sketch showcases procedural generation for sound effects and room layouts, as well as real-time interaction through device orientation and joystick controls.

Preview

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