Fatal ape redux vr

This sketch creates a side-scrolling horror game where a stick-figure player navigates eerie rooms, climbs vents, collects keys, and evades procedurally-drawn monsters. Mobile tilt controls and jumpscare events with procedural audio create a tense, minimalist interactive experience.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make monsters move faster — Increase MONSTER_SPEED from 1.5 to 3—monsters will chase and patrol twice as fast, making the game more intense
  2. Make the player jump lower — Change JUMP_FORCE from -22 to -15 to reduce jump height—the player won't be able to reach high platforms easily
  3. Spawn more monsters — Change MAX_MONSTERS from 5 to 10—twice as many enemies will spawn, making labs more dangerous
  4. Increase monster detection range — Change MONSTER_CHASE_RANGE from 200 to 400—monsters will spot the player from further away
  5. Speed up the player — Change PLAYER_SPEED from 3 to 6—the joystick will move the character twice as fast
  6. Disable the safe zone — Change the room color to something dark or make monsters able to enter by removing the safe zone barrier code
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch creates an interactive side-scrolling horror adventure where your red rectangle hero runs, jumps, climbs vents, and flees from yellow monster rectangles through three connected rooms. The game combines platformer mechanics (jumping, climbing), collision detection, room transitions, and a full-screen jumpscare system with procedural audio—making it one of the most complete interactive experiences you can build in p5.js. Mobile tilt sensing (VR mode) lets you pan the camera by tilting your device, while touch buttons control jumping and climbing.

The code is organized into clear sections: game state variables, the main draw loop that handles rendering and camera updates, player physics and input, a Monster class that hunts the player, procedural room layouts connected by vents and staircases, and a dramatic jumpscare sequence triggered on collision. By studying it, you'll learn how to build a game with multiple rooms, procedural enemy AI, mobile touch and orientation controls, and how to orchestrate a multi-phase jumpscare event with procedural sound design.

⚙️ How It Works

  1. When you touch the canvas, gameStart becomes true and the ambient sound (a low sine-wave drone and filtered white noise) fades in. The player spawns in the safe zone.
  2. Every frame, updatePlayer() applies gravity, moves the player based on joystick input, detects floor/wall collisions, checks for vent climbing, and handles room transitions when the player touches a vent or staircase.
  3. updateMonsters() runs the Monster class update for each creature—they roam their rooms, chase the player if they detect them within MONSTER_CHASE_RANGE, and bounce away if they try to enter the safe zone.
  4. updateCamera() positions the viewport centered on the player, then offsets it by the device's gamma (tilt) angle if VR mode is on, creating a 'look around' effect.
  5. draw() clears the background, updates game state, translates the canvas to show the camera view, then draws all rooms, vents, staircases, the player, monsters, and collectible keys.
  6. If a monster touches the player, triggerJumpscare() is called, which plays a procedural screech and noise burst, displays a morphing dark-red monster body eating the screen, fades to blood-red, then black, and resets the game.

🎓 Concepts You'll Learn

Side-scrolling camera and translationCollision detection and room transitionsMonster AI and pathfinding behaviorMobile touch input and device orientationProcedural audio synthesis with p5.soundGame state management and jumpscare sequencesClass-based game objects

📝 Code Breakdown

preload()

preload() runs once before setup(), and is where you load images, audio, and initialize p5.sound objects. All audio synthesis in this sketch happens here, creating reusable oscillators, noise generators, and envelopes that the game will trigger later.

function preload() {
  ambientSoundOsc = new p5.Oscillator('sine');
  ambientSoundOsc.freq(50);
  ambientSoundOsc.amp(0);
  ambientSoundOsc.start();

  ambientSoundNoise = new p5.Noise('white');
  ambientSoundNoise.amp(0);
  ambientSoundNoise.start();

  ambientFilter = new p5.Filter('lowpass');
  ambientFilter.freq(500);
  ambientFilter.res(2);
  ambientSoundNoise.disconnect();
  ambientSoundNoise.connect(ambientFilter);
  ambientFilter.connect();

  jumpscareNoise = new p5.Noise('white');
  jumpscareNoise.amp(0);
  jumpscareNoise.start();

  jumpscareOsc = new p5.Oscillator('sawtooth');
  jumpscareOsc.freq(1);
  jumpscareOsc.amp(0);
  jumpscareOsc.start();

  jumpscareEnvelope = new p5.Envelope();
  jumpscareEnvelope.setADSR(0.01, 0.2, 0, 0.5);
  jumpscareEnvelope.setRange(1.0, 0);
  jumpscareNoise.amp(jumpscareEnvelope);

  footstepOsc = new p5.Oscillator('sine');
  footstepOsc.amp(0);
  footstepOsc.start();
  footstepEnvelope = new p5.Envelope();
  footstepEnvelope.setADSR(0.01, 0.1, 0, 0.1);
  footstepOsc.amp(footstepEnvelope);

  collectOsc = new p5.Oscillator('triangle');
  collectOsc.amp(0);
  collectOsc.start();
  collectEnvelope = new p5.Envelope();
  collectEnvelope.setADSR(0.01, 0.05, 0, 0.2);
  collectOsc.amp(collectEnvelope);
}
Line-by-line explanation (12 lines)

🔧 Subcomponents:

calculation Ambient sound setup ambientSoundOsc = new p5.Oscillator('sine'); ambientSoundOsc.freq(50); ambientSoundOsc.amp(0); ambientSoundOsc.start();

Creates a low-frequency drone oscillator (50 Hz sine wave) that will play ambient background audio

calculation Ambient noise setup ambientSoundNoise = new p5.Noise('white'); ambientSoundNoise.amp(0); ambientSoundNoise.start(); ambientFilter = new p5.Filter('lowpass'); ambientFilter.freq(500); ambientFilter.res(2); ambientSoundNoise.disconnect(); ambientSoundNoise.connect(ambientFilter); ambientFilter.connect();

Creates filtered white noise (only frequencies below 500 Hz pass through) for a subtle hiss effect

calculation Jumpscare sound setup jumpscareNoise = new p5.Noise('white'); jumpscareNoise.amp(0); jumpscareNoise.start(); jumpscareOsc = new p5.Oscillator('sawtooth'); jumpscareOsc.freq(1); jumpscareOsc.amp(0); jumpscareOsc.start(); jumpscareEnvelope = new p5.Envelope(); jumpscareEnvelope.setADSR(0.01, 0.2, 0, 0.5); jumpscareEnvelope.setRange(1.0, 0); jumpscareNoise.amp(jumpscareEnvelope);

Creates a harsh sawtooth oscillator and noise burst with an envelope that controls their volume over time (attack, decay, sustain, release)

ambientSoundOsc = new p5.Oscillator('sine');
Creates a sine-wave oscillator object which will generate a smooth, pure tone
ambientSoundOsc.freq(50);
Sets the oscillator frequency to 50 Hz, a very low frequency that feels like a distant rumble
ambientSoundOsc.amp(0);
Sets amplitude to 0 so the oscillator is silent until we fade it in later
ambientSoundOsc.start();
Starts the oscillator running (but silently, since amp is 0)
ambientSoundNoise = new p5.Noise('white');
Creates a white noise generator (random noise across all frequencies)
ambientFilter = new p5.Filter('lowpass');
Creates a low-pass filter that will only let low frequencies through, removing harsh high-frequency hiss
ambientFilter.freq(500);
Configures the filter to cut off frequencies above 500 Hz
ambientSoundNoise.disconnect();
Removes the noise from the default audio output so we can route it through the filter first
ambientSoundNoise.connect(ambientFilter);
Connects noise to the filter, so it becomes filtered before reaching the speakers
ambientFilter.connect();
Connects the filtered noise to the master audio output (the speakers)
jumpscareEnvelope.setADSR(0.01, 0.2, 0, 0.5);
Configures the jumpscare envelope: 0.01s attack (quick fade-in), 0.2s decay, 0s sustain (stays at peak), 0.5s release (slow fade-out)
jumpscareEnvelope.setRange(1.0, 0);
Sets the envelope to scale from full volume (1.0) down to silent (0)

setup()

setup() runs once when the sketch starts and is where you initialize all game data: canvas size, room layouts, player state, UI button positions, and the initial monsters and collectibles. It sets up the entire game world before the draw loop begins.

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

  rooms = {
    safeZone: { x: 0, y: 0, w: 800, h: 600, color: '#333333' },
    floor1Lab: { x: 800, y: 0, w: 600, h: 600, color: '#554444' },
    floor2Lab: { x: -600, y: 0, w: 600, h: 600, color: '#445544' }
  };

  vents = [
    { x: rooms.safeZone.x + 200, y: rooms.safeZone.y + 100, w: 80, h: 40, targetRoom: "floor2Lab", targetPlayerX: rooms.floor2Lab.x + 300, targetPlayerY: rooms.floor2Lab.y + 100 },
    { x: rooms.safeZone.x + 200, y: rooms.safeZone.y + rooms.safeZone.h - 140, w: 80, h: 40, targetRoom: "floor1Lab", targetPlayerX: rooms.floor1Lab.x + 300, targetPlayerY: rooms.floor1Lab.y + rooms.floor1Lab.h - 140 },
    { x: rooms.floor1Lab.x + 500, y: rooms.floor1Lab.y + rooms.floor1Lab.h - 140, w: 80, h: 40, targetRoom: "safeZone", targetPlayerX: rooms.safeZone.x + 600, targetPlayerY: rooms.safeZone.y + rooms.safeZone.h - 140 },
    { x: rooms.floor2Lab.x + 10, y: rooms.floor2Lab.y + 100, w: 80, h: 40, targetRoom: "safeZone", targetPlayerX: rooms.safeZone.x + 100, targetPlayerY: rooms.safeZone.y + 100 }
  ];

  staircases = [
    { x: rooms.floor1Lab.x + 10, y: rooms.floor1Lab.y + rooms.floor1Lab.h - 200, w: 80, h: 100, targetRoom: "safeZone", targetPlayerX: rooms.safeZone.x + rooms.safeZone.w - 100, targetPlayerY: rooms.safeZone.y + rooms.safeZone.h - 100 },
    { x: rooms.floor2Lab.x + rooms.floor2Lab.w - 100, y: rooms.floor2Lab.y + rooms.floor2Lab.h - 200, w: 80, h: 100, targetRoom: "safeZone", targetPlayerX: rooms.safeZone.x + 100, targetPlayerY: rooms.safeZone.y + rooms.safeZone.h - 100 }
  ];

  player = {
    x: rooms.safeZone.x + 100,
    y: rooms.safeZone.y + rooms.safeZone.h - 50,
    w: 30,
    h: 50,
    vx: 0,
    vy: 0,
    isJumping: false,
    isClimbing: false,
    onGround: false
  };

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

  jumpButton.x = width * 0.8 - jumpButton.w / 2;
  jumpButton.y = height * 0.8 - jumpButton.h / 2;

  climbUpButton.x = jumpButton.x;
  climbUpButton.y = jumpButton.y - jumpButton.h - 10;

  climbDownButton.x = jumpButton.x;
  climbDownButton.y = jumpButton.y + jumpButton.h + 10;

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

🔧 Subcomponents:

calculation Canvas initialization createCanvas(windowWidth, windowHeight); noStroke(); textAlign(CENTER, CENTER);

Creates the canvas filling the window, disables outlines on shapes, and centers all text

calculation Room layout definition rooms = { safeZone: { x: 0, y: 0, w: 800, h: 600, color: '#333333' }, floor1Lab: { x: 800, y: 0, w: 600, h: 600, color: '#554444' }, floor2Lab: { x: -600, y: 0, w: 600, h: 600, color: '#445544' } };

Defines three connected rooms: safe zone in the center, floor1Lab to the right, floor2Lab to the left, each with position, size, and color

calculation Vent connections vents = [ { x: rooms.safeZone.x + 200, y: rooms.safeZone.y + 100, w: 80, h: 40, targetRoom: "floor2Lab", targetPlayerX: rooms.floor2Lab.x + 300, targetPlayerY: rooms.floor2Lab.y + 100 }, ... ];

Creates four vent portals that transport the player between rooms when touched

calculation Player initialization player = { x: rooms.safeZone.x + 100, y: rooms.safeZone.y + rooms.safeZone.h - 50, w: 30, h: 50, vx: 0, vy: 0, isJumping: false, isClimbing: false, onGround: false };

Creates the player object with initial position in the safe zone, dimensions, velocities, and state flags

calculation UI button positioning jumpButton.x = width * 0.8 - jumpButton.w / 2; jumpButton.y = height * 0.8 - jumpButton.h / 2; climbUpButton.x = jumpButton.x; climbUpButton.y = jumpButton.y - jumpButton.h - 10; climbDownButton.x = jumpButton.x; climbDownButton.y = jumpButton.y + jumpButton.h + 10;

Positions the jump, climb-up, and climb-down buttons on the right side of the screen, stacked vertically

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

Creates the initial population of monsters across lab rooms

createCanvas(windowWidth, windowHeight);
Creates a canvas that fills the entire browser window, making the game responsive
noStroke();
Disables outlines on all shapes drawn after this, using only fills and colors
textAlign(CENTER, CENTER);
Anchors all text to its center point, making it easier to position text on screen
rooms = { safeZone: ..., floor1Lab: ..., floor2Lab: ... };
Creates an object storing three rooms, each with x, y position, width, height, and color for rendering
vents = [ ... ];
Defines an array of four vent portals that trigger room transitions when the player touches them
player = { x: rooms.safeZone.x + 100, y: ..., w: 30, h: 50, vx: 0, vy: 0, isJumping: false, isClimbing: false, onGround: false };
Initializes the player object with starting position, dimensions (30×50 pixels), and state flags (all false at start)
for (let i = 0; i < MAX_MONSTERS; i++) { spawnMonster(); }
Loops MAX_MONSTERS times (5 times), calling spawnMonster() each iteration to populate the labs with enemies
spawnCollectibles();
Creates the initial key items scattered throughout the lab rooms

draw()

draw() runs 60 times per second (the main game loop). It clears the canvas, checks game state, updates positions and camera, applies the camera transformation via translate(), draws the world, then renders UI on top. The push()/pop() sandwich is crucial: it isolates the camera translation so UI stays fixed on screen.

function draw() {
  background(0);

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

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

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

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

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

  pop();

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

🔧 Subcomponents:

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

If the game hasn't started (waiting for first touch), draw the start screen and skip all game logic

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

If a jumpscare is playing, render only the jumpscare animation and skip normal game updates

calculation Camera offset application push(); translate(-cameraX, 0); drawRooms(); drawVents(); drawStaircases(); drawPlayer(); drawMonsters(); drawCollectibles(); drawSafeZoneBoundary(); pop();

Saves the drawing state, applies a horizontal translation to shift the entire game world based on camera position, draws all world elements, then restores the state

background(0);
Clears the entire canvas to black each frame, erasing everything drawn last frame
if (!gameStart) { drawStartScreen(); return; }
Shows the start screen before the player touches the canvas; exits early so no game logic runs
if (jumpscare.isActive) { drawJumpscare(); return; }
If a jumpscare is playing, render only the jumpscare visuals and pause all game updates
updatePlayer();
Calculates new player position, applies gravity, detects collisions, handles room transitions
updateMonsters();
Updates all monster positions, AI behavior, collision detection with player
updateCamera();
Recalculates camera position based on player location and VR tilt, with smoothing
push();
Saves the current drawing transformation (position, rotation, scale) onto a stack
translate(-cameraX, 0);
Shifts the entire coordinate system left by cameraX pixels, making the camera follow the player
drawRooms(); drawVents(); drawStaircases(); drawPlayer(); drawMonsters(); drawCollectibles(); drawSafeZoneBoundary();
Draws all world elements in order from bottom (rooms) to top (boundaries), all translated by camera offset
pop();
Restores the saved drawing transformation, returning to original coordinates for UI rendering
drawJoystick(); drawMonsterTracker(); drawButtons(); drawInventory(); drawVRToggleButton();
Draws all UI elements on top of the world at fixed screen positions (not affected by camera translation)

updatePlayer()

updatePlayer() is called every frame (60 times per second) and handles ALL player physics: gravity, movement, collisions with floors and walls, vent climbing, room transitions, and item collection. It's the heart of the platformer mechanics.

function updatePlayer() {
  if (!player.isClimbing) {
    player.vy += GRAVITY;
  }

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

  let onFloor = false;
  let currentRoomData = rooms[currentRoom];
  if (player.y + player.h >= currentRoomData.y + currentRoomData.h) {
    player.y = currentRoomData.y + currentRoomData.h - player.h;
    player.vy = 0;
    player.isJumping = false;
    player.onGround = true;
    onFloor = true;
  } else {
    player.onGround = false;
  }

  if (player.onGround && abs(player.vx) > 0.1) {
    footstepCounter++;
    if (footstepCounter >= FOOTSTEP_INTERVAL) {
      footstepOsc.freq(random(100, 150));
      footstepEnvelope.play(footstepOsc);
      footstepCounter = 0;
    }
  } else {
    footstepCounter = 0;
  }

  for (let i = collectibles.length - 1; i >= 0; i--) {
    let item = collectibles[i];
    if (item.room === currentRoom && rectCollision(player.x, player.y, player.w, player.h, item.x, item.y, item.w, item.h)) {
      playerInventory.push(item);
      collectibles.splice(i, 1);
      collectOsc.freq(random(600, 800));
      collectEnvelope.play(collectOsc);
      console.log(`Grabbed a ${item.type}! Inventory:`, playerInventory);
    }
  }

  if (player.x < currentRoomData.x) {
    player.x = currentRoomData.x;
    player.vx = 0;
  }
  if (player.x + player.w > currentRoomData.x + currentRoomData.w) {
    player.x = currentRoomData.x + currentRoomData.w - player.w;
    player.vx = 0;
  }

  let foundClimbableVent = false;
  for (let vent of vents) {
    if (rectCollision(player.x, player.y, player.w, player.h, vent.x, vent.y, vent.w, vent.h)) {
      player.isClimbing = true;
      player.vy = 0;
      player.x = vent.x + vent.w / 2 - player.w / 2;
      foundClimbableVent = true;
      break;
    }
  }

  if (!foundClimbableVent && player.isClimbing) {
    player.isClimbing = false;
    player.vy = 0;
  }

  let hasTransitioned = false;

  for (let vent of vents) {
    if (rectCollision(player.x, player.y, player.w, player.h, vent.x, vent.y, vent.w, vent.h)) {
      if (currentRoom !== vent.targetRoom) {
        currentRoom = vent.targetRoom;
        player.x = vent.targetPlayerX;
        player.y = vent.targetPlayerY;
        player.vx = 0;
        player.vy = 0;
        player.isJumping = false;
        player.isClimbing = false;
        hasTransitioned = true;
        break;
      }
    }
  }

  if (hasTransitioned) {
    return;
  }

  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.isJumping = false;
        player.isClimbing = false;
        hasTransitioned = true;
        break;
      }
    }
  }

  if (hasTransitioned) {
    return;
  }

  currentRoomData = rooms[currentRoom];
  player.x = constrain(player.x, currentRoomData.x, currentRoomData.x + currentRoomData.w - player.w);
  player.y = constrain(player.y, currentRoomData.y, currentRoomData.y + currentRoomData.h - player.h);

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

🔧 Subcomponents:

conditional Gravity application if (!player.isClimbing) { player.vy += GRAVITY; }

Increases downward velocity each frame unless the player is climbing a vent

conditional Floor collision detection if (player.y + player.h >= currentRoomData.y + currentRoomData.h) { player.y = currentRoomData.y + currentRoomData.h - player.h; player.vy = 0; player.isJumping = false; player.onGround = true; onFloor = true; }

Detects when the player hits the floor, snaps position, stops vertical movement, and allows jumping again

conditional Footstep audio if (player.onGround && abs(player.vx) > 0.1) { footstepCounter++; if (footstepCounter >= FOOTSTEP_INTERVAL) { footstepOsc.freq(random(100, 150)); footstepEnvelope.play(footstepOsc); footstepCounter = 0; } }

Plays a footstep sound every FOOTSTEP_INTERVAL frames when the player is moving on the ground

for-loop Collectible pickup for (let i = collectibles.length - 1; i >= 0; i--) { let item = collectibles[i]; if (item.room === currentRoom && rectCollision(player.x, player.y, player.w, player.h, item.x, item.y, item.w, item.h)) { playerInventory.push(item); collectibles.splice(i, 1); collectOsc.freq(random(600, 800)); collectEnvelope.play(collectOsc); } }

Checks if the player overlaps any collectible item in the current room, adds it to inventory, plays a chime, and removes it from the world

for-loop Vent climbing detection for (let vent of vents) { if (rectCollision(player.x, player.y, player.w, player.h, vent.x, vent.y, vent.w, vent.h)) { player.isClimbing = true; player.vy = 0; player.x = vent.x + vent.w / 2 - player.w / 2; foundClimbableVent = true; break; } }

Detects if the player is overlapping a vent, enables climbing mode, centers the player in the vent horizontally, and stops vertical movement

for-loop Vent room transition for (let vent of vents) { if (rectCollision(player.x, player.y, player.w, player.h, vent.x, vent.y, vent.w, vent.h)) { if (currentRoom !== vent.targetRoom) { currentRoom = vent.targetRoom; player.x = vent.targetPlayerX; player.y = vent.targetPlayerY; player.vx = 0; player.vy = 0; player.isJumping = false; player.isClimbing = false; hasTransitioned = true; break; } } }

Checks if the player has fully entered a vent (overlapping), and if so, teleports them to a new room and resets their state

if (!player.isClimbing) { player.vy += GRAVITY; }
Every frame, add GRAVITY to downward velocity unless climbing—this creates the falling sensation
player.x += player.vx;
Update horizontal position by adding horizontal velocity (set by joystick input)
player.y += player.vy;
Update vertical position by adding vertical velocity (affected by gravity and jumping)
if (player.y + player.h >= currentRoomData.y + currentRoomData.h) { ... }
Check if the player's bottom edge has reached or passed the room floor; if so, snap to floor and stop falling
player.onGround = true;
Set flag to true so the player can jump again and can play footstep sounds
if (player.onGround && abs(player.vx) > 0.1) { footstepCounter++; ... }
Count frames while moving on ground; every FOOTSTEP_INTERVAL frames, play a footstep sound with random pitch
for (let i = collectibles.length - 1; i >= 0; i--) { ... if (rectCollision(...)) { playerInventory.push(item); collectibles.splice(i, 1); ... } }
Loop backwards through collectibles to safely remove items; if player overlaps an item in this room, add to inventory and play chime sound
if (player.x < currentRoomData.x) { player.x = currentRoomData.x; player.vx = 0; }
If player walks off the left edge, snap to left wall and stop horizontal movement
for (let vent of vents) { if (rectCollision(...)) { player.isClimbing = true; player.vy = 0; player.x = vent.x + vent.w / 2 - player.w / 2; ... } }
If player overlaps a vent, enable climbing, center player horizontally in vent, stop falling
if (!foundClimbableVent && player.isClimbing) { player.isClimbing = false; player.vy = 0; }
If player is climbing but no longer touching a vent, disable climbing and stop vertical movement

drawPlayer()

drawPlayer() is a simple helper that renders the procedural player character as a red rectangle. It's called from within the camera-translated world coordinates, so the player appears at the correct position relative to rooms and scenery.

function drawPlayer() {
  fill(255, 0, 0);
  rect(player.x, player.y, player.w, player.h);
}
Line-by-line explanation (2 lines)
fill(255, 0, 0);
Sets the fill color to pure red (RGB: 255 red, 0 green, 0 blue)
rect(player.x, player.y, player.w, player.h);
Draws a rectangle at the player's current (x, y) position with width w and height h

playerJump()

playerJump() is called when the player presses the Jump button or spacebar. It sets upward velocity and flags, but the actual arc of the jump is calculated by gravity in updatePlayer() pulling the player back down each frame.

function playerJump() {
  if (player.onGround && !player.isJumping) {
    player.vy = JUMP_FORCE;
    player.isJumping = true;
    player.onGround = false;
    ambientSoundOsc.amp(0.1, 0.1);
    ambientSoundNoise.amp(0.05, 0.1);
    setTimeout(() => {
      ambientSoundOsc.amp(0.5, 0.5);
      ambientSoundNoise.amp(0.2, 0.5);
    }, 500);
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

conditional Jump gate if (player.onGround && !player.isJumping) { ... }

Only allows jumping if the player is on ground and not already in mid-jump

calculation Audio ducking ambientSoundOsc.amp(0.1, 0.1); ambientSoundNoise.amp(0.05, 0.1); setTimeout(() => { ambientSoundOsc.amp(0.5, 0.5); ambientSoundNoise.amp(0.2, 0.5); }, 500);

Fades out ambient sound during jump, then fades it back in after 500ms—creates a 'breath' effect in the audio

if (player.onGround && !player.isJumping) {
Only allow jump if player is touching ground AND not already jumping (prevents double-jump)
player.vy = JUMP_FORCE;
Set vertical velocity to JUMP_FORCE (a negative number like -22), which instantly moves the player up next frame
player.isJumping = true;
Set flag to true so gravity takes over and pulls the player back down
player.onGround = false;
Mark player as airborne so they can't jump again until hitting the floor
ambientSoundOsc.amp(0.1, 0.1);
Fade ambient drone to volume 0.1 over 0.1 seconds (instant) when jump happens
setTimeout(() => { ambientSoundOsc.amp(0.5, 0.5); ... }, 500);
After 500 milliseconds, fade ambient sound back to normal volume over 0.5 seconds

playerClimb()

playerClimb() is called when the player presses Climb Up or Climb Down buttons (or arrow keys on desktop). It simply sets vertical velocity while the player is in a vent—the direction parameter is -1 for up, +1 for down.

function playerClimb(direction) {
  if (player.isClimbing) {
    player.vy = direction * WALL_CLIMB_SPEED;
  }
}
Line-by-line explanation (2 lines)
if (player.isClimbing) {
Only allow vertical movement if the player is currently touching a vent
player.vy = direction * WALL_CLIMB_SPEED;
Set vertical velocity: direction is -1 (up) or +1 (down), multiplied by WALL_CLIMB_SPEED to control climb speed

Monster (class)

The Monster class encapsulates all enemy behavior: physics (gravity, walls, floor), AI (roaming vs. chasing), collision with the safe zone, and detection of the player. Each monster instance runs independently, updating its own state every frame. This class-based design makes it easy to spawn multiple enemies and control them all at once.

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

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

    this.vy += GRAVITY;
    this.x += this.vx;
    this.y += this.vy;

    if (this.y + this.h >= roomData.y + roomData.h) {
      this.y = roomData.y + roomData.h - this.h;
      this.vy = 0;
    }

    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.room === currentRoom && currentRoom !== "safeZone") {
      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";
          this.vx = random([-MONSTER_SPEED, MONSTER_SPEED]);
          this.roamTimer = 0;
        }
      }
    } else {
      this.state = "roaming";
      this.vx = random([-MONSTER_SPEED, MONSTER_SPEED]);
      this.roamTimer = 0;
    }

    if (this.state === "roaming") {
      this.roamTimer++;
      if (this.roamTimer > this.roamDuration) {
        this.vx *= -1;
        this.roamTimer = 0;
        this.roamDuration = random(60, 240);
      }
    } else if (this.state === "chasing") {
      this.vx = (player.x > this.x ? MONSTER_SPEED : -MONSTER_SPEED);
    }

    let safeZone = rooms.safeZone;
    if (this.room !== "safeZone" && rectCollision(this.x, this.y, this.w, this.h, safeZone.x, safeZone.y, safeZone.w, safeZone.h)) {
      if (this.vx > 0) {
        this.x = safeZone.x - this.w;
      } else {
        this.x = safeZone.x + safeZone.w;
      }
      this.vx *= -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() {
    fill(255, 255, 0);
    rect(this.x, this.y, this.w, this.h);

    fill(0);
    ellipse(this.x + this.w / 2 + (this.vx > 0 ? 10 : -10), this.y + this.h / 3, 8);
  }
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

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

Sets up a new monster with starting position, dimensions, random movement direction, room assignment, and behavior timers

conditional Monster AI detection if (this.room === currentRoom && currentRoom !== "safeZone") { 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"; this.vx = random([-MONSTER_SPEED, MONSTER_SPEED]); this.roamTimer = 0; } } }

Detects if player is within MONSTER_CHASE_RANGE; if so, switches to chasing state, otherwise counts down and returns to roaming

conditional Monster behavior logic if (this.state === "roaming") { this.roamTimer++; if (this.roamTimer > this.roamDuration) { this.vx *= -1; this.roamTimer = 0; this.roamDuration = random(60, 240); } } else if (this.state === "chasing") { this.vx = (player.x > this.x ? MONSTER_SPEED : -MONSTER_SPEED); }

If roaming, turn around at random intervals; if chasing, move toward the player

conditional Safe zone barrier if (this.room !== "safeZone" && rectCollision(this.x, this.y, this.w, this.h, safeZone.x, safeZone.y, safeZone.w, safeZone.h)) { if (this.vx > 0) { this.x = safeZone.x - this.w; } else { this.x = safeZone.x + safeZone.w; } this.vx *= -1; this.state = "roaming"; this.roamTimer = 0; }

Prevents monsters from entering the safe zone; bounces them back if they try

this.vx = random([-MONSTER_SPEED, MONSTER_SPEED]);
Picks a random direction: either +MONSTER_SPEED (moving right) or -MONSTER_SPEED (moving left)
this.vy += GRAVITY;
Monsters also fall under gravity, so they stay on the ground like the player
this.x += this.vx; this.y += this.vy;
Update monster position by adding velocity, moving it each frame
if (this.y + this.h >= roomData.y + roomData.h) { this.y = roomData.y + roomData.h - this.h; this.vy = 0; }
Snap monster to floor if it falls below room bottom
if (this.x < roomData.x || this.x + this.w > roomData.x + roomData.w) { this.vx *= -1; ... }
If monster hits a wall, flip direction and clamp position to stay in room
let d = dist(this.x, this.y, player.x, player.y);
Calculate distance from monster to player using the Pythagorean theorem
if (d < MONSTER_CHASE_RANGE) { this.state = "chasing"; this.chaseTimer = this.chaseDuration; }
If distance is less than detection range, start chasing; reset chase timer to chase for CHASE_DURATION frames
this.vx = (player.x > this.x ? MONSTER_SPEED : -MONSTER_SPEED);
If player is to the right, move right; if to the left, move left
if (currentRoom === this.room && rectCollision(player.x, player.y, player.w, player.h, this.x, this.y, this.w, this.h)) { triggerJumpscare(); }
If monster and player overlap in the same room, trigger the jumpscare sequence

spawnMonster()

spawnMonster() is a helper that creates a single Monster instance and adds it to the game. It's called MAX_MONSTERS times in setup() to populate the labs, and can be called later to respawn monsters.

function spawnMonster() {
  let targetRoomName = random(["floor1Lab", "floor2Lab"]);
  let targetRoomData = rooms[targetRoomName];
  let x = random(targetRoomData.x, targetRoomData.x + targetRoomData.w - 40);
  let y = targetRoomData.y + targetRoomData.h - 60;
  monsters.push(new Monster(x, y, targetRoomName));
}
Line-by-line explanation (4 lines)
let targetRoomName = random(["floor1Lab", "floor2Lab"]);
Randomly chooses which lab room to spawn the monster in (not the safe zone)
let x = random(targetRoomData.x, targetRoomData.x + targetRoomData.w - 40);
Randomly positions the monster horizontally in the chosen room (accounting for monster width of 40)
let y = targetRoomData.y + targetRoomData.h - 60;
Places the monster on the floor of the room (monster height is 60)
monsters.push(new Monster(x, y, targetRoomName));
Creates a new Monster object and adds it to the monsters array

updateMonsters()

updateMonsters() is called from draw() every frame and simply calls update() on every monster instance. It's a simple loop but essential for keeping all enemies alive and moving.

function updateMonsters() {
  for (let monster of monsters) {
    monster.update();
  }
}
Line-by-line explanation (2 lines)
for (let monster of monsters) {
Loops through every monster in the monsters array
monster.update();
Calls the update() method on each monster, advancing their physics, AI, and collision detection by one frame

drawMonsters()

drawMonsters() is called from draw() and renders only monsters in the current room. It's an optimization: monsters in other rooms aren't drawn, saving processing power.

function drawMonsters() {
  for (let monster of monsters) {
    if (monster.room === currentRoom) {
      monster.draw();
    }
  }
}
Line-by-line explanation (3 lines)
for (let monster of monsters) {
Loops through every monster
if (monster.room === currentRoom) {
Only draws monsters that are in the same room as the player (to avoid drawing off-screen monsters)
monster.draw();
Calls the draw() method on the monster, rendering it as a yellow rectangle with an eye

spawnCollectibles()

spawnCollectibles() is called from setup() once and hardcodes the positions of three keys (one in each lab room). To make the game more interesting, you could randomly generate key positions or add more keys.

function spawnCollectibles() {
  collectibles.push({ x: rooms.floor1Lab.x + 150, y: rooms.floor1Lab.y + rooms.floor1Lab.h - 50, w: 20, h: 30, type: "key", room: "floor1Lab" });
  collectibles.push({ x: rooms.floor1Lab.x + 450, y: rooms.floor1Lab.y + rooms.floor1Lab.h - 150, w: 20, h: 30, type: "key", room: "floor1Lab" });
  collectibles.push({ x: rooms.floor2Lab.x + 250, y: rooms.floor2Lab.y + rooms.floor2Lab.h - 50, w: 20, h: 30, type: "key", room: "floor2Lab" });
}
Line-by-line explanation (1 lines)
collectibles.push({ x: ..., y: ..., w: 20, h: 30, type: "key", room: "floor1Lab" });
Creates a key object with position, size, type, and room assignment, then adds it to the collectibles array

drawCollectibles()

drawCollectibles() is called from draw() every frame and renders only the keys in the current room. Like drawMonsters(), it's an optimization to avoid drawing off-screen items.

function drawCollectibles() {
  for (let item of collectibles) {
    if (item.room === currentRoom) {
      drawKey(item.x, item.y, item.w, item.h);
    }
  }
}
Line-by-line explanation (3 lines)
for (let item of collectibles) {
Loops through every collectible item
if (item.room === currentRoom) {
Only draws items in the same room as the player
drawKey(item.x, item.y, item.w, item.h);
Calls drawKey() to render the item using its position and size

drawKey()

drawKey() draws a procedural key using geometric shapes. It uses push()/pop() to isolate a translate(), making it easy to draw the key at any position without affecting other drawings.

function drawKey(x, y, w, h) {
  fill(200, 200, 0);
  noStroke();

  push();
  translate(x, y);

  ellipse(0, 0, w * 0.8, w * 0.8);
  rect(-w * 0.1, 0, w * 0.2, h * 0.7);
  rect(w * 0.1, h * 0.5, w * 0.4, h * 0.1);
  rect(w * 0.1, h * 0.6, w * 0.2, h * 0.1);

  pop();
}
Line-by-line explanation (6 lines)
fill(200, 200, 0);
Sets color to golden yellow
push(); translate(x, y);
Saves the current transformation and moves the origin to (x, y) so all shapes drawn afterward are relative to that point
ellipse(0, 0, w * 0.8, w * 0.8);
Draws the circular head of the key at origin
rect(-w * 0.1, 0, w * 0.2, h * 0.7);
Draws the vertical shaft of the key
rect(w * 0.1, h * 0.5, w * 0.4, h * 0.1); rect(w * 0.1, h * 0.6, w * 0.2, h * 0.1);
Draws two small rectangles at the bottom to create the key teeth
pop();
Restores the transformation, returning the origin to its previous position

updateCamera()

updateCamera() is called from draw() every frame. It calculates where the camera should be (centered on player), applies VR tilt offset if enabled, clamps to world bounds, then smoothly interpolates to that position. The smoothing prevents jarring camera jumps and makes the experience feel polished.

function updateCamera() {
  targetCameraX = player.x - width / 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 minWorldX = rooms.floor2Lab.x;
  let maxWorldX = rooms.floor1Lab.x + rooms.floor1Lab.w;
  let totalWorldWidth = maxWorldX - minWorldX;

  targetCameraX = constrain(targetCameraX, minWorldX, maxWorldX - width);

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

🔧 Subcomponents:

calculation Camera centering targetCameraX = player.x - width / 2;

Positions the camera so the player is centered horizontally on screen

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

If VR mode is on and device orientation permission is granted, offset camera based on device tilt (gamma angle)

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

Ensures the camera stays within game world boundaries and never shows blank space

calculation Camera smoothing smoothedCameraX = lerp(smoothedCameraX, targetCameraX, 0.1);

Gradually interpolates camera position toward target, creating smooth camera movement instead of jerky jumps

targetCameraX = player.x - width / 2;
Calculate the X position where the camera should be to center the player on screen
if (vrMode && deviceOrientationGranted) {
Only apply VR tilt effect if VR mode is enabled AND permission has been granted
if (abs(deviceGamma) > GAMMA_DEAD_ZONE) {
Only apply offset if tilt is greater than the dead zone (3 degrees), preventing jitter when device is still
gammaOffset = map(deviceGamma, -90, 90, -GAMMA_SENSITIVITY * width, GAMMA_SENSITIVITY * width);
Convert device tilt angle (-90 to 90 degrees) to camera offset (-GAMMA_SENSITIVITY*width to +GAMMA_SENSITIVITY*width)
targetCameraX += gammaOffset;
Add the tilt offset to the camera position, allowing head movements to 'look around'
targetCameraX = constrain(targetCameraX, minWorldX, maxWorldX - width);
Clamp the target camera position to stay within world boundaries
smoothedCameraX = lerp(smoothedCameraX, targetCameraX, 0.1);
Smoothly interpolate from current position toward target at 0.1 (10% per frame)
cameraX = smoothedCameraX;
Update the actual camera X used for translation in draw()

drawRooms()

drawRooms() is called from draw() and renders the three rooms as colored rectangles. It loops through the rooms object and draws each one at its world position, then adds a floor line for visual definition.

function drawRooms() {
  for (let roomName in rooms) {
    let room = rooms[roomName];
    fill(room.color);
    rect(room.x, room.y, room.w, room.h);

    stroke(0);
    strokeWeight(10);
    line(room.x, room.y + room.h, room.x + room.w, room.y + room.h);
    noStroke();
  }
}
Line-by-line explanation (4 lines)
for (let roomName in rooms) {
Loops through all rooms defined in the rooms object (safeZone, floor1Lab, floor2Lab)
fill(room.color);
Sets fill color from the room's color property (a hex string like '#333333')
rect(room.x, room.y, room.w, room.h);
Draws the room as a rectangle at its world position
stroke(0); strokeWeight(10); line(room.x, room.y + room.h, room.x + room.w, room.y + room.h);
Draws a thick black line along the bottom of the room to define the floor visually

drawVents()

drawVents() renders the four vent portal rectangles with procedural vertical line details. It includes a visibility check (rectCollision) to avoid drawing off-screen vents, an important optimization for larger game worlds.

function drawVents() {
  fill(100);
  for (let vent of vents) {
    if (rectCollision(vent.x, vent.y, vent.w, vent.h, cameraX, 0, width, height)) {
      rect(vent.x, vent.y, vent.w, vent.h);
      stroke(50);
      strokeWeight(2);
      for (let i = 0; i < vent.w; i += 10) {
        line(vent.x + i, vent.y, vent.x + i, vent.y + vent.h);
      }
      noStroke();
    }
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional On-screen visibility check if (rectCollision(vent.x, vent.y, vent.w, vent.h, cameraX, 0, width, height)) {

Only draws the vent if it overlaps the camera view, avoiding drawing off-screen vents

for-loop Vent detail lines for (let i = 0; i < vent.w; i += 10) { line(vent.x + i, vent.y, vent.x + i, vent.y + vent.h); }

Draws vertical lines across the vent every 10 pixels to create a grating/mesh visual detail

fill(100);
Sets vent color to dark grey
for (let vent of vents) {
Loops through all vents
if (rectCollision(vent.x, vent.y, vent.w, vent.h, cameraX, 0, width, height)) {
Only draws the vent if it's visible on the current camera view (optimization)
rect(vent.x, vent.y, vent.w, vent.h);
Draws the vent body as a grey rectangle
for (let i = 0; i < vent.w; i += 10) { line(vent.x + i, vent.y, vent.x + i, vent.y + vent.h); }
Draws vertical lines every 10 pixels from top to bottom of the vent for visual detail

drawStaircases()

drawStaircases() renders the staircase portal zones with procedural step lines. Like drawVents(), it includes visibility culling to avoid drawing off-screen stairs.

function drawStaircases() {
  fill(150);
  for (let staircase of staircases) {
    if (rectCollision(staircase.x, staircase.y, staircase.w, staircase.h, cameraX, 0, width, height)) {
      rect(staircase.x, staircase.y, staircase.w, staircase.h);
      stroke(100);
      strokeWeight(2);
      let stepHeight = staircase.h / 4;
      for (let i = 0; i < 4; i++) {
        line(staircase.x, staircase.y + i * stepHeight, staircase.x + staircase.w, staircase.y + i * stepHeight);
        line(staircase.x + i * (staircase.w / 4), staircase.y + staircase.h, staircase.x + i * (staircase.w / 4), staircase.y + (i + 1) * stepHeight);
      }
      noStroke();
    }
  }
}
Line-by-line explanation (4 lines)
fill(150);
Sets staircase color to light grey
if (rectCollision(staircase.x, staircase.y, staircase.w, staircase.h, cameraX, 0, width, height)) {
Only draws if staircase is on-screen
let stepHeight = staircase.h / 4;
Divides staircase into 4 equal steps
for (let i = 0; i < 4; i++) { line(...); line(...); }
Draws horizontal and vertical lines to create a staircase visual pattern

drawSafeZoneBoundary()

drawSafeZoneBoundary() draws a green outline around the safe zone to help the player visualize the boundary where monsters cannot enter. It's a visual aid for understanding the game world.

function drawSafeZoneBoundary() {
  let safeZone = rooms.safeZone;
  stroke(0, 255, 0);
  strokeWeight(2);
  noFill();
  rect(safeZone.x, safeZone.y, safeZone.w, safeZone.h);
  noStroke();
}
Line-by-line explanation (4 lines)
stroke(0, 255, 0);
Sets outline color to green
strokeWeight(2);
Sets outline thickness to 2 pixels
noFill();
Disables fill so only the outline is drawn
rect(safeZone.x, safeZone.y, safeZone.w, safeZone.h);
Draws an outlined rectangle around the safe zone

drawJoystick()

drawJoystick() renders the on-screen mobile joystick UI: a fixed base circle and a movable knob circle. It's drawn at fixed screen coordinates (not camera-translated) so it stays on the left side of the screen.

function drawJoystick() {
  fill(100);
  ellipse(joystick.baseX, joystick.baseY, joystick.radius * 2);

  fill(200);
  ellipse(joystick.knobX, joystick.knobY, joystick.radius * 1.2);
}
Line-by-line explanation (2 lines)
fill(100); ellipse(joystick.baseX, joystick.baseY, joystick.radius * 2);
Draws the joystick base as a dark grey circle
fill(200); ellipse(joystick.knobX, joystick.knobY, joystick.radius * 1.2);
Draws the joystick knob as a lighter grey circle that moves when touched

drawMonsterTracker()

drawMonsterTracker() is a real-time radar that finds the nearest monster in the current room and draws an arrow pointing to it on the bottom-right of the screen. It uses atan2() to calculate angle and rotate() to orient the arrow—a great example of using math for directional UI.

function drawMonsterTracker() {
  if (currentRoom === "safeZone") return;

  let nearestMonster = null;
  let minDistance = Infinity;

  for (let monster of monsters) {
    if (monster.room === currentRoom) {
      let d = dist(player.x, player.y, monster.x, monster.y);
      if (d < minDistance) {
        minDistance = d;
        nearestMonster = monster;
      }
    }
  }

  if (nearestMonster) {
    let trackerX = width * 0.8;
    let trackerY = height * 0.8;
    let trackerSize = 50;
    let trackerIconSize = 20;

    fill(50);
    ellipse(trackerX, trackerY, trackerSize);

    let dx = nearestMonster.x - player.x;
    let dy = nearestMonster.y - player.y;
    let angle = atan2(dy, dx);

    push();
    translate(trackerX, trackerY);
    rotate(angle);
    fill(255, 0, 0);
    triangle(-trackerIconSize / 2, -trackerIconSize / 2,
             -trackerIconSize / 2, trackerIconSize / 2,
             trackerIconSize / 2, 0);
    pop();

    fill(255);
    textSize(12);
    text(round(minDistance) + "m", trackerX, trackerY + trackerSize / 2 + 10);
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Safe zone check if (currentRoom === "safeZone") return;

Tracker is disabled in safe zone (no monsters there anyway)

calculation Arrow direction let dx = nearestMonster.x - player.x; let dy = nearestMonster.y - player.y; let angle = atan2(dy, dx); push(); translate(trackerX, trackerY); rotate(angle); fill(255, 0, 0); triangle(...);

Calculates angle to nearest monster and draws an arrow pointing toward it

if (currentRoom === "safeZone") return;
If in safe zone, skip tracker rendering and exit early
for (let monster of monsters) { if (monster.room === currentRoom) { ... } }
Loop through monsters, checking only those in the current room
let d = dist(player.x, player.y, monster.x, monster.y);
Calculate distance from player to this monster
if (d < minDistance) { minDistance = d; nearestMonster = monster; }
If this monster is closer than the previous nearest, update nearest monster
let angle = atan2(dy, dx);
Calculate the angle from player to nearest monster using atan2
rotate(angle);
Rotate the arrow to point in the direction of the monster
triangle(...);
Draw a red arrow triangle pointing to the right (before rotation)

drawButtons()

drawButtons() renders three UI buttons: a blue Jump button always, plus Climb Up and Climb Down buttons that change color based on whether the player is climbing. Using color as feedback (green = active, grey = inactive) helps players understand game state at a glance.

function drawButtons() {
  fill(0, 0, 150);
  rectMode(CENTER);
  rect(jumpButton.x, jumpButton.y, jumpButton.w, jumpButton.h, 10);
  fill(255);
  textSize(18);
  text(jumpButton.text, jumpButton.x, jumpButton.y);

  if (player.isClimbing) {
    fill(0, 150, 0);
  } else {
    fill(50);
  }
  rect(climbUpButton.x, climbUpButton.y, climbUpButton.w, climbUpButton.h, 10);
  fill(255);
  textSize(18);
  text(climbUpButton.text, climbUpButton.x, climbUpButton.y);

  if (player.isClimbing) {
    fill(0, 100, 0);
  } else {
    fill(50);
  }
  rect(climbDownButton.x, climbDownButton.y, climbDownButton.w, climbDownButton.h, 10);
  fill(255);
  textSize(18);
  text(climbDownButton.text, climbDownButton.x, climbDownButton.y);

  rectMode(CORNER);
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

calculation Jump button rendering fill(0, 0, 150); rectMode(CENTER); rect(jumpButton.x, jumpButton.y, jumpButton.w, jumpButton.h, 10); fill(255); textSize(18); text(jumpButton.text, jumpButton.x, jumpButton.y);

Draws the blue JUMP button with white text, always in the same color

conditional Climb Up button rendering if (player.isClimbing) { fill(0, 150, 0); } else { fill(50); } rect(climbUpButton.x, climbUpButton.y, climbUpButton.w, climbUpButton.h, 10);

Draws Climb Up button: green if climbing, dark grey if not (visual feedback)

conditional Climb Down button rendering if (player.isClimbing) { fill(0, 100, 0); } else { fill(50); } rect(climbDownButton.x, climbDownButton.y, climbDownButton.w, climbDownButton.h, 10);

Draws Climb Down button: darker green if climbing, dark grey if not

fill(0, 0, 150); rectMode(CENTER);
Set color to blue and switch to CENTER mode (so rect(x, y, w, h) draws centered at x, y)
rect(jumpButton.x, jumpButton.y, jumpButton.w, jumpButton.h, 10);
Draw the Jump button as a blue rounded rectangle (the 10 is corner radius)
if (player.isClimbing) { fill(0, 150, 0); } else { fill(50); }
Climb Up button is green if player is climbing, grey otherwise—provides visual feedback
text(climbUpButton.text, climbUpButton.x, climbUpButton.y);
Draw the text 'CLIMB UP' centered on the button

drawInventory()

drawInventory() displays the player's collected keys in the top-left corner. It's a simple list that updates every frame as items are added. The textAlign(LEFT, TOP) is important—it ensures the inventory text starts at the top-left instead of being centered.

function drawInventory() {
  fill(255);
  textSize(14);
  textAlign(LEFT, TOP);
  text("Inventory:", 10, 10);
  let yOffset = 30;
  if (playerInventory.length === 0) {
    text("Empty", 10, yOffset);
  } else {
    for (let i = 0; i < playerInventory.length; i++) {
      text(`- ${playerInventory[i].type}`, 10, yOffset + i * 20);
    }
  }
  textAlign(CENTER, CENTER);
}
Line-by-line explanation (4 lines)
fill(255); textSize(14); textAlign(LEFT, TOP);
Set text color to white, size to 14, and align to left-top for label text
text("Inventory:", 10, 10);
Draw the 'Inventory:' header at screen position (10, 10)
if (playerInventory.length === 0) { text("Empty", 10, yOffset); } else { for (...) }
If inventory is empty, show 'Empty'; otherwise loop through items and display each one on a new line
textAlign(CENTER, CENTER);
Reset text alignment back to center (for other UI elements)

drawVRToggleButton()

drawVRToggleButton() renders a button in the top-left corner that toggles VR mode on and off. It uses ternary operators (? :) to switch colors and text based on the vrMode boolean—a compact way to express conditional rendering.

function drawVRToggleButton() {
  let buttonX = width * 0.1;
  let buttonY = height * 0.1;
  let buttonW = 120;
  let buttonH = 60;

  fill(vrMode ? 0 : 50, vrMode ? 150 : 50, vrMode ? 0 : 50);
  rectMode(CENTER);
  rect(buttonX, buttonY, buttonW, buttonH, 10);
  fill(255);
  textSize(16);
  text(vrMode ? "VR MODE: ON" : "VR MODE: OFF", buttonX, buttonY);
  rectMode(CORNER);
}
Line-by-line explanation (2 lines)
fill(vrMode ? 0 : 50, vrMode ? 150 : 50, vrMode ? 0 : 50);
Use ternary operator to set button color: green (0, 150, 0) if VR on, grey (50, 50, 50) if off
text(vrMode ? "VR MODE: ON" : "VR MODE: OFF", buttonX, buttonY);
Display text based on vrMode state: 'VR MODE: ON' if true, 'VR MODE: OFF' if false

triggerJumpscare()

triggerJumpscare() is called when the player collides with a monster. It activates the jumpscare state machine, fades out ambient sound, and starts a multi-part procedural audio sequence: a noise burst plus a sawtooth oscillator that sweeps from high to low, creating the iconic jumpscare 'screech' sound.

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

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

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

  jumpscareEnvelope.play(jumpscareNoise);

  jumpscareOsc.amp(0.8, 0.05);
  jumpscareOsc.freq(2000, 0.2);
  jumpscareOsc.freq(500, 0.5, '+0.2');
  jumpscareOsc.amp(0, 0.5, '+0.2');

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

🔧 Subcomponents:

conditional Jumpscare gate if (jumpscare.isActive) return;

Prevents multiple jumpscares from triggering simultaneously

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

Activates jumpscare and resets animation frame and phase

calculation Jumpscare audio synthesis ambientSoundOsc.amp(0, 0.1); ambientSoundNoise.amp(0, 0.1); jumpscareEnvelope.play(jumpscareNoise); jumpscareOsc.amp(0.8, 0.05); jumpscareOsc.freq(2000, 0.2); jumpscareOsc.freq(500, 0.5, '+0.2'); jumpscareOsc.amp(0, 0.5, '+0.2'); setTimeout(() => jumpscareOsc.stop(), 950);

Fades out ambient sound, plays a noise burst, and sweeps a sawtooth oscillator down to create a screech effect

if (jumpscare.isActive) return;
If a jumpscare is already playing, ignore this call (prevents overlapping jumpscares)
jumpscare.isActive = true;
Flag that a jumpscare is now active
jumpscare.animationFrame = 0;
Reset animation counter to start from frame 0
jumpscare.phase = "monster";
Set phase to 'monster' so drawJumpscare() starts with the monster morphing animation
ambientSoundOsc.amp(0, 0.1); ambientSoundNoise.amp(0, 0.1);
Fade out ambient drone and noise instantly over 0.1 seconds
jumpscareEnvelope.play(jumpscareNoise);
Trigger the noise burst using its envelope (controls attack/decay/release)
jumpscareOsc.amp(0.8, 0.05);
Fade the screech oscillator to 80% volume over 0.05 seconds
jumpscareOsc.freq(2000, 0.2);
Sweep the frequency to 2000 Hz over 0.2 seconds (high-pitched screech)
jumpscareOsc.freq(500, 0.5, '+0.2');
After 0.2 seconds, sweep frequency down to 500 Hz over the next 0.5 seconds (pitch drop)

drawJumpscare()

drawJumpscare() orchestrates a three-phase horror sequence: (1) a growing dark-red monster with wiggling arms and flashing 'YOU DIED!' text, (2) a blood-red screen, (3) a fade to black before reset. It uses sin() oscillations for wiggle/pulsing effects, translate/rotate for transformations, and phase transitions to create a cinematic flow.

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, 2.0, true);
      let monsterW = width * monsterScale;
      let monsterH = height * monsterScale * 0.8;

      fill(30, 0, 0);
      noStroke();
      ellipse(0, 0, monsterW, monsterH);

      let armLength = monsterH * 0.4;
      let armThickness = monsterW * 0.05;
      let armOffset = monsterW * 0.3;
      let wiggleAngle = sin(jumpscare.animationFrame * 0.2) * 0.2;

      push();
      translate(-armOffset, 0);
      rotate(PI / 4 + wiggleAngle);
      rectMode(CENTER);
      rect(0, 0, armLength, armThickness);
      pop();

      push();
      translate(armOffset, 0);
      rotate(-PI / 4 - wiggleAngle);
      rectMode(CENTER);
      rect(0, 0, armLength, armThickness);
      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);
      textSize(24);
      fill(255);
      text("Beware the shadows...", width / 2, height / 2 + 70);

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

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

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

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

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

🔧 Subcomponents:

conditional Monster animation phase case "monster": ... ellipse(0, 0, monsterW, monsterH); ...

Renders a growing dark-red monster body with wiggling arms, growing from center toward player for first half of duration

conditional Blood screen phase case "blood": background(136, 0, 0); ...

Shows a blood-red background with 'YOU DIED!' text for a fixed duration

conditional Fade to black phase case "fadeToBlack": let alpha = lerp(0, 255, fadeProgress); fill(0, alpha); rect(0, 0, width, height);

Fades the screen from transparent to opaque black, then resets the game

switch (jumpscare.phase) {
Use switch to handle three phases of the jumpscare: 'monster', 'blood', 'fadeToBlack'
let monsterScale = map(jumpscare.animationFrame, 0, jumpscare.duration * 0.5, 0.1, 2.0, true);
Scale the monster from 0.1x to 2.0x size over the first half of the jumpscare duration
fill(30, 0, 0); ellipse(0, 0, monsterW, monsterH);
Draw a dark-red (nearly black) ellipse for the monster body
let wiggleAngle = sin(jumpscare.animationFrame * 0.2) * 0.2;
Use sine to create an oscillating wiggle angle for the arms
translate(-armOffset, 0); rotate(PI / 4 + wiggleAngle); rect(0, 0, armLength, armThickness);
Position left arm, rotate it, and draw as a rectangle, adding wiggle by modulating rotation angle
let redFlash = map(sin(jumpscare.animationFrame * 0.2), -1, 1, 100, 255);
Map a sine wave to red flash brightness (100-255), creating a pulsing red color
text("YOU DIED!", width / 2, height / 2);
Draw the death message in the center of the screen in flashing red
jumpscare.animationFrame++; if (jumpscare.animationFrame >= jumpscare.duration * 0.5) { jumpscare.phase = "blood"; }
Increment animation frame, and after 50% of duration, transition to blood phase
case "blood": background(136, 0, 0); ...
Fill screen with blood-red color (dark red) for 30 frames
let alpha = lerp(0, 255, fadeProgress); fill(0, alpha); rect(0, 0, width, height);
Fade a black rectangle from 0 to 255 opacity over fadeToBlack duration
if (jumpscare.bloodFadeTimer >= jumpscare.bloodFadeDuration) { jumpscare.isActive = false; ... resetGame(); }
After fade completes, reset game state and return player to safe zone

resetGame()

resetGame() is called at the end of a jumpscare and resets the entire game state: clears jumpscare flags, teleports player to safe zone, despawns and respawns monsters, clears inventory and items, and resumes ambient audio. It's the 'continue after death' logic.

function resetGame() {
  jumpscare.isActive = false;
  jumpscare.timer = 0;
  jumpscare.animationFrame = 0;
  jumpscare.phase = "monster";

  player.x = rooms.safeZone.x + 100;
  player.y = rooms.safeZone.y + rooms.safeZone.h - player.h;
  player.vx = 0;
  player.vy = 0;
  player.isJumping = false;
  player.isClimbing = false;
  player.onGround = false;
  currentRoom = "safeZone";

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

  collectibles = [];
  playerInventory = [];
  spawnCollectibles();

  ambientSoundOsc.amp(0.5, 1);
  ambientSoundNoise.amp(0.2, 1);
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

calculation Jumpscare state reset jumpscare.isActive = false; jumpscare.timer = 0; jumpscare.animationFrame = 0; jumpscare.phase = "monster";

Clears all jumpscare state for the next potential encounter

calculation Player reset player.x = rooms.safeZone.x + 100; player.y = rooms.safeZone.y + rooms.safeZone.h - player.h; player.vx = 0; player.vy = 0; player.isJumping = false; player.isClimbing = false; player.onGround = false; currentRoom = "safeZone";

Resets player position, velocity, and state flags, returning them to the safe zone

calculation Monster respawning monsters = []; for (let i = 0; i < MAX_MONSTERS; i++) { spawnMonster(); }

Clears all monsters and spawns a new population

calculation Collectible reset collectibles = []; playerInventory = []; spawnCollectibles();

Clears inventory and re-populates collectibles

calculation Audio resumption ambientSoundOsc.amp(0.5, 1); ambientSoundNoise.amp(0.2, 1);

Fades ambient sound back in over 1 second

jumpscare.isActive = false;
Mark jumpscare as inactive so game logic resumes
player.x = rooms.safeZone.x + 100; player.y = rooms.safeZone.y + rooms.safeZone.h - player.h;
Teleport player back to starting position in safe zone
player.vx = 0; player.vy = 0;
Stop all movement
currentRoom = "safeZone";
Set current room to safe zone
monsters = []; for (let i = 0; i < MAX_MONSTERS; i++) { spawnMonster(); }
Clear monsters array and spawn new batch
collectibles = []; playerInventory = [];
Clear collected items and inventory
spawnCollectibles();
Re-populate the game world with keys
ambientSoundOsc.amp(0.5, 1); ambientSoundNoise.amp(0.2, 1);
Fade ambient drone and noise back in over 1 second

touchStarted()

touchStarted() fires when the user first touches the screen. It handles multiple interactions: starting the game on first touch, toggling VR mode, pressing buttons (Jump, Climb), and activating the joystick. It also manages touch IDs to track which finger pressed which control.

function touchStarted() {
  let buttonX = width * 0.1;
  let buttonY = height * 0.1;
  let buttonW = 120;
  let buttonH = 60;

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

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

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

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

    return false;
  }

  if (rectCollision(mouseX, mouseY, 1, 1, jumpButton.x - jumpButton.w / 2, jumpButton.y - jumpButton.h / 2, jumpButton.w, jumpButton.h)) {
    playerJump();
    jumpButton.touchId = touches[0] ? touches[0].id : -1;
    return false;
  }

  if (player.isClimbing && rectCollision(mouseX, mouseY, 1, 1, climbUpButton.x - climbUpButton.w / 2, climbUpButton.y - climbUpButton.h / 2, climbUpButton.w, climbUpButton.h)) {
    playerClimb(-1);
    climbUpButton.touchId = touches[0] ? touches[0].id : -1;
    return false;
  }

  if (player.isClimbing && rectCollision(mouseX, mouseY, 1, 1, climbDownButton.x - climbDownButton.w / 2, climbDownButton.y - climbDownButton.h / 2, climbDownButton.w, climbDownButton.h)) {
    playerClimb(1);
    climbDownButton.touchId = touches[0] ? touches[0].id : -1;
    return false;
  }

  if (mouseX < joystick.maxX) {
    joystick.active = true;
    joystick.touchId = touches[0] ? touches[0].id : -1;
    joystick.knobX = mouseX;
    joystick.knobY = mouseY;
  }

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

🔧 Subcomponents:

conditional VR toggle button tap if (gameStart && rectCollision(..., buttonX - buttonW / 2, buttonY - buttonH / 2, buttonW, buttonH)) { vrMode = !vrMode; if (vrMode && !deviceOrientationGranted) { DeviceOrientationEvent.requestPermission() ... } return false; }

Detects tap on VR button, toggles VR mode, and requests device orientation permission if needed

conditional Start game on touch if (!gameStart) { gameStart = true; userStartAudio(); ambientSoundOsc.amp(0.5, 1); ambientSoundNoise.amp(0.2, 1); return false; }

First touch starts the game, initializes audio context, and fades in ambient sound

conditional Jump button tap if (rectCollision(mouseX, mouseY, 1, 1, jumpButton.x - jumpButton.w / 2, jumpButton.y - jumpButton.h / 2, jumpButton.w, jumpButton.h)) { playerJump(); jumpButton.touchId = touches[0] ? touches[0].id : -1; return false; }

Detects jump button tap and calls playerJump()

conditional Joystick activation if (mouseX < joystick.maxX) { joystick.active = true; joystick.touchId = touches[0] ? touches[0].id : -1; joystick.knobX = mouseX; joystick.knobY = mouseY; }

Tapping left side of screen activates joystick

if (gameStart && rectCollision(...buttonX - buttonW / 2, buttonY - buttonH / 2, buttonW, buttonH)) {
Check if game has started AND tap is on the VR button (top-left)
vrMode = !vrMode;
Toggle VR mode on/off
DeviceOrientationEvent.requestPermission().then(...)
Request iOS device orientation permission (needed for head tracking)
if (!gameStart) { gameStart = true; userStartAudio(); ... }
If game hasn't started, set gameStart to true (to exit start screen), and initialize audio context
if (rectCollision(mouseX, mouseY, 1, 1, jumpButton.x - jumpButton.w / 2, ...)) { playerJump(); ... }
If tap is on Jump button, call playerJump()
if (mouseX < joystick.maxX) { joystick.active = true; ... }
If tap is on left side of screen (where joystick lives), activate joystick and set knob position to touch location

touchMoved()

touchMoved() fires as the user drags their finger, updating the joystick knob position and converting the gesture into player velocity. It clamps the knob to the joystick circle, includes a dead zone to ignore tiny movements, and allows vertical joystick control when climbing.

function touchMoved() {
  if (joystick.active) {
    let touch = touches.find(t => t.id === joystick.touchId);
    if (touch) {
      let dx = touch.x - joystick.baseX;
      let dy = touch.y - joystick.baseY;
      let distance = dist(joystick.baseX, joystick.baseY, touch.x, touch.y);
      let angle = atan2(dy, dx);

      if (distance > joystick.radius) {
        joystick.knobX = joystick.baseX + joystick.radius * cos(angle);
        joystick.knobY = joystick.baseY + joystick.radius * sin(angle);
      } else {
        joystick.knobX = touch.x;
        joystick.knobY = touch.y;
      }

      if (distance > joystick.radius * 0.1) {
        player.vx = PLAYER_SPEED * cos(angle);
        if (player.isClimbing) {
          player.vy = PLAYER_SPEED * sin(angle);
        }
      } else {
        player.vx = 0;
        if (player.isClimbing) player.vy = 0;
      }
    }
  }
  return false;
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Joystick touch tracking let touch = touches.find(t => t.id === joystick.touchId);

Finds the touch that is controlling the joystick by matching touch ID

conditional Joystick knob clamping if (distance > joystick.radius) { joystick.knobX = joystick.baseX + joystick.radius * cos(angle); joystick.knobY = joystick.baseY + joystick.radius * sin(angle); } else { joystick.knobX = touch.x; joystick.knobY = touch.y; }

Clamps joystick knob to stay within the circular radius, preventing it from moving too far away from base

conditional Player velocity from joystick angle if (distance > joystick.radius * 0.1) { player.vx = PLAYER_SPEED * cos(angle); if (player.isClimbing) { player.vy = PLAYER_SPEED * sin(angle); } }

Converts joystick angle and distance into player velocity, with a dead zone (0.1 * radius)

if (joystick.active) {
Only process joystick if it's active
let touch = touches.find(t => t.id === joystick.touchId);
Find the specific touch controlling the joystick
let dx = touch.x - joystick.baseX; let dy = touch.y - joystick.baseY;
Calculate distance vector from joystick base to touch point
let angle = atan2(dy, dx);
Calculate angle in radians (-PI to PI) pointing from base toward touch
if (distance > joystick.radius) { joystick.knobX = joystick.baseX + joystick.radius * cos(angle); ... }
If touch is beyond radius, clamp knob to circle edge; otherwise, place knob at touch
if (distance > joystick.radius * 0.1) { player.vx = PLAYER_SPEED * cos(angle); ... }
If distance exceeds dead zone, set player velocity based on angle (cos for horizontal, sin for vertical)
if (player.isClimbing) { player.vy = PLAYER_SPEED * sin(angle); }
If climbing, joystick vertical movement also controls climb speed

touchEnded()

touchEnded() fires when the user lifts their finger. It detects which control was released (joystick or button) by checking if the touch ID is still in the touches array, then resets state accordingly (stopping movement, clearing flags).

function touchEnded() {
  if (joystick.active && touches.every(t => t.id !== joystick.touchId)) {
    joystick.active = false;
    joystick.knobX = joystick.baseX;
    joystick.knobY = joystick.baseY;
    player.vx = 0;
    if (player.isClimbing) player.vy = 0;
  }

  if (jumpButton.touchId !== -1 && touches.every(t => t.id !== jumpButton.touchId)) {
    jumpButton.touchId = -1;
  }

  if (climbUpButton.touchId !== -1 && touches.every(t => t.id !== climbUpButton.touchId)) {
    climbUpButton.touchId = -1;
    if (player.isClimbing) {
      player.vy = 0;
    }
  }

  if (climbDownButton.touchId !== -1 && touches.every(t => t.id !== climbDownButton.touchId)) {
    climbDownButton.touchId = -1;
    if (player.isClimbing) {
      player.vy = 0;
    }
  }

  if (player.isClimbing && touches.every(t => t.id !== joystick.touchId) && climbUpButton.touchId === -1 && climbDownButton.touchId === -1) {
    player.vy = 0;
  }

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

🔧 Subcomponents:

conditional Joystick release if (joystick.active && touches.every(t => t.id !== joystick.touchId)) { joystick.active = false; joystick.knobX = joystick.baseX; joystick.knobY = joystick.baseY; player.vx = 0; if (player.isClimbing) player.vy = 0; }

When the joystick touch ends, deactivate joystick, reset knob position, and stop player movement

conditional Button release handling if (jumpButton.touchId !== -1 && touches.every(t => t.id !== jumpButton.touchId)) { jumpButton.touchId = -1; }

When a button touch ends, clear its touchId flag

if (joystick.active && touches.every(t => t.id !== joystick.touchId)) {
If joystick is active AND the joystick touch is no longer in the touches array (finger lifted), deactivate
joystick.active = false;
Mark joystick as inactive
joystick.knobX = joystick.baseX; joystick.knobY = joystick.baseY;
Reset knob to base position for next touch
player.vx = 0; if (player.isClimbing) player.vy = 0;
Stop horizontal movement; stop vertical if climbing
if (jumpButton.touchId !== -1 && touches.every(t => t.id !== jumpButton.touchId)) { jumpButton.touchId = -1; }
If jump button touch has ended, clear the touchId
if (climbUpButton.touchId !== -1 && touches.every(t => t.id !== climbUpButton.touchId)) { climbUpButton.touchId = -1; if (player.isClimbing) player.vy = 0; }
If climb up button touch ends, clear touchId and stop upward climb

keyPressed()

keyPressed() enables keyboard controls for desktop testing: any key starts the game, V toggles VR, Space jumps, Up/Down arrows climb. This makes the game playable on desktop without touch controls.

function keyPressed() {
  if (!gameStart) {
    gameStart = true;
    userStartAudio();
    ambientSoundOsc.amp(0.5, 1);
    ambientSoundNoise.amp(0.2, 1);
    return false;
  }

  if (keyCode === 86) {
    vrMode = !vrMode;
    console.log(`VR Mode: ${vrMode ? "ON" : "OFF"}`);
    return false;
  }

  if (keyCode === 32) {
    playerJump();
  }
  if (player.isClimbing && keyCode === UP_ARROW) {
    playerClimb(-1);
  }
  if (player.isClimbing && keyCode === DOWN_ARROW) {
    playerClimb(1);
  }
  return false;
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Start on any key if (!gameStart) { gameStart = true; userStartAudio(); ambientSoundOsc.amp(0.5, 1); ambientSoundNoise.amp(0.2, 1); return false; }

Pressing any key on first start initializes the game and audio

conditional VR toggle key if (keyCode === 86) { vrMode = !vrMode; console.log(`VR Mode: ${vrMode ? "ON" : "OFF"}`); return false; }

Pressing 'V' toggles VR mode (for desktop testing)

conditional Jump on spacebar if (keyCode === 32) { playerJump(); }

Spacebar triggers jump

conditional Climb on arrow keys if (player.isClimbing && keyCode === UP_ARROW) { playerClimb(-1); } if (player.isClimbing && keyCode === DOWN_ARROW) { playerClimb(1); }

Up/Down arrow keys control climbing when in a vent

if (!gameStart) { gameStart = true; userStartAudio(); ... }
Start game on first keyboard press
if (keyCode === 86) { vrMode = !vrMode; ... }
Pressing 'V' (keyCode 86) toggles VR mode for desktop testing
if (keyCode === 32) { playerJump(); }
Spacebar (keyCode 32) triggers jump
if (player.isClimbing && keyCode === UP_ARROW) { playerClimb(-1); }
Up arrow climbs up (direction -1), but only if already climbing
if (player.isClimbing && keyCode === DOWN_ARROW) { playerClimb(1); }
Down arrow climbs down (direction +1), but only if already climbing

keyReleased()

keyReleased() fires when a key is released. It stops climbing movement when climbing keys or spacebar are released—important so the player doesn't keep climbing after releasing the key.

function keyReleased() {
  if (keyCode === 32) {
    if (player.isClimbing) {
      player.vy = 0;
    }
  }
  if (player.isClimbing && (keyCode === UP_ARROW || keyCode === DOWN_ARROW)) {
    player.vy = 0;
  }
  return false;
}
Line-by-line explanation (2 lines)
if (keyCode === 32) { if (player.isClimbing) { player.vy = 0; } }
When spacebar is released, stop climbing if currently climbing
if (player.isClimbing && (keyCode === UP_ARROW || keyCode === DOWN_ARROW)) { player.vy = 0; }
When up/down arrow is released, stop vertical movement

windowResized()

windowResized() is called by p5.js whenever the browser window resizes. It adjusts the canvas size and repositions all UI controls proportionally to the new screen size, ensuring the game stays responsive.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  joystick.baseX = width * 0.2;
  joystick.baseY = height * 0.8;
  joystick.knobX = joystick.baseX;
  joystick.knobY = joystick.baseY;
  joystick.maxX = width * 0.4;

  jumpButton.x = width * 0.8 - jumpButton.w / 2;
  jumpButton.y = height * 0.8 - jumpButton.h / 2;

  climbUpButton.x = jumpButton.x;
  climbUpButton.y = jumpButton.y - jumpButton.h - 10;

  climbDownButton.x = jumpButton.x;
  climbDownButton.y = jumpButton.y + jumpButton.h + 10;

  updateCamera();
}
Line-by-line explanation (4 lines)
resizeCanvas(windowWidth, windowHeight);
Resize the canvas to match new window dimensions
joystick.baseX = width * 0.2; joystick.baseY = height * 0.8;
Reposition joystick base to 20% left, 80% down (bottom-left area)
jumpButton.x = width * 0.8 - jumpButton.w / 2; jumpButton.y = height * 0.8 - jumpButton.h / 2;
Reposition jump button to 80% right, 80% down (bottom-right area)
updateCamera();
Recalculate camera position after resize

rectCollision()

rectCollision() is a utility function that checks if two axis-aligned rectangles (AABB) overlap. It's called throughout the code to detect collisions: player with floor, player with vents, player with monsters, etc. It returns true if they overlap, false otherwise.

function rectCollision(x1, y1, w1, h1, x2, y2, w2, h2) {
  return x1 < x2 + w2 &&
         x1 + w1 > x2 &&
         y1 < y2 + h2 &&
         y1 + h1 > y2;
}
Line-by-line explanation (4 lines)
return x1 < x2 + w2 &&
Check if rect1's left edge is before rect2's right edge
x1 + w1 > x2 &&
Check if rect1's right edge is after rect2's left edge
y1 < y2 + h2 &&
Check if rect1's top edge is before rect2's bottom edge
y1 + h1 > y2;
Check if rect1's bottom edge is after rect2's top edge—if all four are true, rectangles overlap

📦 Key Variables

player object

Stores the player's position (x, y), dimensions (w, h), velocity (vx, vy), and state flags (isJumping, isClimbing, onGround)

let player = { x: 100, y: 500, w: 30, h: 50, vx: 0, vy: 0, isJumping: false, isClimbing: false, onGround: false };
monsters array

Array of Monster objects currently in the game world; each monster has position, velocity, room, and AI state

let monsters = [];
joystick object

Stores joystick state: baseX/baseY (center), knobX/knobY (current position), radius, active flag, and touchId

let joystick = { baseX: 100, baseY: 300, knobX: 100, knobY: 300, radius: 60, active: false, touchId: -1 };
currentRoom string

Name of the room the player is currently in ('safeZone', 'floor1Lab', or 'floor2Lab')

let currentRoom = 'safeZone';
rooms object

Object mapping room names to room data (x, y, width, height, color); defines the game world layout

let rooms = { safeZone: { x: 0, y: 0, w: 800, h: 600, color: '#333333' }, ... };
cameraX number

Current camera X position; used to translate the entire world when rendering

let cameraX = 0;
jumpscare object

Stores jumpscare state: isActive (boolean), timer, duration, animationFrame, phase ('monster', 'blood', 'fadeToBlack')

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

Flag indicating whether the player has touched the screen to start the game; hides start screen when true

let gameStart = false;
deviceGamma number

Device orientation tilt angle in degrees (left-right); used for VR head tracking camera offset

let deviceGamma = 0;
vrMode boolean

Flag indicating whether VR mode (device orientation head tracking) is enabled

let vrMode = false;
collectibles array

Array of collectible items (keys) in the game world; each has x, y, w, h, type, room

let collectibles = [];
playerInventory array

Array of items the player has collected; displayed in UI

let playerInventory = [];

🔧 Potential Improvements (6)

Here are some ways this code could be enhanced:

BUG updatePlayer() vent detection

Player can get stuck in vents if they touch multiple vents simultaneously, causing confusion about which room they'll transition to

💡 Add a brief cooldown (e.g., 10 frames) after room transition before allowing another transition, preventing rapid oscillation between rooms

PERFORMANCE drawMonsters() and drawCollectibles()

Every frame, all collectibles loop through the collectibles array checking room membership, even though items rarely move between rooms

💡 Pre-sort collectibles by room on spawn, or use a spatial hash to avoid looping through all items every frame

STYLE Monster class update() method

The AI logic for roaming, chasing, and safe zone detection is spread across many nested conditionals, making it hard to follow

💡 Extract roaming and chasing behavior into separate methods (updateRoaming(), updateChasing()) to improve readability

FEATURE Game state management

The game has no pause or menu system—once started, it runs until jumpscare

💡 Add a pause button that freezes game logic but keeps audio playing, allowing players to take breaks

BUG touchStarted() and touchEnded()

Multiple simultaneous touches can cause button and joystick conflicts—if user presses jump and joystick at same time, behavior is unpredictable

💡 Implement proper multi-touch gesture handling: prioritize joystick on left, buttons on right, and ignore conflicting inputs

PERFORMANCE drawJumpscare() monster phase

Draws arms and body every frame using push()/pop() and trigonometry; with multiple scale/rotation operations, this could be optimized

💡 Pre-calculate arm positions and wiggle angles before drawing; cache sin/cos values to avoid recalculating every frame

🔄 Code Flow

Code flow showing preload, setup, draw, updateplayer, drawplayer, playerjump, playerclimb, monster, spawnmonster, updatemonsters, drawmonsters, spawncollectibles, drawcollectibles, drawkey, updatecamera, drawrooms, drawvents, drawstaircases, drawsafezoneboundary, drawjoystick, drawmonstertracker, drawbuttons, drawinventory, drawvrtogglebtn, triggerjumpscare, drawjumpscare, resetgame, touchstarted, touchmoved, touchended, keypressed, keyreleased, windowresized, rectcollision

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

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] click setup href "#fn-setup" click draw href "#fn-draw" setup --> canvas-setup[canvas-setup] setup --> rooms-definition[rooms-definition] setup --> vents-definition[vents-definition] setup --> player-init[player-init] setup --> ui-positioning[ui-positioning] setup --> spawn-monsters[spawn-monsters] click canvas-setup href "#sub-canvas-setup" click rooms-definition href "#sub-rooms-definition" click vents-definition href "#sub-vents-definition" click player-init href "#sub-player-init" click ui-positioning href "#sub-ui-positioning" click spawn-monsters href "#sub-spawn-monsters" draw --> start-screen-check[start-screen-check] draw --> jumpscare-check[jumpscare-check] draw --> camera-translation[camera-translation] draw --> updatecamera[updatecamera] draw --> drawrooms[drawrooms] draw --> drawvents[drawvents] draw --> drawstaircases[drawstaircases] draw --> drawsafezoneboundary[drawsafezoneboundary] draw --> drawcollectibles[drawcollectibles] draw --> drawmonsters[drawmonsters] draw --> drawplayer[drawplayer] draw --> drawjoystick[drawjoystick] draw --> drawbuttons[drawbuttons] draw --> drawinventory[drawinventory] draw --> drawmonstertracker[drawmonstertracker] click start-screen-check href "#sub-start-screen-check" click jumpscare-check href "#sub-jumpscare-check" click camera-translation href "#sub-camera-translation" click updatecamera href "#fn-updatecamera" click drawrooms href "#fn-drawrooms" click drawvents href "#fn-drawvents" click drawstaircases href "#fn-drawstaircases" click drawsafezoneboundary href "#fn-drawsafezoneboundary" click drawcollectibles href "#fn-drawcollectibles" click drawmonsters href "#fn-drawmonsters" click drawplayer href "#fn-drawplayer" click drawjoystick href "#fn-drawjoystick" click drawbuttons href "#fn-drawbuttons" click drawinventory href "#fn-drawinventory" click drawmonstertracker href "#fn-drawmonstertracker" updateplayer --> gravity-apply[gravity-apply] updateplayer --> floor-collision[floor-collision] updateplayer --> collectible-collision[collectible-collision] updateplayer --> vent-climb[vent-climb] updateplayer --> vent-transition[vent-transition] updateplayer --> jump-condition[jump-condition] click gravity-apply href "#sub-gravity-apply" click floor-collision href "#sub-floor-collision" click collectible-collision href "#sub-collectible-collision" click vent-climb href "#sub-vent-climb" click vent-transition href "#sub-vent-transition" click jump-condition href "#sub-jump-condition" playerjump --> audio-fade[audio-fade] click audio-fade href "#sub-audio-fade" playerclimb --> joystick-activation[joystick-activation] playerclimb --> joystick-touch-tracking[joystick-touch-tracking] playerclimb --> joystick-clamping[joystick-clamping] click joystick-activation href "#sub-joystick-activation" click joystick-touch-tracking href "#sub-joystick-touch-tracking" click joystick-clamping href "#sub-joystick-clamping" monster --> monster-constructor[monster-constructor] monster --> monster-ai-detection[monster-ai-detection] monster --> monster-behavior[monster-behavior] monster --> monster-safezone-block[monster-safezone-block] click monster-constructor href "#sub-monster-constructor" click monster-ai-detection href "#sub-monster-ai-detection" click monster-behavior href "#sub-monster-behavior" click monster-safezone-block href "#sub-monster-safezone-block" drawmonsters --> tracker-safezone-early-exit[tracker-safezone-early-exit] drawmonsters --> tracker-nearest-search[tracker-nearest-search] drawmonsters --> tracker-arrow[tracker-arrow] click tracker-safezone-early-exit href "#sub-tracker-safezone-early-exit" click tracker-nearest-search href "#sub-tracker-nearest-search" click tracker-arrow href "#sub-tracker-arrow" drawbuttons --> jump-button-render[jump-button-render] drawbuttons --> climb-up-button-render[climb-up-button-render] drawbuttons --> climb-down-button-render[climb-down-button-render] click jump-button-render href "#sub-jump-button-render" click climb-up-button-render href "#sub-climb-up-button-render" click climb-down-button-render href "#sub-climb-down-button-render" triggerjumpscare --> jumpscare-gate[jumpscare-gate] triggerjumpscare --> jumpscare-state-init[jumpscare-state-init] triggerjumpscare --> jumpscare-audio[jumpscare-audio] click jumpscare-gate href "#sub-jumpscare-gate" click jumpscare-state-init href "#sub-jumpscare-state-init" click jumpscare-audio href "#sub-jumpscare-audio" drawjumpscare --> monster-phase[monster-phase] drawjumpscare --> blood-phase[blood-phase] drawjumpscare --> fadebook-phase[fadebook-phase] click monster-phase href "#sub-monster-phase" click blood-phase href "#sub-blood-phase" click fadebook-phase href "#sub-fadebook-phase" resetgame --> reset-jumpscare-state[reset-jumpscare-state] resetgame --> reset-player[reset-player] resetgame --> reset-enemies[reset-enemies] resetgame --> reset-items[reset-items] resetgame --> resume-audio[resume-audio] click reset-jumpscare-state href "#sub-reset-jumpscare-state" click reset-player href "#sub-reset-player" click reset-enemies href "#sub-reset-enemies" click reset-items href "#sub-reset-items" click resume-audio href "#sub-resume-audio" touchstarted --> game-start-tap[game-start-tap] touchstarted --> vr-toggle-tap[vr-toggle-tap] touchstarted --> jump-button-tap[jump-button-tap] touchstarted --> joystick-activation[joystick-activation] click game-start-tap href "#sub-game-start-tap" click vr-toggle-tap href "#sub-vr-toggle-tap" click jump-button-tap href "#sub-jump-button-tap" keypressed --> game-start-key[game-start-key] keypressed --> vr-toggle-key[vr-toggle-key] keypressed --> jump-key[jump-key] keypressed --> climb-keys[climb-keys] click game-start-key href "#sub-game-start-key" click vr-toggle-key href "#sub-vr-toggle-key" click jump-key href "#sub-jump-key" click climb-keys href "#sub-climb-keys"

❓ Frequently Asked Questions

What kind of visual experience does Fatal Ape Redux VR offer?

Fatal Ape Redux VR creates a moody, minimalist line-art landscape with stick-figure characters, eerie rooms, and morphing shapes that transform into lurking monsters.

How can players interact with the Fatal Ape Redux VR sketch?

Players can control their stick-figure hero using mobile tilt controls for camera movement and on-screen buttons to jump and climb, enhancing the immersive experience.

What creative coding techniques are showcased in Fatal Ape Redux VR?

This sketch demonstrates procedural drawing techniques, device orientation for interaction, and game state management to create an engaging horror adventure.

Preview

Fatal ape redux vr - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Fatal ape redux vr - Code flow showing preload, setup, draw, updateplayer, drawplayer, playerjump, playerclimb, monster, spawnmonster, updatemonsters, drawmonsters, spawncollectibles, drawcollectibles, drawkey, updatecamera, drawrooms, drawvents, drawstaircases, drawsafezoneboundary, drawjoystick, drawmonstertracker, drawbuttons, drawinventory, drawvrtogglebtn, triggerjumpscare, drawjumpscare, resetgame, touchstarted, touchmoved, touchended, keypressed, keyreleased, windowresized, rectcollision
Code Flow Diagram