monkey chase 3 by corbun

This sketch creates an eerie game where a flat monkey sprite chases your mouse cursor with AI steering behavior in a 3D WEBGL environment. When it catches you, the canvas freezes and slowly dissolves into glitchy crash images overlaid with fading static noise over seven minutes.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make the monkey glow brighter — The point light that follows the monkey sets its luminance—raising the RGB values makes it shine more intensely
  2. Make the monkey hunt smarter (look farther ahead) — The predictive lead multiplier of 12 determines how many pixels the monkey aims ahead—raising it makes it more aggressive at intercepting your path
  3. Slow down the crash image fade — Phase 1 fades over 5 minutes—multiply that duration by 2 to stretch it to 10 minutes for a more eerie, prolonged glitch
  4. Triple the speed on each taunt instead of doubling — Change the speed multiplier from 2x per taunt to 3x per taunt—escalates the difficulty much faster (1x, 3x, 9x, 27x)
  5. Make the collision zone smaller (harder to catch you) — The catch radius of 0.45 means the monkey must get within 45% of its sprite width—lowering it makes collision harder to trigger
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch builds an unsettling interactive experience: a monkey sprite pursues your cursor relentlessly across a 3D canvas using AI steering behavior, and when it finally touches you, the entire scene locks in place and gradually morphs into corrupted images accompanied by white noise. The game uses p5.js' WEBGL renderer to draw a flat sprite billboard, applies steering algorithms to make the monkey intelligent about chasing, detects collision with smooth radius checking, and orchestrates a multi-phase crash sequence using timed image fading and sound ramping.

The code is organized into a setup phase that loads images and initializes the Monkey object, a continuous draw loop that updates physics and checks for collision, and a Monkey class that handles all AI behavior including chase steering and edge bouncing. By reading it you will learn how to build state machines (play / crashing / crashed), implement predictive targeting to lead a moving cursor, apply the steering force pattern used in creature simulations, and sequence multiple visual and audio effects over time using elapsed milliseconds.

⚙️ How It Works

  1. When you click START GAME, setup() creates a WEBGL canvas spanning your window, spawns the Monkey sprite near the top-left corner, and initializes white-noise static (silent until collision) alongside three loaded images: the monkey face, a first crash screen, and a second crash screen.
  2. Every frame in the draw loop, the drawScene() function clears the background, calculates a predictive target lead by a few pixels ahead of your mouse velocity, and tells the monkey to steer toward that target using the seek() method—a steering algorithm that accelerates smoothly toward the goal.
  3. The monkey's display3D() method renders its sprite image as a textured plane in WEBGL space and adds a subtle bobbing animation using sine wave oscillation, making it feel alive.
  4. Each time you press 'T' or tap the TAUNT button, triggerTaunt() doubles the speed multiplier (1x → 2x → 4x → 8x...), making the chase more frantic and aggressive.
  5. When the monkey's circle collides with your cursor (distance < catch radius), triggerCrash() freezes the last frame using get(), switches the game state to 'crashing', starts the static noise ramp-up, and removes the TAUNT button from the DOM.
  6. Over the next seven minutes, drawCrashTransition() plays a three-stage animation: Phase 1 (0–5 min) fades the first crash image over the frozen frame; Phase 2 (5–7 min) fades the second crash image on top; and after 7 minutes, static fades out, the game state becomes 'crashed', and noLoop() stops all drawing.

🎓 Concepts You'll Learn

Steering behaviorPredictive targetingCollision detectionWEBGL sprite renderingState machinesTimed animation sequencesAI pathfinding

📝 Code Breakdown

preload()

preload() runs before setup() and guarantees all external assets (fonts, images) finish loading. Without it, images would be null when draw() tries to render them, causing blank or broken visuals. Always load media here.

function preload() {
  uiFont = loadFont(
    "https://cdn.jsdelivr.net/fontsource/fonts/roboto@latest/latin-400-normal.woff"
  );

  // Main sprite image (the monkey face)
  monkeyImg = loadImage(
    "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRbl5eODr1SJybpcuw7MsYEbPVUhhVmITXjng&s"
  );

  // First crash screen image (fades in after collision)
  crashImg = loadImage(
    "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTgtFQUdSVRY_u5I8be9kJcbHGia5PLCg3OVQ&s"
  );

  // Second crash screen image (fades in after 2 more minutes)
  crashImg2 = loadImage(
    "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRKtsduK6_gd19NEyj2MizOpjz2ZaPHYrvT2g&s"
  );
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

calculation Font Loading uiFont = loadFont(...)

Loads Roboto font from CDN for HUD text display

calculation Monkey Sprite Loading monkeyImg = loadImage(...)

Loads the main sprite image that will be drawn as a 3D billboard

calculation First Crash Image Loading crashImg = loadImage(...)

Loads the glitch image that fades in during Phase 1 of the crash sequence

calculation Second Crash Image Loading crashImg2 = loadImage(...)

Loads the second glitch image that fades in during Phase 2 of the crash sequence

uiFont = loadFont(...)
Fetches the Roboto font file from a CDN and stores it; preload() guarantees it finishes loading before setup() runs
monkeyImg = loadImage(...)
Downloads the monkey sprite image from the URL and stores it in a variable so display3D() can texture it onto a plane later
crashImg = loadImage(...)
Pre-downloads the first crash screen image so it's ready to fade in after collision without delay
crashImg2 = loadImage(...)
Pre-downloads the second crash screen image to display during Phase 2 of the crash transition

setup()

setup() runs once when the sketch initializes. Here we configure the canvas, spawn the player object, set up audio, create UI buttons, and attach event listeners. The START GAME button delays the actual game start (loop() call) until the player explicitly clicks, a common pattern for web games that need audio to be user-triggered for browser policies.

function setup() {
  // WEBGL renderer for 3D/sprite
  canvas = createCanvas(windowWidth, windowHeight, WEBGL);
  canvas.elt.style.display = "none"; // Hide canvas initially

  // Spawn sprite near the top-left corner (but fully on screen)
  const spawnX = MONKEY_SIZE * 0.6;
  const spawnY = MONKEY_SIZE * 0.6;
  monkey = new Monkey(spawnX, spawnY);

  textFont(uiFont);
  textSize(16);
  textAlign(CENTER, CENTER);

  lastMouseX = mouseX;
  lastMouseY = mouseY;

  // Create static noise generator (p5.sound)
  staticNoise = new p5.Noise("white");
  staticNoise.amp(0);    // start silent
  staticNoise.start();

  // TAUNT button via p5
  tauntButton = createButton("TAUNT");
  tauntButton.id("taunt-button");          // CSS positions it bottom-left
  tauntButton.style("display", "none");    // hidden until game starts
  tauntButton.mousePressed(triggerTaunt);  // TAUNT works on click/tap

  // Startup warning logic
  const startupWarning = document.getElementById("startup-warning");
  const startGameButton = document.getElementById("start-game-button");
  startGameButton.addEventListener("click", () => {
    // unlock audio on user gesture
    if (typeof userStartAudio === "function") {
      userStartAudio();
    }

    startupWarning.classList.add("hidden");
    canvas.elt.style.display = "block";    // Show canvas
    tauntButton.style("display", "block"); // Show taunt button
    gameState = "play";
    loop();                                // Start draw loop
  });

  // Pause draw loop until game starts
  noLoop();
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

calculation WEBGL Canvas Creation canvas = createCanvas(windowWidth, windowHeight, WEBGL);

Creates a full-screen 3D canvas using WEBGL rendering, initially hidden until START is clicked

calculation Monkey Spawning monkey = new Monkey(spawnX, spawnY);

Instantiates the Monkey object at a safe position inset from the top-left corner

calculation Text Styling textFont(uiFont); textSize(16); textAlign(CENTER, CENTER);

Configures the font, size, and alignment for all HUD text

calculation Static Noise Setup staticNoise = new p5.Noise("white"); staticNoise.amp(0); staticNoise.start();

Creates a white-noise generator from p5.sound, starts it playing silently so it can ramp up on collision

calculation TAUNT Button Creation tauntButton = createButton("TAUNT"); tauntButton.id("taunt-button"); tauntButton.style("display", "none"); tauntButton.mousePressed(triggerTaunt);

Creates an interactive button styled by CSS and initially hidden; shows when game starts

conditional START GAME Button Handler startGameButton.addEventListener("click", () => { ... });

When START is clicked, shows the canvas, displays TAUNT, unlocks audio, and begins the draw loop

canvas = createCanvas(windowWidth, windowHeight, WEBGL);
Creates a full-window WEBGL canvas (for 3D sprite rendering) and stores it in a variable so we can control its display
canvas.elt.style.display = "none"; // Hide canvas initially
Hides the canvas element using CSS until the player clicks START, keeping the startup warning visible
monkey = new Monkey(spawnX, spawnY);
Creates a new Monkey instance at x=MONKEY_SIZE*0.6, y=MONKEY_SIZE*0.6 (safe inset from top-left)
textFont(uiFont);
Tells p5 to use the Roboto font (loaded in preload) for all text drawn after this line
staticNoise = new p5.Noise("white");
Creates a white-noise audio source from the p5.sound library (TV static sound)
staticNoise.amp(0);
Sets the volume to 0 (silent) so the noise plays inaudibly until collision triggers it to ramp up
staticNoise.start();
Starts the noise oscillator playing; it will remain silent because amp is 0, but is ready to amplify instantly
tauntButton = createButton("TAUNT");
Creates a clickable button labeled TAUNT and stores it so we can show/hide and remove it
tauntButton.mousePressed(triggerTaunt);
Registers the triggerTaunt function to fire whenever the button is clicked or tapped
noLoop();
Pauses the draw loop until the player clicks START; this keeps the sketch idle while the warning is shown

draw()

draw() is the main loop that runs 60 times per second. Using a state machine (gameState variable) lets us cleanly separate different behaviors: play mode renders the game normally, crashing mode displays the glitch sequence, and crashed mode stops the loop entirely. This is a scalable pattern for sketches with multiple modes.

function draw() {
  if (gameState === "play") {
    drawScene();
  } else if (gameState === "crashing") {
    drawCrashTransition();
  }
  // When gameState === "crashed", noLoop() has run and draw() stops.
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Play State Router if (gameState === "play") { drawScene(); }

Routes to the main game loop when the player is actively avoiding the monkey

conditional Crash State Router } else if (gameState === "crashing") { drawCrashTransition(); }

Routes to the crash animation sequence after collision

if (gameState === "play") {
Checks if the game is in active play mode (monkey is chasing, collision hasn't happened yet)
drawScene();
Calls the main drawing function to render the monkey, update physics, and draw the HUD
} else if (gameState === "crashing") {
Checks if collision just happened and we're in the crash transition sequence
drawCrashTransition();
Calls the crash animation function to fade images and play static over 7 minutes

drawScene()

drawScene() is the beating heart of active gameplay. It combines camera setup (lighting), enemy AI (predictive targeting and steering), player-enemy interaction (collision detection), and HUD rendering. The predictive lead is a sophisticated chase tactic: instead of chasing your current position (which you've already moved from), the monkey aims for where you're heading, making it feel intelligent and threatening. Studying this function teaches you how to layer multiple behaviors into a single frame update.

🔬 This block predicts where you're going and makes the monkey chase ahead. What happens if you remove the entire if-statement and just keep target = createVector(mouseX, mouseY)? How does the monkey's chasing behavior change without prediction?

  // Predictive target
  const mouseVel = createVector(mouseX - lastMouseX, mouseY - lastMouseY);
  let target = createVector(mouseX, mouseY);
  if (mouseVel.mag() > 0.5) {
    const lead = mouseVel.copy().mult(12);
    target.add(lead);
  }

🔬 The catch radius is set to 45% of the sprite's size. What happens if you change 0.45 to 0.2? Or to 0.8? How does this make the game easier or harder?

  // Collision → start crash transition
  const catchRadius = monkey.size * 0.45;
  if (dToMouse < catchRadius) {
    triggerCrash();
  }
function drawScene() {
  background(15, 20, 35);

  // Lights (not necessary for sprite but harmless)
  ambientLight(80);
  directionalLight(200, 200, 230, 0.3, -0.7, -1);
  pointLight(
    255, 190, 150,
    monkey.pos.x - width / 2,
    monkey.pos.y - height / 2,
    250
  );

  // Predictive target
  const mouseVel = createVector(mouseX - lastMouseX, mouseY - lastMouseY);
  let target = createVector(mouseX, mouseY);
  if (mouseVel.mag() > 0.5) {
    const lead = mouseVel.copy().mult(12);
    target.add(lead);
  }
  target.x = constrain(target.x, 0, width);
  target.y = constrain(target.y, 0, height);

  // Always chasing
  const dToMouse = dist(monkey.pos.x, monkey.pos.y, mouseX, mouseY);
  monkey.setState("chasing");

  monkey.update(target);
  monkey.display3D(); // draws your image as a sprite

  // 2D overlay
  resetMatrix();
  translate(-width / 2, -height / 2, 0);
  noLights();

  drawHUD();

  // Collision → start crash transition
  const catchRadius = monkey.size * 0.45;
  if (dToMouse < catchRadius) {
    triggerCrash();
  }

  lastMouseX = mouseX;
  lastMouseY = mouseY;
}
Line-by-line explanation (17 lines)

🔧 Subcomponents:

calculation Background Clear background(15, 20, 35);

Erases the previous frame with a dark blue-black color, preparing for fresh drawing

calculation 3D Lighting ambientLight(80); directionalLight(200, 200, 230, 0.3, -0.7, -1); pointLight(...);

Sets up ambient, directional, and point lights that subtly illuminate the 3D scene (more atmospheric than functional)

calculation Predictive Target Lead const mouseVel = createVector(mouseX - lastMouseX, mouseY - lastMouseY); let target = createVector(mouseX, mouseY); if (mouseVel.mag() > 0.5) { const lead = mouseVel.copy().mult(12); target.add(lead); }

Calculates where your cursor is heading and offsets the target slightly ahead so the monkey chases your predicted path, not your current position

calculation Target Boundary Clamp target.x = constrain(target.x, 0, width); target.y = constrain(target.y, 0, height);

Keeps the predictive lead target on-screen so the monkey doesn't chase a phantom point off-canvas

calculation Distance to Mouse const dToMouse = dist(monkey.pos.x, monkey.pos.y, mouseX, mouseY);

Measures the straight-line distance between the monkey and your cursor for collision checking

calculation Set Chasing State monkey.setState("chasing");

Tells the monkey object to use chase steering instead of wandering (though always chasing in this sketch)

calculation Monkey Physics Update monkey.update(target); monkey.display3D();

Updates the monkey's velocity and position based on steering, then draws it on screen

calculation HUD Rendering resetMatrix(); translate(-width / 2, -height / 2, 0); noLights(); drawHUD();

Resets the WEBGL transform, switches to 2D overlay coordinates, disables lighting, and draws text instructions

conditional Collision Detection if (dToMouse < catchRadius) { triggerCrash(); }

If distance drops below the catch radius, initiates the crash sequence

background(15, 20, 35);
Fills the entire canvas with a dark blue-black RGB color, erasing the previous frame
ambientLight(80);
Adds uniform ambient lighting at brightness 80 so the 3D scene isn't completely dark
directionalLight(200, 200, 230, 0.3, -0.7, -1);
Creates a directional light (like the sun) with cool blue-white color shining from above and behind
pointLight(255, 190, 150, monkey.pos.x - width / 2, ...);
Creates a warm point light that follows the monkey's position, adding a subtle glow effect around it
const mouseVel = createVector(mouseX - lastMouseX, mouseY - lastMouseY);
Calculates your cursor's velocity by subtracting its last position from this frame's position
if (mouseVel.mag() > 0.5) {
Only applies predictive lead if your cursor is moving faster than 0.5 pixels (filters out static/jitter)
const lead = mouseVel.copy().mult(12);
Projects your cursor's velocity forward by multiplying it by 12, creating a lead offset 12 frames ahead
target.add(lead);
Adds the lead offset to the target, so the monkey chases a point ahead of your actual cursor
target.x = constrain(target.x, 0, width);
Clamps the predictive target's x to stay on-screen (between 0 and canvas width)
const dToMouse = dist(monkey.pos.x, monkey.pos.y, mouseX, mouseY);
Uses p5's dist() function to calculate the Euclidean distance between monkey and your actual cursor position
monkey.setState("chasing");
Always sets the monkey to chase state (in this sketch it never wanders, but this is extensible)
monkey.update(target);
Updates the monkey's physics (velocity, position, acceleration) based on steering toward the target
monkey.display3D();
Renders the monkey sprite as a textured plane in 3D space with a bobbing animation
resetMatrix();
Resets the WEBGL transformation matrix so subsequent 2D drawing uses screen coordinates instead of 3D
translate(-width / 2, -height / 2, 0);
Translates to 2D overlay coordinate system so text drawn at (0,0) appears at top-left, not center
if (dToMouse < catchRadius) {
Triggers crash sequence if the actual distance between monkey and cursor is less than the collision radius
lastMouseX = mouseX;
Stores current mouse x for next frame so we can calculate velocity next iteration

drawCrashTransition()

drawCrashTransition() orchestrates a complex multi-phase animation using elapsed time and alpha blending. It's a masterclass in timed state transitions: Phase 1 (0–5 min) fades one image, Phase 2 (5–7 min) layers a second image on top, and the final phase stops audio and drawing. The key technique is constrain(elapsed / duration, 0, 1) which converts raw milliseconds into a normalized 0-to-1 progress value perfect for mixing colors or alpha. This pattern is reusable for any long-duration visual transition or effect sequence.

🔬 This Phase 1 block fades one image in over 5 minutes using a simple linear interpolation (t1 goes 0→1). What happens if you change '255 * t1' to '255 * t1 * t1'—does the fade speed up or slow down, and why? (Hint: squaring t1 makes early values smaller.)

  if (elapsed < PHASE1_DURATION) {
    // Phase 1: 0 → 5 minutes: fade in crashImg over the frozen frame
    const t1 = constrain(elapsed / PHASE1_DURATION, 0, 1);
    if (crashImg) {
      tint(255, 255 * t1);
      image(crashImg, 0, 0, width, height);
      noTint();
    }
  }
function drawCrashTransition() {
  background(0);
  resetMatrix();
  translate(-width / 2, -height / 2, 0);
  noLights();

  const elapsed = millis() - crashStartTime;

  // Draw the frozen last frame underneath
  if (freezeFrame) {
    image(freezeFrame, 0, 0, width, height);
  }

  if (elapsed < PHASE1_DURATION) {
    // Phase 1: 0 → 5 minutes: fade in crashImg over the frozen frame
    const t1 = constrain(elapsed / PHASE1_DURATION, 0, 1);
    if (crashImg) {
      tint(255, 255 * t1);
      image(crashImg, 0, 0, width, height);
      noTint();
    }
  } else if (elapsed < PHASE1_DURATION + PHASE2_DURATION) {
    // Phase 2: 5 → 7 minutes: crashImg is solid; fade in crashImg2 on top
    const t2 = constrain(
      (elapsed - PHASE1_DURATION) / PHASE2_DURATION,
      0, 1
    );
    if (crashImg) {
      image(crashImg, 0, 0, width, height);
    }
    if (crashImg2) {
      tint(255, 255 * t2);
      image(crashImg2, 0, 0, width, height);
      noTint();
    }
  } else {
    // Final state: crashImg2 fully visible, stop all sound and drawing
    if (crashImg2) {
      image(crashImg2, 0, 0, width, height);
    }

    if (staticNoise && !soundStopped) {
      // Fade out static over 1 second and stop the noise
      staticNoise.amp(0, 1);
      if (typeof staticNoise.stop === "function") {
        staticNoise.stop(1); // stop after 1 second
      }
      soundStopped = true;
    }

    gameState = "crashed";
    noLoop();
  }
}
Line-by-line explanation (12 lines)

🔧 Subcomponents:

conditional Phase 1: First Crash Image Fade if (elapsed < PHASE1_DURATION) { const t1 = constrain(elapsed / PHASE1_DURATION, 0, 1); if (crashImg) { tint(255, 255 * t1); image(crashImg, 0, 0, width, height); noTint(); } }

Over 5 minutes, slowly fades the first crash image in from transparent (alpha 0) to opaque (alpha 255)

conditional Phase 2: Second Crash Image Fade } else if (elapsed < PHASE1_DURATION + PHASE2_DURATION) { const t2 = constrain((elapsed - PHASE1_DURATION) / PHASE2_DURATION, 0, 1); if (crashImg) { image(crashImg, 0, 0, width, height); } if (crashImg2) { tint(255, 255 * t2); image(crashImg2, 0, 0, width, height); noTint(); } }

For the next 2 minutes, keeps crashImg solid while fading in crashImg2 on top of it

conditional Final Crash State } else { if (crashImg2) { image(crashImg2, 0, 0, width, height); } if (staticNoise && !soundStopped) { staticNoise.amp(0, 1); if (typeof staticNoise.stop === "function") { staticNoise.stop(1); } soundStopped = true; } gameState = "crashed"; noLoop(); }

After 7 minutes total, displays crashImg2 fully, fades out static, stops the game loop, and sets final state

background(0);
Clears the screen to pure black each frame (not strictly necessary since images fill it, but ensures clean state)
const elapsed = millis() - crashStartTime;
Calculates milliseconds since collision—used to determine which phase of the crash sequence we're in
if (freezeFrame) { image(freezeFrame, 0, 0, width, height); }
Draws the captured frozen frame (the game scene at the moment of collision) as the backdrop for all crash images
const t1 = constrain(elapsed / PHASE1_DURATION, 0, 1);
Calculates a normalized time value from 0 to 1 over the 5-minute Phase 1; 0 means just started, 1 means phase is done
tint(255, 255 * t1);
Sets the alpha transparency of the next image() call to increase from 0 (transparent) to 255 (opaque) over time
image(crashImg, 0, 0, width, height);
Draws the first crash image at full screen size with the tinted alpha; becomes increasingly visible
noTint();
Removes the tint after drawing so subsequent images aren't accidentally tinted
const t2 = constrain((elapsed - PHASE1_DURATION) / PHASE2_DURATION, 0, 1);
Calculates a normalized time for Phase 2, resetting the clock to 0 after Phase 1 ends
staticNoise.amp(0, 1);
Ramps the noise volume from current level down to 0 over 1 second
staticNoise.stop(1);
Stops the noise oscillator after 1 second, fully silencing it
gameState = "crashed";
Sets game state to final 'crashed' so draw() no longer calls any update logic
noLoop();
Stops the draw loop entirely; draw() will never run again and nothing more is rendered

class Monkey

The Monkey class implements Craig Reynolds' steering behaviors pattern, one of the most influential algorithms in game AI. It separates concerns cleanly: update() orchestrates physics (acceleration → velocity → position), seek() implements goal-chasing with smooth steering, wander() generates exploration, and handleEdges() enforces boundaries. The key insight is that steering is a force applied to acceleration, not directly to velocity—this allows smooth, natural motion instead of jerky teleporting. Study this class if you want to understand how most creatures in games move.

class Monkey {
  constructor(x, y) {
    this.pos = createVector(x, y);
    this.vel = p5.Vector.random2D().mult(2);
    this.acc = createVector(0, 0);

    this.baseMaxSpeed = 4.0;
    this.baseMaxForce = 0.2;
    this.maxSpeed = this.baseMaxSpeed;
    this.maxForce = this.baseMaxForce;

    this.size = MONKEY_SIZE;

    this.state = "wandering";
    this.justChangedState = false;

    this.wanderTheta = random(TWO_PI);
  }

  setState(newState) {
    if (newState !== this.state) {
      this.state = newState;
      this.justChangedState = true;
    }
  }

  applyForce(force) {
    this.acc.add(force);
  }

  update(target) {
    this.maxSpeed = this.baseMaxSpeed * speedMultiplier;
    this.maxForce = this.baseMaxForce * speedMultiplier;

    let steering;
    if (this.state === "chasing") {
      steering = this.seek(target);
    } else {
      steering = this.wander();
    }

    this.applyForce(steering);

    this.vel.add(this.acc);
    this.vel.limit(this.maxSpeed);
    this.pos.add(this.vel);
    this.acc.mult(0);

    this.handleEdges();
  }

  seek(target) {
    const desired = p5.Vector.sub(target, this.pos);
    const d = desired.mag();

    if (d === 0) return createVector(0, 0);

    let speed = this.maxSpeed;
    if (d < 120) {
      speed = map(d, 0, 120, 0, this.maxSpeed);
    }
    desired.setMag(speed);

    const steer = p5.Vector.sub(desired, this.vel);
    steer.limit(this.maxForce * 1.8);
    return steer;
  }

  wander() {
    const noiseScale = 0.005;
    this.wanderTheta = noise(frameCount * noiseScale) * TWO_PI * 2;

    const desired = p5.Vector.fromAngle(this.wanderTheta);
    desired.setMag(this.maxSpeed * 0.7);

    const steer = p5.Vector.sub(desired, this.vel);
    steer.limit(this.maxForce * 0.8);
    return steer;
  }

  handleEdges() {
    const r = this.size * 0.5;

    if (this.pos.x < r) {
      this.pos.x = r;
      this.vel.x *= -0.7;
    } else if (this.pos.x > width - r) {
      this.pos.x = width - r;
      this.vel.x *= -0.7;
    }

    if (this.pos.y < r) {
      this.pos.y = r;
      this.vel.y *= -0.7;
    } else if (this.pos.y > height - r) {
      this.pos.y = height - r;
      this.vel.y *= -0.7;
    }
  }

  // Draw your image as a billboard sprite in WEBGL
  display3D() {
    if (!monkeyImg) return;

    push();
    noStroke();

    // Convert screen coords to WEBGL coords
    translate(this.pos.x - width / 2, this.pos.y - height / 2, 0);

    // Small up‑down bob
    const bob = sin(frameCount * 0.08 + this.pos.x * 0.01) * 4;
    translate(0, bob, 0);

    // Keep sprite facing camera; disable lights for flat image
    noLights();
    texture(monkeyImg);
    plane(this.size, this.size); // draw the image on a square plane

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

🔧 Subcomponents:

calculation Monkey Constructor constructor(x, y) { ... }

Initializes position, velocity, steering limits, and state when a new Monkey object is created

calculation setState Method setState(newState) { ... }

Updates the monkey's behavior mode (chasing, wandering) and flags state changes

calculation applyForce Method applyForce(force) { this.acc.add(force); }

Adds a steering force to the accumulated acceleration (classic physics pattern)

calculation update Method update(target) { ... }

Main physics loop: scales speed by taunt multiplier, calculates steering, integrates velocity and position, resets acceleration

calculation seek Method (Steering Behavior) seek(target) { ... }

Implements Craig Reynolds' steering-to-target algorithm: desires the target direction but applies limited force for smooth pursuit

calculation wander Method (Exploration) wander() { ... }

Generates smooth random wandering using Perlin noise to pick angles, so motion looks natural instead of jittery

calculation handleEdges Method (Boundary Bounce) handleEdges() { ... }

Keeps the monkey on-screen by clamping position and flipping velocity when it hits a wall

calculation display3D Method (Sprite Rendering) display3D() { ... }

Renders the monkey sprite as a textured square plane in WEBGL with bobbing animation

this.pos = createVector(x, y);
Stores the initial position as a p5.Vector so we can use vector math for motion
this.vel = p5.Vector.random2D().mult(2);
Creates a random initial velocity of magnitude 2 in a random direction, so the monkey doesn't start perfectly still
this.acc = createVector(0, 0);
Initializes acceleration to zero; it will be recalculated every frame based on steering forces
this.baseMaxSpeed = 4.0;
Sets the base speed limit (pixels per frame) before any taunt multiplier; used as a reference
this.baseMaxForce = 0.2;
Sets the base steering force limit; higher values let the monkey turn sharper
this.wanderTheta = random(TWO_PI);
Initializes a random wander angle (0 to 2π radians) for smooth noise-based wandering
this.maxSpeed = this.baseMaxSpeed * speedMultiplier;
Inside update(), recalculates maxSpeed each frame so taunts instantly affect movement
if (this.state === "chasing") { steering = this.seek(target); }
Chooses between chase steering or wander steering based on current state
this.applyForce(steering);
Adds the steering force to the monkey's accumulated acceleration
this.vel.add(this.acc);
Applies accumulated acceleration to velocity (acceleration is the rate of change of velocity)
this.vel.limit(this.maxSpeed);
Caps velocity magnitude at maxSpeed so the monkey never moves too fast
this.pos.add(this.vel);
Updates position by adding velocity (position is the sum of all previous velocities)
this.acc.mult(0);
Resets acceleration to zero each frame so old forces don't accumulate
const desired = p5.Vector.sub(target, this.pos);
Calculates the vector pointing from the monkey toward the target
const d = desired.mag();
Measures the distance to the target (magnitude of the desired vector)
if (d < 120) { speed = map(d, 0, 120, 0, this.maxSpeed); }
Slows the monkey as it approaches (from 120 pixels away, linearly slow until it stops at distance 0)
desired.setMag(speed);
Sets the desired vector's magnitude to the calculated speed while keeping its direction
const steer = p5.Vector.sub(desired, this.vel);
Steering force = (where we want to go) - (where we're going); this creates a corrective vector
steer.limit(this.maxForce * 1.8);
Limits steering sharpness by capping its magnitude (1.8 is a tuned multiplier for faster chase response)
this.wanderTheta = noise(frameCount * noiseScale) * TWO_PI * 2;
Uses Perlin noise to pick a smooth random angle each frame; smoother than random() because noise values are correlated
const desired = p5.Vector.fromAngle(this.wanderTheta);
Creates a unit vector pointing in the wandering direction
desired.setMag(this.maxSpeed * 0.7);
Scales it to 70% of max speed so wandering is slower and more exploratory than chasing
if (this.pos.x < r) { this.pos.x = r; this.vel.x *= -0.7; }
If monkey hits the left edge, clamp it back on-screen and reverse its horizontal velocity, damping by 0.7
const bob = sin(frameCount * 0.08 + this.pos.x * 0.01) * 4;
Calculates a small up-down bobbing offset using sine wave; amplitude 4 pixels, phase varies with position
translate(this.pos.x - width / 2, this.pos.y - height / 2, 0);
Converts the monkey's screen coordinates to WEBGL coordinates (WEBGL origin is center, not top-left)
texture(monkeyImg);
Tells p5 to use the monkey image as the texture for the next shape
plane(this.size, this.size);
Draws a square plane (billboard) with the texture on it; WEBGL automatically faces it toward the camera

drawHUD()

drawHUD() is a simple overlay text function that displays game state to the player. It's called after resetMatrix() and translate() so it draws in 2D screen space rather than 3D world space. The dynamic text using template literals (the Taunts and Speed counters) shows current game state, giving immediate feedback for player actions. This is a common pattern: game logic updates global variables, and HUD functions read them to display status.

function drawHUD() {
  push();
  textAlign(LEFT, TOP);
  textSize(16);
  fill(255, 240);
  text("AI Sprite: avoid letting it touch your cursor!", 16, 16);
  text("Move the mouse. If it catches you, the scene freezes and glitch screens fade in with static.", 16, 36);
  text(
    "Press 'T' or tap TAUNT to enrage it (speed x2 each time).",
    16, 56
  );
  text(`Taunts: ${tauntCount}    Speed: x${speedMultiplier}`, 16, 76);
  pop();
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

calculation Text Formatting textAlign(LEFT, TOP); textSize(16); fill(255, 240);

Configures text to draw from top-left, at 16px size, in near-white color with slight transparency

calculation Game Instructions text("AI Sprite: avoid letting it touch your cursor!", 16, 16);

Draws the main objective as the first line of on-screen text

calculation Score Display text(`Taunts: ${tauntCount} Speed: x${speedMultiplier}`, 16, 76);

Shows dynamic counters: how many times the player taunted and the current speed multiplier

push();
Saves the current graphics state (color, text settings, etc.) so changes don't affect other drawing
textAlign(LEFT, TOP);
Aligns text to the left edge horizontally and top edge vertically (default is center/center)
textSize(16);
Sets font size to 16 pixels for all text() calls until changed
fill(255, 240);
Sets text color to almost-white with slight transparency (alpha 240 out of 255)
text("AI Sprite: avoid letting it touch your cursor!", 16, 16);
Draws the objective at screen position (16, 16), inset from the top-left corner
text(`Taunts: ${tauntCount} Speed: x${speedMultiplier}`, 16, 76);
Uses template literals (backticks) to embed the tauntCount and speedMultiplier variables directly in the text
pop();
Restores the saved graphics state, undoing the text formatting changes

triggerCrash()

triggerCrash() is the event handler that fires when collision is detected. It does three critical things: captures a frozen snapshot of the game (so the crash transition has a base layer), switches the game state machine to 'crashing' (redirecting draw() logic), and begins the audio effect (static ramp-up). The state guard (early return) is crucial—it prevents the crash from being triggered twice if the collision check runs for multiple consecutive frames. This function is the pivot point between active gameplay and the post-game sequence.

function triggerCrash() {
  if (gameState !== "play") return;

  // Capture the last drawn frame (includes sprite & HUD)
  freezeFrame = get(0, 0, width, height);

  gameState = "crashing";
  crashStartTime = millis();
  soundStopped = false;

  // Remove TAUNT UI
  if (tauntButton) {
    tauntButton.remove();
    tauntButton = null;
  }

  // Start static noise
  if (staticNoise) {
    staticNoise.amp(0.2, 0.05); // ramp up quickly to 0.2 volume
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional State Guard if (gameState !== "play") return;

Prevents triggerCrash() from running multiple times if the monkey is already caught or game is crashing

calculation Freeze Frame Capture freezeFrame = get(0, 0, width, height);

Uses p5's get() to capture the entire canvas as an image, freezing the last game frame

calculation State and Timing Setup gameState = "crashing"; crashStartTime = millis();

Switches to crash mode and records the collision timestamp for phase timing

calculation UI Removal if (tauntButton) { tauntButton.remove(); tauntButton = null; }

Removes the TAUNT button from the DOM so the player can't interact with it during the crash sequence

calculation Static Noise Ramp-Up staticNoise.amp(0.2, 0.05);

Fades the white noise from silent (0) to 0.2 volume over 0.05 seconds (50ms) for an eerie buzzing effect

if (gameState !== "play") return;
Safety check: if the game is already crashing or crashed, exit immediately without re-triggering the crash
freezeFrame = get(0, 0, width, height);
Captures the entire current canvas (from top-left [0,0] to bottom-right [width, height]) as a p5.Image
gameState = "crashing";
Changes the game mode so draw() switches to calling drawCrashTransition() instead of drawScene()
crashStartTime = millis();
Records the current timestamp in milliseconds; drawCrashTransition() will use this to calculate elapsed time for phases
soundStopped = false;
Resets the flag so drawCrashTransition() knows it can start fading out sound when the time comes
tauntButton.remove();
Removes the button DOM element from the page entirely
tauntButton = null;
Sets the variable to null so future checks know the button is gone and won't try to access it
staticNoise.amp(0.2, 0.05);
Ramps (fades) the noise amplitude from its current level to 0.2 over 0.05 seconds (very quick, creates an abrupt eerie buzz)

keyPressed()

keyPressed() is a p5.js callback that fires every time the player presses a keyboard key. The key variable contains the character that was pressed. This simple function gates input based on game state and ties the 'T' key to the taunt mechanic. You could expand this to add more keyboard shortcuts (e.g., 'R' to restart, 'P' to pause).

function keyPressed() {
  if (gameState === "crashing" || gameState === "crashed") return;
  if (key === "t" || key === "T") {
    triggerTaunt();
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Game State Check if (gameState === "crashing" || gameState === "crashed") return;

Prevents keyboard input during and after the crash sequence

conditional TAUNT Key Detector if (key === "t" || key === "T") {

Checks if the player pressed 'T' or 't' and triggers the taunt if so

if (gameState === "crashing" || gameState === "crashed") return;
Ignores all keyboard input if the game has already transitioned to crash or final state
if (key === "t" || key === "T") {
Checks if the pressed key is lowercase 't' or uppercase 'T' (in case Caps Lock is on)
triggerTaunt();
Calls triggerTaunt() to increment the taunt counter and double the speed multiplier

triggerTaunt()

triggerTaunt() is the simplest function but has huge gameplay impact. Each call doubles the speed, creating an exponential curve (1x, 2x, 4x, 8x...). The state guard prevents taunts after collision. The speedMultiplier is used in the Monkey's update() method to scale maxSpeed, so changing it instantly affects chasing behavior. This is a tight feedback loop: player action (press T) → variable change → immediate visual effect (monkey speeds up).

function triggerTaunt() {
  if (gameState !== "play") return;

  tauntCount++;
  speedMultiplier *= 2;
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional State Guard if (gameState !== "play") return;

Prevents taunts from working during or after the crash sequence

calculation Taunt Counter tauntCount++;

Increments the display counter so the HUD shows how many times the player has taunted

calculation Speed Doubler speedMultiplier *= 2;

Doubles the current speed multiplier, making the monkey twice as fast (1x → 2x → 4x → 8x, etc.)

if (gameState !== "play") return;
Safety check: if not in play mode, don't allow taunts
tauntCount++;
Increments the counter by 1 (the ++ operator adds 1 to the variable)
speedMultiplier *= 2;
Multiplies the current speed by 2 (the *= operator multiplies and assigns in one step)

windowResized()

windowResized() is a p5.js callback that fires whenever the browser window is resized. Without it, the canvas would stay its original size and become letterboxed or clipped. Calling resizeCanvas() adapts the game to the new viewport. The TAUNT button uses CSS positioning (bottom-left) so it automatically repositions with the window; the monkey also bounces off the new canvas edges thanks to handleEdges(). This function is crucial for responsive web sketches.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  // TAUNT button is positioned via CSS; no change needed
}
Line-by-line explanation (1 lines)

🔧 Subcomponents:

calculation Canvas Resize resizeCanvas(windowWidth, windowHeight);

Updates the p5.js canvas to match the new window dimensions when the browser is resized

resizeCanvas(windowWidth, windowHeight);
Tells p5.js to resize its canvas to the current window width and height (updates the drawing surface)

📦 Key Variables

uiFont p5.Font

Stores the loaded Roboto font used for all HUD text rendering

let uiFont;
canvas p5.Renderer

Stores a reference to the WEBGL canvas so we can toggle its display visibility during startup

let canvas;
monkeyImg p5.Image

Stores the monkey sprite image loaded from the URL; textured onto the 3D plane in display3D()

let monkeyImg;
crashImg p5.Image

Stores the first glitch/crash screen image that fades in during Phase 1 of the crash sequence

let crashImg;
crashImg2 p5.Image

Stores the second glitch/crash screen image that fades in during Phase 2 of the crash sequence

let crashImg2;
MONKEY_SIZE number (constant)

Width and height of the monkey sprite in pixels; used for rendering and collision detection

const MONKEY_SIZE = 120;
monkey Monkey (custom object)

The main player-hunter; stores position, velocity, state, and steering behavior

let monkey;
gameState string

Tracks the current mode: 'play' (active gameplay), 'crashing' (transition sequence), or 'crashed' (final frozen state)

let gameState = "play";
lastMouseX number

Stores the mouse x position from the previous frame, used to calculate velocity for predictive targeting

let lastMouseX;
lastMouseY number

Stores the mouse y position from the previous frame, used to calculate velocity for predictive targeting

let lastMouseY;
tauntButton p5.Renderer (HTML button)

Reference to the TAUNT button created by p5; allows us to show, hide, and remove it

let tauntButton;
speedMultiplier number

Multiplier applied to the monkey's base speed; starts at 1 and doubles each taunt (1x, 2x, 4x, 8x...)

let speedMultiplier = 1;
tauntCount number

Counter for how many times the player has taunted; displayed in the HUD

let tauntCount = 0;
staticNoise p5.Noise (p5.sound)

White-noise oscillator that starts silent and ramps up when collision is detected, creating eerie buzzing

let staticNoise;
freezeFrame p5.Image

Captured screenshot of the game scene at the moment of collision; displayed as a base layer during the crash sequence

let freezeFrame;
crashStartTime number (milliseconds)

Timestamp (in ms) when collision was detected; used to calculate elapsed time for phase transitions

let crashStartTime = 0;
soundStopped boolean

Flag to ensure the static noise fade-out only triggers once, preventing repeated stop() calls

let soundStopped = false;
PHASE1_DURATION number (constant, milliseconds)

How long Phase 1 lasts (5 minutes = 300,000 ms); controls when crashImg finishes fading in

const PHASE1_DURATION = 5 * 60 * 1000;
PHASE2_DURATION number (constant, milliseconds)

How long Phase 2 lasts (2 minutes = 120,000 ms); controls when crashImg2 finishes fading in

const PHASE2_DURATION = 2 * 60 * 1000;

🔧 Potential Improvements (6)

Here are some ways this code could be enhanced:

BUG drawScene() predictive targeting

If the player's cursor moves very fast, the predicted lead can push the target off-screen far enough to wrap around; constraining it helps but doesn't fully solve jittery prediction

💡 Use a smaller lead multiplier or add a maximum distance check: if (target.dist(mouseX, mouseY) > 100) target = createVector(mouseX, mouseY) to fall back to current position on wild mouse movements

PERFORMANCE draw() loop

The pointLight() is calculated every frame and follows the monkey—for a static scene, lights are expensive in WEBGL and this could tax lower-end GPUs

💡 Consider removing the pointLight() entirely or moving it to a fixed position; the ambient and directional lights are sufficient for a flat sprite. If you want dynamic lighting, only update it every 5–10 frames using a counter

STYLE Monkey class methods

The seek() and wander() methods duplicate the steering calculation pattern; there's no shared helper for limiting and applying forces

💡 Extract a helper method like limitSteer(desired, speed) that encapsulates the limit and return logic, making both methods cleaner and easier to modify together

FEATURE crash sequence

Once crashed, the sketch is completely frozen—no way to restart or return to the menu without reloading the page

💡 Add a 'Restart Game' button in the final crash state that resets gameState to 'play', clears the images, re-spawns the monkey, and re-shows the TAUNT button, allowing players to try again

BUG triggerCrash()

If the game is running at a very low frame rate, the collision check might trigger on multiple consecutive frames, and get() is called multiple times unnecessarily (expensive operation)

💡 Add an extra state variable like caught = false and set it to true in triggerCrash(), then gate the collision check with if (!caught) so get() only runs once

PERFORMANCE display3D()

Every frame, noLights() is called even though lighting was already disabled by the previous frame—unnecessary state changes

💡 Move noLights() outside the display3D() loop to drawScene(), or cache the light state and only change it when needed

🔄 Code Flow

Code flow showing preload, setup, draw, drawscene, drawcrashTransition, monkey, drawhud, triggercrash, keypressed, triggertaunt, windowresized

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

graph TD start[Start] --> setup[setup] setup --> preload[preload] preload --> load-font[load-font] preload --> load-monkey[load-monkey] preload --> load-crash1[load-crash1] preload --> load-crash2[load-crash2] setup --> create-webgl-canvas[create-webgl-canvas] setup --> spawn-monkey[spawn-monkey] setup --> setup-text[setup-text] setup --> setup-audio[setup-audio] setup --> create-taunt-button[create-taunt-button] setup --> startup-event-listener[startup-event-listener] click preload href "#fn-preload" click setup href "#fn-setup" click load-font href "#sub-load-font" click load-monkey href "#sub-load-monkey" click load-crash1 href "#sub-load-crash1" click load-crash2 href "#sub-load-crash2" click create-webgl-canvas href "#sub-create-webgl-canvas" click spawn-monkey href "#sub-spawn-monkey" click setup-text href "#sub-setup-text" click setup-audio href "#sub-setup-audio" click create-taunt-button href "#sub-create-taunt-button" click startup-event-listener href "#sub-startup-event-listener" setup --> draw[draw loop] draw --> play-state-check[play-state-check] play-state-check --> background-clear[background-clear] background-clear --> lighting-setup[lighting-setup] lighting-setup --> drawscene[drawscene] drawscene --> predictive-targeting[predictive-targeting] drawscene --> target-clamping[target-clamping] drawscene --> collision-distance[collision-distance] drawscene --> monkey-state-set[monkey-state-set] drawscene --> monkey-update[monkey-update] monkey-update --> display3d[display3d] drawscene --> hud-draw[hud-draw] hud-draw --> hud-text-setup[hud-text-setup] hud-draw --> hud-instructions[hud-instructions] hud-draw --> hud-score[hud-score] draw --> crashing-state-check[crashing-state-check] crashing-state-check --> collision-check[collision-check] collision-check --> triggercrash[triggercrash] triggercrash --> frame-capture[frame-capture] triggercrash --> state-transition[state-transition] triggercrash --> ui-cleanup[ui-cleanup] triggercrash --> sound-ramp[sound-ramp] draw --> keypressed[keypressed] keypressed --> taunt-key-check[taunt-key-check] taunt-key-check --> state-guard-taunt[state-guard-taunt] state-guard-taunt --> triggertaunt[triggertaunt] triggertaunt --> increment-counter[increment-counter] increment-counter --> double-speed[double-speed] click draw href "#fn-draw" click play-state-check href "#sub-play-state-check" click background-clear href "#sub-background-clear" click lighting-setup href "#sub-lighting-setup" click drawscene href "#fn-drawscene" click predictive-targeting href "#sub-predictive-targeting" click target-clamping href "#sub-target-clamping" click collision-distance href "#sub-collision-distance" click monkey-state-set href "#sub-monkey-state-set" click monkey-update href "#sub-monkey-update" click display3d href "#sub-display3d" click hud-draw href "#sub-hud-draw" click hud-text-setup href "#sub-hud-text-setup" click hud-instructions href "#sub-hud-instructions" click hud-score href "#sub-hud-score" click crashing-state-check href "#sub-crashing-state-check" click collision-check href "#sub-collision-check" click triggercrash href "#fn-triggercrash" click frame-capture href "#sub-frame-capture" click state-transition href "#sub-state-transition" click ui-cleanup href "#sub-ui-cleanup" click sound-ramp href "#sub-sound-ramp" click keypressed href "#fn-keypressed" click taunt-key-check href "#sub-taunt-key-check" click state-guard-taunt href "#sub-state-guard-taunt" click triggertaunt href "#fn-triggertaunt" click increment-counter href "#sub-increment-counter" click double-speed href "#sub-double-speed" draw --> windowresized[windowresized] windowresized --> canvas-resize[canvas-resize] click windowresized href "#fn-windowresized" click canvas-resize href "#sub-canvas-resize"

❓ Frequently Asked Questions

What visual experience does the monkey chase 3 sketch provide?

The sketch features a flat 'monkey' sprite zipping around a 3D-style canvas, with a dramatic transition to eerie crash images and fading static when it catches the player.

How can users interact with the monkey chase 3 sketch?

Users can move the mouse or touch to tease the monkey, and press the TAUNT button to double its speed, making the chase more challenging.

What creative coding techniques are showcased in the monkey chase 3 sketch?

This sketch demonstrates steering behaviors for sprite movement, collision detection, and a timed visual transition that enhances the narrative experience.

Preview

monkey chase 3 by corbun - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of monkey chase 3 by corbun - Code flow showing preload, setup, draw, drawscene, drawcrashTransition, monkey, drawhud, triggercrash, keypressed, triggertaunt, windowresized
Code Flow Diagram