Lethal ape: redux revamp update

This sketch creates a tense top-down maze exploration game where the player navigates multi-room levels while dodging intelligent roaming monsters. The camera smoothly follows the player, UI buttons enable running and menu interactions, and procedural sounds build an eerie horror atmosphere.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make the player run by default — Change player.isRunning to true in setup() so the player sprints from the start—feel how the game becomes easier and less tense.
  2. Add more monsters to the game — Increase MAX_MONSTERS from 5 to 8 or 10—more enemies spawn on the floors and the game becomes significantly harder.
  3. Make monsters permanently faster — Increase MONSTER_CHASE_SPEED from 2.5 to 4 so monsters can catch the player more easily—notice how the difficulty spikes.
  4. Recolor the main room — Change the mainRoom color from '#333333' to '#1a0000' (dark red) to give the safe zone a more ominous feel.
  5. Extend the jumpscare duration — Increase jumpscare.duration from 120 to 240 frames (4 seconds instead of 2) so the monster stays on screen longer and is more terrifying.
  6. Make the player smaller — Reduce player dimensions from 30x30 to 15x15 so the character appears more fragile and vulnerable.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch is a complete maze exploration game built in p5.js that combines procedural drawing, camera tracking, collision detection, and hierarchical audio design to create a tense horror experience. Players explore a multi-floor dungeon from a fixed top-down perspective, dodging monsters that roam each room and chase when nearby. The visual design relies entirely on procedural shapes—no image assets—while procedural oscillators and noise generators craft an unsettling soundscape. The sketch teaches sophisticated game architecture: state machines controlling game phases (loading, start screen, gameplay, menus, jumpscares), touch-based joystick input, smooth camera following with device orientation support, and AI pathfinding for monsters across room transitions.

The code is organized into several interconnected systems: a setup() function that defines room geometry and initializes audio; a draw() function that dispatches to different render functions based on gameState; updatePlayer() and updateMonsters() that handle collision detection and room transitions; and specialized drawing functions for UI elements, collectibles, and interactive machines. By studying this sketch, you'll learn how to structure a multi-screen game with menus, implement smooth camera following with lerp(), design a touch joystick from first principles, create state-aware AI that navigates multi-room environments, and synthesize procedural audio with p5.sound to enhance immersion.

⚙️ How It Works

  1. When the sketch loads, preload() initializes all procedural audio: ambient oscillators and noise for background drones, envelope-controlled synthesis for footsteps and jumpscare screams, and delay effects for a boombox echo. setup() creates the canvas, defines five floors as connected rooms in a global coordinate system, spawns the player in the main safe room, and initializes five monsters across the floors.
  2. The draw() function runs 60 times per second and dispatches based on gameState: 'loading' shows a 3-second startup screen with procedural audio, 'startScreen' displays touch-to-start instructions, and 'game' is the main loop that updates player position, monster AI, and camera, then renders the world with camera translation applied.
  3. In the game state, updatePlayer() reads joystick and keyboard input to set the player's velocity (with separate speeds for walk vs. run), checks for collectible pickups and scrap, handles room transitions via staircases with a cooldown to prevent repeated triggers, and updates audio based on proximity to the boombox.
  4. updateMonsters() runs each monster's AI: monsters roam randomly within their room until a player enters that room within chase range, then pursue the player. If the player is on a different floor (determined by room metadata), monsters path-find toward staircases that lead closer to the player's floor, then transition rooms and continue chasing—implementing cross-room enemy intelligence.
  5. updateCamera() calculates the target camera position centered on the player, optionally applies device orientation (gamma/beta from phone tilt) when VR mode is active, clamps the camera to world boundaries, and smoothly lerps toward the target to prevent jarring snaps.
  6. When a monster touches the player, triggerJumpscare() plays a harsh procedural screech (sawtooth oscillator frequency sweep), silences ambient audio, sets gameState to 'jumpscare', and animates a multi-phase jumpscare: the monster scales up and fills the screen with red flashing text, then transitions to a blood-red screen, and fades to black before resetting the game back to the start screen.

🎓 Concepts You'll Learn

Game state machine (loading, menus, gameplay, jumpscare)Camera tracking and smooth lerp interpolationProcedural audio synthesis with p5.sound oscillators and envelopesTouch input and mobile joystick from scratchCollision detection (AABB) for pickups and room transitionsHierarchical room system with world-space coordinatesMonster AI with cross-room pathfindingDevice orientation (gyroscope) for VR camera controlMulti-phase animation sequences (jumpscare three-act structure)

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. It is the place to initialize all game objects (player, monsters, rooms, UI buttons) and set up the p5.sound audio context. Understanding setup() is critical because every game object you reference later in draw() must be created here first.

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

  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 }
      ],
      computer: { x: 300, y: 100, w: 60, 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;

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

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

  spawnCollectibles();

  if (typeof DeviceOrientationEvent !== 'undefined' && typeof DeviceOrientationEvent.requestPermission === 'function') {
  } else {
    window.addEventListener('deviceorientation', handleDeviceOrientation);
    deviceOrientationGranted = true;
  }

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

🔧 Subcomponents:

object-initialization Room Geometry Setup rooms = { mainRoom: {...}, floor0: {...}, floor1: {...} };

Defines the world as a collection of named rooms with x, y, width, height coordinates, background colors, and floor numbers used by AI for pathfinding

object-initialization 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, dimensions, velocity, and state flags for animation

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

Populates the monsters array with five randomly positioned enemies across the floors

createCanvas(windowWidth, windowHeight);
Creates a canvas that fills the entire window—this full-screen approach is ideal for immersive mobile games
noStroke();
Disables outlines on all shapes so procedural drawing looks cleaner and less cluttered
textAlign(CENTER, CENTER);
Sets all text to center horizontally and vertically, making UI text positioning intuitive (text drawn at x,y appears centered)
rooms = { mainRoom: {...}, floor0: {...} };
Defines the entire game world as a collection of connected rooms, each with position, size, color, and a floor number for AI pathfinding logic
player = { x: rooms.mainRoom.x + 100, y: rooms.mainRoom.y + 100, ... };
Initializes the player at a fixed spawn position in the main room with zero velocity and isMoving/isRunning flags for state management
for (let i = 0; i < MAX_MONSTERS; i++) { spawnMonster(); }
Loops to spawn five monsters spread randomly across floor rooms (never in main room or monster room)
spawnCollectibles();
Populates the collectibles array with keys and scrap pieces across all floors based on mod settings
gameState = "loading";
Sets the game to display the loading screen on first frame before transitioning to the start screen

draw()

draw() is the heart of p5.js animation—it runs 60 times per second (60 FPS) and controls all rendering and game logic. The switch statement is a state machine pattern that lets a single draw() function handle vastly different game phases (loading, gameplay, menus, jumpscare). Separating logic into update functions (updatePlayer, updateMonsters) and drawing functions (drawPlayer, drawMonsters) keeps the code organized and makes it easier to debug and extend.

function draw() {
  background(0);

  switch (gameState) {
    case "loading":
      drawLoadingScreen();
      break;
    case "startScreen":
      drawStartScreen();
      break;
    case "game":
      updatePlayer();
      updateMonsters();
      updateCamera();

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

      drawRooms();
      drawStaircases();

      if (currentRoom === "mainRoom") {
        drawBoombox(boombox.x, boombox.y, boombox.w, boombox.h);
      }

      if (currentRoom === "mainRoom") {
        drawScrapMachine(rooms.mainRoom.scrapMachine.x, rooms.mainRoom.scrapMachine.y, rooms.mainRoom.scrapMachine.w, rooms.mainRoom.scrapMachine.h);
      }

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

      if (currentRoom === "mainRoom") {
        drawComputer(rooms.mainRoom.computer.x, rooms.mainRoom.computer.y, rooms.mainRoom.computer.w, rooms.mainRoom.computer.h);
      }

      drawPlayer();
      drawMonsters();
      drawCollectibles();

      pop();

      drawJoystick();
      drawMonsterTracker();
      drawInventory();
      drawVRToggleButton();
      drawRunButton();
      drawModMenuButton();
      drawScrapMachineButton();
      drawLockerButton();
      drawComputerButton();
      break;
    case "jumpscare":
      drawJumpscare();
      break;
    case "modMenu":
      drawModMenu();
      break;
    case "scrapMachine":
      drawScrapMachineUI();
      break;
    case "lockerMenu":
      drawLockerMenuUI();
      break;
    case "multiplayerMenu":
      drawMultiplayerMenu();
      break;
    case "monsterRoomView":
      drawMonsterRoomView();
      break;
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

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

Routes all rendering and update logic based on current game phase—loading screen, start screen, active gameplay, menus, jumpscares, or special views

calculation Camera Translation Block push(); translate(-cameraX, -cameraY); drawRooms(); ... pop();

Applies camera offset so the entire world translates as if following the player; push/pop ensures UI stays on-screen

for-loop UI Rendering Sequence drawJoystick(); drawMonsterTracker(); drawInventory(); drawVRToggleButton(); ... drawComputerButton();

Draws all UI elements after the world so they appear on top and are never affected by camera translation

background(0);
Fills the entire canvas with black (0 on 0–255 RGB scale) every frame, erasing the previous frame and preventing motion trails
switch (gameState) { case "loading": ... case "game": ... }
Dispatches to different draw functions based on gameState: the state machine pattern that controls which UI and game logic runs each frame
updatePlayer(); updateMonsters(); updateCamera();
Runs the core game logic each frame: moves the player based on input, updates monster AI and position, and calculates the new camera target
push(); translate(-cameraX, -cameraY);
Saves the current graphics state and applies a camera offset that shifts the entire world to follow the player, making the camera appear to track smoothly
drawRooms(); drawStaircases(); drawPlayer(); drawMonsters(); drawCollectibles();
Renders all world objects (rooms, interactive elements, player, enemies, pickups) in sequence; all coordinates are in world space and affected by camera translation
pop();
Restores the graphics state, ending camera translation so subsequent UI draws (joystick, inventory) appear at fixed screen positions
drawJoystick(); drawMonsterTracker(); drawInventory(); ... drawComputerButton();
Draws all UI elements after camera restoration, so they stay fixed on screen and are always visible regardless of camera position

updatePlayer()

updatePlayer() runs every frame and is the core of movement logic. It combines input handling (joystick velocity), physics (position += velocity * speed), collision detection (player vs. items, player vs. staircases), and audio control (footsteps, proximity-based boombox volume). This function teaches collision detection, spatial audio, and cooldown timers—all essential game programming concepts. Notice how it separates concerns: update position, check collisions, update audio, show UI buttons. Each step is independent and can be modified without breaking others.

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)) {
      if (item.type === "key") {
        playerInventory.push(item);
        collectOsc.freq(random(600, 800));
        collectEnvelope.play(collectOsc);
        console.log(`Grabbed a ${item.type}! Inventory:`, playerInventory);
      } else if (item.type === "scrap") {
        playerScrap += 1;
        scrapCollectOsc.freq(random(200, 300));
        scrapCollectEnv.play(scrapCollectOsc);
        console.log(`Grabbed some scrap! Scrap: ${playerScrap}`);
      }
      collectibles.splice(i, 1);
    }
  }

  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);
          console.log(`Transitioned to ${currentRoom}`);
          break;
        }
      }
    }

    if (hasTransitioned) {
      if (currentRoom === "monsterRoom") {
        gameState = "monsterRoomView";
      }
      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 && touches.length === 0) {
    player.vx = 0;
    player.vy = 0;
  }

  if (currentRoom === "mainRoom") {
    lobbySoundEnv.play(lobbySoundOsc);

    let d = dist(player.x, player.y, boombox.x, boombox.y);
    let targetAmp = 0;
    let targetDelayAmp = 0;

    if (d < boombox.nearbyThreshold) {
      targetAmp = map(d, boombox.nearbyThreshold - boombox.fadeZone, boombox.nearbyThreshold, 0.5, 0, true);
      targetDelayAmp = map(d, boombox.nearbyThreshold - boombox.fadeZone, boombox.nearbyThreshold, 0.8, 0, true);
    } else {
      targetAmp = 0;
      targetDelayAmp = 0;
    }

    boombox.osc.amp(targetAmp, 0.5);
    boombox.delay.amp(targetDelayAmp, 0.5);
  } else {
    lobbySoundOsc.amp(0, 0.5);
    boombox.osc.amp(0, 0.5);
    boombox.delay.amp(0, 0.5);
  }

  scrapMachineButton.visible = false;
  lockerButton.visible = false;
  computerButton.visible = false;

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

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

    let computerData = rooms.mainRoom.computer;
    if (rectCollision(player.x, player.y, player.w, player.h, computerData.x, computerData.y, computerData.w, computerData.h)) {
      computerButton.visible = true;
    }
  }
}
Line-by-line explanation (12 lines)

🔧 Subcomponents:

calculation Speed Modifier let currentSpeed = player.isRunning ? PLAYER_RUN_SPEED : PLAYER_SPEED;

Selects between normal and fast speed based on whether the run button is held, allowing dynamic speed changes each frame

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

Updates player position based on velocity (input direction) and current speed, creating smooth continuous movement

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

Iterates backwards through collectibles to detect overlaps with player and remove picked-up items safely without breaking iteration

conditional Room Transition Logic if (rectCollision(player.x, player.y, player.w, player.h, staircase.x, staircase.y, staircase.w, staircase.h)) { ... }

Checks if player is touching a staircase and transfers player to the target room while enforcing a cooldown to prevent repeated triggers

conditional Distance-Based Audio Fading let d = dist(player.x, player.y, boombox.x, boombox.y); if (d < boombox.nearbyThreshold) { targetAmp = map(...); }

Calculates distance to boombox and fades audio volume up/down based on proximity, creating spatial audio immersion

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

Shows interaction buttons only when the player is near machines or lockers, reducing UI clutter and guiding player intent

let currentSpeed = player.isRunning ? PLAYER_RUN_SPEED : PLAYER_SPEED;
Uses a ternary operator to select between two constant speeds: PLAYER_RUN_SPEED (5) when running, PLAYER_SPEED (3) when walking
player.x += player.vx * currentSpeed;
Multiplies the input direction (vx) by the current speed and adds it to the player's x position each frame, creating movement
player.isMoving = (abs(player.vx) > 0.1 || abs(player.vy) > 0.1);
Sets a flag to true if the player is moving (velocity is non-zero), used to trigger footstep sounds and animations
if (footstepCounter >= FOOTSTEP_INTERVAL) { footstepOsc.freq(random(100, 150)); footstepEnvelope.play(footstepOsc); }
Every 25 frames (FOOTSTEP_INTERVAL), plays a procedural footstep sound by triggering an oscillator envelope with a randomized pitch for realism
for (let i = collectibles.length - 1; i >= 0; i--) {
Iterates backwards (descending index) through the collectibles array—this is safe because when you splice (remove) an element, indices behind it don't shift
if (item.room === currentRoom && rectCollision(player.x, player.y, player.w, player.h, item.x, item.y, item.w, item.h)) {
Checks two conditions: the item is in the player's current room AND the player's bounding box overlaps the item's bounding box (AABB collision)
if (item.type === "key") { playerInventory.push(item); collectOsc.freq(random(600, 800)); collectEnvelope.play(collectOsc); }
If the item is a key, adds it to the inventory and plays a high-pitched ding sound with randomized pitch; if it's scrap, increments the scrap counter and plays a lower-pitched sound
collectibles.splice(i, 1);
Removes the collected item from the collectibles array so it no longer renders or collides in future frames
if (canTransition) { if (rectCollision(...)) { currentRoom = staircase.targetRoom; player.x = staircase.targetPlayerX; ... setTimeout(() => { canTransition = true; }, TRANSITION_COOLDOWN); } }
Only allows a room transition if the cooldown flag is true; when player touches a staircase, immediately warps player position to the target room and disables transitions for 500ms to prevent rapid re-triggering
let currentRoomData = rooms[currentRoom]; player.x = constrain(player.x, currentRoomData.x, currentRoomData.x + currentRoomData.w - player.w);
Clamps the player's x position to the bounds of the current room so the player cannot walk off the edges of the level
let d = dist(player.x, player.y, boombox.x, boombox.y); if (d < boombox.nearbyThreshold) { targetAmp = map(d, ..., 0.5, 0, true); }
Calculates distance to the boombox and uses map() to interpolate the audio volume: closer distance = louder sound, farther distance = quieter sound
if (rectCollision(player.x, player.y, player.w, player.h, scrapMachineData.x, scrapMachineData.y, scrapMachineData.w, scrapMachineData.h)) { scrapMachineButton.visible = true; }
Shows the interaction button for the scrap machine only when the player is standing on top of it, encouraging discovery through proximity

updateMonsters()

updateMonsters() is deliberately simple—it just loops and calls update() on each monster. The actual AI logic lives in the Monster class's update() method. This separation of concerns keeps the main draw loop clean and makes it easy to add or remove monsters or extend AI behavior without touching draw().

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

drawPlayer()

drawPlayer() demonstrates procedural character drawing using simple geometric shapes scaled relative to the player's width and height. The use of translate() centers the coordinate system on the player's position, making it easy to position facial features symmetrically. This modular approach means you can change player dimensions in setup() and all the features scale automatically—try doubling player.w and player.h in setup() and watch the character grow with all features intact.

🔬 These lines draw two white circles (eyes) and then two red circles (pupils) on top. What happens if you remove the second set of fill(255, 0, 0) lines? Notice the eyes become fully white without pupils. Now try changing the pupil size from 0.1 to 0.2—they become bigger, making the character look more surprised.

  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:

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

Draws the main body as a dark red ellipse centered at the player's position

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

Draws the head as an even darker red ellipse above the body

calculation Eyes Drawing fill(255); 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);

Draws white eye backgrounds and red pupils to give the character expression and personality

push(); translate(player.x + player.w / 2, player.y + player.h / 2);
Saves graphics state and moves the origin to the center of the player's bounding box so all subsequent shapes are drawn relative to that center point
fill(200, 0, 0); ellipse(0, 0, player.w * 1.2, player.h * 1.5);
Draws a dark red body as an ellipse centered at (0, 0)—which is now the player's center due to translate()
fill(150, 0, 0); ellipse(0, -player.h * 0.7, player.w * 0.8, player.h * 0.8);
Draws an even darker red head positioned above the body (negative y) and slightly smaller than the body
fill(255); ellipse(-player.w * 0.2, -player.h * 0.8, player.w * 0.2, player.h * 0.2);
Draws white eye backgrounds as circles positioned on the head, offset left and right
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, creating a focused, somewhat menacing gaze
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 simple red horizontal line for a mouth, creating expression without needing complex shapes
stroke(200, 0, 0); strokeWeight(4); line(-player.w * 0.6, -player.h * 0.3, -player.w * 0.2, 0);
Draws arms as thick red lines extending left and right from the body, giving the character a humanoid silhouette
pop();
Restores the graphics state and coordinate system so subsequent draws are not affected by the translate()

Monster class (constructor and update)

The Monster class is the most sophisticated AI in the sketch. Its update() method manages three types of behavior: random roaming (wander aimlessly), player detection (switch to chase when player is nearby), and multi-floor pathfinding (follow staircases toward the player even across rooms). The constructor initializes properties that persist across frames, while update() runs each frame to recalculate state and position. This class teaches OOP design: encapsulation (each monster has its own state), state machines (roaming vs. chasing), and pathfinding (calculating which staircase to use). The draw() method visualizes the monster using procedural shapes with rotation based on velocity direction, making the monster 'face' where it's moving.

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 speedModifier = monsterSpeedFast ? (MONSTER_CHASE_SPEED / MONSTER_SPEED) : 1;
    let currentMonsterSpeed = MONSTER_SPEED * speedModifier;

    if (this.state === "chasing") {
      currentMonsterSpeed = MONSTER_CHASE_SPEED * speedModifier;
    }

    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" && currentRoom !== "monsterRoomView") {
      let d = dist(this.x, this.y, player.x, player.y);
      if (d < MONSTER_CHASE_RANGE) {
        this.state = "chasing";
        this.chaseTimer = this.chaseDuration;
      } else if (this.state === "chasing") {
        this.chaseTimer--;
        if (this.chaseTimer <= 0) {
          this.state = "roaming";
          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";
      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;
        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 && staircase.targetRoom !== "mainRoom" && staircase.targetRoom !== "monsterRoom") {
            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 {
          let baseRoamSpeed = monsterSpeedFast ? MONSTER_CHASE_SPEED : MONSTER_SPEED;
          this.vx = random([-baseRoamSpeed, baseRoamSpeed]);
          this.vy = random([-baseRoamSpeed, baseRoamSpeed]);
        }
      } else {
        this.vx = (player.x > this.x ? 1 : -1) * currentMonsterSpeed;
        this.vy = (player.y > this.y ? 1 : -1) * currentMonsterSpeed;
      }
    }

    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 (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;
          } else if (this.vx < 0 && this.x + this.w > safeRoom.x && this.x < safeRoom.x) {
            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) {
            this.y = safeRoom.y + safeRoom.h;
            this.vy *= -1;
          } else if (this.vy < 0 && this.y + this.h > safeRoom.y && this.y < safeRoom.y) {
            this.y = safeRoom.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 (13 lines)

🔧 Subcomponents:

calculation Monster Constructor constructor(x, y, roomName) { this.x = x; this.y = y; this.state = "roaming"; ... }

Initializes a new monster with position, dimensions, initial roaming velocity, and state variables for AI behavior

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

Detects when monster hits room boundaries and reverses horizontal velocity to bounce off walls

conditional Player Detection if (this.room === currentRoom) { let d = dist(this.x, this.y, player.x, player.y); if (d < MONSTER_CHASE_RANGE) { this.state = "chasing"; } }

Calculates distance to player and switches monster state to chasing when player is within detection range

conditional Cross-Room Pathfinding if (playerFloor !== monsterFloor && monsterFloor !== -1 && this.monsterCanTransition) { for (let staircase of staircases) { ... if (targetFloor moves towards player) { this.room = staircase.targetRoom; } } }

Enables monsters to hunt across multiple rooms by finding and using staircases that move them closer to the player's floor

conditional Safe Zone Barrier if (rectCollision(this.x, this.y, this.w, this.h, safeRoom.x, safeRoom.y, safeRoom.w, safeRoom.h)) { this.vx *= -1; this.x = safeRoom.x + safeRoom.w; }

Prevents monsters from entering the main room by reversing velocity and pushing them back out

constructor(x, y, roomName) {
ES6 class constructor function that runs once when a new Monster is created, initializing all properties
this.vx = random([-MONSTER_SPEED, MONSTER_SPEED]);
Sets initial roaming velocity to a random value of either +1.5 or -1.5, giving the monster a random starting direction
this.state = "roaming";
Sets initial state to 'roaming' so the monster wanders randomly until the player enters its room within chase range
let speedModifier = monsterSpeedFast ? (MONSTER_CHASE_SPEED / MONSTER_SPEED) : 1;
Checks the monsterSpeedFast mod toggle: if true, calculates a multiplier (2.5/1.5 ≈ 1.67) to speed up the monster; otherwise, multiplier is 1 (no change)
this.x += this.vx * currentMonsterSpeed; this.y += this.vy * currentMonsterSpeed;
Updates monster position by adding velocity multiplied by speed—identical to player movement but monsters use their own velocity and speed constants
if (this.x < roomData.x || this.x + this.w > roomData.x + roomData.w) { this.vx *= -1; }
Detects horizontal wall collision and reverses velocity to bounce—the monster bounces off left and right walls
if (this.room === currentRoom && currentRoom !== "mainRoom" && currentRoom !== "monsterRoomView") {
Only runs AI logic if the monster is in the same room as the player AND that room is a playable floor (not main room or special views)
let d = dist(this.x, this.y, player.x, player.y); if (d < MONSTER_CHASE_RANGE) { this.state = "chasing"; }
Calculates distance to player; if within 200 pixels, switches state to 'chasing' and resets chase timer to chase for up to 120 frames
if (playerFloor !== monsterFloor && monsterFloor !== -1 && this.monsterCanTransition) {
Checks if monster should attempt cross-room pathfinding: player is on a different floor AND monster is not in safe zones AND transition cooldown is active
if ((playerFloor > monsterFloor && targetStaircaseFloor > monsterFloor) || (playerFloor < monsterFloor && targetStaircaseFloor < monsterFloor) || (playerFloor === targetStaircaseFloor)) {
Smart pathfinding: only use a staircase if it moves the monster toward the player's floor (e.g., if player is on floor 2 and monster is on floor 0, use stairs that go up)
this.vx = (player.x > this.x ? 1 : -1) * currentMonsterSpeed; this.vy = (player.y > this.y ? 1 : -1) * currentMonsterSpeed;
Direct chase: sets velocity to ±1 in each direction depending on whether player is right/left/above/below, then multiplies by speed to move toward player
if (rectCollision(this.x, this.y, this.w, this.h, safeRoom.x, safeRoom.y, safeRoom.w, safeRoom.h)) { this.vx *= -1; this.x = safeRoom.x + safeRoom.w; }
Safe zone barrier: if monster collides with main room, reverse velocity and snap monster position outside the room boundary to prevent clipping
if (currentRoom === this.room && rectCollision(player.x, player.y, player.w, player.h, this.x, this.y, this.w, this.h)) { triggerJumpscare(); }
Game over condition: if monster touches player, trigger the jumpscare sequence and reset the game

triggerJumpscare()

triggerJumpscare() demonstrates procedural audio synthesis with the p5.sound library and scheduling in Web Audio API. It uses oscillator frequency ramping and amplitude envelopes to create a terrifying screech effect. The function also shows good design practices: the early return prevents bugs, state setup is centralized, and audio scheduling uses currentTime to synchronize multiple synthesis parameters. Understanding this function teaches how professional game audio works—layering noise bursts with controlled oscillator sweeps to create emotional impact.

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

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

  ambientSoundOsc.amp(0, 0.1);
  ambientSoundNoise.amp(0, 0.1);
  lobbySoundOsc.amp(0, 0.1);
  boombox.osc.amp(0, 0.1);
  boombox.delay.amp(0, 0.1);

  jumpscareEnvelope.play(jumpscareNoise);

  let currentTime = getAudioContext().currentTime;

  if (isFinite(2000) && isFinite(0.2) && isFinite(500) && isFinite(0.5) && isFinite(0.8) && isFinite(0.05)) {
      jumpscareOsc.amp(0.8, 0.05, currentTime);
      jumpscareOsc.freq(2000, 0.2, currentTime);
      jumpscareOsc.freq(500, 0.5, currentTime + 0.2);
      jumpscareOsc.amp(0, 0.5, currentTime + 0.2);
      jumpscareOsc.amp(0, 0.1, currentTime + 0.2 + 0.5);
  } else {
      console.error("Jumpscare Oscillator parameters are not finite. Skipping screech effect.");
      jumpscareOsc.amp(0);
  }
}
Line-by-line explanation (13 lines)

🔧 Subcomponents:

conditional Duplicate Prevention if (jumpscare.isActive) return;

Prevents multiple jumpscares from triggering simultaneously by returning early if one is already active

calculation Jumpscare State Initialization jumpscare.isActive = true; jumpscare.timer = jumpscare.duration; jumpscare.animationFrame = 0; jumpscare.phase = "monster"; gameState = "jumpscare";

Sets all jumpscare variables to their initial values and switches the game state so draw() renders the jumpscare UI

calculation Ambient Audio Fade Out ambientSoundOsc.amp(0, 0.1); ambientSoundNoise.amp(0, 0.1); lobbySoundOsc.amp(0, 0.1); boombox.osc.amp(0, 0.1); boombox.delay.amp(0, 0.1);

Fades all ambient and boombox audio to silent over 0.1 seconds so the scary sounds cut through clearly

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

Synthesizes a terrifying frequency sweep: ramp volume and pitch up quickly (2000 Hz), then sweep down to 500 Hz while fading out

if (jumpscare.isActive) return;
Early guard clause: prevents multiple jumpscares from stacking by returning immediately if one is already running
jumpscare.isActive = true;
Sets the flag so future calls to triggerJumpscare() are ignored until the jumpscare completes
jumpscare.timer = jumpscare.duration;
Resets the timer to 120 frames (2 seconds at 60 FPS), which counts down in drawJumpscare()
jumpscare.phase = "monster";
Sets the phase to 'monster' so drawJumpscare() first shows the scaled monster, then transitions to 'blood', then 'fadeToBlack'
gameState = "jumpscare";
Changes the game state to 'jumpscare' so draw() switches to renderingjumpscare visuals instead of gameplay
ambientSoundOsc.amp(0, 0.1); ambientSoundNoise.amp(0, 0.1);
Fades the ambient drone and hiss oscillators/noise to silent over 0.1 seconds (100 milliseconds)
boombox.osc.amp(0, 0.1); boombox.delay.amp(0, 0.1);
Silences the boombox music and its echo effect so the jumpscare screech is the only sound
jumpscareEnvelope.play(jumpscareNoise);
Triggers the white noise burst by playing the envelope attached to the jumpscare noise (harsh static/screaming effect)
let currentTime = getAudioContext().currentTime;
Gets the current Web Audio API time (in seconds from when audio context started) for precise frequency and amplitude scheduling
jumpscareOsc.amp(0.8, 0.05, currentTime);
Schedules the sawtooth oscillator amplitude to fade from 0 to 0.8 over 50 milliseconds (fast attack for shock)
jumpscareOsc.freq(2000, 0.2, currentTime);
Schedules a frequency ramp to 2000 Hz (a very high, piercing note) over 0.2 seconds, creating a rising scream effect
jumpscareOsc.freq(500, 0.5, currentTime + 0.2);
After the pitch rises for 0.2 seconds, schedules a descending ramp to 500 Hz over 0.5 seconds (frequency sweep down)
jumpscareOsc.amp(0, 0.5, currentTime + 0.2);
After 0.2 seconds, fades the volume back to 0 over 0.5 seconds while the pitch is descending, creating a fading-out shriek

updateCamera()

updateCamera() implements smooth camera following—a fundamental game design pattern. It uses lerp() to interpolate the camera position over multiple frames, preventing jarring snaps. The function also integrates device orientation data (gyroscope) for VR head tracking: when the player tilts their phone, the camera offset shifts, creating an immersive 'look around' effect. Clamping ensures the camera never leaves the game world boundaries. Understanding this function teaches camera design, lerp interpolation, and mobile sensor integration—skills used in almost every mobile game.

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

🔧 Subcomponents:

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

Calculates camera position to keep player centered on screen by offsetting camera by half the screen width/height

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

When VR mode is on, adds device tilt (gamma/beta) as an offset to the camera, allowing head tracking to look around

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

Prevents camera from scrolling past the edges of the game world so the view doesn't show empty black space

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

Uses lerp() to gradually move camera toward target instead of snapping instantly, creating fluid motion that feels cinematic

targetCameraX = player.x - width / 2;
Calculates camera X position so player appears at the center of the screen: subtract half the screen width from player X position
targetCameraY = player.y - height / 2;
Calculates camera Y position similarly for vertical centering
if (vrMode && deviceOrientationGranted) {
Only applies device orientation if VR mode is enabled AND the browser has granted permission to read device sensors
if (abs(deviceGamma) > GAMMA_DEAD_ZONE) {
Only applies gamma offset if device tilt exceeds the dead zone (3 degrees)—this prevents jittery camera movements from tiny sensor fluctuations
gammaOffset = map(deviceGamma, -90, 90, -GAMMA_SENSITIVITY * width, GAMMA_SENSITIVITY * width);
Maps device gamma (phone left-right tilt in degrees, range -90 to 90) to a camera offset in pixels: -90 degrees maps to -width pixels, 90 degrees to +width pixels
targetCameraX += gammaOffset;
Adds the gamma-based offset to the base camera position, allowing head tilt to shift the view left/right
let minWorldX = rooms.mainRoom.x; let maxWorldX = rooms.floor4.x + rooms.floor4.w;
Defines the bounds of the game world in world-space coordinates (from mainRoom.x to the right edge of floor 4)
targetCameraX = constrain(targetCameraX, minWorldX, maxWorldX - width);
Clamps camera X so it cannot scroll past the world boundaries: minWorldX prevents scrolling too far left, maxWorldX - width prevents scrolling too far right
smoothedCameraX = lerp(smoothedCameraX, targetCameraX, 0.1);
Interpolates between current smoothed position and target position with a factor of 0.1: moves 10% of the distance toward target each frame for smooth animation
cameraX = smoothedCameraX;
Assigns the smoothed camera position to cameraX, which is used in draw() to translate the world view

touchStarted()

touchStarted() is the entry point for all touch input. It runs once when the user first touches the screen and is critical for mobile game design. The function uses touch IDs to track which touch is controlling which UI element—this allows the player to hold the joystick with one finger and tap the run button with another. The state machine pattern (if gameState === ...) ensures different inputs are handled depending on what screen the player is viewing. By studying this function, you learn multi-touch handling, UI activation, and mobile input design patterns.

function touchStarted() {
  if (touches.length > 1 && touches[touches.length - 1].x === mouseX && touches[touches.length - 1].y === mouseY) {
    return false;
  }

  if (gameState === "loading") return false;

  if (gameState === "game" && isHovering(mouseX, mouseY, modButton.x, modButton.y, modButton.w, modButton.h)) {
    gameState = "modMenu";
    currentInputCode = "";
    console.log("Opened mod menu.");
    return false;
  }

  if (gameState === "modMenu") {
    handleModMenuInteraction(mouseX, mouseY);
    return false;
  }

  if (gameState === "scrapMachine") {
    handleScrapMachineUIInteraction(mouseX, mouseY);
    return false;
  }

  if (gameState === "lockerMenu") {
    handleLockerUIInteraction(mouseX, mouseY);
    return false;
  }

  if (gameState === "multiplayerMenu") {
    handleMultiplayerUIInteraction(mouseX, mouseY);
    return false;
  }

  if (gameState === "monsterRoomView") {
    handleMonsterRoomViewInteraction(mouseX, mouseY);
    return false;
  }

  let vrButtonX = width * 0.1;
  let vrButtonY = height * 0.1;
  let vrButtonW = 120;
  let vrButtonH = 60;

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

  if (gameState === "game" && isHovering(newTouch.x, newTouch.y, vrButtonX, vrButtonY, 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 (gameState === "game" && isHovering(newTouch.x, newTouch.y, runButton.x, runButton.y, 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 (gameState === "game" && scrapMachineButton.visible && isHovering(newTouch.x, newTouch.y, scrapMachineButton.x, scrapMachineButton.y, scrapMachineButton.w, scrapMachineButton.h)) {
    gameState = "scrapMachine";
    currentInputCode = "";
    console.log("Opened scrap machine.");
    return false;
  }

  if (gameState === "game" && lockerButton.visible && isHovering(newTouch.x, newTouch.y, lockerButton.x, lockerButton.y, lockerButton.w, lockerButton.h)) {
    gameState = "lockerMenu";
    currentInputCode = "";
    console.log("Opened locker.");
    return false;
  }

  if (gameState === "game" && computerButton.visible && isHovering(newTouch.x, newTouch.y, computerButton.x, computerButton.y, computerButton.w, computerButton.h)) {
    gameState = "multiplayerMenu";
    currentInputCode = "";
    console.log("Opened multiplayer terminal.");
    return false;
  }

  if (gameState === "startScreen") {
    gameState = "game";
    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.2, 1);
    ambientSoundNoise.amp(0.1, 1);
    return false;
  }

  if (gameState === "game" && 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 (7 lines)

🔧 Subcomponents:

conditional Multi-Touch Dedupe if (touches.length > 1 && touches[touches.length - 1].x === mouseX && touches[touches.length - 1].y === mouseY) { return false; }

Prevents double-firing by detecting when mouse and touch events overlap, common on some devices

switch-case Game State Routing if (gameState === "loading") return false; ... if (gameState === "modMenu") { handleModMenuInteraction(...); }

Routes touch input to different handlers based on current game state—loading screen, menus, or gameplay

conditional Button and Joystick Activation if (gameState === "game" && isHovering(newTouch.x, newTouch.y, runButton.x, runButton.y, runButton.w, runButton.h)) { runButton.active = true; runButton.touchId = newTouch.id; }

Activates UI buttons and the joystick by assigning the touch ID and setting active flags

if (touches.length > 1 && touches[touches.length - 1].x === mouseX && touches[touches.length - 1].y === mouseY) { return false; }
Prevents double-firing when both touchStarted() and mousePressed() fire for the same event on certain devices by checking for coordinate overlap
if (gameState === "loading") return false;
Ignores all touches during the loading screen so players can't interact before the game is ready
if (gameState === "game" && isHovering(mouseX, mouseY, modButton.x, modButton.y, modButton.w, modButton.h)) { gameState = "modMenu"; }
Checks if the new touch is inside the mod menu button bounds and transitions to the mod menu if so
let newTouch = touches[touches.length - 1];
Gets the most recent touch (the one that just started this frame) from the touches array for activation tracking
if (gameState === "game" && isHovering(newTouch.x, newTouch.y, vrButtonX, vrButtonY, vrButtonW, vrButtonH)) { vrMode = !vrMode; }
Toggles VR mode if the new touch is inside the VR button; if enabling VR, requests device orientation permission from the browser
if (gameState === "game" && isHovering(newTouch.x, newTouch.y, runButton.x, runButton.y, runButton.w, runButton.h)) { runButton.active = true; runButton.touchId = newTouch.id; }
Activates the run button by setting its active flag to true and storing the touch ID; the touchMoved() and touchEnded() functions use this ID to track the button's touch
if (gameState === "game" && newTouch.x < joystick.maxX) { joystick.active = true; joystick.touchId = newTouch.id; joystick.knobX = newTouch.x; joystick.knobY = newTouch.y; }
Activates the joystick if the touch is on the left side of the screen (x < joystick.maxX); stores the initial touch position as the joystick knob position

📦 Key Variables

player object

Stores the player character's position (x, y), dimensions (w, h), velocity (vx, vy), and state flags (isMoving, isRunning)

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

Array of Monster objects that roam and chase the player across the game world

let monsters = [];
currentRoom string

Stores the name of the room the player is currently in (e.g., 'mainRoom', 'floor0', 'floor1')

let currentRoom = "mainRoom";
rooms object

Defines all rooms in the game world as named objects containing position, size, color, floor number, and interactive objects

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

Horizontal camera offset applied to the world view via translate() in draw() to follow the player

let cameraX = 0;
cameraY number

Vertical camera offset applied to the world view to follow the player vertically

let cameraY = 0;
gameState string

Tracks which screen the game is displaying ('loading', 'startScreen', 'game', 'jumpscare', 'modMenu', 'scrapMachine', 'lockerMenu', 'multiplayerMenu', 'monsterRoomView')

let gameState = "loading";
joystick object

Defines the mobile joystick's base position, radius, knob position, active state, and associated touch ID

joystick = { baseX: 100, baseY: 700, radius: 60, knobX: 100, knobY: 700, active: false, touchId: -1 };
collectibles array

Array of collectible items (keys and scrap) with position, type, and room location

collectibles = [{ x: 150, y: 150, w: 20, h: 30, type: 'key', room: 'floor0' }];
playerInventory array

Stores collected items owned by the player (currently used for keys)

let playerInventory = [];
playerScrap number

Counter tracking how much scrap the player has collected for trading at the scrap machine

let playerScrap = 0;
jumpscare object

Stores jumpscare animation state including phase (monster/blood/fadeToBlack), timer, animation frame, and sound data

jumpscare = { isActive: false, timer: 0, duration: 120, animationFrame: 0, phase: 'monster' };
vrMode boolean

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

let vrMode = false;
deviceGamma number

Stores the device's left-right tilt angle in degrees (-90 to 90) from the device orientation sensor

let deviceGamma = 0;
deviceBeta number

Stores the device's front-back tilt angle in degrees (-180 to 180) from the device orientation sensor

let deviceBeta = 0;
monsterSpeedFast boolean

Mod menu toggle that when true increases monster speed significantly for increased difficulty

let monsterSpeedFast = false;
keysSpawnEnabled boolean

Mod menu toggle that controls whether key collectibles spawn across the floors

let keysSpawnEnabled = true;
scrapSpawnEnabled boolean

Mod menu toggle that controls whether scrap collectibles spawn across the floors

let scrapSpawnEnabled = true;
canTransition boolean

Cooldown flag that prevents the player from triggering multiple room transitions in rapid succession

let canTransition = true;

🔧 Potential Improvements (6)

Here are some ways this code could be enhanced:

BUG Monster class pathfinding

When a monster transitions rooms via staircase, it resets velocity to 0 but does not resume chasing immediately—it wanders briefly before re-engaging the player, making the AI feel broken

💡 After transitioning rooms, set this.state = 'chasing' and reset this.chaseTimer to resume pursuit without delay: this.state = 'chasing'; this.chaseTimer = this.chaseDuration;

PERFORMANCE updateMonsters()

Every monster loops through all staircases to calculate pathfinding, and this happens every frame—with 5+ monsters, this creates O(n*m) complexity where n=monsters and m=staircases (currently 10+ staircases)

💡 Cache the nearest staircase to the player at the start of draw() and store it in a global variable, then have monsters reference it instead of recalculating every frame

STYLE setup() room definitions

Room definitions are hardcoded as a massive object literal with nested data—this is difficult to modify or add new rooms without risking syntax errors

💡 Create a helper function buildRoom(name, x, y, w, h, color, floor) that returns a room object, then call it for each room to make the code more readable and maintainable

FEATURE Monster AI

Monsters have only one type (green monster) with identical behavior—the game becomes predictable after learning one AI pattern

💡 Add a second monster class with different behavior (e.g., faster roaming but shorter chase range, or vice versa) and randomly spawn either type to increase variety

BUG collectible pickup collision detection

If two collectibles overlap (rare but possible), collecting one does not check the second in the same frame—items can remain uncollected if stacked

💡 The backward iteration (i--) prevents most overlap issues, but wrap the pickup logic in a while loop or check multiple frames to ensure overlapped items are collected in sequence

STYLE Audio initialization in preload()

Oscillators and envelopes are created and configured in multiple lines scattered across preload()—the code is verbose and hard to follow

💡 Create a helper function createAudioOscillator(waveform, freq, adsr) that encapsulates oscillator and envelope creation, reducing boilerplate

🔄 Code Flow

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

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

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> state-switch[Game State Dispatcher] state-switch -->|loading| loading[Loading Screen] state-switch -->|start| startscreen[Start Screen] state-switch -->|gameplay| gameplay[Active Gameplay] state-switch -->|menus| menus[Menus] state-switch -->|jumpscare| jumpscare[Jumpscare] state-switch -->|special| special[Special Views] gameplay --> updateplayer[updatePlayer] gameplay --> updatemonsters[updateMonsters] gameplay --> drawplayer[drawPlayer] gameplay --> camera-push-pop[Camera Translation Block] gameplay --> ui-draw-sequence[UI Rendering Sequence] updateplayer --> speed-selection[Speed Modifier] updateplayer --> position-update[Position Update] updateplayer --> collectible-loop[Collectible Pickup Loop] updateplayer --> staircase-transition[Room Transition Logic] updateplayer --> audio-proximity[Distance-Based Audio Fading] updateplayer --> interaction-button-visibility[Interaction Button Visibility] drawplayer --> player-body[Body Drawing] drawplayer --> player-head[Head Drawing] drawplayer --> player-eyes[Eyes Drawing] updatemonsters --> monster-constructor[Monster Constructor] updatemonsters --> monster-wall-collision[Wall Bounce] updatemonsters --> monster-detection[Player Detection] updatemonsters --> monster-pathfinding[Cross-Room Pathfinding] updatemonsters --> monster-safe-zone-block[Safe Zone Barrier] jumpscare --> early-return[Duplicate Prevention] jumpscare --> state-setup[Jumpscare State Initialization] jumpscare --> audio-silencing[Ambient Audio Fade Out] jumpscare --> screech-synthesis[Procedural Screech Synthesis] updatecamera --> base-camera-center[Center Player in View] updatecamera --> device-orientation-offset[VR Device Orientation Offset] updatecamera --> camera-boundary-clamp[Boundary Clamping] updatecamera --> camera-smoothing[Smooth Camera Interpolation] touchtstarted --> multi-touch-filter[Multi-Touch Dedupe] touchtstarted --> state-routing[Game State Routing] touchtstarted --> button-activation[Button and Joystick Activation] click setup href "#fn-setup" click draw href "#fn-draw" click updateplayer href "#fn-updateplayer" click updatemonsters href "#fn-updatemonsters" click drawplayer href "#fn-drawplayer" click jumpscare href "#fn-triggerjumpscare" click updatecamera href "#fn-updatecamera" click touchtstarted href "#fn-touchtstarted" click room-definitions href "#sub-room-definitions" click player-initialization href "#sub-player-initialization" click button-initialization href "#sub-button-initialization" click state-switch href "#sub-state-switch" click camera-push-pop href "#sub-camera-push-pop" click ui-draw-sequence href "#sub-ui-draw-sequence" click speed-selection href "#sub-speed-selection" click position-update href "#sub-position-update" click collectible-loop href "#sub-collectible-loop" click staircase-transition href "#sub-staircase-transition" click audio-proximity href "#sub-audio-proximity" click interaction-button-visibility href "#sub-interaction-button-visibility" click player-body href "#sub-player-body" click player-head href "#sub-player-head" click player-eyes href "#sub-player-eyes" click monster-constructor href "#sub-monster-constructor" click monster-wall-collision href "#sub-monster-wall-collision" click monster-detection href "#sub-monster-detection" click monster-pathfinding href "#sub-monster-pathfinding" click monster-safe-zone-block href "#sub-monster-safe-zone-block" click early-return href "#sub-early-return" click state-setup href "#sub-state-setup" click audio-silencing href "#sub-audio-silencing" click screech-synthesis href "#sub-screech-synthesis" click base-camera-center href "#sub-base-camera-center" click device-orientation-offset href "#sub-device-orientation-offset" click camera-boundary-clamp href "#sub-camera-boundary-clamp" click camera-smoothing href "#sub-camera-smoothing" click multi-touch-filter href "#sub-multi-touch-filter" click state-routing href "#sub-state-routing" click button-activation href "#sub-button-activation"

❓ Frequently Asked Questions

What visual experience does the Lethal Ape: Redux Revamp Update sketch provide?

The sketch offers a hand-drawn, minimalist aesthetic, presenting a 2D top-down view of a multi-room maze filled with roaming monsters and a tense atmosphere.

How can players interact with the Lethal Ape: Redux Revamp Update sketch?

Users can interact by tapping on on-screen buttons to run faster and engage with various rooms and machines throughout the maze.

What creative coding concepts are illustrated in the Lethal Ape: Redux Revamp Update?

The sketch demonstrates techniques like smooth camera movement, game state management, and simple animation effects for enhancing gameplay experience.

Preview

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