Lethal ape: redux

This sketch creates a hand-drawn, side-scrolling horror maze game where players navigate dark lab floors while avoiding monsters and collecting items. Using a joystick or keyboard, players move through interconnected rooms, experience the camera smoothly following their position, and face jump-scares when colliding with wandering creatures.

🧪 Try This!

Experiment with the code by making these changes:

  1. Speed up the monsters — Increase MONSTER_SPEED to make all monsters move faster both during roaming and chasing patterns
  2. Make the jump-scare monster larger
  3. Spawn more monsters — Increase MAX_MONSTERS to make the game harder by adding more enemies to hunt you
  4. Change the main room color — Modify the mainRoom background color to a different shade, making the safe zone visually distinct
  5. Increase monster detection range — Raise MONSTER_CHASE_RANGE so monsters spot the player from farther away, making it harder to sneak
  6. Double the boombox audio distance — Change the nearbyThreshold so the boombox music is heard from twice as far away
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch builds a complete horror maze game with procedural artwork, where you navigate multiple interconnected lab floors while avoiding intelligent monsters and collecting keys and scrap. The game combines several powerful p5.js techniques: smooth camera following with lerp, entity collision detection using AABB rectangles, finite-state machines to organize different game screens (loading, start, gameplay, jump-scare), and even procedural sound synthesis with the p5.sound library to create ambient drones, monster chase alerts, and jump-scare screams.

The code is organized around a central game state system that switches between loading screens, menus, and gameplay. You'll learn how to structure a multi-scene game, manage multiple moving entities (player and monsters) in different rooms, implement room transitions via staircases, and create audio experiences entirely through code. The Monster class demonstrates object-oriented design with state management (roaming vs. chasing), while the camera system shows how to smoothly interpolate positions for cinematic feel.

⚙️ How It Works

  1. When the sketch loads, preload() initializes all procedural sound objects (oscillators, envelopes, filters) that will power the ambient music, footsteps, and jump-scares. setup() then defines the game world as a grid of connected rooms, spawns the player in the main safe room, places monsters in the lab floors, and scatters collectible keys and scrap across rooms.
  2. The draw loop switches through game states: during 'loading' (3 seconds), a title screen appears with a low-frequency tone. Touching the screen transitions to 'game' state, which calls updatePlayer(), updateMonsters(), and updateCamera() each frame.
  3. Player movement is driven by a joystick (left side of screen) or keyboard (WASD/arrow keys). The joystick uses touch tracking to position a knob and calculate velocity vectors; keyboard input directly sets velocity. The player moves at PLAYER_SPEED (3 px/frame) or PLAYER_RUN_SPEED (5 px/frame) when the run button is held.
  4. The camera uses a smooth lerp() interpolation to follow the player from a distance, creating cinematic motion. In VR mode, device tilt (gamma and beta from DeviceOrientationEvent) further offsets the camera view, letting players 'look around' by tilting their phone.
  5. Five monsters spawn randomly on floors 0–4 and either roam in patterns or chase the player when within 200 pixels. If the player enters a different floor, monsters intelligently navigate staircases to hunt them down. When a monster collides with the player, triggerJumpscare() activates: the game state switches to 'jumpscare', ambient sounds fade out, and a terrifying monster face fills the screen with screaming sound effects before fading to black and resetting.
  6. Collectibles (golden keys and grey scrap) grant inventory items or currency. The player can interact with a scrap machine in the main room to sell scrap or buy speed boosts, or open lockers (flavor content). All sounds—footsteps, key collection, ambient drones, monster alerts, and jump-scare screams—are procedural synthesis, avoiding the need for audio files.

🎓 Concepts You'll Learn

Game state machineTop-down camera with lerp smoothingAABB collision detectionProcedural sound synthesisTouch input and joystick controlObject-oriented design (Monster class)Finite-state AI (roaming vs. chasing)Room-based level designProcedural drawing and shapes

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. It initializes the entire game world—defining all rooms, placing the player and entities, configuring mobile input buttons, and setting the first game state. This single function establishes the foundation for everything the game does.

🔬 The rooms object defines the game world layout. What happens if you change mainRoom's width (w: 600) to w: 1000 or w: 300? How does the safe zone change?

  rooms = {
    mainRoom: { x: 0, y: 0, w: 600, h: 400, color: '#333333', floor: -1,
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 }
      ]
    },
    monsterRoom: { x: 600, y: 0, w: 400, h: 400, color: '#222222', floor: -1 },
    floor0: { x: 0, y: 400, w: 800, h: 600, color: '#554444', floor: 0 },
    floor1: { x: 800, y: 400, w: 800, h: 600, color: '#445544', floor: 1 },
    floor2: { x: 1600, y: 400, w: 800, h: 600, color: '#665555', floor: 2 },
    floor3: { x: 2400, y: 400, w: 800, h: 600, color: '#556655', floor: 3 },
    floor4: { x: 3200, y: 400, w: 800, h: 600, color: '#776666', floor: 4 }
  };

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

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

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

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

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

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

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

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

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

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

  spawnCollectibles();

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

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

🔧 Subcomponents:

function-call Canvas and drawing initialization createCanvas(windowWidth, windowHeight);

Creates a full-screen canvas matching the device size; noStroke() removes outlines from all shapes; textAlign centers text

object-definition Room definitions rooms = { mainRoom: { x: 0, y: 0, w: 600, h: 400, ... }, ... };

Defines the game world layout—each room is a rectangle with a position, size, background color, and floor number; stairs connect them

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

Places the player in the main room with zero velocity and stores dimensions for collision detection

object-definition Mobile UI button initialization joystick = { baseX: width * 0.2, ... }; runButton = { x: width * 0.8, ... };

Positions the joystick (left side) and run button (right side) relative to screen size for touch control

function-call Entity spawning for (let i = 0; i < MAX_MONSTERS; i++) { spawnMonster(); } spawnCollectibles();

Populates the game world with monsters on lab floors and keys/scrap scattered across rooms

createCanvas(windowWidth, windowHeight);
Creates a canvas that fills the entire browser window; uses window dimensions for responsive design
noStroke();
Disables outlines on all shapes drawn afterward—shapes will have fill colors only
textAlign(CENTER, CENTER);
Centers all text both horizontally and vertically relative to the coordinates you give text()
rooms = { mainRoom: { x: 0, y: 0, w: 600, h: 400, color: '#333333', floor: -1, ... }, ... };
Creates an object where each room is keyed by name (e.g., 'mainRoom', 'floor0') with properties defining its position, size, appearance, and connectivity
player = { x: rooms.mainRoom.x + 100, y: rooms.mainRoom.y + 100, w: 30, h: 30, vx: 0, vy: 0, isMoving: false, isRunning: false };
Initializes the player object at coordinates 100 pixels into the main room with 30×30 dimensions, zero velocity, and false states for movement and running
joystick = { baseX: width * 0.2, baseY: height * 0.8, radius: 60, ... };
Creates a joystick object positioned at 20% from left and 80% from top of screen (bottom-left) with a 60-pixel interaction radius
for (let i = 0; i < MAX_MONSTERS; i++) { spawnMonster(); }
Spawns MAX_MONSTERS (default 5) monsters randomly on the lab floors (not in safe zones)
spawnCollectibles();
Places one key on each floor and 5 scrap pieces per floor for the player to collect
gameState = "loading"; loadingTimer = 0;
Sets the initial game state to 'loading' so the draw loop displays the loading screen for 3 seconds

draw()

draw() is the heart of the game loop, called 60 times per second. It uses a switch statement to implement a finite-state machine, branching to completely different rendering logic based on gameState. The 'game' case demonstrates the standard game loop pattern: (1) update all entities' logic, (2) set up camera transforms, (3) render the world in those transforms, (4) restore transforms, (5) render UI on top. Understanding this structure is key to building any multi-state game.

🔬 This entire block happens inside a push/pop pair with translate() that creates the camera effect. What if you add 'scale(0.5)' after the translate? How does the zoom level change?

      push();
      translate(-cameraX, -cameraY); // Apply camera offset for top-down

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

      // Draw staircases (procedural)
      drawStaircases();

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

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

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

      // Draw player (procedural)
      drawPlayer();

      // Draw monsters (procedural)
      drawMonsters();

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

      pop(); // End camera translation
function draw() {
  background(0); // Dark background for horror theme

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

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

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

      // Draw staircases (procedural)
      drawStaircases();

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

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

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

      // Draw player (procedural)
      drawPlayer();

      // Draw monsters (procedural)
      drawMonsters();

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

      pop(); // End camera translation

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

🔧 Subcomponents:

switch-case Game state dispatcher switch (gameState) { case "loading": ... case "game": ... }

Routes execution to different rendering functions based on which screen/state the game is in; this is the core of the finite-state machine

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

Calculates the new state of all game entities each frame before drawing; happens every 60 FPS

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

Shifts the entire world so the camera appears to follow the player—all game-world drawing happens within this translation block

rendering-block World rendering drawRooms(); drawStaircases(); drawBoombox(...); drawPlayer(); drawMonsters(); drawCollectibles();

Draws all game-world graphics in the correct order (back to front): backgrounds, then interactive objects, then entities

rendering-block UI layer rendering drawJoystick(); drawMonsterTracker(); drawInventory(); drawVRToggleButton(); drawRunButton(); drawModMenuButton(); drawScrapMachineButton(); drawLockerButton();

Draws all HUD elements on top of the world—buttons, inventory, trackers—these are not affected by camera translation

background(0);
Clears the screen with black (RGB 0,0,0) at the start of every frame, erasing the previous frame's content
switch (gameState) { case "loading": drawLoadingScreen(); break; ... }
A switch statement routes the entire draw logic based on the current game state—if loading, draw the loading screen; if game, update and render everything; if jumpscare, animate the jumpscare
updatePlayer(); updateMonsters(); updateCamera();
Calls three update functions in sequence: player motion and collision, monster AI and movement, then camera position based on player location
push(); translate(-cameraX, -cameraY);
Saves the current transform matrix with push(), then translates the drawing origin by negative camera offsets—this makes the camera 'follow' by shifting all world graphics
drawRooms(); drawStaircases();
Draws all room backgrounds and staircases in world-space coordinates; the camera translation makes them move relative to screen center
if (currentRoom === "mainRoom") { drawBoombox(...); drawScrapMachine(...); for (let locker of rooms.mainRoom.lockers) { drawLocker(...); } }
Conditionally draws interactive objects (boombox, scrap machine, lockers) only when the player is in the main room—avoids drawing unnecessary objects
drawPlayer(); drawMonsters(); drawCollectibles();
Draws all moving and collectible entities in world-space; their positions are affected by the camera translation
pop();
Restores the transform matrix, ending camera translation—all subsequent drawing is in screen-space coordinates
drawJoystick(); drawMonsterTracker(); drawInventory(); drawVRToggleButton(); drawRunButton(); drawModMenuButton(); drawScrapMachineButton(); drawLockerButton();
Draws UI elements in screen-space (unaffected by camera)—buttons and HUD stay fixed on screen while the world scrolls behind them

updatePlayer()

updatePlayer() is called every frame (60 times per second) and handles all logic related to the player: movement, collision detection with items and staircases, audio effects, and interaction prompts. It demonstrates several core game programming patterns: velocity-based movement, AABB collision detection, finite-state management (canTransition cooldown), and proximity-based audio. Understanding this function is essential to building any interactive game.

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

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

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

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

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

  // --- Handle Room Transitions (Staircases) ---
  if (canTransition) {
    let hasTransitioned = false;
    for (let staircase of staircases) {
      if (rectCollision(player.x, player.y, player.w, player.h, staircase.x, staircase.y, staircase.w, staircase.h)) {
        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;
    }
  }

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

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

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

    if (d < boombox.nearbyThreshold) {
      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 {
    boombox.osc.amp(0, 0.5);
    boombox.delay.amp(0, 0.5);
  }

  // --- Interaction Button Visibility ---
  scrapMachineButton.visible = false;
  lockerButton.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;
      }
    }
  }
}
Line-by-line explanation (18 lines)

🔧 Subcomponents:

calculation Speed based on run state let currentSpeed = player.isRunning ? PLAYER_RUN_SPEED : PLAYER_SPEED;

Selects either the run speed (5 px/frame) or walk speed (3 px/frame) depending on whether run button is held

calculation Apply velocity to position player.x += player.vx * currentSpeed; player.y += player.vy * currentSpeed;

Moves the player by multiplying the joystick direction (±1) by the current speed each frame

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

Plays a periodic footstep sound every 25 frames when moving; resets counter when stationary

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

Checks every collectible for overlap with player; if hit, plays sound, adds to inventory/scrap, and removes from world

conditional Room transition on staircase overlap if (canTransition) { for (let staircase of staircases) { if (rectCollision(...)) { currentRoom = staircase.targetRoom; ... } } }

Detects when player overlaps a staircase and teleports them to the target room with a cooldown to prevent repeated triggering

constraint Player position clamping player.x = constrain(player.x, currentRoomData.x, currentRoomData.x + currentRoomData.w - player.w);

Ensures player never moves outside the walls of the current room using constrain()

conditional Boombox volume based on proximity let d = dist(player.x, player.y, boombox.x, boombox.y); if (d < boombox.nearbyThreshold) { targetAmp = map(...); }

Calculates distance to boombox and fades its oscillator and delay in/out based on proximity; creates a distance-based audio effect

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

Checks if player overlaps the scrap machine or lockers and shows the corresponding interaction button

let currentSpeed = player.isRunning ? PLAYER_RUN_SPEED : PLAYER_SPEED;
Uses a ternary operator to select either PLAYER_RUN_SPEED (5) or PLAYER_SPEED (3) based on whether the run button is active
player.x += player.vx * currentSpeed;
Updates player's x position by adding velocity (from joystick, typically ±1) multiplied by current speed each frame
player.isMoving = (abs(player.vx) > 0.1 || abs(player.vy) > 0.1);
Sets a flag to true if either velocity component is greater than 0.1 in absolute value—used to determine if footstep sound should play
if (player.isMoving) { footstepCounter++; if (footstepCounter >= FOOTSTEP_INTERVAL) { footstepOsc.freq(random(100, 150)); footstepEnvelope.play(footstepOsc); footstepCounter = 0; } }
Increments a counter each frame; when it reaches FOOTSTEP_INTERVAL (25 frames), plays a footstep sound with a random pitch and resets the counter
for (let i = collectibles.length - 1; i >= 0; i--) {
Loops backward through the collectibles array (from end to start) so splicing doesn't skip items
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 current room AND there's an AABB collision between player and item rectangles
if (item.type === "key") { playerInventory.push(item); collectOsc.freq(random(600, 800)); collectEnvelope.play(collectOsc); }
If item is a key, adds it to the inventory array and plays a high-pitched collect sound (600–800 Hz)
collectibles.splice(i, 1);
Removes the collected item from the collectibles array by splicing out 1 element at index i
if (canTransition) {
Only processes room transitions if the canTransition flag is true—prevents rapid re-triggering via a 500ms cooldown
for (let staircase of staircases) { if (rectCollision(player.x, player.y, player.w, player.h, staircase.x, staircase.y, staircase.w, staircase.h)) {
Loops through all staircases and checks for overlap with the player using AABB collision
currentRoom = staircase.targetRoom; player.x = staircase.targetPlayerX; player.y = staircase.targetPlayerY;
Teleports the player to the target room and sets their position to the designated spawn point on that staircase
canTransition = false; setTimeout(() => { canTransition = true; }, TRANSITION_COOLDOWN);
Disables transitions and schedules re-enabling them after 500 milliseconds, preventing accidental back-and-forth transitions
player.x = constrain(player.x, currentRoomData.x, currentRoomData.x + currentRoomData.w - player.w);
Clamps player's x position to the room boundaries—prevents moving past left/right walls by constraining between room left edge and (right edge minus player width)
if (!joystick.active && touches.length === 0) { player.vx = 0; player.vy = 0; }
Stops player movement if neither joystick is being touched nor any touch is active on the screen (mobile); desktop keyboard release is handled by keyReleased()
let d = dist(player.x, player.y, boombox.x, boombox.y);
Calculates the Euclidean distance in pixels between the player's center and the boombox's center
if (d < boombox.nearbyThreshold) { targetAmp = map(d, boombox.nearbyThreshold - boombox.fadeZone, boombox.nearbyThreshold, 0.5, 0, true); }
Uses map() to interpolate amplitude based on distance: when player is close, volume is high (0.5); as distance increases toward threshold, volume fades to 0
boombox.osc.amp(targetAmp, 0.5);
Smoothly changes the oscillator's amplitude to the target value over 0.5 seconds, creating a fade-in/fade-out effect
if (rectCollision(player.x, player.y, player.w, player.h, scrapMachineData.x, scrapMachineData.y, scrapMachineData.w, scrapMachineData.h)) { scrapMachineButton.visible = true; }
Shows the 'Interact (Scrap Machine)' button on screen if the player is touching the scrap machine's hitbox

updateMonsters()

updateMonsters() is a simple delegating function that updates all monsters by calling their individual update() methods. This pattern keeps the draw loop clean and demonstrates object-oriented design: each entity manages its own behavior. The Monster class itself contains all the interesting AI logic.

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 the update() method on every Monster object each frame to handle movement, collision, and state changes

for (let monster of monsters) {
Uses a for...of loop to iterate through every Monster instance in the monsters array
monster.update();
Calls the update() method on each monster, which handles its movement, AI state changes, and collision with player

class Monster

The Monster class is a complete example of object-oriented AI design. It manages its own state (roaming vs. chasing), speed, position, and direction. The update() method runs each frame and contains sophisticated logic: basic patrol patterns, distance-based chase triggering, cross-floor navigation via pathfinding, and collision reactions. The draw() method renders the monster as a rotated irregular shape. This class pattern is reusable—you could spawn monsters with different stats or behaviors by extending this class or modifying its properties.

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;

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

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

    // Behavior: Roam or Chase
    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;
        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 && 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 {
          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);
      }
    }

    // Main Room (safe zone) interaction: Monsters cannot enter the main room or monsterRoom
    let safeRooms = ["mainRoom", "monsterRoom"];
    for (let safeRoomName of safeRooms) {
      if (this.room !== safeRoomName) {
        let safeRoom = rooms[safeRoomName];
        if (rectCollision(this.x, this.y, this.w, this.h, safeRoom.x, safeRoom.y, safeRoom.w, safeRoom.h)) {
          if (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;
        }
      }
    }

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

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

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

    // Body (dark green/brown, irregular shape)
    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);

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

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

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

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

🔧 Subcomponents:

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

Creates a new monster at position (x, y) in a specific room with random initial velocity and roaming state

calculation Velocity application this.x += this.vx * currentMonsterSpeed; this.y += this.vy * currentMonsterSpeed;

Moves the monster by adding scaled velocity components each frame

conditional Wall collision and reversal 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

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

Maintains roaming patrol for a random duration, then reverses direction

conditional Chase state detection if (d < MONSTER_CHASE_RANGE) { this.state = "chasing"; this.chaseTimer = this.chaseDuration; currentMonsterSpeed = MONSTER_CHASE_SPEED; }

Enters chasing state when player is within MONSTER_CHASE_RANGE (200 px); uses faster speed

conditional Cross-floor navigation during chase if (playerFloor !== monsterFloor && monsterFloor !== -1 && this.monsterCanTransition) { ... }

Intelligently navigates staircases to chase player across different floors, checking which staircase moves toward target floor

conditional Safe zone barrier let safeRooms = ["mainRoom", "monsterRoom"]; for (let safeRoomName of safeRooms) { if (rectCollision(...)) { ... this.vx *= -1; } }

Prevents monsters from entering the main room by bouncing them back at the boundary

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

Detects collision with player and activates the jumpscare sequence

constructor(x, y, roomName) {
Defines the constructor function that is called when a new Monster is created with 'new Monster(x, y, room)'
this.vx = random([-MONSTER_SPEED, MONSTER_SPEED]);
Sets initial horizontal velocity to either +MONSTER_SPEED (1.5) or -MONSTER_SPEED (−1.5) randomly, creating unpredictable patrol patterns
this.state = "roaming";
Initializes the monster in roaming state, not chasing; used for finite-state behavior
this.x += this.vx * currentMonsterSpeed;
Updates x position by velocity; uses currentMonsterSpeed which changes from MONSTER_SPEED (1.5) to MONSTER_CHASE_SPEED (2.5) when chasing
if (this.x < roomData.x || this.x + this.w > roomData.x + roomData.w) { this.vx *= -1; }
Checks if monster has crossed the left or right boundary of its room; if so, reverses horizontal velocity to bounce back
if (this.room === currentRoom && currentRoom !== "mainRoom" && currentRoom !== "monsterRoomView") {
Only processes chase logic if the monster is in the same room as the player AND that room is a lab floor (not safe zones)
let d = dist(this.x, this.y, player.x, player.y);
Calculates the Euclidean distance in pixels between the monster and the player
if (d < MONSTER_CHASE_RANGE) { this.state = "chasing"; this.chaseTimer = this.chaseDuration; currentMonsterSpeed = MONSTER_CHASE_SPEED; }
If player is closer than 200 pixels, switches to chasing state, starts a 2-second chase timer, and increases speed from 1.5 to 2.5 px/frame
this.chaseTimer--; if (this.chaseTimer <= 0) { this.state = "roaming"; }
Decrements the chase timer each frame; when it reaches 0, switches back to roaming even if the player is still nearby
if (this.roamTimer > this.roamDuration) { this.vx *= -1; this.vy *= -1; }
When the roaming timer exceeds a random duration (60–240 frames), reverses both velocity components to change patrol direction
let playerFloor = rooms[currentRoom].floor; let monsterFloor = rooms[this.room].floor;
Looks up the floor numbers of where the player and monster are currently located
if ((playerFloor > monsterFloor && targetStaircaseFloor > monsterFloor) || (playerFloor < monsterFloor && targetStaircaseFloor < monsterFloor) || (playerFloor === targetStaircaseFloor)) {
Checks if the staircase leads toward the player's floor: either upward (if player is higher), downward (if player is lower), or directly to the player's floor
let angle = atan2(dy, dx); this.vx = cos(angle) * currentMonsterSpeed; this.vy = sin(angle) * currentMonsterSpeed;
Calculates the angle from monster to target staircase and sets velocity components to move toward it at the current speed
if (rectCollision(this.x, this.y, this.w, this.h, safeRoom.x, safeRoom.y, safeRoom.w, safeRoom.h)) {
Detects when the monster overlaps the safe zone (main room) and bounces it back by reversing the appropriate velocity component
if (currentRoom === this.room && rectCollision(player.x, player.y, player.w, player.h, this.x, this.y, this.w, this.h)) { triggerJumpscare(); }
Checks if the monster and player are in the same room AND their rectangles overlap; if so, calls triggerJumpscare() to start the game-over sequence
let rotationAngle = atan2(this.vy, this.vx); rotate(rotationAngle);
Calculates the angle of movement and rotates the monster graphic so it faces the direction it's moving
beginShape(); vertex(...); vertex(...); endShape(CLOSE);
Draws a custom irregular polygon shape for the monster body using vertices; CLOSE connects the last vertex back to the first

updateCamera()

updateCamera() demonstrates professional game camera design. It calculates a target position that keeps the player centered, applies optional VR head-tracking offsets for immersive control, clamps to world boundaries to prevent viewing empty space, and uses lerp() to smooth the motion for cinematic appeal. The lerp() function is crucial—it's how games create smooth, eased camera motion instead of jerky position snapping. Adjusting the lerp factor (0.1) lets you tune the 'feel' of the camera from sluggish to snappy.

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

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

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

  // Determine total game world width and height for clamping
  let minWorldX = rooms.mainRoom.x;
  let maxWorldX = rooms.floor4.x + rooms.floor4.w;
  let minWorldY = rooms.mainRoom.y;
  let maxWorldY = rooms.floor4.y + rooms.floor4.h;

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

  // Smoothly interpolate the camera to the target position
  smoothedCameraX = lerp(smoothedCameraX, targetCameraX, 0.1);
  smoothedCameraY = lerp(smoothedCameraY, targetCameraY, 0.1);
  cameraX = smoothedCameraX;
  cameraY = smoothedCameraY;
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

calculation Base camera centering targetCameraX = player.x - width / 2; targetCameraY = player.y - height / 2;

Calculates where the camera should point to keep the player centered on screen (player at world position minus half the canvas size)

conditional VR device tilt 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; }

In VR mode, adds additional camera offset based on device tilt (gamma = left/right, beta = up/down), allowing 'looking around' by tilting the phone

constraint World boundary clamping targetCameraX = constrain(targetCameraX, minWorldX, maxWorldX - width);

Ensures the camera doesn't pan beyond the game world edges, preventing viewing empty space outside the level

interpolation Smooth camera interpolation smoothedCameraX = lerp(smoothedCameraX, targetCameraX, 0.1);

Uses lerp() to gradually move the camera toward the target position over multiple frames, creating a cinematic follow effect instead of jerky jumps

targetCameraX = player.x - width / 2;
Calculates the camera X position that would place the player at the horizontal center of the screen; subtracts half the screen width from player X
targetCameraY = player.y - height / 2;
Calculates the camera Y position that would place the player at the vertical center of the screen; subtracts half the screen height from player Y
if (vrMode && deviceOrientationGranted) {
Only applies VR head-tracking offsets if VR mode is toggled on AND device orientation permission has been granted
if (abs(deviceGamma) > GAMMA_DEAD_ZONE) {
Only applies gamma offset if the device tilt exceeds the dead zone (3 degrees); ignores tiny jitter when device is still
gammaOffset = map(deviceGamma, -90, 90, -GAMMA_SENSITIVITY * width, GAMMA_SENSITIVITY * width);
Maps device gamma angle (−90° to +90°) to a camera offset range; tilting left maps to negative offset, tilting right to positive, creating 'look around' effect
targetCameraX += gammaOffset;
Adds the gamma-based offset to the base camera position, allowing head tilt to shift the view left and right
let minWorldX = rooms.mainRoom.x; let maxWorldX = rooms.floor4.x + rooms.floor4.w;
Defines the horizontal boundaries of the entire game world: left edge is mainRoom X, right edge is the right side of floor4
targetCameraX = constrain(targetCameraX, minWorldX, maxWorldX - width);
Clamps the target camera X so it never scrolls past the left or right edge of the world; maxWorldX - width ensures the right edge of the screen doesn't go beyond the world
smoothedCameraX = lerp(smoothedCameraX, targetCameraX, 0.1);
Uses linear interpolation (lerp) to move smoothedCameraX 10% of the way toward targetCameraX each frame, creating a smooth 'easing' motion instead of instant snapping
cameraX = smoothedCameraX;
Assigns the smoothed position to cameraX, which is used in the draw() translate() call to shift the entire world view

triggerJumpscare()

triggerJumpscare() is the event handler called when a monster collides with the player. It switches the game into a specialized 'jumpscare' state where drawJumpscare() animates a terrifying monster face and plays a shrieking sound. The audio design uses p5.sound envelopes and oscillator frequency ramping to create a procedural screech that builds fear without any pre-recorded sound files—pure code-generated horror. This pattern (state change + audio trigger + animation) is fundamental to event-driven games.

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

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

  // Silence ambient sounds and boombox music
  ambientSoundOsc.amp(0, 0.1);
  ambientSoundNoise.amp(0, 0.1);
  boombox.osc.amp(0, 0.1);
  boombox.delay.amp(0, 0.1);

  // Play procedural jumpscare noise burst controlled by envelope
  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 (11 lines)

🔧 Subcomponents:

conditional Guard against double-trigger if (jumpscare.isActive) return;

Prevents the function from running if a jumpscare is already active, avoiding multiple simultaneous scares

assignment Jumpscare state initialization jumpscare.isActive = true; jumpscare.timer = jumpscare.duration; jumpscare.animationFrame = 0; jumpscare.phase = "monster"; gameState = "jumpscare";

Activates jumpscare mode and switches game state to the jumpscare sequence

function-call Ambient audio silence ambientSoundOsc.amp(0, 0.1); ambientSoundNoise.amp(0, 0.1); boombox.osc.amp(0, 0.1); boombox.delay.amp(0, 0.1);

Fades out all ambient sounds and music quickly (0.1 second) to create silence before the jumpscare sound

audio-envelope Jumpscare audio sequence jumpscareEnvelope.play(jumpscareNoise); let currentTime = getAudioContext().currentTime; jumpscareOsc.amp(0.8, 0.05, currentTime); jumpscareOsc.freq(2000, 0.2, currentTime); jumpscareOsc.freq(500, 0.5, currentTime + 0.2);

Triggers a white-noise burst and a sawtooth oscillator screech that starts at 2000 Hz, sweeps down to 500 Hz, and fades out—a terrifying shriek

if (jumpscare.isActive) return;
Early return if a jumpscare is already happening—prevents the function from running twice at the same time
jumpscare.isActive = true;
Sets the jumpscare active flag to true, marking that the jumpscare sequence is underway
jumpscare.animationFrame = 0;
Resets the animation frame counter to 0, so the drawJumpscare() function can animate the monster from the start
gameState = "jumpscare";
Changes the game state to 'jumpscare', which causes the draw() function to call drawJumpscare() instead of normal game rendering
ambientSoundOsc.amp(0, 0.1);
Fades the ambient drone oscillator to silence over 0.1 seconds, creating an eerie silence right before the scare
jumpscareEnvelope.play(jumpscareNoise);
Plays the jumpscare noise burst (white noise) using its pre-configured envelope, which has a fast attack and decay
let currentTime = getAudioContext().currentTime;
Gets the current Web Audio API time in seconds; used for scheduling the screech oscillator's frequency and amplitude changes
jumpscareOsc.amp(0.8, 0.05, currentTime);
Starts ramping the screech oscillator's amplitude to 0.8 over 0.05 seconds from the current time; creates a quick fade-in
jumpscareOsc.freq(2000, 0.2, currentTime);
Sweeps the oscillator frequency to 2000 Hz over 0.2 seconds; starts the high screech sound
jumpscareOsc.freq(500, 0.5, currentTime + 0.2);
After 0.2 seconds, sweeps the frequency down to 500 Hz over 0.5 seconds; creates a descending, horrifying wail
jumpscareOsc.amp(0, 0.5, currentTime + 0.2);
Also starting at 0.2 seconds (time-shifted), fades the amplitude to 0 over 0.5 seconds for a trailing fade-out

drawJumpscare()

drawJumpscare() implements a three-phase horror sequence using state-driven animation. Each phase (monster attack, blood screen, fade-to-black) uses different timing and visual effects. The phase system is a clean pattern for multi-stage animations: instead of one big function with nested conditionals, you use a state variable and a switch statement to keep each phase independent. The use of lerp() and sin() oscillation demonstrates how procedural, math-based animation can create compelling visual effects without pre-rendered graphics.

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 + 70 + height * 0.2);

      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 selector switch (jumpscare.phase) { case "monster": ... case "blood": ... case "fadeToBlack": ... }

Routes the jumpscare animation through three phases: monster jump scare, blood-red screen, and fade to black before reset

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

Maps animation frames to monster size, growing from 0.1 to 1.2 scale to create a 'zooming in' effect as the monster lunges at the screen

calculation Flashing red text let redFlash = map(sin(jumpscare.animationFrame * 0.2), -1, 1, 100, 255); fill(redFlash, 0, 0);

Uses a sine wave to oscillate the red channel of the 'YOU DIED!' text, creating a pulsing red glow effect

rendering Blood screen rendering background(136, 0, 0); fill(255); textSize(64); text("YOU DIED!", width / 2, height / 2);

Fills the entire screen with blood red (RGB 136,0,0) and displays white text on top

interpolation Alpha fade to black let fadeProgress = jumpscare.bloodFadeTimer / jumpscare.bloodFadeDuration; let alpha = lerp(0, 255, fadeProgress); fill(0, alpha); rect(0, 0, width, height);

Gradually increases the opacity of a black rectangle from transparent (0) to opaque (255), creating a fade-to-black transition

switch (jumpscare.phase) {
Dispatches to different rendering code based on which phase of the jumpscare sequence is active
background(0);
Clears the screen with black for the monster phase
let monsterScale = map(jumpscare.animationFrame, 0, jumpscare.duration * 0.5, 0.1, 1.2, true);
Maps the animation frame counter (0 to 60, since duration is 120 and phase lasts 0.5 of that) to monster scale (0.1 to 1.2), creating a grow-in animation
push(); scale(2.5); drawJumpscareMonster(0, 0, headSize, bodySize); pop();
Saves the transform, scales everything by 2.5x to make the monster huge, draws the monster, and restores the transform
let redFlash = map(sin(jumpscare.animationFrame * 0.2), -1, 1, 100, 255);
Uses sin() to create an oscillating value between −1 and 1; maps this to a red channel value between 100 and 255, creating a pulsing effect
fill(redFlash, 0, 0);
Sets the fill color to red with varying intensity (redFlash); green and blue are 0, so only red pulses
jumpscare.animationFrame++; if (jumpscare.animationFrame >= jumpscare.duration * 0.5) { jumpscare.phase = "blood"; }
Increments the frame counter; when it reaches half the duration (60 frames), switches to the blood phase
case "blood": background(136, 0, 0);
In the blood phase, fills the entire screen with a deep red color (RGB 136, 0, 0)
jumpscare.bloodFadeTimer++; if (jumpscare.bloodFadeTimer >= jumpscare.bloodFadeDuration) { jumpscare.phase = "fadeToBlack"; }
Increments a separate timer for the blood phase; after 30 frames (0.5 seconds), transitions to the fade-to-black phase
let fadeProgress = jumpscare.bloodFadeTimer / jumpscare.bloodFadeDuration;
Calculates a normalized value (0 to 1) representing how far through the fade-out we are
let alpha = lerp(0, 255, fadeProgress);
Uses lerp() to interpolate the alpha transparency from 0 (transparent) to 255 (opaque) based on progress
fill(0, alpha); rect(0, 0, width, height);
Draws a rectangle covering the entire screen with black color and the interpolated alpha, creating a fade-to-black effect
if (jumpscare.bloodFadeTimer >= jumpscare.bloodFadeDuration) { jumpscare.isActive = false; jumpscareNoise.amp(0); jumpscareOsc.amp(0); resetGame(); }
When the fade completes, silences the jumpscare sounds, deactivates the jumpscare, and calls resetGame() to restart from the main room

drawPlayer()

drawPlayer() demonstrates procedural character design: every visual detail is constructed from basic p5.js shapes (ellipses, lines) sized relative to the player's dimensions (player.w, player.h). By using relative sizes, if you ever change player width and height, the whole character scales proportionally without rewriting any shape code. The translate-to-center, draw, translate-back pattern (push/translate/drawing/pop) is a standard technique for drawing objects at their own local origin rather than the world origin.

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

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

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

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

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

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

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

🔧 Subcomponents:

transform Player centering transform push(); translate(player.x + player.w / 2, player.y + player.h / 2);

Centers the drawing coordinate system at the player's center so all shapes are drawn relative to that center point

shape-rendering Body and head ellipses 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);

Draws a red ellipse for the body and a darker red ellipse for the head, both sized relative to player dimensions

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

Draws white eyes and red pupils, giving the player character an ominous glare

shape-rendering 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 line for a simple, emotionless mouth

shape-rendering 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, -player.h * 0.3, player.w * 0.2, 0);

Draws two angled lines for arms extending from the upper body

push();
Saves the current transform matrix so any transformations only affect the player drawing
translate(player.x + player.w / 2, player.y + player.h / 2);
Moves the origin to the center of the player's bounding box, so all subsequent drawing is relative to that center
fill(200, 0, 0);
Sets the fill color to dark red (200, 0, 0 in RGB) for the body ellipse
ellipse(0, 0, player.w * 1.2, player.h * 1.5);
Draws an ellipse at the origin (now the player's center) with width 1.2× the player width and height 1.5× the player height
fill(150, 0, 0);
Changes the fill color to a darker red (150, 0, 0) for the head
ellipse(0, -player.h * 0.7, player.w * 0.8, player.h * 0.8);
Draws the head ellipse positioned 0.7× player height above the origin (negative Y is up)
fill(255); ellipse(-player.w * 0.2, -player.h * 0.8, player.w * 0.2, player.h * 0.2);
Changes fill to white and draws the left white eye ellipse
fill(255, 0, 0); ellipse(-player.w * 0.2, -player.h * 0.8, player.w * 0.1, player.h * 0.1);
Changes fill to red and draws a smaller red pupil on top of the white eye
stroke(255, 0, 0); strokeWeight(2); line(-player.w * 0.2, -player.h * 0.6, player.w * 0.2, -player.h * 0.6);
Sets stroke color to red and weight to 2 pixels, then draws a horizontal line from left to right for a straight-faced mouth
stroke(200, 0, 0); strokeWeight(4); line(-player.w * 0.6, -player.h * 0.3, -player.w * 0.2, 0);
Changes stroke weight to 4 pixels and draws the left arm from upper-left to lower-right
pop();
Restores the saved transform matrix, undoing the translate() so subsequent drawings are not centered on the player

spawnMonster()

spawnMonster() is a simple factory function that creates a new Monster object and adds it to the game. It demonstrates two patterns: random selection from an array (useful for procedural spawning) and object instantiation (creating new instances of a class). Called repeatedly in setup(), it populates the world with enemies. You could extend this function to accept parameters, spawn monsters near the player, or use noise-based distribution for more interesting placement patterns.

function spawnMonster() {
  let floorNames = ["floor0", "floor1", "floor2", "floor3", "floor4"];
  let targetRoomName = random(floorNames);
  let targetRoomData = rooms[targetRoomName];
  let x = random(targetRoomData.x, targetRoomData.x + targetRoomData.w - 40);
  let y = random(targetRoomData.y, targetRoomData.y + targetRoomData.h - 40);
  monsters.push(new Monster(x, y, targetRoomName));
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

random-selection Random floor selection let floorNames = ["floor0", "floor1", "floor2", "floor3", "floor4"]; let targetRoomName = random(floorNames);

Randomly selects one of the five lab floors to spawn a monster in

calculation Random position within room let x = random(targetRoomData.x, targetRoomData.x + targetRoomData.w - 40); let y = random(targetRoomData.y, targetRoomData.y + targetRoomData.h - 40);

Generates random x and y coordinates within the selected room, ensuring the monster doesn't spawn halfway off the edge

object-instantiation Monster instantiation monsters.push(new Monster(x, y, targetRoomName));

Creates a new Monster object at the calculated position and room, then adds it to the monsters array

let floorNames = ["floor0", "floor1", "floor2", "floor3", "floor4"];
Creates an array of string names for all the lab floors; excludes mainRoom and monsterRoom where players are safe
let targetRoomName = random(floorNames);
Uses p5.js random() to pick one floor name from the array; each call has a 20% chance of picking any given floor
let targetRoomData = rooms[targetRoomName];
Looks up the room object (with x, y, w, h properties) using the selected room name as a key
let x = random(targetRoomData.x, targetRoomData.x + targetRoomData.w - 40);
Generates a random x coordinate within the room's horizontal span; subtracts 40 to ensure the 40-pixel-wide monster doesn't spawn past the right edge
monsters.push(new Monster(x, y, targetRoomName));
Creates a new Monster instance with the calculated position and room name, then appends it to the global monsters array

drawRooms()

drawRooms() iterates through the rooms object and draws each room as a colored rectangle. It demonstrates a common game pattern: separating data (room positions and colors) from rendering (drawing them). Each room's appearance is defined once in setup(), and drawRooms() blindly renders whatever data exists—making it easy to add, remove, or modify rooms without touching rendering code.

function drawRooms() {
  for (let roomName in rooms) {
    let room = rooms[roomName];
    // Draw room background with procedural color
    fill(room.color);
    rectMode(CORNER);
    rect(room.x, room.y, room.w, room.h);
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

for-loop Room enumeration for (let roomName in rooms) { let room = rooms[roomName]; ... }

Iterates through every room object stored in the rooms object using a for...in loop

shape-rendering Room rectangle drawing fill(room.color); rectMode(CORNER); rect(room.x, room.y, room.w, room.h);

Draws a filled rectangle for each room using its stored color and position/size data

for (let roomName in rooms) {
Uses for...in to loop through all properties (room names) in the rooms object
let room = rooms[roomName];
Retrieves the room data object (containing x, y, w, h, color) for the current room name
fill(room.color);
Sets the fill color using the color string stored in the room object (e.g., '#333333' for dark grey)
rectMode(CORNER);
Sets rectangle drawing mode so the x, y coordinates are the top-left corner, not the center
rect(room.x, room.y, room.w, room.h);
Draws a rectangle at the room's position with its width and height, creating the visual floor

📦 Key Variables

player object

Stores the player's position (x, y), dimensions (w, h), velocity (vx, vy), and state flags (isMoving, isRunning); updated each frame by updatePlayer()

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

Holds all Monster class instances spawned in the game; updated by spawnMonster() and iterated by updateMonsters() and drawMonsters()

let monsters = [];
joystick object

Tracks the on-screen mobile joystick's base position, knob position, and active state; updated by touch input

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

Horizontal camera offset applied via translate() in draw()—smoothly follows the player with lerp interpolation

let cameraX = 0;
cameraY number

Vertical camera offset applied via translate() in draw()—smoothly follows the player and responds to device tilt in VR mode

let cameraY = 0;
currentRoom string

Stores the name of the room the player is currently in (e.g., 'mainRoom', 'floor0'); used to determine what to draw and where monsters can chase

let currentRoom = "mainRoom";
rooms object

Defines all game world rooms with their positions, dimensions, colors, and floor numbers; initialized in setup()

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

Manages jumpscare state and animation: isActive flag, timer, duration, animationFrame counter, and phase ('monster', 'blood', 'fadeToBlack')

let jumpscare = { isActive: false, timer: 0, duration: 120, animationFrame: 0, phase: 'monster', bloodFadeTimer: 0, bloodFadeDuration: 30 };
gameState string

Controls which screen/mode the game displays: 'loading', 'startScreen', 'game', 'jumpscare', 'modMenu', 'scrapMachine', 'lockerMenu', or 'monsterRoomView'

let gameState = "loading";
PLAYER_SPEED number

Constant defining pixels per frame the player moves during normal walking (3 px/frame by default)

const PLAYER_SPEED = 3;
PLAYER_RUN_SPEED number

Constant defining pixels per frame when the player holds the run button (5 px/frame by default)

const PLAYER_RUN_SPEED = 5;
MONSTER_SPEED number

Constant defining the base roaming speed of monsters in pixels per frame (1.5 px/frame by default)

const MONSTER_SPEED = 1.5;
MONSTER_CHASE_SPEED number

Constant defining the speed at which monsters move when chasing the player (2.5 px/frame by default)

const MONSTER_CHASE_SPEED = 2.5;
MONSTER_CHASE_RANGE number

Constant defining the distance in pixels within which a monster will detect and begin chasing the player (200 px by default)

const MONSTER_CHASE_RANGE = 200;
MAX_MONSTERS number

Constant defining the maximum number of monsters spawned at game start (5 by default)

const MAX_MONSTERS = 5;
collectibles array

Stores all key and scrap items in the world; items are removed when the player collides with them

let collectibles = [];
playerInventory array

Stores collected items (currently keys) that the player has picked up; displayed in the HUD

let playerInventory = [];
playerScrap number

Tracks the amount of scrap the player has collected; used as currency at the scrap machine

let playerScrap = 0;
vrMode boolean

Toggle flag for VR camera mode; when true, device orientation (tilt) affects the camera view

let vrMode = false;
deviceGamma number

Current device tilt in the left-right axis (−90 to +90 degrees); used to offset camera X in VR mode

let deviceGamma = 0;
deviceBeta number

Current device tilt in the front-back axis (−180 to +180 degrees); used to offset camera Y in VR mode

let deviceBeta = 0;
ambientSoundOsc p5.Oscillator

Sine wave oscillator generating the ambient drone at 50 Hz; fades in/out based on game state

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

White noise generator creating a subtle hiss/static for ambient atmosphere; filtered with a low-pass filter

let ambientSoundNoise = new p5.Noise('white');
jumpscareEnvelope p5.Envelope

Controls the amplitude envelope of the jumpscare noise burst; defines attack, decay, sustain, and release times

let jumpscareEnvelope = new p5.Envelope(); jumpscareEnvelope.setADSR(0.01, 0.2, 0.0, 0.5);
jumpscareOsc p5.Oscillator

Sawtooth oscillator for the jumpscare screech; frequency-swept and envelope-controlled for a terrifying effect

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

Stores the boombox entity position (x, y, w, h), oscillator, delay effect, and audio proximity thresholds

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

Cooldown flag preventing rapid repeated room transitions; re-enabled after TRANSITION_COOLDOWN (500ms) expires

let canTransition = true;

🔧 Potential Improvements (7)

Here are some ways this code could be enhanced:

BUG Monster.update() - staircase transition

When a monster transitions to a new room via staircase, it resets its velocity to 0, which can cause it to get stuck temporarily before resuming movement. This creates brief, unnatural pauses in monster behavior.

💡 After transitioning, maintain the monster's chase momentum by calculating the direction to the player in the new room and setting velocity accordingly instead of zeroing it out.

PERFORMANCE updatePlayer() and Monster.update()

Both player and monsters use rectCollision() to check against every staircase every frame, resulting in up to 5 monsters × 12 staircases = 60 collision checks per frame. With more monsters or staircases, this becomes expensive.

💡 Cache which staircases are near the player/monsters using spatial partitioning or a grid-based lookup, so only nearby staircases are checked for collision.

STYLE draw() function

The switch statement for gameState is very long (300+ lines) with deeply nested conditionals for each state, making it hard to follow and maintain. Adding new game states requires editing the central draw() function.

💡 Extract each game state case into its own function (drawGameState, drawLoadingState, drawJumpscareState, etc.) and call them from the switch, keeping draw() clean and modular.

FEATURE Monster class

All monsters are identical in appearance, speed, and behavior. There's no variety in enemy types, making the game less engaging over time.

💡 Create different Monster subclasses (e.g., FastMonster, StealthMonster) with different speeds, colors, and AI strategies; randomly spawn a mix of types.

BUG Monster.update() - cross-floor pathfinding

The monster cross-floor pathfinding is complex but has a subtle bug: if a monster is chasing across multiple floors and the player suddenly moves to a different room, the monster may continue toward an outdated staircase instead of recalculating.

💡 Add a 'lastPlayerFloor' check so monsters recalculate their pathfinding target whenever the player's floor changes.

PERFORMANCE preload() - Audio initialization

All sound oscillators and envelopes are pre-created and kept running in the background, consuming CPU and memory even when silent. Seven oscillators + several envelopes is overkill.

💡 Use object pooling: create sound objects on-demand or reuse a smaller pool of oscillators/envelopes, disposing of unused instances.

STYLE Monster class - draw() method

The monster drawing code uses hardcoded relative sizes (0.7, 0.6, etc.) without clear meaning. The shape is constructed from magic numbers scattered throughout the function.

💡 Define monster feature sizes as class properties (headScale, eyeScale, mouthScale) so different monster types can easily have different proportions.

🔄 Code Flow

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

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

graph TD start[Start] --> setup[setup] setup --> canvas-setup[Canvas Setup] setup --> rooms-definition[Rooms Definition] setup --> player-init[Player Initialization] setup --> ui-buttons-init[UI Buttons Initialization] setup --> spawn-entities[Spawn Entities] setup --> draw[draw loop] click setup href "#fn-setup" click canvas-setup href "#sub-canvas-setup" click rooms-definition href "#sub-rooms-definition" click player-init href "#sub-player-init" click ui-buttons-init href "#sub-ui-buttons-init" click spawn-entities href "#sub-spawn-entities" draw --> gamestate-switch[Game State Switch] gamestate-switch -->|game| game-update-phase[Game Update Phase] game-update-phase --> updateplayer[updatePlayer] updateplayer --> speed-calculation[Speed Calculation] speed-calculation --> position-update[Position Update] position-update --> footstep-logic[Footstep Logic] footstep-logic --> collectible-collision[Collectible Collision] collectible-collision --> staircase-transition[Staircase Transition] staircase-transition --> room-boundary-clamp[Room Boundary Clamp] room-boundary-clamp --> boombox-distance-audio[Boombox Distance Audio] boombox-distance-audio --> interaction-button-visibility[Interaction Button Visibility] interaction-button-visibility --> updatecamera[updateCamera] updatecamera --> base-camera-calc[Base Camera Calc] base-camera-calc --> vr-offset[VR Offset] vr-offset --> camera-clamping[Camera Clamping] camera-clamping --> camera-smoothing[Camera Smoothing] camera-smoothing --> monster-loop[Monster Loop] monster-loop --> updatemonsters[updateMonsters] updatemonsters --> constructor[Constructor] constructor --> movement[Movement] movement --> wall-bounce[Wall Bounce] wall-bounce --> roaming-behavior[Roaming Behavior] roaming-behavior --> chase-detection[Chase Detection] chase-detection --> cross-floor-chase[Cross-Floor Chase] cross-floor-chase --> safe-zone-collision[Safe Zone Collision] safe-zone-collision --> player-collision[Player Collision] player-collision --> triggerjumpscare[Trigger Jumpscare] gamestate-switch -->|jumpscare| state-init[State Init] state-init --> audio-fadeout[Audio Fadeout] audio-fadeout --> jumpscare-sound[Jumpscare Sound] jumpscare-sound --> drawjumpscare[drawJumpscare] drawjumpscare --> phase-switch[Phase Switch] phase-switch --> monster-scaling[Monster Scaling] monster-scaling --> red-flash[Red Flash] red-flash --> blood-phase-display[Blood Phase Display] blood-phase-display --> fade-to-black[Fade to Black] draw --> world-rendering[World Rendering] world-rendering --> drawrooms[drawRooms] drawrooms --> room-iteration[Room Iteration] room-iteration --> room-rendering[Room Rendering] draw --> ui-rendering[UI Layer Rendering] click draw href "#fn-draw" click gamestate-switch href "#sub-gamestate-switch" click game-update-phase href "#sub-game-update-phase" click updateplayer href "#fn-updateplayer" click speed-calculation href "#sub-speed-calculation" click position-update href "#sub-position-update" click footstep-logic href "#sub-footstep-logic" click collectible-collision href "#sub-collectible-collision" click staircase-transition href "#sub-staircase-transition" click room-boundary-clamp href "#sub-room-boundary-clamp" click boombox-distance-audio href "#sub-boombox-distance-audio" click interaction-button-visibility href "#sub-interaction-button-visibility" click updatecamera href "#fn-updatecamera" click base-camera-calc href "#sub-base-camera-calc" click vr-offset href "#sub-vr-offset" click camera-clamping href "#sub-camera-clamping" click camera-smoothing href "#sub-camera-smoothing" click monster-loop href "#sub-monster-loop" click updatemonsters href "#fn-updatemonsters" click constructor href "#sub-constructor" click movement href "#sub-movement" click wall-bounce href "#sub-wall-bounce" click roaming-behavior href "#sub-roaming-behavior" click chase-detection href "#sub-chase-detection" click cross-floor-chase href "#sub-cross-floor-chase" click safe-zone-collision href "#sub-safe-zone-collision" click player-collision href "#sub-player-collision" click triggerjumpscare href "#fn-triggerjumpscare" click state-init href "#sub-state-init" click audio-fadeout href "#sub-audio-fadeout" click jumpscare-sound href "#sub-jumpscare-sound" click drawjumpscare href "#fn-drawjumpscare" click phase-switch href "#sub-phase-switch" click monster-scaling href "#sub-monster-scaling" click red-flash href "#sub-red-flash" click blood-phase-display href "#sub-blood-phase-display" click fade-to-black href "#sub-fade-to-black" click world-rendering href "#sub-world-rendering" click drawrooms href "#fn-drawrooms" click room-iteration href "#sub-room-iteration" click room-rendering href "#sub-room-rendering" click ui-rendering href "#sub-ui-rendering"

❓ Frequently Asked Questions

What kind of visual experience does the Lethal ape: redux sketch offer?

The sketch creates a hand-drawn, eerie side-scrolling horror maze where simple shapes transform into unsettling rooms and monsters, enhancing the atmosphere with jumpscares.

How can users interact with the Lethal ape: redux sketch?

Users can navigate the maze using on-screen controls or by tilting their device, allowing for movement, running, and looking around as the camera follows their actions.

What creative coding concepts does the Lethal ape: redux sketch showcase?

This sketch demonstrates procedural drawing techniques and dynamic game state management, creating an immersive horror experience without using image assets.

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, updatecamera, triggerjumpscare, drawjumpscare, drawplayer, spawnmonster, drawrooms
Code Flow Diagram