Sketch 2026-03-06 23:200

Escape Benson Island is an action shooter game where players control a yellow banana at the bottom of the screen and fire banana projectiles upward to destroy descending red Among Us enemy characters. The game progresses through 15 rounds of increasing difficulty, with enemies spawning faster and in greater numbers each round, and the player loses if any enemy reaches the bottom.

🧪 Try This!

Experiment with the code by making these changes:

  1. Double the fire rate — Halving reloadTime lets you shoot twice as fast, making gameplay feel more responsive
  2. Make enemies spawn slower — Reducing the enemy-per-round multiplier makes the game much less overwhelming—easier to see what's happening
  3. Make the banana bigger — A larger banana sprite is easier to aim with and looks more prominent on screen
  4. Award more points per kill — Changing the score increment from 10 to 50 makes victories feel more satisfying and tracks progress faster
  5. Change enemy color — Swapping the red fill for green (or any RGB values) customizes the enemy appearance without changing gameplay
  6. Slow down enemy fall speed — Reducing the speed multiplier gives you more reaction time to aim and shoot
Prefer the full editor? Open it there →

📖 About This Sketch

Escape Benson Island is a complete action game built with p5.js that combines shooting mechanics, collision detection, and progressive difficulty. Players aim and fire banana projectiles to destroy descending Among Us characters, with the visual charm of a banana fighting cartoonish aliens. The code demonstrates three key p5.js techniques: class-based game objects (the Banana player, BananaShot projectiles, and AmongUsCharacter enemies), collision detection using distance calculations, and a finite state machine that manages game flow from start screen through 15 rounds to victory or defeat.

The sketch is organized around three custom classes representing game actors, a main game loop that updates and displays all objects, and helper functions that manage game state transitions and input handling. By studying this code, you will learn how to structure a complete game with spawning enemies, persistent game objects in arrays, frame-based animation, and audio feedback using p5.sound oscillators. The state machine pattern teaches you how to cleanly switch between different screens and game modes.

⚙️ How It Works

  1. When the sketch loads, setup() creates a full-window canvas and initializes a player Banana object at the bottom center. The p5.sound oscillators are prepared to play sound effects without needing external audio files.
  2. The draw loop runs 60 times per second and uses a switch statement to handle five different game states: START_SCREEN shows instructions, PLAYING runs the main game logic, ROUND_WON celebrates completing a round, GAME_WON celebrates beating all 15 rounds, and GAME_OVER shows the failure screen.
  3. During the PLAYING state, the banana player stays at the bottom and rotates to aim toward the player's mouse or touch position. When clicked, it fires a BananaShot projectile with velocity calculated from the aim angle.
  4. Enemies (AmongUsCharacters) are spawned at the top in increasing numbers each round and fall downward at increasing speeds. The game checks if any enemy reaches the bottom (game loss) and if all enemies are destroyed (round victory).
  5. Collision detection uses the dist() function to check if banana projectiles overlap with enemies. When a hit occurs, the enemy and projectile are removed from their arrays, the score increases by 10, and a hit sound plays.
  6. When all enemies in a round are destroyed, the game transitions to ROUND_WON and waits for the player to click. On click, currentRound increments, new enemies spawn, and the game returns to PLAYING. After round 15, GAME_WON displays and the player can restart.

🎓 Concepts You'll Learn

Object-Oriented Programming (ES6 Classes)Game State MachinesCollision DetectionProjectile SystemsProgressive Difficulty ScalingEvent Handling and InputArray ManagementTrigonometry and Angles

📝 Code Breakdown

preload()

preload() runs once before setup() and is the ideal place to load assets like images, sounds, and fonts. By creating oscillators here, we avoid delays during gameplay. p5.Oscillator generates sounds from math, while PolySynth can play chords—both are great for simple sound effects without external files.

function preload() {
  // Placeholder sounds using p5.sound oscillators
  // These will create audible sounds without needing external files.
  // For actual sound effects, you would use:
  // shootSound = loadSound('assets/shoot.wav');
  // hitSound = loadSound('assets/hit.wav');
  // winSound = loadSound('assets/win.wav');
  // loseSound = loadSound('assets/lose.wav');

  // Shoot sound: short, high-pitched sine wave
  shootSound = new p5.Oscillator('sine');
  shootSound.freq(800);
  shootSound.amp(0); // Start silent
  shootSound.stop(); // Ensure it's stopped initially

  // Hit sound: short, slightly lower-pitched triangle wave
  hitSound = new p5.Oscillator('triangle');
  hitSound.freq(400);
  hitSound.amp(0); // Start silent
  hitSound.stop();

  // Win sound: short, rising arpeggio (PolySynth for multiple notes)
  winSound = new p5.PolySynth();
  // FIX: Use setAmp() for p5.PolySynth objects
  winSound.setAmp(0); // Start silent
  winSound.stop();

  // Lose sound: short, falling arpeggio (PolySynth for multiple notes)
  loseSound = new p5.PolySynth();
  // FIX: Use setAmp() for p5.PolySynth objects
  loseSound.setAmp(0); // Start silent
  loseSound.stop();
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

initialization Oscillator Sound Setup shootSound = new p5.Oscillator('sine');

Creates a sine-wave oscillator for the shoot sound effect without loading external audio files

initialization PolySynth Sound Setup winSound = new p5.PolySynth();

Creates a PolySynth object capable of playing multiple notes simultaneously for the win sound arpeggio

shootSound = new p5.Oscillator('sine');
Creates a sine-wave oscillator object that will generate the shooting sound—sine waves have a smooth, pure tone
shootSound.freq(800);
Sets the frequency of the oscillator to 800 Hz, a high pitch suitable for a 'pew' sound
shootSound.amp(0);
Sets the amplitude (volume) to 0 so the sound is silent until we explicitly start it during gameplay
hitSound = new p5.Oscillator('triangle');
Creates a triangle-wave oscillator for the hit sound—triangle waves sound slightly buzzy compared to sine waves
hitSound.freq(400);
Sets the hit sound frequency to 400 Hz, lower than the shoot sound to differentiate the audio feedback
winSound = new p5.PolySynth();
Creates a PolySynth object that can play multiple notes with different pitches and timing, used for victory music
loseSound = new p5.PolySynth();
Creates another PolySynth for the lose sound, which will play falling notes to signal defeat

setup()

setup() runs once when the sketch starts and is where you initialize your canvas, variables, and game objects. By using windowWidth and windowHeight, the canvas automatically adjusts to the browser window size, making the game responsive on phones, tablets, and desktops.

function setup() {
  createCanvas(windowWidth, windowHeight);
  userStartAudio(); // Required for p5.sound to work
  textAlign(CENTER, CENTER);
  textSize(24);

  // Create player banana
  playerBanana = new Banana(width / 2, height - 50);
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

initialization Canvas Setup createCanvas(windowWidth, windowHeight);

Creates a canvas that fills the entire browser window, making the game fullscreen

initialization Audio Context Initialization userStartAudio();

Enables p5.sound by starting the audio context (required by browsers for security reasons)

initialization Player Banana Instantiation playerBanana = new Banana(width / 2, height - 50);

Creates the player-controlled banana at the horizontal center, 50 pixels from the bottom of the screen

createCanvas(windowWidth, windowHeight);
Creates a canvas as large as the browser window, so the game fills the entire screen
userStartAudio();
Starts the Web Audio context—p5.sound needs this before it can play any sounds, and browsers require user interaction first
textAlign(CENTER, CENTER);
Sets all future text to be centered both horizontally and vertically around its position
textSize(24);
Sets the font size for all text drawn during the game to 24 pixels
playerBanana = new Banana(width / 2, height - 50);
Creates a new Banana object at the screen's horizontal center and near the bottom—this is the player's character

draw()

draw() runs 60 times per second and is the heart of your animation loop. By clearing the background each frame and redrawing everything in new positions, we create smooth motion. The switch statement acts as a router, letting different game screens share the same draw function.

🔬 This switch statement is the game's brain—it routes to different screens based on gameState. What happens if you comment out the break statement on one case, so the code falls through to the next case?

  switch (gameState) {
    case 'START_SCREEN':
      drawStartScreen();
      break;
    case 'PLAYING':
      drawGame();
      break;
    case 'ROUND_WON':
      drawRoundWonScreen();
      break;
function draw() {
  background(173, 216, 230); // Light blue background for Benson Island

  switch (gameState) {
    case 'START_SCREEN':
      drawStartScreen();
      break;
    case 'PLAYING':
      drawGame();
      break;
    case 'ROUND_WON':
      drawRoundWonScreen();
      break;
    case 'GAME_WON':
      drawGameWonScreen();
      break;
    case 'GAME_OVER':
      drawGameOverScreen();
      break;
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

initialization Frame Clear background(173, 216, 230);

Erases the previous frame with a light blue color, so old drawings don't leave trails

switch-case Game State Router switch (gameState) { ... }

Directs the draw loop to run different code based on the current game state

background(173, 216, 230);
Clears the canvas each frame by filling it with light blue (RGB: 173, 216, 230)—this prevents drawing trails and makes animation smooth
switch (gameState) {
Starts a switch statement that checks the value of gameState and routes to the correct screen-drawing function
case 'PLAYING':
If gameState equals 'PLAYING', the code runs drawGame(), which updates all game objects and checks for collisions

drawGame()

drawGame() is the main game logic function—it updates all game actors, checks for collisions and win/loss conditions, and draws the score. The nested loops (shots inside enemies) are where collision detection happens. Looping backward through arrays when deleting items is a common pattern to avoid skipping elements.

🔬 This loop updates all shots from the end of the array backward. Why does the code loop backward (i--) instead of forward (i++)? Hint: what happens when you remove an item from an array you're looping through?

  for (let i = bananaShots.length - 1; i >= 0; i--) {
    bananaShots[i].update();
    bananaShots[i].display();
    if (bananaShots[i].isOffScreen()) {
      bananaShots.splice(i, 1);
    }
  }

🔬 This nested loop checks every shot against every enemy. What does the break statement do? What would happen if you removed it?

    for (let j = bananaShots.length - 1; j >= 0; j--) {
      if (bananaShots[j].hits(amongUsCharacters[i])) {
        score += 10;
        // Play hit sound
        hitSound.amp(0.3, 0.05);
        hitSound.start();
        hitSound.amp(0, 0.1); // Fade out
        amongUsCharacters.splice(i, 1);
        bananaShots.splice(j, 1);
        break; // Stop checking shots for this among us character
      }
    }
function drawGame() {
  // Update and draw player
  playerBanana.update();
  playerBanana.display();

  // Update and draw banana shots
  for (let i = bananaShots.length - 1; i >= 0; i--) {
    bananaShots[i].update();
    bananaShots[i].display();
    if (bananaShots[i].isOffScreen()) {
      bananaShots.splice(i, 1);
    }
  }

  // Update and draw among us characters
  for (let i = amongUsCharacters.length - 1; i >= 0; i--) {
    amongUsCharacters[i].update();
    amongUsCharacters[i].display();

    // Check if among us character reaches player's side (loss condition)
    if (amongUsCharacters[i].y > height) {
      gameState = 'GAME_OVER';
      // Play lose sound
      // FIX: Use setAmp() for p5.PolySynth objects
      loseSound.setAmp(0.3, 0.1);
      loseSound.play('C5', 0.1, 0, 0.1);
      loseSound.play('A4', 0.1, 0.1, 0.1);
      loseSound.play('F4', 0.1, 0.2, 0.1);
      // FIX: Use setAmp() for p5.PolySynth objects
      loseSound.setAmp(0, 0.3, 0.3); // Fade out
      return; // End draw loop early as game is over
    }

    // Check for collisions with banana shots
    for (let j = bananaShots.length - 1; j >= 0; j--) {
      if (bananaShots[j].hits(amongUsCharacters[i])) {
        score += 10;
        // Play hit sound
        hitSound.amp(0.3, 0.05);
        hitSound.start();
        hitSound.amp(0, 0.1); // Fade out
        amongUsCharacters.splice(i, 1);
        bananaShots.splice(j, 1);
        break; // Stop checking shots for this among us character
      }
    }
  }

  // Check if round is complete
  if (amongUsCharacters.length === 0) {
    if (currentRound < MAX_ROUNDS) {
      gameState = 'ROUND_WON';
      // Play win sound
      // FIX: Use setAmp() for p5.PolySynth objects
      winSound.setAmp(0.3, 0.1);
      winSound.play('C5', 0.1, 0, 0.1);
      winSound.play('E5', 0.1, 0.1, 0.1);
      winSound.play('G5', 0.1, 0.2, 0.1);
      // FIX: Use setAmp() for p5.PolySynth objects
      winSound.setAmp(0, 0.3, 0.3); // Fade out
    } else {
      gameState = 'GAME_WON';
      // Play game won sound
      // FIX: Use setAmp() for p5.PolySynth objects
      winSound.setAmp(0.5, 0.1);
      winSound.play('C5', 0.1, 0, 0.1);
      winSound.play('E5', 0.1, 0.1, 0.1);
      winSound.play('G5', 0.1, 0.2, 0.1);
      winSound.play('C6', 0.1, 0.3, 0.1);
      // FIX: Use setAmp() for p5.PolySynth objects
      winSound.setAmp(0, 0.5, 0.4); // Fade out
    }
  }

  // Display score and round
  fill(0);
  text(`Round: ${currentRound}/${MAX_ROUNDS}`, 100, 40);
  text(`Score: ${score}`, 100, 80);
}
Line-by-line explanation (14 lines)

🔧 Subcomponents:

initialization Update and Draw Player playerBanana.update(); playerBanana.display();

Updates the banana's state and draws it on screen each frame

for-loop Projectile Update Loop for (let i = bananaShots.length - 1; i >= 0; i--) { bananaShots[i].update(); bananaShots[i].display(); if (bananaShots[i].isOffScreen()) { bananaShots.splice(i, 1); } }

Updates and displays all banana shots, removing those that go off-screen

for-loop Enemy Update and Collision Loop for (let i = amongUsCharacters.length - 1; i >= 0; i--) { ... }

Updates and displays all enemies, checks for loss condition, and handles collisions with projectiles

conditional Game Over Loss Condition if (amongUsCharacters[i].y > height) { gameState = 'GAME_OVER'; ... }

Ends the game if any enemy reaches the bottom of the screen

for-loop Collision Detection Nested Loop for (let j = bananaShots.length - 1; j >= 0; j--) { if (bananaShots[j].hits(amongUsCharacters[i])) { ... } }

Checks each projectile against the current enemy and removes both on hit

conditional Round Victory Check if (amongUsCharacters.length === 0) { ... }

Advances to the next round if all enemies are destroyed, or shows game won screen after round 15

playerBanana.update();
Calls the Banana's update() method to recalculate its position and state (in this case, aiming direction)
playerBanana.display();
Calls the Banana's display() method to draw it on the canvas at its current position and angle
for (let i = bananaShots.length - 1; i >= 0; i--) {
Loops backward through the bananaShots array (from end to start) so we can safely remove items while looping
if (bananaShots[i].isOffScreen()) {
Checks if the current shot has left the canvas boundaries using the shot's isOffScreen() method
bananaShots.splice(i, 1);
Removes the off-screen shot from the array—splice(i, 1) removes 1 item starting at index i
if (amongUsCharacters[i].y > height) {
Checks if the enemy's y position has passed the bottom of the screen, triggering a loss
gameState = 'GAME_OVER';
Changes the game state to GAME_OVER, which will display the game over screen on the next draw call
if (bananaShots[j].hits(amongUsCharacters[i])) {
Calls the shot's hits() method to check for collision between this shot and the current enemy
score += 10;
Adds 10 points to the player's score when an enemy is destroyed
amongUsCharacters.splice(i, 1);
Removes the hit enemy from the array
bananaShots.splice(j, 1);
Removes the projectile that hit the enemy from the array
if (amongUsCharacters.length === 0) {
Checks if all enemies have been destroyed by seeing if the array is empty
if (currentRound < MAX_ROUNDS) {
If we haven't reached round 15 yet, advance to ROUND_WON; otherwise go to GAME_WON
text(`Round: ${currentRound}/${MAX_ROUNDS}`, 100, 40);
Displays the current round number (e.g., 'Round: 3/15') at the top-left of the screen

spawnAmongUsCharacters()

This function uses linear scaling to increase difficulty: more enemies each round and faster speeds. This is called progressive difficulty scaling. The random(width) and random(-200, -50) calls create variation so enemies don't appear in the same pattern every time.

🔬 These formulas control difficulty progression. What happens if you change currentRound * 2 to currentRound * 0.5? How does the game feel if enemies barely increase in number?

  const numCharacters = 5 + currentRound * 2; // Increase characters per round
  const characterSpeed = 1 + currentRound * 0.2; // Increase speed per round
  for (let i = 0; i < numCharacters; i++) {
    const x = random(width);
    const y = random(-200, -50); // Start above the screen
function spawnAmongUsCharacters() {
  const numCharacters = 5 + currentRound * 2; // Increase characters per round
  const characterSpeed = 1 + currentRound * 0.2; // Increase speed per round
  for (let i = 0; i < numCharacters; i++) {
    const x = random(width);
    const y = random(-200, -50); // Start above the screen
    amongUsCharacters.push(new AmongUsCharacter(x, y, characterSpeed));
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

calculation Enemy Count Calculation const numCharacters = 5 + currentRound * 2;

Calculates how many enemies to spawn based on the current round—more enemies as rounds progress

calculation Enemy Speed Calculation const characterSpeed = 1 + currentRound * 0.2;

Calculates the speed for all enemies this round—faster speed as rounds progress

for-loop Enemy Creation Loop for (let i = 0; i < numCharacters; i++) { const x = random(width); const y = random(-200, -50); amongUsCharacters.push(new AmongUsCharacter(x, y, characterSpeed)); }

Creates all enemies for this round at random horizontal positions above the screen

const numCharacters = 5 + currentRound * 2;
Calculates the number of enemies to spawn—starts at 5 for round 1, then adds 2 more each round (7 in round 2, 9 in round 3, etc.)
const characterSpeed = 1 + currentRound * 0.2;
Calculates the falling speed for enemies—starts at 1.2 pixels/frame in round 1, increasing by 0.2 each round (1.4 in round 2, 1.6 in round 3, etc.)
for (let i = 0; i < numCharacters; i++) {
Loops numCharacters times, creating one enemy per iteration
const x = random(width);
Picks a random horizontal position anywhere across the canvas width
const y = random(-200, -50);
Picks a random vertical position above the screen (between -200 and -50 pixels, so enemies appear to fall in from above)
amongUsCharacters.push(new AmongUsCharacter(x, y, characterSpeed));
Creates a new AmongUsCharacter at the random position with the round's speed and adds it to the array

handleInput()

handleInput() is the state machine router for player actions. Each game state branches to different behavior: starting the game, advancing rounds, or playing. By organizing input this way, you keep all game state logic in one place.

🔬 When the game starts, it resets currentRound and score. What happens if you remove the line 'currentRound = 1;' and let it start at currentRound = 3 instead? How does the difficulty feel?

  if (gameState === 'START_SCREEN') {
    gameState = 'PLAYING';
    currentRound = 1;
    score = 0;
    amongUsCharacters = [];
    bananaShots = [];
    spawnAmongUsCharacters();
function handleInput() {
  if (gameState === 'START_SCREEN') {
    gameState = 'PLAYING';
    currentRound = 1;
    score = 0;
    amongUsCharacters = [];
    bananaShots = [];
    spawnAmongUsCharacters();
  } else if (gameState === 'ROUND_WON') {
    currentRound++;
    amongUsCharacters = [];
    bananaShots = [];
    spawnAmongUsCharacters();
    gameState = 'PLAYING';
  } else if (gameState === 'GAME_OVER' || gameState === 'GAME_WON') {
    gameState = 'START_SCREEN';
  } else if (gameState === 'PLAYING') {
    // Player aims with mouseX/touchX, mouseY/touchY
    playerBanana.aim(mouseX, mouseY); // mouseX/Y work for touch too
    playerBanana.shoot();
  }
}
Line-by-line explanation (12 lines)

🔧 Subcomponents:

conditional Start Game Handler if (gameState === 'START_SCREEN') { ... }

Resets the game state and spawns the first round when the player clicks on the start screen

conditional Next Round Handler else if (gameState === 'ROUND_WON') { ... }

Advances to the next round, clears enemies and shots, and spawns new enemies

conditional End Game Return to Menu else if (gameState === 'GAME_OVER' || gameState === 'GAME_WON') { ... }

Returns to the start screen when the player clicks after winning or losing

conditional Gameplay Input Handler else if (gameState === 'PLAYING') { ... }

Aims and fires when the player clicks during active gameplay

if (gameState === 'START_SCREEN') {
Checks if the game is currently showing the start screen
gameState = 'PLAYING';
Changes the game state to PLAYING, which will trigger the main game logic in the next draw call
currentRound = 1;
Resets the round counter to 1 for a fresh game
score = 0;
Resets the score to 0
amongUsCharacters = [];
Clears the enemy array to remove any stray enemies
bananaShots = [];
Clears the projectile array to remove any stray shots
spawnAmongUsCharacters();
Calls the spawn function to create enemies for round 1
else if (gameState === 'ROUND_WON') {
Checks if the game is on the round-won screen
currentRound++;
Increments the round counter by 1, advancing to the next round
else if (gameState === 'PLAYING') {
If the game is actively playing, handle the player's shot input
playerBanana.aim(mouseX, mouseY);
Aims the banana toward the player's click position (mouseX and mouseY work for both mouse and touch)
playerBanana.shoot();
Fires a banana projectile from the player's current aim angle

class Banana

The Banana class demonstrates three key p5.js techniques: atan2 for angle calculations (used in many games for aiming), millis() for time-based delays, and push/rotate for rotating sprites. The constrain() function teaches us how to limit values to a range—a technique used everywhere in creative coding.

🔬 The constrain line limits aiming to upward angles (-π to 0). What happens if you remove the constrain, allowing atan2 to calculate angles freely? Can you now aim downward?

  aim(targetX, targetY) {
    // Aim from banana's position to target position
    this.aimAngle = atan2(targetY - this.y, targetX - this.x);
    // Constrain angle to shoot upwards
    this.aimAngle = constrain(this.aimAngle, -PI, 0); // Allow left to right upwards arc

🔬 The reloadTime check prevents rapid firing by comparing elapsed time. What happens if you change '>' to '<'? Does the game become unplayable?

  shoot() {
    if (millis() - this.lastShotTime > this.reloadTime) {
      // Play shoot sound
      shootSound.amp(0.5, 0.05);
      shootSound.start();
      shootSound.amp(0, 0.2); // Fade out
class Banana {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.width = 60;
    this.height = 30;
    this.speed = 10; // Speed of banana shots
    this.aimAngle = -PI / 2; // Default aim straight up
    this.reloadTime = 200; // milliseconds between shots
    this.lastShotTime = 0;
  }

  aim(targetX, targetY) {
    // Aim from banana's position to target position
    this.aimAngle = atan2(targetY - this.y, targetX - this.x);
    // Constrain angle to shoot upwards
    this.aimAngle = constrain(this.aimAngle, -PI, 0); // Allow left to right upwards arc
  }

  shoot() {
    if (millis() - this.lastShotTime > this.reloadTime) {
      // Play shoot sound
      shootSound.amp(0.5, 0.05);
      shootSound.start();
      shootSound.amp(0, 0.2); // Fade out

      const shot = new BananaShot(this.x, this.y, this.aimAngle, this.speed);
      bananaShots.push(shot);
      this.lastShotTime = millis();
    }
  }

  update() {
    // Banana doesn't move, just aims
  }

  display() {
    push();
    translate(this.x, this.y);
    rotate(this.aimAngle + PI / 2); // Rotate to face direction of aim + 90 deg for upright
    // Draw banana shape
    fill(255, 255, 0); // Yellow
    noStroke();
    ellipse(0, 0, this.width, this.height); // Body
    rect(-5, -this.height / 2 - 10, 10, 15, 5); // Stem
    pop();
  }
}
Line-by-line explanation (16 lines)

🔧 Subcomponents:

initialization Banana Constructor constructor(x, y) { ... }

Initializes a Banana object with position, dimensions, speed, aim angle, and reload timing

calculation Aim Method aim(targetX, targetY) { ... }

Calculates the angle from the banana to the player's cursor and constrains it to upward angles

conditional Shoot Method with Reload shoot() { ... }

Fires a projectile if enough time has passed since the last shot, enforcing a reload delay

initialization Display Method with Rotation display() { ... }

Draws the banana sprite rotated to face the aiming direction

this.x = x;
Stores the banana's x position (column on screen)
this.y = y;
Stores the banana's y position (row on screen)
this.aimAngle = -PI / 2;
Sets the default aiming direction to straight up (-π/2 radians = 270 degrees)
this.reloadTime = 200;
Sets a 200-millisecond delay between shots, preventing rapid-fire spam
this.aimAngle = atan2(targetY - this.y, targetX - this.x);
Uses atan2 to calculate the angle from the banana to the target (cursor) position
this.aimAngle = constrain(this.aimAngle, -PI, 0);
Constrains the angle to the range -π to 0, which limits aiming to upward angles only (no shooting downward)
if (millis() - this.lastShotTime > this.reloadTime) {
Checks if enough milliseconds have passed since the last shot—prevents firing faster than reloadTime allows
shootSound.amp(0.5, 0.05);
Sets the shoot sound's amplitude to 0.5 over 0.05 seconds (fade in)
shootSound.start();
Starts playing the shoot sound
const shot = new BananaShot(this.x, this.y, this.aimAngle, this.speed);
Creates a new BananaShot projectile at the banana's position with the current aim angle and speed
bananaShots.push(shot);
Adds the new shot to the bananaShots array so it will be updated and drawn in the game loop
this.lastShotTime = millis();
Records the current time in milliseconds, so the next shot can check against this timestamp
translate(this.x, this.y);
Moves the drawing origin to the banana's position, making rotation happen around it
rotate(this.aimAngle + PI / 2);
Rotates the drawing by the aim angle (plus 90 degrees to make the banana point correctly)
ellipse(0, 0, this.width, this.height);
Draws the banana's body as an ellipse centered at the origin
rect(-5, -this.height / 2 - 10, 10, 15, 5);
Draws the banana's stem as a rounded rectangle above the body

class BananaShot

The BananaShot class teaches two critical p5.js skills: p5.Vector for physics (angle + speed = velocity), and dist() for collision detection. This circular collision method (distance between centers vs. sum of radii) is the fastest and most common collision check in 2D games.

🔬 This method returns true if the shot leaves the screen. Why add this.radius to the boundary checks (e.g., 'width + this.radius' instead of just 'width')? What would happen without it?

  isOffScreen() {
    return this.x < -this.radius || this.x > width + this.radius ||
           this.y < -this.radius || this.y > height + this.radius;
  }

🔬 This uses circular collision detection. What happens if you change '<' to '>' or '==='? Try changing the distance check to 'd < this.radius * 2'—does it make shots easier or harder to land?

  hits(amongUsCharacter) {
    // Simple circular collision detection
    const d = dist(this.x, this.y, amongUsCharacter.x, amongUsCharacter.y);
    return d < this.radius + amongUsCharacter.radius;
  }
class BananaShot {
  constructor(x, y, angle, speed) {
    this.x = x;
    this.y = y;
    this.radius = 10;
    this.speed = speed;
    this.velocity = p5.Vector.fromAngle(angle).mult(this.speed);
  }

  update() {
    this.x += this.velocity.x;
    this.y += this.velocity.y;
  }

  display() {
    push();
    translate(this.x, this.y);
    rotate(this.velocity.heading() + PI / 2); // Rotate to face direction of travel
    // Draw small banana shape
    fill(255, 255, 0); // Yellow
    noStroke();
    ellipse(0, 0, this.radius * 2, this.radius); // Body
    rect(-3, -this.radius - 5, 6, 8, 3); // Stem
    pop();
  }

  isOffScreen() {
    return this.x < -this.radius || this.x > width + this.radius ||
           this.y < -this.radius || this.y > height + this.radius;
  }

  hits(amongUsCharacter) {
    // Simple circular collision detection
    const d = dist(this.x, this.y, amongUsCharacter.x, amongUsCharacter.y);
    return d < this.radius + amongUsCharacter.radius;
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

initialization BananaShot Constructor constructor(x, y, angle, speed) { ... }

Initializes a projectile with position, radius, and velocity calculated from an angle and speed

calculation Velocity Vector Creation this.velocity = p5.Vector.fromAngle(angle).mult(this.speed);

Converts an angle and speed into a 2D velocity vector using p5.Vector

calculation Collision Detection Method hits(amongUsCharacter) { ... }

Uses distance calculation to detect if this projectile overlaps with an enemy

this.velocity = p5.Vector.fromAngle(angle).mult(this.speed);
Creates a vector pointing in the direction of angle, then multiplies it by speed—this is the projectile's per-frame movement
this.x += this.velocity.x;
Moves the projectile horizontally by adding the x component of its velocity each frame
this.y += this.velocity.y;
Moves the projectile vertically by adding the y component of its velocity each frame
rotate(this.velocity.heading() + PI / 2);
Rotates the projectile to face the direction it's traveling—velocity.heading() returns the angle of the velocity vector
return this.x < -this.radius || this.x > width + this.radius ||
Checks if the projectile has moved off the left or right edge of the screen
this.y < -this.radius || this.y > height + this.radius;
Checks if the projectile has moved off the top or bottom edge of the screen—returns true if any condition is true
const d = dist(this.x, this.y, amongUsCharacter.x, amongUsCharacter.y);
Calculates the distance between the projectile and the enemy using p5.js's dist() function
return d < this.radius + amongUsCharacter.radius;
Returns true if the distance is less than the sum of both radii—meaning they overlap and have collided

class AmongUsCharacter

The AmongUsCharacter class demonstrates shape drawing with beginShape/endShape and bezierVertex—advanced p5.js drawing techniques for custom sprites. The radius calculation shows a common pattern: approximating complex shapes as circles for fast collision detection.

🔬 Currently enemies only move down. What if you added 'this.x += random(-1, 1);' to make them wiggle left and right as they fall? How would this change gameplay difficulty?

  update() {
    this.y += this.speed; // Move downwards
  }
class AmongUsCharacter {
  constructor(x, y, speed) {
    this.x = x;
    this.y = y;
    this.width = 40;
    this.height = 50;
    this.speed = speed;
    this.radius = max(this.width, this.height) / 2; // For collision detection
  }

  update() {
    this.y += this.speed; // Move downwards
  }

  display() {
    push();
    translate(this.x, this.y);
    // Draw Among Us character shape (red)
    fill(200, 0, 0); // Red
    noStroke();
    // Body
    beginShape();
    vertex(-this.width / 2, this.height / 2);
    vertex(this.width / 2, this.height / 2);
    vertex(this.width / 2, -this.height / 2 + this.width / 4);
    bezierVertex(this.width / 2, -this.height / 2, this.width / 4, -this.height / 2, 0, -this.height / 2);
    bezierVertex(-this.width / 4, -this.height / 2, -this.width / 2, -this.height / 2, -this.width / 2, -this.height / 2 + this.width / 4);
    endShape(CLOSE);
    // Backpack
    rect(-this.width / 2, -this.height / 4, this.width / 4, this.height / 2, 5);
    // Visor
    fill(150, 200, 255); // Blue-ish
    ellipse(this.width * 0.1, -this.height * 0.2, this.width * 0.8, this.height * 0.4);
    pop();
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

initialization AmongUsCharacter Constructor constructor(x, y, speed) { ... }

Initializes an enemy with position, dimensions, falling speed, and collision radius

calculation Downward Movement this.y += this.speed;

Moves the enemy downward each frame by its speed value

initialization Complex Shape Drawing display() { ... }

Draws a detailed Among Us character sprite using bezier curves, rectangles, and ellipses

this.radius = max(this.width, this.height) / 2;
Calculates a collision radius using the larger of width or height divided by 2—this approximates the sprite as a circle
this.y += this.speed;
Adds the speed value to y each frame, making the enemy fall down the screen
translate(this.x, this.y);
Moves the drawing origin to the enemy's position, so everything draws relative to that point
beginShape(); ... endShape(CLOSE);
Draws a custom polygonal shape by defining vertices and bezier curves, creating the body outline
bezierVertex(this.width / 2, -this.height / 2, this.width / 4, -this.height / 2, 0, -this.height / 2);
Uses bezier curves to draw smooth rounded corners at the top of the character's head
rect(-this.width / 2, -this.height / 4, this.width / 4, this.height / 2, 5);
Draws a rounded rectangle on the left side representing the backpack
ellipse(this.width * 0.1, -this.height * 0.2, this.width * 0.8, this.height * 0.4);
Draws a blue ellipse for the visor (helmet/face glass)

📦 Key Variables

playerBanana Banana

Stores the player-controlled Banana object; represents the character the player controls

let playerBanana;
bananaShots array

Stores all active BananaShot projectiles fired by the player; used to update, display, and check collisions

let bananaShots = [];
amongUsCharacters array

Stores all active enemy AmongUsCharacter objects currently descending on screen; checked for collisions and loss conditions

let amongUsCharacters = [];
gameState string

Tracks the current game screen or mode ('START_SCREEN', 'PLAYING', 'ROUND_WON', 'GAME_WON', 'GAME_OVER'); controls what the draw loop displays

let gameState = 'START_SCREEN';
currentRound number

Tracks which round (1–15) the player is currently playing; used to scale enemy count and speed

let currentRound = 1;
score number

Stores the player's current score; incremented by 10 for each enemy destroyed

let score = 0;
MAX_ROUNDS number

Constant that defines the total number of rounds required to win (set to 15)

const MAX_ROUNDS = 15;
shootSound p5.Oscillator

Sound oscillator played when the player fires a shot

let shootSound;
hitSound p5.Oscillator

Sound oscillator played when a projectile hits an enemy

let hitSound;
winSound p5.PolySynth

Sound synthesizer that plays a rising arpeggio when a round is won

let winSound;
loseSound p5.PolySynth

Sound synthesizer that plays a falling arpeggio when the game is lost

let loseSound;

🔧 Potential Improvements (6)

Here are some ways this code could be enhanced:

BUG Banana.aim() method

The constrain limits aiming to -π to 0 (upward angles), but doesn't actually prevent the player from clicking below the banana and making the aiming unintuitive

💡 Consider relaxing the constraint or adding visual feedback (e.g., a laser sight) so players understand the aiming limits

PERFORMANCE drawGame() collision loop

Nested for-loops checking every shot against every enemy runs in O(n²) time; with many enemies and projectiles, this can slow down on older devices

💡 Use spatial partitioning (divide canvas into grid cells) or broad-phase collision culling to reduce comparisons in later rounds

STYLE Sound setup in preload()

Multiple comments saying 'FIX: Use setAmp() for p5.PolySynth objects' suggest the code uses setAmp() for PolySynth but amp() for Oscillators, which is inconsistent

💡 Standardize to one approach or add a helper function that abstracts sound amplitude setting across different p5.sound types

FEATURE Game mechanics

No pause feature or sound toggle, which are standard in games

💡 Add a 'P' key handler to pause gameplay and a 'M' key to mute audio—these improve accessibility and user experience

BUG windowResized() function

The player banana is repositioned on window resize, but existing enemies and shots remain at their old canvas-relative positions, causing visual confusion

💡 Scale all game objects' positions or reset the round when the canvas resizes, or use normalized coordinates (0–1) instead of pixel positions

FEATURE Difficulty scaling

Difficulty increases linearly (5→7→9 enemies, 1→1.2→1.4 speed), but players hit a skill wall around round 5–6 where it becomes frustratingly hard

💡 Implement curved difficulty scaling using quadratic or exponential functions, or add intermediate difficulty levels between rounds

🔄 Code Flow

Code flow showing preload, setup, draw, drawgame, spawnAmongUsCharacters, handleInput, banana, bananashot, amonguscharacter

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

graph TD start[Start] --> setup[setup] setup --> preload[preload] preload --> audio-init[audio-init] audio-init --> oscillator-creation[oscillator-creation] audio-init --> polysynth-creation[polysynth-creation] setup --> canvas-creation[canvas-creation] setup --> player-creation[player-creation] setup --> draw[draw loop] click preload href "#fn-preload" click audio-init href "#sub-audio-init" click oscillator-creation href "#sub-oscillator-creation" click polysynth-creation href "#sub-polysynth-creation" click canvas-creation href "#sub-canvas-creation" click player-creation href "#sub-player-creation" draw --> background-clear[background-clear] background-clear --> state-switch[state-switch] state-switch --> drawgame[drawgame] drawgame --> player-update[player-update] player-update --> shots-loop[shots-loop] shots-loop --> enemies-loop[enemies-loop] enemies-loop --> loss-check[loss-check] loss-check -->|if loss| end-state[end-state] loss-check --> collision-check[collision-check] collision-check -->|if hit| round-complete[round-complete] round-complete -->|if complete| round-won-state[round-won-state] round-complete -->|if not complete| enemies-loop click draw href "#fn-draw" click background-clear href "#sub-background-clear" click state-switch href "#sub-state-switch" click drawgame href "#fn-drawgame" click player-update href "#sub-player-update" click shots-loop href "#sub-shots-loop" click enemies-loop href "#sub-enemies-loop" click loss-check href "#sub-loss-check" click collision-check href "#sub-collision-check" click round-complete href "#sub-round-complete" click round-won-state href "#sub-round-won-state" click end-state href "#sub-end-state" drawgame --> spawnAmongUsCharacters[spawnAmongUsCharacters] spawnAmongUsCharacters --> num-calc[num-calc] spawnAmongUsCharacters --> speed-calc[speed-calc] spawnAmongUsCharacters --> spawn-loop[spawn-loop] spawn-loop -->|for each enemy| constructor-auc[constructor-auc] constructor-auc --> update-auc[update-auc] update-auc --> display-auc[display-auc] click spawnAmongUsCharacters href "#fn-spawnAmongUsCharacters" click num-calc href "#sub-num-calc" click speed-calc href "#sub-speed-calc" click spawn-loop href "#sub-spawn-loop" click constructor-auc href "#sub-constructor-auc" click update-auc href "#sub-update-auc" click display-auc href "#sub-display-auc" shots-loop -->|for each shot| constructor-bs[constructor-bs] constructor-bs --> velocity-calc[velocity-calc] velocity-calc --> collision-method[collision-method] click constructor-bs href "#sub-constructor-bs" click velocity-calc href "#sub-velocity-calc" click collision-method href "#sub-collision-method" player-update --> constructor[constructor] constructor --> aim-method[aim-method] aim-method --> shoot-method[shoot-method] shoot-method --> display-method[display-method] click constructor href "#sub-constructor" click aim-method href "#sub-aim-method" click shoot-method href "#sub-shoot-method" click display-method href "#sub-display-method" state-switch -->|if start| start-state[start-state] start-state --> drawgame click start-state href "#sub-start-state" state-switch -->|if round won| round-won-state round-won-state --> drawgame click round-won-state href "#sub-round-won-state" state-switch -->|if end| end-state click end-state href "#sub-end-state"

❓ Frequently Asked Questions

What visual elements can I expect to see in the p5.js sketch?

This sketch features dynamic graphics related to a game involving player-controlled bananas and Among Us characters, with animations that respond to gameplay.

How can I interact with the sketch during gameplay?

Users can interact with the sketch by shooting banana projectiles at Among Us characters, aiming to score points and progress through rounds.

What creative coding techniques are showcased in this p5.js sketch?

The sketch demonstrates sound synthesis using p5.js audio features and game state management to create an engaging interactive experience.

Preview

Sketch 2026-03-06 23:200 - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Sketch 2026-03-06 23:200 - Code flow showing preload, setup, draw, drawgame, spawnAmongUsCharacters, handleInput, banana, bananashot, amonguscharacter
Code Flow Diagram