cosmic defender

Cosmic Defender is a fast-paced multiplayer space shooter where players dodge enemies, collect power-ups, and battle bosses across five distinct game modes. The sketch combines real-time collision detection, particle effects, procedural enemy spawning, and dynamic difficulty to create an engaging arcade experience.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make the game harder faster — Change the enemy spawn rate decrease—higher values like 0.5 instead of 0.3 make enemies spawn much faster as the game progresses
  2. Bosses appear more often — Lower the bossSpawnInterval from 1800 frames (30 seconds) to 1000 frames (about 16 seconds) for more frequent boss encounters
  3. More dramatic explosions — Spawn 40 particles instead of 20 when enemies die—creates bigger, more impressive explosion effects
  4. Players move faster — Increase player speed from 8 to 12 pixels/frame for snappier controls—try this in BLACK-HOLE mode to escape the gravity well
  5. Black hole pulls stronger — Increase black hole pull strength from 0.3 to 0.6—objects get sucked in more aggressively in BLACK-HOLE mode
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch creates a fully-featured multiplayer space shooter game with five distinct modes: 1-Player, 2-Player Co-op, 2-Player Versus, Survival, and Black Hole. The visuals combine geometric enemies, rotating bosses, sparkling particles, and a gravity-warping black hole. It demonstrates advanced p5.js techniques including collision detection between multiple object types, procedural enemy spawning with difficulty scaling, particle system animation, sound synthesis with p5.sound oscillators, and complex game state management across multiple screens.

The code is organized into a main game loop in draw() that manages all game states, then delegates to specialized classes for Player, Enemy, Boss, Bullet, PowerUp, Particle, Star, and BlackHole objects. By reading this sketch, you will learn how to structure a complete multiplayer game, handle keyboard input for two players simultaneously, implement physics-based movement (velocity, gravity, acceleration), manage dynamic difficulty curves, and compose complex visual effects by layering simpler drawing operations.

⚙️ How It Works

  1. When the sketch loads, setup() initializes p5.sound oscillators for sound effects and creates 100 Star objects that scroll downward to form a parallax starfield background.
  2. The player selects a game mode (1P, 2P-COOP, 2P-VS, SURVIVAL, or BLACK-HOLE) by clicking buttons. Each mode has different rules: 1P and SURVIVAL are single-player, 2P modes split the canvas controls between two players, and BLACK-HOLE adds a gravity well in the center.
  3. Once the game starts, draw() enters a continuous loop: it updates and displays all game objects (players, enemies, bosses, bullets, power-ups, particles), checks for collisions between bullets and enemies/bosses, applies gravity if in BLACK-HOLE mode, spawns new enemies at increasing rates, and spawns bosses every 30 seconds with a warning animation.
  4. Player input is captured by keyPressed() and keyReleased(), storing pressed keys in the keysPressed object. Each frame, Player.update() reads this object to move the player; shooting fires a Bullet that travels straight up at fixed velocity.
  5. Collisions trigger explosive particle bursts, sound effects via oscillator frequency modulation, and score/health updates. When an enemy or boss is destroyed, it spawns 20 or 60 particles respectively, each falling and fading over several frames.
  6. When health reaches zero or a player is consumed by the black hole, checkGameOver() evaluates the game-over condition based on the current mode (single-player: P1 dies; co-op: both die; versus: either dies). The gameOver flag halts the main loop and draws a results screen.

🎓 Concepts You'll Learn

Game state managementObject-oriented design with classesCollision detectionParticle systemsSound synthesis with oscillatorsKeyboard input handlingProcedural difficulty scaling

📝 Code Breakdown

setup()

setup() runs once when p5.js starts. It initializes the canvas, prepares all sound oscillators (keeping them silent with amp(0)), and populates the starfield. By starting oscillators here and leaving them running in the background, the sketch avoids the delay of creating new sound objects mid-game—it just changes frequency and amplitude when needed.

function setup() {
  createCanvas(windowWidth, windowHeight);
  
  userStartAudio();
  
  // Create starfield
  for (let i = 0; i < 100; i++) {
    stars.push(new Star());
  }
  
  // Create sound effects
  shootSound = new p5.Oscillator('sine');
  shootSound.amp(0);
  shootSound.freq(440);
  shootSound.start();
  
  explosionSound = new p5.Oscillator('sawtooth');
  explosionSound.amp(0);
  explosionSound.freq(100);
  explosionSound.start();
  
  powerUpSound = new p5.Oscillator('triangle');
  powerUpSound.amp(0);
  powerUpSound.freq(660);
  powerUpSound.start();
  
  hitSound = new p5.Oscillator('square');
  hitSound.amp(0);
  hitSound.freq(220);
  hitSound.start();
  
  bossSound = new p5.Oscillator('sawtooth');
  bossSound.amp(0);
  bossSound.freq(80);
  bossSound.start();
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

for-loop Starfield initialization for (let i = 0; i < 100; i++) { stars.push(new Star()); }

Creates 100 Star objects and adds them to the stars array to populate the background

calculation Sound oscillator creation shootSound = new p5.Oscillator('sine');

Creates oscillators for each sound effect—they stay silent (amp 0) until playSound() is called

createCanvas(windowWidth, windowHeight);
Creates a canvas that fills the entire browser window, making the game responsive to window size
userStartAudio();
Initializes p5.sound and unlocks audio playback (required by modern browsers before sound can play)
for (let i = 0; i < 100; i++) {
Loops 100 times to create a starfield
stars.push(new Star());
Creates a new Star object with random position, speed, and brightness, then adds it to the stars array
shootSound = new p5.Oscillator('sine');
Creates an oscillator that will produce a sine-wave sound for shooting—starts with zero amplitude so it's silent
shootSound.amp(0);
Sets initial amplitude to 0 (silent)—playSound() will increase it when a shot fires
shootSound.freq(440);
Sets the default frequency to 440 Hz (musical note A4)—playSound() can change this per shot
shootSound.start();
Starts the oscillator running—it stays silent until playSound() increases its amplitude

draw()

draw() is the heart of the game loop—it runs 60 times per second and orchestrates every game system. The loop uses a state machine (modeSelected → gameStarted → gameOver) to route to different screens. Most importantly, it uses backward loops (for (let i = array.length - 1; i >= 0; i--)) when removing array elements, because removing an element shifts all higher indices down; iterating backward avoids skipping items. The sketch also demonstrates a key pattern: updating every object, checking collisions, applying effects, and cleaning up dead objects—then drawing the UI last so it appears on top.

🔬 This block spawns one enemy every enemySpawnRate frames and then makes the game harder. What happens if you remove the enemySpawnRate -= 0.3 line so enemies spawn at a constant rate forever?

  // Spawn enemies
  enemySpawnTimer++;
  if (enemySpawnTimer >= enemySpawnRate) {
    enemies.push(new Enemy());
    enemySpawnTimer = 0;
    
    // Increase difficulty
    if (enemySpawnRate > 20) {
      enemySpawnRate -= 0.3;
    }
  }

🔬 When the boss hits the player, it deals 30 damage and the boss takes 20 damage back. What happens if you change bosses[i].takeDamage(20) to takeDamage(100) so the boss loses nearly all health in one hit?

    if (bosses[i].hits(player1)) {
      health1 -= 30;
      playSound(hitSound, 220, 0.3, 150);
      createExplosion(player1.x, player1.y, color(255, 100, 100));
      bosses[i].takeDamage(20);
function draw() {
  background(10, 10, 30);
  
  // Draw starfield
  for (let star of stars) {
    star.update();
    star.display();
  }
  
  if (!modeSelected) {
    drawModeSelection();
    return;
  }
  
  if (!gameStarted) {
    drawStartScreen();
    return;
  }
  
  if (gameOver) {
    drawGameOver();
    return;
  }
  
  // Black hole mode effects
  if (gameMode === 'BLACK-HOLE' && blackHole) {
    blackHole.update();
    blackHole.display();
  }
  
  // Boss warning effect
  if (showBossWarning) {
    bossWarningTimer++;
    if (bossWarningTimer > 120) { // 2 seconds
      showBossWarning = false;
      bossWarningTimer = 0;
    }
    drawBossWarning();
  }
  
  // Update and display players
  player1.update();
  player1.display();
  
  if (gameMode === '2P-COOP' || gameMode === '2P-VS' || gameMode === 'BLACK-HOLE') {
    if (player2) {
      player2.update();
      player2.display();
    }
  }
  
  // Update and display bullets
  for (let i = bullets.length - 1; i >= 0; i--) {
    bullets[i].update();
    bullets[i].display();
    
    if (bullets[i].offScreen()) {
      bullets.splice(i, 1);
      continue;
    }
    
    // Black hole gravity on bullets
    if (gameMode === 'BLACK-HOLE' && blackHole) {
      blackHole.applyGravity(bullets[i]);
      if (blackHole.consumes(bullets[i])) {
        bullets.splice(i, 1);
      }
    }
  }
  
  // Spawn enemies
  enemySpawnTimer++;
  if (enemySpawnTimer >= enemySpawnRate) {
    enemies.push(new Enemy());
    enemySpawnTimer = 0;
    
    // Increase difficulty
    if (enemySpawnRate > 20) {
      enemySpawnRate -= 0.3;
    }
  }
  
  // Boss spawn logic (not in black hole mode)
  if (gameMode !== 'BLACK-HOLE') {
    bossSpawnTimer++;
    if (bossSpawnTimer >= bossSpawnInterval - 120 && !showBossWarning && bosses.length === 0) {
      showBossWarning = true;
      playSound(bossSound, 80, 0.3, 200);
    }
    
    if (bossSpawnTimer >= bossSpawnInterval && bosses.length === 0) {
      let bossType = floor(random(3));
      bosses.push(new Boss(bossType));
      bossSpawnTimer = 0;
      showBossWarning = false;
      bossWarningTimer = 0;
      
      if (bossSpawnInterval > 600) {
        bossSpawnInterval -= 100;
      }
    }
  }
  
  // Update and display bosses
  for (let i = bosses.length - 1; i >= 0; i--) {
    let target = player1;
    if ((gameMode === '2P-COOP' || gameMode === '2P-VS') && player2) {
      let dist1 = dist(bosses[i].x, bosses[i].y, player1.x, player1.y);
      let dist2 = dist(bosses[i].x, bosses[i].y, player2.x, player2.y);
      if (dist2 < dist1) target = player2;
    }
    
    bosses[i].update(target);
    bosses[i].display();
    
    if (bosses[i].hits(player1)) {
      health1 -= 30;
      playSound(hitSound, 220, 0.3, 150);
      createExplosion(player1.x, player1.y, color(255, 100, 100));
      bosses[i].takeDamage(20);
      
      if (health1 <= 0) {
        checkGameOver();
      }
    }
    
    if ((gameMode === '2P-COOP' || gameMode === '2P-VS') && player2 && bosses[i].hits(player2)) {
      health2 -= 30;
      playSound(hitSound, 220, 0.3, 150);
      createExplosion(player2.x, player2.y, color(255, 100, 100));
      bosses[i].takeDamage(20);
      
      if (health2 <= 0) {
        checkGameOver();
      }
    }
    
    for (let j = bullets.length - 1; j >= 0; j--) {
      if (bosses[i].hits(bullets[j])) {
        bosses[i].takeDamage(10);
        
        if (gameMode === '2P-VS') {
          if (bullets[j].owner === 1) score1 += 5;
          else score2 += 5;
        } else {
          score1 += 5;
          if (gameMode === '2P-COOP') score2 = score1;
        }
        
        playSound(hitSound, 300, 0.15, 40);
        createExplosion(bullets[j].x, bullets[j].y, color(255, 200, 0));
        bullets.splice(j, 1);
      }
    }
    
    if (bosses[i].health <= 0) {
      if (gameMode === '2P-VS') {
        score1 += 200;
        score2 += 200;
      } else {
        score1 += 200;
        if (gameMode === '2P-COOP' || gameMode === 'BLACK-HOLE') score2 = score1;
      }
      
      playSound(explosionSound, 60, 0.5, 300);
      createBigExplosion(bosses[i].x, bosses[i].y);
      
      for (let k = 0; k < 3; k++) {
        powerUps.push(new PowerUp(
          bosses[i].x + random(-40, 40),
          bosses[i].y + random(-40, 40)
        ));
      }
      
      bosses.splice(i, 1);
      bossesDefeated++;
    }
  }
  
  // Update and display enemies
  for (let i = enemies.length - 1; i >= 0; i--) {
    enemies[i].update();
    enemies[i].display();
    
    // Black hole gravity on enemies
    if (gameMode === 'BLACK-HOLE' && blackHole) {
      blackHole.applyGravity(enemies[i]);
      if (blackHole.consumes(enemies[i])) {
        createExplosion(enemies[i].x, enemies[i].y, color(150, 0, 255));
        enemies.splice(i, 1);
        continue;
      }
    }
    
    if (enemies[i].hits(player1)) {
      health1 -= 20;
      playSound(hitSound, 220, 0.2, 100);
      createExplosion(enemies[i].x, enemies[i].y, color(255, 100, 100));
      enemies.splice(i, 1);
      
      if (health1 <= 0) {
        checkGameOver();
      }
      continue;
    }
    
    if ((gameMode === '2P-COOP' || gameMode === '2P-VS' || gameMode === 'BLACK-HOLE') && player2 && enemies[i].hits(player2)) {
      health2 -= 20;
      playSound(hitSound, 220, 0.2, 100);
      createExplosion(enemies[i].x, enemies[i].y, color(255, 100, 100));
      enemies.splice(i, 1);
      
      if (health2 <= 0) {
        checkGameOver();
      }
      continue;
    }
    
    for (let j = bullets.length - 1; j >= 0; j--) {
      if (enemies[i].hits(bullets[j])) {
        if (gameMode === '2P-VS') {
          if (bullets[j].owner === 1) score1 += 10;
          else score2 += 10;
        } else {
          score1 += 10;
          if (gameMode === '2P-COOP' || gameMode === 'BLACK-HOLE') score2 = score1;
        }
        
        playSound(explosionSound, 100, 0.3, 80);
        createExplosion(enemies[i].x, enemies[i].y, color(255, 150, 0));
        
        if (gameMode !== 'SURVIVAL' && random() < 0.15) {
          powerUps.push(new PowerUp(enemies[i].x, enemies[i].y));
        }
        
        enemies.splice(i, 1);
        bullets.splice(j, 1);
        break;
      }
    }
    
    if (i >= 0 && enemies[i] && enemies[i].offScreen()) {
      enemies.splice(i, 1);
    }
  }
  
  // Update and display power-ups
  for (let i = powerUps.length - 1; i >= 0; i--) {
    powerUps[i].update();
    powerUps[i].display();
    
    // Black hole gravity on power-ups
    if (gameMode === 'BLACK-HOLE' && blackHole) {
      blackHole.applyGravity(powerUps[i]);
      if (blackHole.consumes(powerUps[i])) {
        powerUps.splice(i, 1);
        continue;
      }
    }
    
    let collected = false;
    
    if (powerUps[i].hits(player1)) {
      health1 = min(100, health1 + 30);
      if (gameMode === '2P-VS') score1 += 50;
      else score1 += 50;
      collected = true;
    }
    
    if ((gameMode === '2P-COOP' || gameMode === '2P-VS' || gameMode === 'BLACK-HOLE') && player2 && powerUps[i].hits(player2)) {
      health2 = min(100, health2 + 30);
      if (gameMode === '2P-VS') score2 += 50;
      else score2 += 50;
      collected = true;
    }
    
    if (collected) {
      playSound(powerUpSound, 660, 0.2, 100);
      createExplosion(powerUps[i].x, powerUps[i].y, color(100, 255, 100));
      powerUps.splice(i, 1);
    } else if (powerUps[i].offScreen()) {
      powerUps.splice(i, 1);
    }
  }
  
  // Update and display particles
  for (let i = particles.length - 1; i >= 0; i--) {
    particles[i].update();
    particles[i].display();
    
    if (particles[i].isDead()) {
      particles.splice(i, 1);
    }
  }
  
  // Check if players are consumed by black hole
  if (gameMode === 'BLACK-HOLE' && blackHole) {
    blackHole.applyGravity(player1);
    if (blackHole.consumes(player1)) {
      health1 = 0;
      checkGameOver();
    }
    
    if (player2) {
      blackHole.applyGravity(player2);
      if (blackHole.consumes(player2)) {
        health2 = 0;
        checkGameOver();
      }
    }
  }
  
  // Draw UI
  drawUI();
}
Line-by-line explanation (14 lines)

🔧 Subcomponents:

conditional Game state screen routing if (!modeSelected) { drawModeSelection(); return; }

If no game mode is selected, draw the mode selection screen and skip the rest of the game loop

conditional Enemy spawn difficulty increase if (enemySpawnRate > 20) { enemySpawnRate -= 0.3; }

Each enemy spawned reduces the spawn interval slightly, making the game progressively harder without spawning enemies faster than 20 frames apart

conditional Boss target switching if ((gameMode === '2P-COOP' || gameMode === '2P-VS') && player2) { let dist1 = dist(bosses[i].x, bosses[i].y, player1.x, player1.y); let dist2 = dist(bosses[i].x, bosses[i].y, player2.x, player2.y); if (dist2 < dist1) target = player2; }

In 2-player modes, bosses chase the nearest player by calculating distances to both

for-loop Backward loop for safe array removal for (let i = bullets.length - 1; i >= 0; i--) {

Looping backward through the array allows safe removal of elements without skipping items that shift forward

background(10, 10, 30);
Fills the entire canvas with a dark blue-black color each frame, clearing previous drawings and creating smooth animation
for (let star of stars) {
Loops through each Star object and calls its update() and display() methods every frame
if (!modeSelected) {
Checks if the player has not yet selected a game mode; if so, shows the mode selection screen and returns early
enemySpawnTimer++;
Increments a counter that tracks frames since the last enemy spawn
if (enemySpawnTimer >= enemySpawnRate) {
When the timer reaches the spawn interval, a new enemy is created
if (enemySpawnRate > 20) { enemySpawnRate -= 0.3; }
After each spawn, the interval shrinks by 0.3 frames (but never below 20), making enemies appear faster over time and increasing difficulty
if (bosses[i].hits(player1)) {
Uses the hits() method (defined in Boss class) to check if boss and player are touching—distance-based collision
health1 -= 30;
Subtracts 30 health when hit; player starts with 100, so three boss hits end a game
createExplosion(player1.x, player1.y, color(255, 100, 100));
Spawns 20 reddish particles at the collision point to visualize impact
for (let i = enemies.length - 1; i >= 0; i--) {
Loops backward through enemies so removing one doesn't skip the next enemy in the array
if (enemies[i].hits(bullets[j])) {
Nested loop checks each enemy-bullet pair for collision
score1 += 10; if (gameMode === '2P-COOP' || gameMode === 'BLACK-HOLE') score2 = score1;
In co-op mode, both players share the same score by synchronizing score1 to score2 after every point
if (gameMode !== 'SURVIVAL' && random() < 0.15) {
15% chance to spawn a power-up when enemy dies (but never in SURVIVAL mode)
drawUI();
Renders the heads-up display showing scores, health bars, and game mode text at the end of each frame

keyPressed()

keyPressed() fires once when a key goes down. To enable smooth movement (holding a key), the sketch stores pressed keys in keysPressed object; Player.update() then reads this object every frame. The shoot keys differ from movement keys: they fire once per press (event-based), while movement checks keysPressed every frame (state-based). This is the standard pattern for responsive game controls.

🔬 These two shooting blocks have different conditions: Player 2's Q key has an extra check for game mode, while Player 1's U doesn't. What happens if you remove the gameMode check from Player 2, so both players can shoot in SURVIVAL mode?

  // Player 1 shoot (U key)
  if (key.toLowerCase() === 'u') {
    if (gameStarted && !gameOver && gameMode !== 'SURVIVAL') {
      shoot(1);
    }
  }
  
  // Player 2 shoot (Q key)
  if (key.toLowerCase() === 'q' && (gameMode === '2P-COOP' || gameMode === '2P-VS' || gameMode === 'BLACK-HOLE')) {
    if (gameStarted && !gameOver && gameMode !== 'SURVIVAL') {
      shoot(2);
    }
  }
function keyPressed() {
  keysPressed[key.toLowerCase()] = true;
  
  if (key === ' ') {
    if (!modeSelected) {
      return;
    }
    if (!gameStarted) {
      startGame();
    } else if (gameOver) {
      resetGame();
    }
  }
  
  // Player 1 shoot (U key)
  if (key.toLowerCase() === 'u') {
    if (gameStarted && !gameOver && gameMode !== 'SURVIVAL') {
      shoot(1);
    }
  }
  
  // Player 2 shoot (Q key)
  if (key.toLowerCase() === 'q' && (gameMode === '2P-COOP' || gameMode === '2P-VS' || gameMode === 'BLACK-HOLE')) {
    if (gameStarted && !gameOver && gameMode !== 'SURVIVAL') {
      shoot(2);
    }
  }
  
  return false;
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

calculation Key press state storage keysPressed[key.toLowerCase()] = true;

Records that a key is currently pressed in an object, allowing continuous movement even when a key is held

conditional Space key state transitions if (key === ' ') { if (!modeSelected) { return; } if (!gameStarted) { startGame(); } else if (gameOver) { resetGame(); } }

The spacebar advances through game states: does nothing on mode screen, starts game from menu, restarts after game-over

conditional Player 1 shoot validation if (key.toLowerCase() === 'u') { if (gameStarted && !gameOver && gameMode !== 'SURVIVAL') { shoot(1); } }

Only allows shooting if game is active, not over, and not in SURVIVAL mode (where shooting is disabled)

keysPressed[key.toLowerCase()] = true;
Converts the key to lowercase and stores true in the keysPressed object (e.g., keysPressed['i'] = true), marking it as currently pressed
if (key === ' ') {
Checks if the spacebar was pressed (uses exact case ' ' not toLowerCase because ' ' is not a letter)
if (!modeSelected) { return; }
If no mode is selected yet, spacebar does nothing (return exits the function)
if (!gameStarted) { startGame(); }
If a mode is selected but game hasn't started, spacebar triggers startGame()
if (key.toLowerCase() === 'u') {
Checks if Player 1's shoot key (U) was pressed
if (gameStarted && !gameOver && gameMode !== 'SURVIVAL') {
Only shoots if the game is running, not over, and not in SURVIVAL mode (where no shooting exists)
shoot(1);
Calls the shoot() function with player number 1, creating a bullet
if (key.toLowerCase() === 'q' && (gameMode === '2P-COOP' || gameMode === '2P-VS' || gameMode === 'BLACK-HOLE')) {
Player 2's shoot key (Q) only works in 2-player modes; in 1P or SURVIVAL this condition is false
return false;
Returns false to prevent the browser's default key behavior (like scrolling when spacebar is pressed)

keyReleased()

keyReleased() fires once when a key goes up. It clears the key from keysPressed so movement stops. Without this, keys would stay 'pressed' forever and the player would never stop moving.

function keyReleased() {
  keysPressed[key.toLowerCase()] = false;
}
Line-by-line explanation (1 lines)
keysPressed[key.toLowerCase()] = false;
Marks the released key as no longer pressed in the keysPressed object, so Player.update() stops moving in that direction

shoot(playerNum)

shoot() is called by keyPressed() when U or Q is pressed. It creates a Bullet object owned by the player and plays a pitched sound. The owner field lets the game track whose bullet hit an enemy (important for VS mode scoring).

function shoot(playerNum) {
  if (playerNum === 1) {
    bullets.push(new Bullet(player1.x, player1.y, 1));
    playSound(shootSound, 440, 0.1, 50);
  } else if (playerNum === 2 && player2) {
    bullets.push(new Bullet(player2.x, player2.y, 2));
    playSound(shootSound, 520, 0.1, 50);
  }
}
Line-by-line explanation (4 lines)
if (playerNum === 1) {
Checks if Player 1 is shooting
bullets.push(new Bullet(player1.x, player1.y, 1));
Creates a new Bullet at Player 1's position with owner ID 1, then adds it to the bullets array
playSound(shootSound, 440, 0.1, 50);
Plays the shoot sound at 440 Hz (a musical note) with 0.1 amplitude for 50 milliseconds
playSound(shootSound, 520, 0.1, 50);
Player 2's bullet uses 520 Hz (higher pitch) so shots sound different and identifiable

playSound(osc, freq, volume, duration)

playSound() is a helper that reuses the same oscillator to make many sounds without creating new objects. It sets frequency, turns the sound on, waits, then turns it off. This is more efficient than creating a new oscillator per sound, especially in a fast-paced game firing dozens of bullets per second.

function playSound(osc, freq, volume, duration) {
  osc.freq(freq);
  osc.amp(volume, 0.01);
  setTimeout(() => {
    osc.amp(0, 0.1);
  }, duration);
}
Line-by-line explanation (3 lines)
osc.freq(freq);
Sets the oscillator's frequency in Hz, determining the pitch of the sound
osc.amp(volume, 0.01);
Sets amplitude (volume) to the specified level over 0.01 seconds (10 milliseconds for smooth fade-in)
setTimeout(() => { osc.amp(0, 0.1); }, duration);
After the duration (in milliseconds), fades the amplitude to 0 over 0.1 seconds (100 milliseconds), creating a tail

createExplosion(x, y, col)

createExplosion() spawns a burst of particles at a location. Each particle has random velocity so they scatter outward. The particles array is updated and drawn every frame, so particles fade and fall until they're removed. This is a common technique for creating visual feedback when things die or collide.

function createExplosion(x, y, col) {
  for (let i = 0; i < 20; i++) {
    particles.push(new Particle(x, y, col));
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

for-loop Particle spawn loop for (let i = 0; i < 20; i++) {

Creates 20 particles in a burst, each with random velocity for an explosive effect

for (let i = 0; i < 20; i++) {
Loops 20 times, creating 20 particles per explosion
particles.push(new Particle(x, y, col));
Creates a Particle at position (x, y) with the specified color, then adds it to the particles array for updating and drawing each frame

checkGameOver()

checkGameOver() enforces mode-specific win conditions. Single-player and survival end when Player 1 dies. Co-op requires both to die (cooperation). Versus ends when either dies (competition). This is called mode-dependent logic: the same event (health <= 0) has different meanings in different game modes.

function checkGameOver() {
  if (gameMode === '1P' || gameMode === 'SURVIVAL') {
    if (health1 <= 0) gameOver = true;
  } else if (gameMode === '2P-COOP' || gameMode === 'BLACK-HOLE') {
    if (health1 <= 0 && health2 <= 0) gameOver = true;
  } else if (gameMode === '2P-VS') {
    if (health1 <= 0 || health2 <= 0) gameOver = true;
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Co-op game-over condition if (health1 <= 0 && health2 <= 0) gameOver = true;

In co-op, both players must die for the game to end (both health <= 0)

conditional Versus game-over condition if (health1 <= 0 || health2 <= 0) gameOver = true;

In versus, the game ends when either player dies (OR logic), determining a winner

if (gameMode === '1P' || gameMode === 'SURVIVAL') {
Checks if in single-player or survival mode
if (health1 <= 0) gameOver = true;
Game ends when Player 1's health reaches zero; there's no Player 2 to save the game
if (health1 <= 0 && health2 <= 0) gameOver = true;
In co-op, the game only ends when BOTH players die; one player can survive and keep going
if (health1 <= 0 || health2 <= 0) gameOver = true;
In versus mode, the game ends as soon as either player dies (OR), so you can see who survived

drawUI()

drawUI() renders the heads-up display (score, health bars, mode name). It uses ternary operators for conditional coloring, map() to scale numeric values to visual sizes, and textAlign() to position text relative to a point. The map() function is especially useful: map(health1, 0, 100, 0, 200) translates a health range into a pixel range for the bar width.

function drawUI() {
  if (gameMode === '1P' || gameMode === 'SURVIVAL') {
    fill(255);
    textAlign(LEFT, TOP);
    textSize(24);
    text(`Score: ${score1}`, 20, 20);
    
    fill(100);
    rect(20, 60, 200, 20);
    fill(health1 > 30 ? color(100, 255, 100) : color(255, 100, 100));
    rect(20, 60, map(health1, 0, 100, 0, 200), 20);
    
    fill(255);
    textSize(16);
    textAlign(CENTER, CENTER);
    text(`HEALTH: ${health1}%`, 120, 70);
    
    if (gameMode !== 'SURVIVAL') {
      fill(255, 200, 100);
      textAlign(LEFT, TOP);
      textSize(18);
      text(`Bosses Defeated: ${bossesDefeated}`, 20, 95);
    }
  } else {
    fill(100, 200, 255);
    textAlign(LEFT, TOP);
    textSize(20);
    text(`P1: ${score1}`, 20, 20);
    
    fill(100);
    rect(20, 50, 150, 15);
    fill(health1 > 30 ? color(100, 255, 255) : color(255, 100, 100));
    rect(20, 50, map(health1, 0, 100, 0, 150), 15);
    
    fill(255, 150, 100);
    textAlign(RIGHT, TOP);
    text(`P2: ${score2}`, width - 20, 20);
    
    fill(100);
    rect(width - 170, 50, 150, 15);
    fill(health2 > 30 ? color(255, 200, 100) : color(255, 100, 100));
    rect(width - 170, 50, map(health2, 0, 100, 0, 150), 15);
    
    if (gameMode !== 'BLACK-HOLE') {
      fill(255, 200, 100);
      textAlign(CENTER, TOP);
      textSize(18);
      text(`Bosses Defeated: ${bossesDefeated}`, width / 2, 75);
    }
  }
  
  fill(255, 255, 255, 100);
  textAlign(CENTER, TOP);
  textSize(14);
  text(gameMode === '2P-COOP' ? 'CO-OP MODE' : 
       gameMode === '2P-VS' ? 'VERSUS MODE' :
       gameMode === 'SURVIVAL' ? 'SURVIVAL MODE' :
       gameMode === 'BLACK-HOLE' ? '🌀 BLACK HOLE MODE 🌀' : '', width / 2, 10);
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

conditional Dynamic health bar coloring fill(health1 > 30 ? color(100, 255, 100) : color(255, 100, 100));

Health bar turns red when below 30% as a warning to the player

calculation Health bar width mapping rect(20, 60, map(health1, 0, 100, 0, 200), 20);

Uses map() to scale health (0-100) to bar width (0-200 pixels), so bar grows/shrinks with health

if (gameMode === '1P' || gameMode === 'SURVIVAL') {
Checks if in single-player mode to render a single-player UI
text(`Score: ${score1}`, 20, 20);
Draws the score in the top-left corner using template literal syntax to embed the score1 variable
fill(100); rect(20, 60, 200, 20);
Draws a dark gray background rectangle for the health bar
fill(health1 > 30 ? color(100, 255, 100) : color(255, 100, 100));
Uses a ternary operator: if health > 30, fill is green; else fill is red (warning color)
rect(20, 60, map(health1, 0, 100, 0, 200), 20);
Draws the health bar's colored rectangle over the background; map() scales health 0-100 to width 0-200 pixels
text(`HEALTH: ${health1}%`, 120, 70);
Displays the numeric health percentage in the center of the health bar

Player (class)

The Player class shows how a game object encapsulates state (position, size, color) and behavior (update, display). The constructor assigns player-specific colors and key bindings. The update() method reads the keysPressed object each frame to enable smooth movement—this is called state-based input, distinct from event-based input (keyPressed). The display() method uses translate() and local coordinates to simplify drawing relative to the player's position, then restores the coordinate system with pop().

🔬 Player 1 uses IJKL keys, Player 2 uses WASD. What happens if you swap their key assignments so P1 gets WASD and P2 gets IJKL? Will it break anything?

    if (this.playerNum === 1) {
      if (keysPressed['j']) moveX -= this.speed;
      if (keysPressed['l']) moveX += this.speed;
      if (keysPressed['i']) moveY -= this.speed;
      if (keysPressed['k']) moveY += this.speed;
    } else {
      if (keysPressed['a']) moveX -= this.speed;
      if (keysPressed['d']) moveX += this.speed;
      if (keysPressed['w']) moveY -= this.speed;
      if (keysPressed['s']) moveY += this.speed;
    }
class Player {
  constructor(x, y, playerNum) {
    this.x = x;
    this.y = y;
    this.size = 30;
    this.speed = 8;
    this.playerNum = playerNum;
    this.color = playerNum === 1 ? color(100, 200, 255) : color(255, 150, 100);
    this.vx = 0;
    this.vy = 0;
  }
  
  update() {
    let moveX = 0;
    let moveY = 0;
    
    if (this.playerNum === 1) {
      if (keysPressed['j']) moveX -= this.speed;
      if (keysPressed['l']) moveX += this.speed;
      if (keysPressed['i']) moveY -= this.speed;
      if (keysPressed['k']) moveY += this.speed;
    } else {
      if (keysPressed['a']) moveX -= this.speed;
      if (keysPressed['d']) moveX += this.speed;
      if (keysPressed['w']) moveY -= this.speed;
      if (keysPressed['s']) moveY += this.speed;
    }
    
    this.vx = moveX;
    this.vy = moveY;
    this.x += this.vx;
    this.y += this.vy;
    
    this.x = constrain(this.x, this.size, width - this.size);
    this.y = constrain(this.y, this.size, height - this.size);
  }
  
  display() {
    push();
    translate(this.x, this.y);
    
    fill(red(this.color), green(this.color), blue(this.color), 100);
    noStroke();
    ellipse(0, 15, 20, 30);
    
    fill(this.color);
    stroke(red(this.color) * 0.7, green(this.color) * 0.7, blue(this.color) * 0.7);
    strokeWeight(2);
    triangle(-this.size / 2, this.size / 2, 
             this.size / 2, this.size / 2,
             0, -this.size / 2);
    
    fill(255);
    ellipse(0, 0, 12, 12);
    
    fill(0);
    noStroke();
    textSize(10);
    textAlign(CENTER, CENTER);
    text(this.playerNum, 0, 0);
    
    pop();
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

calculation Player initialization this.color = playerNum === 1 ? color(100, 200, 255) : color(255, 150, 100);

Sets color based on player number: blue for P1, orange for P2

conditional Input state reading if (keysPressed['j']) moveX -= this.speed;

Reads the keysPressed object to check if a key is currently held, enabling smooth continuous movement

calculation Screen boundary clamping this.x = constrain(this.x, this.size, width - this.size);

Keeps player on-screen by clamping position to canvas bounds

constructor(x, y, playerNum) {
Constructor receives starting position and player number; P1 or P2
this.color = playerNum === 1 ? color(100, 200, 255) : color(255, 150, 100);
Ternary operator: Player 1 is blue (100, 200, 255), Player 2 is orange (255, 150, 100)
if (keysPressed['j']) moveX -= this.speed;
Checks if 'j' key is pressed by reading the keysPressed object; if true, adds leftward velocity
this.vx = moveX;
Stores the computed movement in the velocity variable
this.x += this.vx;
Applies velocity by adding it to position; this creates smooth animation over many frames
this.x = constrain(this.x, this.size, width - this.size);
Uses constrain() to keep x between this.size and width - this.size, preventing the player from leaving the canvas
translate(this.x, this.y);
Moves the drawing origin to the player's position, so all subsequent shapes are drawn relative to (0, 0) in local coordinates
triangle(-this.size / 2, this.size / 2, this.size / 2, this.size / 2, 0, -this.size / 2);
Draws a triangle pointing upward (away from player direction) centered at origin; three vertices form a 30-pixel point

Enemy (class)

The Enemy class demonstrates procedural shape generation using trigonometry. Each enemy has random size and descent speed, creating variety. The wobble mechanism uses a counter and sine function to produce smooth oscillation—this is preferable to random() because random is jerky; sine creates organic, predictable motion. The hits() method uses simple distance-based collision detection (AABB would be faster for many objects, but circle collision is intuitive here).

🔬 This loop draws a 6-vertex hexagon by mapping i (0 to 6) to angles around a circle. What happens if you change 6 to 8 to make an octagon, or to 3 to make a triangle?

    beginShape();
    for (let i = 0; i < 6; i++) {
      let angle = map(i, 0, 6, 0, TWO_PI);
      let r = this.size / 2;
      let x = cos(angle) * r;
      let y = sin(angle) * r;
      vertex(x, y);
    }
    endShape(CLOSE);
class Enemy {
  constructor() {
    this.x = random(30, width - 30);
    this.y = -30;
    this.size = random(20, 40);
    this.vx = 0;
    this.vy = random(2, 5);
    this.speed = this.vy;
    this.wobble = random(TWO_PI);
    this.wobbleSpeed = random(0.02, 0.05);
  }
  
  update() {
    this.wobble += this.wobbleSpeed;
    this.x += this.vx + sin(this.wobble) * 2;
    this.y += this.vy;
  }
  
  display() {
    push();
    translate(this.x, this.y);
    
    fill(255, 50, 50, 50);
    noStroke();
    ellipse(0, 0, this.size * 1.5, this.size * 1.5);
    
    fill(255, 100, 100);
    stroke(255, 50, 50);
    strokeWeight(2);
    
    beginShape();
    for (let i = 0; i < 6; i++) {
      let angle = map(i, 0, 6, 0, TWO_PI);
      let r = this.size / 2;
      let x = cos(angle) * r;
      let y = sin(angle) * r;
      vertex(x, y);
    }
    endShape(CLOSE);
    
    fill(255, 200, 200);
    ellipse(0, 0, this.size * 0.4, this.size * 0.4);
    
    pop();
  }
  
  hits(other) {
    let d = dist(this.x, this.y, other.x, other.y);
    return d < (this.size + other.size) / 2;
  }
  
  offScreen() {
    return this.y > height + this.size;
  }
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

calculation Sinusoidal horizontal drift this.x += this.vx + sin(this.wobble) * 2;

Adds a smooth side-to-side sway to enemy movement using sine wave, making paths look organic

for-loop Hexagon vertex generation for (let i = 0; i < 6; i++) { let angle = map(i, 0, 6, 0, TWO_PI); let r = this.size / 2; let x = cos(angle) * r; let y = sin(angle) * r; vertex(x, y); }

Creates a regular hexagon by placing 6 vertices evenly around a circle using trigonometry

this.x = random(30, width - 30);
Enemy spawns at a random x position across the canvas width (with 30-pixel margin)
this.y = -30;
Enemy starts just above the canvas (y = -30) so it scrolls down into view
this.size = random(20, 40);
Each enemy has a random size between 20 and 40 pixels, creating visual variety
this.wobble += this.wobbleSpeed;
Increments a wobble counter each frame; passed to sin() this produces a continuous oscillating value
this.x += this.vx + sin(this.wobble) * 2;
Moves enemy down and adds sine-based horizontal sway (±2 pixels), creating a wavy descent
let angle = map(i, 0, 6, 0, TWO_PI);
Maps loop counter i (0-6) to angles (0 to 2π), spacing 6 vertices evenly around a circle
let x = cos(angle) * r;
Calculates x-coordinate on a circle using cosine; r is radius
let d = dist(this.x, this.y, other.x, other.y);
Uses dist() to compute Euclidean distance between enemy center and another object (player or bullet)
return d < (this.size + other.size) / 2;
Circle collision: if distance is less than the sum of radii divided by 2, objects overlap (crude but fast)

Boss (class)

The Boss class demonstrates procedural content variation: three types with different stats, speeds, colors, and shapes. The homing behavior uses atan2() and trigonometric functions (sin/cos) to calculate pursuit vectors toward a target—a fundamental AI pattern. Type 2's evasive wobble adds unpredictability. The health bar is rendered separately below the boss (not inside the transformed coordinate system) so it always appears above and readable.

🔬 Three shapes: square (type 0), triangle (type 1), and star (type 2). The star uses alternating radii (r = size/2 or size/4). What happens if you make them all the same radius?

    if (this.type === 0) {
      rectMode(CENTER);
      rect(0, 0, this.size, this.size);
    } else if (this.type === 1) {
      triangle(-this.size/2, this.size/2, this.size/2, this.size/2, 0, -this.size/2);
    } else {
      beginShape();
      for (let i = 0; i < 8; i++) {
        let angle = map(i, 0, 8, 0, TWO_PI);
        let r = i % 2 === 0 ? this.size / 2 : this.size / 4;
        let x = cos(angle) * r;
        let y = sin(angle) * r;
        vertex(x, y);
      }
      endShape(CLOSE);
    }
class Boss {
  constructor(type) {
    this.x = random(100, width - 100);
    this.y = -100;
    this.type = type;
    this.maxHealth = 100;
    this.health = this.maxHealth;
    
    if (type === 0) {
      this.size = 80;
      this.speed = 2;
      this.color = color(150, 0, 150);
    } else if (type === 1) {
      this.size = 60;
      this.speed = 4;
      this.color = color(255, 100, 0);
    } else {
      this.size = 70;
      this.speed = 3;
      this.color = color(0, 255, 150);
    }
    
    this.angle = 0;
  }
  
  update(target) {
    let dx = target.x - this.x;
    let dy = target.y - this.y;
    let angle = atan2(dy, dx);
    
    if (this.type === 2) {
      this.x += cos(angle) * this.speed + sin(frameCount * 0.1) * 3;
      this.y += sin(angle) * this.speed + cos(frameCount * 0.1) * 3;
    } else {
      this.x += cos(angle) * this.speed;
      this.y += sin(angle) * this.speed;
    }
    
    this.angle += 0.05;
    
    this.x = constrain(this.x, this.size, width - this.size);
    this.y = constrain(this.y, this.size, height - this.size);
  }
  
  display() {
    push();
    translate(this.x, this.y);
    rotate(this.angle);
    
    fill(red(this.color), green(this.color), blue(this.color), 50);
    noStroke();
    ellipse(0, 0, this.size * 2, this.size * 2);
    
    fill(this.color);
    stroke(red(this.color) * 0.5, green(this.color) * 0.5, blue(this.color) * 0.5);
    strokeWeight(3);
    
    if (this.type === 0) {
      rectMode(CENTER);
      rect(0, 0, this.size, this.size);
    } else if (this.type === 1) {
      triangle(-this.size/2, this.size/2, this.size/2, this.size/2, 0, -this.size/2);
    } else {
      beginShape();
      for (let i = 0; i < 8; i++) {
        let angle = map(i, 0, 8, 0, TWO_PI);
        let r = i % 2 === 0 ? this.size / 2 : this.size / 4;
        let x = cos(angle) * r;
        let y = sin(angle) * r;
        vertex(x, y);
      }
      endShape(CLOSE);
    }
    
    fill(255, 0, 0);
    ellipse(0, 0, this.size * 0.3, this.size * 0.3);
    
    pop();
    
    let barWidth = this.size * 1.5;
    let barHeight = 8;
    push();
    fill(100);
    rect(this.x - barWidth/2, this.y - this.size/2 - 20, barWidth, barHeight);
    fill(255, 0, 0);
    rect(this.x - barWidth/2, this.y - this.size/2 - 20, 
         map(this.health, 0, this.maxHealth, 0, barWidth), barHeight);
    pop();
  }
  
  takeDamage(amount) {
    this.health -= amount;
  }
  
  hits(other) {
    let d = dist(this.x, this.y, other.x, other.y);
    return d < (this.size + other.size) / 2;
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional Boss type configuration if (type === 0) { this.size = 80; this.speed = 2; this.color = color(150, 0, 150); }

Three boss types have different sizes, speeds, and colors for visual variety

calculation Angle-based homing let angle = atan2(dy, dx); this.x += cos(angle) * this.speed;

Calculates angle toward target and moves in that direction using trigonometry

conditional Shape rendering by type if (this.type === 0) { rectMode(CENTER); rect(0, 0, this.size, this.size);

Each boss type draws a different shape: square, triangle, or star

if (type === 0) {
Boss type 0 is defined with size 80, speed 2, and purple color
let dx = target.x - this.x;
Calculates horizontal distance from boss to target (negative = target is left, positive = right)
let angle = atan2(dy, dx);
Computes the angle toward the target using atan2 (arctangent of y/x in radians)
this.x += cos(angle) * this.speed;
Moves toward target by stepping in the direction of angle by speed pixels; cos gives x-component
if (this.type === 2) { this.x += cos(angle) * this.speed + sin(frameCount * 0.1) * 3;
Type 2 boss adds extra sine-based motion to its pursuit for evasive, wobbly movement
this.angle += 0.05;
Increments rotation angle each frame so all bosses slowly spin, making them visually dynamic
rotate(this.angle);
Rotates the drawing by the accumulated angle before drawing the shape, creating continuous spinning
map(this.health, 0, this.maxHealth, 0, barWidth)
Scales health (0-100) to health bar width (0-barWidth), so the bar shrinks as boss takes damage

BlackHole (class)

The BlackHole class demonstrates visual effects composition and physics-based attraction. The display() uses multiple techniques: layered glows (concentric transparent circles), orbiting particles (rotate+translate in a loop), and a rotating ring. The applyGravity() method implements distance-falloff gravity: force decreases linearly as distance increases, creating the illusion of a gravity well. By applying this each frame to all objects, it creates realistic-feeling attraction without heavy physics simulation. The consumes() method detects when objects cross the event horizon.

🔬 This loop draws 5 concentric circles with increasing size and alpha. What happens if you change 0, 5 in map() to 0, 255 to invert the glow (outer darker, inner brighter)?

    // Outer glow
    for (let i = 5; i > 0; i--) {
      let alpha = map(i, 0, 5, 0, 50);
      fill(150, 0, 255, alpha);
      noStroke();
      ellipse(0, 0, this.radius * 2 * (1 + i * 0.3), this.radius * 2 * (1 + i * 0.3));
    }
class BlackHole {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.radius = 60;
    this.pullRadius = 400;
    this.pullStrength = 0.3;
    this.angle = 0;
  }
  
  update() {
    this.angle += 0.03;
  }
  
  display() {
    push();
    translate(this.x, this.y);
    
    // Outer glow
    for (let i = 5; i > 0; i--) {
      let alpha = map(i, 0, 5, 0, 50);
      fill(150, 0, 255, alpha);
      noStroke();
      ellipse(0, 0, this.radius * 2 * (1 + i * 0.3), this.radius * 2 * (1 + i * 0.3));
    }
    
    // Accretion disk
    rotate(this.angle);
    for (let i = 0; i < 8; i++) {
      let angle = map(i, 0, 8, 0, TWO_PI);
      let r = this.radius * 1.5;
      push();
      rotate(angle);
      translate(r, 0);
      fill(100, 0, 200, 150);
      ellipse(0, 0, 20, 10);
      pop();
    }
    
    // Core
    fill(0, 0, 0);
    stroke(150, 0, 255);
    strokeWeight(3);
    ellipse(0, 0, this.radius * 2, this.radius * 2);
    
    // Event horizon
    noFill();
    stroke(200, 0, 255, 100);
    strokeWeight(2);
    ellipse(0, 0, this.radius * 1.5, this.radius * 1.5);
    
    pop();
  }
  
  applyGravity(obj) {
    let dx = this.x - obj.x;
    let dy = this.y - obj.y;
    let distance = dist(this.x, this.y, obj.x, obj.y);
    
    if (distance < this.pullRadius && distance > 0) {
      let force = this.pullStrength * (1 - distance / this.pullRadius);
      let angle = atan2(dy, dx);
      
      if (obj.vx !== undefined && obj.vy !== undefined) {
        obj.vx += cos(angle) * force;
        obj.vy += sin(angle) * force;
      } else {
        obj.x += cos(angle) * force;
        obj.y += sin(angle) * force;
      }
    }
  }
  
  consumes(obj) {
    let distance = dist(this.x, this.y, obj.x, obj.y);
    return distance < this.radius;
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

for-loop Multi-layer glow effect for (let i = 5; i > 0; i--) { let alpha = map(i, 0, 5, 0, 50); fill(150, 0, 255, alpha); ellipse(0, 0, this.radius * 2 * (1 + i * 0.3), this.radius * 2 * (1 + i * 0.3)); }

Draws 5 nested circles with decreasing alpha to create a fading purple glow

for-loop Rotating accretion disk for (let i = 0; i < 8; i++) { let angle = map(i, 0, 8, 0, TWO_PI); let r = this.radius * 1.5; push(); rotate(angle); translate(r, 0); fill(100, 0, 200, 150); ellipse(0, 0, 20, 10); pop(); }

Places 8 small ellipses around the black hole and rotates them each frame to simulate orbiting matter

calculation Gravity force vector let force = this.pullStrength * (1 - distance / this.pullRadius);

Calculates pull strength as a function of distance: closer = stronger, farther = weaker

this.radius = 60;
Event horizon radius—the point of no return; objects at distance < 60 are consumed
this.pullRadius = 400;
Gravity range; objects within 400 pixels feel attraction, beyond 400 pixels are unaffected
let alpha = map(i, 0, 5, 0, 50);
Maps loop counter i (0-5) to alpha (0-50), so innermost glow is most transparent, outermost is most opaque
rotate(angle); translate(r, 0); fill(100, 0, 200, 150); ellipse(0, 0, 20, 10); pop();
Draws a small ellipse, rotates it around origin, translates it to radius distance, then restores coordinate system—creates orbiting particles
let force = this.pullStrength * (1 - distance / this.pullRadius);
Force decreases linearly with distance: at pullRadius (max distance), force = 0; at distance 0, force = pullStrength
if (obj.vx !== undefined && obj.vy !== undefined) { obj.vx += cos(angle) * force;
Checks if object has velocity properties; if so, adds gravitational acceleration to existing velocity
} else { obj.x += cos(angle) * force;
If object has no velocity (like particles), directly move it—simpler for passive objects
return distance < this.radius;
Returns true if object is inside event horizon (distance < 60), marking it for removal

Particle (class)

The Particle class is a minimal but effective particle system. Each particle stores position, velocity, size, transparency, and color. Update applies physics (velocity, gravity via vy += 0.1), decay (alpha and size shrink exponentially), and then removes it when dead. This pattern scales well: createExplosion() spawns 20 particles, and the main loop updates all of them together, creating beautiful explosion effects without much code.

class Particle {
  constructor(x, y, col) {
    this.x = x;
    this.y = y;
    this.vx = random(-3, 3);
    this.vy = random(-3, 3);
    this.alpha = 255;
    this.size = random(3, 8);
    this.color = col;
  }
  
  update() {
    this.x += this.vx;
    this.y += this.vy;
    this.vy += 0.1;
    this.alpha -= 5;
    this.size *= 0.95;
  }
  
  display() {
    noStroke();
    fill(red(this.color), green(this.color), blue(this.color), this.alpha);
    ellipse(this.x, this.y, this.size);
  }
  
  isDead() {
    return this.alpha <= 0;
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

calculation Fade and shrink this.alpha -= 5; this.size *= 0.95;

Each frame, particle becomes more transparent and shrinks, creating a fade-out effect

calculation Simple gravity this.vy += 0.1;

Adds constant downward acceleration, making particles fall realistically

this.vx = random(-3, 3);
Random horizontal velocity between -3 and 3 pixels/frame, making particles scatter
this.alpha = 255;
Starts fully opaque (alpha 255); decreases each frame until reaching 0
this.x += this.vx;
Moves particle horizontally by its velocity each frame
this.vy += 0.1;
Adds constant downward acceleration, simulating gravity
this.alpha -= 5;
Decreases alpha by 5 each frame; takes 51 frames to fade from 255 to 0
this.size *= 0.95;
Multiplies size by 0.95 each frame, shrinking it to 95% of previous size—exponential decay
fill(red(this.color), green(this.color), blue(this.color), this.alpha);
Extracts RGB channels from the particle's color and applies current alpha for fade effect
return this.alpha <= 0;
Particle is marked dead when alpha reaches 0 or below

Star (class)

The Star class creates a parallax starfield background. Each star has independent speed and brightness, so slower stars appear closer and faster stars appear farther (or vice versa depending on interpretation). The wrapping logic makes the starfield continuous—stars that exit bottom reappear at top, creating seamless motion. This is efficient: only 100 stars are created once in setup(), then updated and drawn every frame, rather than creating new ones.

class Star {
  constructor() {
    this.x = random(width);
    this.y = random(height);
    this.speed = random(1, 3);
    this.size = random(1, 3);
    this.brightness = random(150, 255);
  }
  
  update() {
    this.y += this.speed;
    if (this.y > height) {
      this.y = 0;
      this.x = random(width);
    }
  }
  
  display() {
    noStroke();
    fill(this.brightness);
    ellipse(this.x, this.y, this.size);
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Starfield wrapping if (this.y > height) { this.y = 0; this.x = random(width); }

When star exits bottom of screen, it wraps to top and a new random x position

this.speed = random(1, 3);
Each star scrolls at a different speed (1-3 pixels/frame), creating parallax depth illusion
this.brightness = random(150, 255);
Stars have varying brightness (opacity) to create depth—dim stars feel farther away
this.y += this.speed;
Star moves down each frame at its speed
if (this.y > height) { this.y = 0; this.x = random(width); }
When star scrolls off the bottom, it wraps to the top with a new random x position, creating infinite scroll

📦 Key Variables

gameMode string

Stores the current game mode: '1P', '2P-COOP', '2P-VS', 'SURVIVAL', or 'BLACK-HOLE'

let gameMode = '1P';
player1, player2 object (Player)

Reference to Player class instances; player1 always exists, player2 exists in 2-player modes

let player1, player2;
bullets array of Bullet objects

Stores all active bullets in flight; updated and drawn each frame, removed when off-screen or on collision

let bullets = [];
enemies array of Enemy objects

Stores all active enemy ships; spawned at increasing rates, removed when destroyed or off-screen

let enemies = [];
bosses array of Boss objects

Stores active boss entities; spawned every 30 seconds, removed when health reaches 0

let bosses = [];
particles array of Particle objects

Stores all floating particle effects; created by explosions, updated with gravity and fade, removed when alpha <= 0

let particles = [];
powerUps array of PowerUp objects

Stores health restoration pickups; spawned randomly from defeated enemies and bosses, removed on collection or off-screen

let powerUps = [];
health1, health2 number

Player health values (0-100); decrease on collision, increase on power-up collection

let health1 = 100;
score1, score2 number

Cumulative scores; increased by defeating enemies/bosses and collecting power-ups, synced in co-op mode

let score1 = 0;
enemySpawnRate number

Frames between each enemy spawn; decreases slightly each spawn to increase difficulty

let enemySpawnRate = 60;
keysPressed object (key → boolean map)

Records which keys are currently held down; enables continuous movement by checking this each frame

let keysPressed = {};
gameStarted, gameOver, modeSelected boolean

State flags: modeSelected = mode screen shown, gameStarted = game is running, gameOver = game ended

let gameStarted = false;
blackHole object (BlackHole)

Reference to the black hole in BLACK-HOLE mode; null in other modes

let blackHole;

🔧 Potential Improvements (6)

Here are some ways this code could be enhanced:

BUG draw() - enemy collision detection

After removing an enemy with splice(), the code checks if (i >= 0 && enemies[i]) but if multiple enemies collide bullets in the same frame, i can skip valid indices

💡 Use a more robust collision loop: collect collisions in a first pass, then remove in a second pass to avoid index shifting bugs

PERFORMANCE draw() - nested collision loops

O(n²) or worse complexity: enemies loop vs bullets loop. With 50 enemies and 30 bullets, this is 1500 comparisons per frame

💡 Implement spatial hashing or quadtree to only check nearby objects; alternatively, use enemies' built-in hits() method more efficiently

STYLE keysPressed object usage

keysPressed stores lowercase keys but some checks use exact case (space key uses ' ' not toLowerCase). Inconsistent

💡 Consistently lowercase all keys before storing and retrieving; handle special keys (space, enter) separately with clear naming

BUG Player.update() - velocity application

Player velocity is set each frame based on current keysPressed but never decays. Multiple keys pressed simultaneously add correctly, but releasing all keys should stop movement—it does, but only because moveX and moveY reset to 0

💡 Consider implementing acceleration/deceleration for smoother physics, or add comments clarifying that velocity resets are intentional

FEATURE Bullet class

Bullets always move upward (vy = -12). In a rotating game, players might want angled shots or bullet-by-bullet direction tracking

💡 Store bullet direction as an angle; let Player pass direction when creating bullets for more flexible shooting

PERFORMANCE Star class - starfield wrapping

Creating new random x each frame a star wraps is cheap but could batch this into a 'Star pool' pattern for extreme numbers

💡 For 100 stars it's fine; if expanding to 1000+, consider pre-allocating a pool and resetting positions rather than creating new objects

🔄 Code Flow

Code flow showing setup, draw, keypressed, keyreleased, shoot, playsound, createexplosion, checkgameover, drawui, player, enemy, boss, blackhole, particle, star

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

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> game-state-screen-selection[Game State Screen Selection] game-state-screen-selection -->|No mode selected| drawui[drawUI] game-state-screen-selection -->|Mode selected| difficulty-scaling[Difficulty Scaling] difficulty-scaling --> backward-loop-removal[Backward Loop Removal] backward-loop-removal --> input-polling-loop[Input Polling Loop] input-polling-loop --> key-state-storage[Key State Storage] key-state-storage --> p1-shoot-guard[P1 Shoot Guard] p1-shoot-guard -->|Can shoot| shoot[shoot] shoot --> playsound[playSound] playsound --> drawui drawui --> particle-loop[Particle Loop] particle-loop --> createexplosion[createExplosion] createexplosion --> particle particle --> drawui drawui --> checkgameover[checkGameOver] checkgameover -->|Game Over| drawui checkgameover -->|Not Over| draw draw --> boss-target-selection[Boss Target Selection] boss-target-selection -->|2-player mode| target-pursuit[Target Pursuit] target-pursuit --> boss[boss] boss --> drawui draw --> starfield-loop[Starfield Loop] starfield-loop --> star[star] star --> drawui drawui --> blackhole[blackhole] blackhole --> drawui drawui -->|End of Frame| draw click setup href "#fn-setup" click draw href "#fn-draw" click game-state-screen-selection href "#sub-game-state-screen-selection" click difficulty-scaling href "#sub-difficulty-scaling" click backward-loop-removal href "#sub-backward-loop-removal" click input-polling-loop href "#sub-input-polling-loop" click key-state-storage href "#sub-key-state-storage" click p1-shoot-guard href "#sub-p1-shoot-guard" click shoot href "#fn-shoot" click playsound href "#fn-playsound" click particle-loop href "#sub-particle-loop" click createexplosion href "#fn-createexplosion" click checkgameover href "#fn-checkgameover" click boss-target-selection href "#sub-boss-target-selection" click target-pursuit href "#sub-target-pursuit" click starfield-loop href "#sub-starfield-loop" click star href "#fn-star" click blackhole href "#fn-blackhole"

Preview

cosmic defender - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of cosmic defender - Code flow showing setup, draw, keypressed, keyreleased, shoot, playsound, createexplosion, checkgameover, drawui, player, enemy, boss, blackhole, particle, star
Code Flow Diagram