orb-wars

Orb Run is an arcade-style dodge-and-collect game where players control a glowing orb with their mouse, avoiding enemy orbs that chase them while collecting collectible orbs to increase their score. The game features dynamic difficulty that increases over time, ambient audio feedback, and a starfield background.

๐ŸŽ“ Concepts You'll Learn

Game state managementObject-oriented design with classesCollision detectionAudio synthesis with p5.soundAnimation and visual effectsMouse input handlingDifficulty scalingParticle trailsColor gradients and lerping

๐Ÿ”„ Code Flow

Code flow showing setup, draw, initgame, updategame, drawgame, drawhud, drawmenu, drawgameover, backgroundgradient, setupaudio, startaudioifneeded, startambient, updateambient, playbeep, playstartgamesound, playcollectsound, playenemyspawnsound, playgameoversound, endgame, mousepressed, keypressed, windowresized

๐Ÿ’ก Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> gamestate[gamerstate-conditional] gamestate -->|menu| drawmenu[drawmenu] gamestate -->|playing| updategame[updategame] gamestate -->|gameover| drawgameover[drawgameover] draw --> starfield[starfield-loop] draw --> drawgame[drawgame] draw --> drawhud[drawhud] updategame --> difficulty[difficulty-scaling] updategame --> enemycheck[enemy-spawn-check] updategame --> orbcheck[orb-spawn-check] updategame --> enemyloop[enemy-update-loop] updategame --> orbloop[orb-collection-loop] drawgame --> orbdraw[orb-draw-loop] drawgame --> enemydraw[enemy-draw-loop] drawhud --> scoreformat[score-formatting] drawhud --> hudbg[HUD Background Box] drawmenu --> instruction[instruction-loop] drawmenu --> blinking[blinking-text] drawgameover --> overlay[Game Over Overlay] click setup href "#fn-setup" click draw href "#fn-draw" click gamestate href "#sub-gamestate-conditional" click drawmenu href "#fn-drawmenu" click updategame href "#fn-updategame" click drawgameover href "#fn-drawgameover" click starfield href "#sub-starfield-loop" click drawgame href "#fn-drawgame" click drawhud href "#fn-drawhud" click difficulty href "#sub-difficulty-scaling" click enemycheck href "#sub-enemy-spawn-check" click orbcheck href "#sub-orb-spawn-check" click enemyloop href "#sub-enemy-update-loop" click orbloop href "#sub-orb-collection-loop" click orbdraw href "#sub-orb-draw-loop" click enemydraw href "#sub-enemy-draw-loop" click scoreformat href "#sub-score-formatting" click hudbg href "#sub-hud-background" click instruction href "#sub-instruction-loop" click blinking href "#sub-blinking-text" click overlay href "#sub-game-over-overlay"

๐Ÿ“ Code Breakdown

setup()

setup() runs once when the sketch starts. It's the perfect place to initialize your canvas, load resources, and set up initial game state.

function setup() {
  createCanvas(windowWidth, windowHeight);
  setupAudio();
  initGame();
}

Line by Line:

createCanvas(windowWidth, windowHeight)
Creates a canvas that fills the entire browser window, making the game responsive to different screen sizes
setupAudio()
Initializes the audio system by creating oscillators for ambient background sound
initGame()
Initializes all game objects (player, enemies, orbs) and resets game variables to start state

draw()

draw() runs 60 times per second (default frame rate). It's the main animation loop where you update positions and redraw everything. Using game states (menu, playing, gameover) is a clean way to manage different game modes.

function draw() {
  backgroundGradient();

  // Draw and update starfield
  for (let star of bgStars) {
    star.update();
    star.draw();
  }

  if (gameState === 'menu') {
    drawMenu();
  } else if (gameState === 'playing') {
    updateGame();
    drawGame();
  } else if (gameState === 'gameover') {
    drawGame(); // shows frozen last state
    drawGameOver();
  }

  updateAmbient();
}

๐Ÿ”ง Subcomponents:

for-loop Starfield Update Loop for (let star of bgStars) { star.update(); star.draw(); }

Updates and draws each star in the background, creating a scrolling starfield effect

switch-case Game State Manager if (gameState === 'menu') { ... } else if (gameState === 'playing') { ... } else if (gameState === 'gameover') { ... }

Routes execution to the correct game mode based on current game state

Line by Line:

backgroundGradient()
Draws a smooth color gradient background that changes based on difficulty level
for (let star of bgStars) { star.update(); star.draw(); }
Loops through all stars, updating their positions and drawing them each frame
if (gameState === 'menu')
Checks if the game is in menu state and displays the menu screen
else if (gameState === 'playing')
Checks if the game is actively playing, then updates game logic and draws all game objects
else if (gameState === 'gameover')
Checks if the game has ended, shows the frozen last game state with game over screen overlay
updateAmbient()
Updates the ambient background sound frequencies based on current difficulty level

initGame()

initGame() is called when the game starts and when the player restarts after game over. It resets all game variables and creates fresh game objects. This is a common pattern for resettable games.

function initGame() {
  player = new Player();
  enemies = [];
  orbs = [];
  score = 0;
  difficultyLevel = 1;
  startTime = millis();
  lastEnemySpawn = frameCount;
  lastOrbSpawn = frameCount;

  // Create background stars
  bgStars = [];
  const numStars = floor((width * height) / 15000);
  for (let i = 0; i < numStars; i++) {
    bgStars.push(new Star());
  }
}

๐Ÿ”ง Subcomponents:

for-loop Star Generation Loop for (let i = 0; i < numStars; i++) { bgStars.push(new Star()); }

Creates stars proportional to canvas size (one star per 15,000 pixels) for the starfield background

Line by Line:

player = new Player()
Creates a new Player object at the center of the screen
enemies = []
Resets the enemies array to empty, clearing any previous enemies
orbs = []
Resets the orbs array to empty, clearing any previously collected orbs
score = 0
Resets the player's score to zero for a new game
difficultyLevel = 1
Resets difficulty to 1 (easiest), which will increase over time during gameplay
startTime = millis()
Records the current time in milliseconds to track how long the player has survived
const numStars = floor((width * height) / 15000)
Calculates how many stars to create based on canvas size (approximately 1 star per 15,000 pixels)
bgStars.push(new Star())
Creates a new Star object and adds it to the bgStars array

updateGame()

updateGame() is the core game logic function. It handles difficulty scaling, spawning enemies and orbs, updating all game objects, and checking for collisions. Notice it loops backwards through arrays (i--) when removing items - this prevents skipping elements when you splice.

function updateGame() {
  const elapsedSec = (millis() - startTime) / 1000.0;
  difficultyLevel = 1 + elapsedSec / 15.0; // gets harder over time

  player.update();

  // Spawn enemies
  if (
    frameCount - lastEnemySpawn >
    max(10, ENEMY_SPAWN_INTERVAL / difficultyLevel)
  ) {
    enemies.push(new Enemy());
    lastEnemySpawn = frameCount;
    playEnemySpawnSound();
  }

  // Spawn orbs
  if (
    frameCount - lastOrbSpawn >
    max(15, ORB_SPAWN_INTERVAL / (0.5 + difficultyLevel * 0.3))
  ) {
    orbs.push(new OrbCollectible());
    lastOrbSpawn = frameCount;
  }

  // Update enemies and check collisions
  for (let i = enemies.length - 1; i >= 0; i--) {
    const e = enemies[i];
    e.update();

    if (e.hitsPlayer()) {
      endGame();
      return;
    }

    // Remove if way off-screen
    if (
      e.x < -200 ||
      e.x > width + 200 ||
      e.y < -200 ||
      e.y > height + 200
    ) {
      enemies.splice(i, 1);
    }
  }

  // Update orbs and check collection
  for (let i = orbs.length - 1; i >= 0; i--) {
    const o = orbs[i];
    o.update();
    if (o.collectedBy(player)) {
      score += 10 + floor(difficultyLevel * 2);
      playCollectSound();
      orbs.splice(i, 1);
    }
  }

  // Small passive score over time
  score += 0.02 * difficultyLevel;
}

๐Ÿ”ง Subcomponents:

calculation Difficulty Progression difficultyLevel = 1 + elapsedSec / 15.0

Increases difficulty by 1 point every 15 seconds of gameplay

conditional Enemy Spawn Logic if (frameCount - lastEnemySpawn > max(10, ENEMY_SPAWN_INTERVAL / difficultyLevel))

Spawns enemies at intervals that get shorter as difficulty increases

conditional Orb Spawn Logic if (frameCount - lastOrbSpawn > max(15, ORB_SPAWN_INTERVAL / (0.5 + difficultyLevel * 0.3)))

Spawns collectible orbs at intervals that increase with difficulty

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

Updates each enemy, checks collision with player, and removes off-screen enemies

for-loop Orb Collection Loop for (let i = orbs.length - 1; i >= 0; i--)

Updates each orb and checks if the player collected it

Line by Line:

const elapsedSec = (millis() - startTime) / 1000.0
Calculates how many seconds have passed since the game started by subtracting start time from current time and dividing by 1000
difficultyLevel = 1 + elapsedSec / 15.0
Increases difficulty over time - after 15 seconds difficulty is 2, after 30 seconds it's 3, etc.
player.update()
Updates the player's position based on mouse movement
if (frameCount - lastEnemySpawn > max(10, ENEMY_SPAWN_INTERVAL / difficultyLevel))
Checks if enough frames have passed to spawn a new enemy. Higher difficulty means shorter wait time (faster spawning)
enemies.push(new Enemy())
Creates a new Enemy object and adds it to the enemies array
if (e.hitsPlayer())
Checks if the current enemy is touching the player, which ends the game
if (e.x < -200 || e.x > width + 200 || e.y < -200 || e.y > height + 200)
Removes enemies that have traveled far off-screen to save memory
if (o.collectedBy(player))
Checks if the player is touching the orb
score += 10 + floor(difficultyLevel * 2)
Awards points for collecting an orb - more points at higher difficulty levels
score += 0.02 * difficultyLevel
Adds a small amount of passive score each frame based on difficulty, rewarding survival

drawGame()

drawGame() handles all the visual rendering during gameplay. It draws game objects in order: orbs first (background), then enemies, then player (foreground). The HUD is drawn last so it appears on top. This layering order is important for visual clarity.

function drawGame() {
  // Draw collectibles
  for (let o of orbs) {
    o.draw();
  }

  // Draw enemies
  for (let e of enemies) {
    e.draw();
  }

  // Draw player
  player.draw();

  drawHUD();
}

๐Ÿ”ง Subcomponents:

for-loop Orb Drawing Loop for (let o of orbs) { o.draw(); }

Draws all collectible orbs on screen

for-loop Enemy Drawing Loop for (let e of enemies) { e.draw(); }

Draws all enemy orbs on screen

Line by Line:

for (let o of orbs) { o.draw(); }
Loops through each collectible orb and calls its draw() method to render it
for (let e of enemies) { e.draw(); }
Loops through each enemy and calls its draw() method to render it
player.draw()
Draws the player orb with its trail and glow effects
drawHUD()
Draws the heads-up display showing score, level, and time

drawHUD()

drawHUD() renders the user interface showing game statistics. It uses nf() to format numbers with decimal places, and creates a visually appealing box with a colored accent bar. The conditional check for timeText shows how to display different information based on game state.

function drawHUD() {
  const padding = 16;

  textFont('system-ui');
  textAlign(LEFT, TOP);
  noStroke();

  // Score box
  const scoreText = 'Score: ' + nf(score, 1, 1);
  const levelText = 'Lv ' + nf(difficultyLevel, 1, 1);
  const timeText =
    gameState === 'playing'
      ? 'Time: ' + nf((millis() - startTime) / 1000.0, 1, 1) + 's'
      : '';

  const boxWidth = 220;
  const boxHeight = 70;

  // Bg box
  fill(0, 0, 0, 140);
  rect(padding, padding, boxWidth, boxHeight, 8);

  // Accent bar
  fill(255, 80, 160, 200);
  rect(padding, padding, 5, boxHeight, 8, 0, 0, 8);

  // Text
  fill(235);
  textSize(16);
  text(scoreText, padding + 14, padding + 8);
  fill(170, 210, 255);
  textSize(14);
  text(levelText, padding + 14, padding + 30);
  if (timeText) {
    fill(200);
    text(timeText, padding + 14, padding + 48);
  }

  // Best score top-right
  const bText = 'Best: ' + nf(bestScore, 1, 1);
  textAlign(RIGHT, TOP);
  fill(255, 215, 0, 210);
  textSize(16);
  text(bText, width - padding, padding);
}

๐Ÿ”ง Subcomponents:

calculation Score Text Formatting const scoreText = 'Score: ' + nf(score, 1, 1)

Formats the score with 1 digit before and 1 digit after decimal point

calculation HUD Background Box fill(0, 0, 0, 140); rect(padding, padding, boxWidth, boxHeight, 8)

Draws a semi-transparent dark box with rounded corners to hold score information

Line by Line:

const padding = 16
Sets the spacing from the edge of the screen for UI elements
textFont('system-ui')
Sets the font to the system default for consistent rendering across browsers
textAlign(LEFT, TOP)
Aligns text to the left horizontally and top vertically
const scoreText = 'Score: ' + nf(score, 1, 1)
Creates score text with nf() formatting to show 1 decimal place
const timeText = gameState === 'playing' ? 'Time: ' + ... : ''
Only shows elapsed time if game is actively playing (not on menu or game over)
fill(0, 0, 0, 140); rect(padding, padding, boxWidth, boxHeight, 8)
Draws a semi-transparent black box with 8-pixel rounded corners as the HUD background
fill(255, 80, 160, 200); rect(padding, padding, 5, boxHeight, 8, 0, 0, 8)
Draws a pink accent bar on the left side of the HUD box for visual emphasis
textAlign(RIGHT, TOP)
Changes text alignment to right for the best score display in the top-right corner

drawMenu()

drawMenu() creates the main menu screen with title, instructions, and a pulsing start button. The blinking effect uses sin(frameCount) to create smooth animation. The push/pop pattern is important for managing transforms - it saves the current drawing state, applies transforms, draws, then restores the original state.

function drawMenu() {
  textAlign(CENTER, CENTER);
  fill(255);
  textFont('system-ui');

  // Title
  textSize(48);
  fill(255, 120, 200);
  text('ORB RUN', width / 2, height * 0.3);

  // Subtitle
  textSize(20);
  fill(210);
  text('Dodge the hunters. Collect the light.', width / 2, height * 0.38);

  // Instructions
  fill(220);
  textSize(16);
  const lines = [
    'Move the mouse to control your orb.',
    'Avoid enemies that home in on you.',
    'Collect glowing orbs to boost your score.',
    'Survive as long as you can as difficulty increases.'
  ];

  let y = height * 0.48;
  for (let line of lines) {
    text(line, width / 2, y);
    y += 24;
  }

  // Start hint
  textSize(18);
  fill(255, 200, 255);
  const blink = sin(frameCount * 0.1) * 0.5 + 0.5;
  push();
  translate(width / 2, height * 0.7);
  scale(1 + blink * 0.05);
  text('Click or press any key to begin', 0, 0);
  pop();

  // Best score display
  if (bestScore > 0) {
    textSize(16);
    fill(255, 215, 0);
    text('Best score: ' + nf(bestScore, 1, 1), width / 2, height * 0.78);
  }
}

๐Ÿ”ง Subcomponents:

for-loop Instruction Lines Loop for (let line of lines) { text(line, width / 2, y); y += 24; }

Draws each instruction line vertically spaced 24 pixels apart

calculation Blinking Start Button const blink = sin(frameCount * 0.1) * 0.5 + 0.5

Creates a smooth pulsing effect using sine wave to scale the start text

Line by Line:

textAlign(CENTER, CENTER)
Centers text both horizontally and vertically
textSize(48); fill(255, 120, 200); text('ORB RUN', width / 2, height * 0.3)
Draws the large pink title 'ORB RUN' at 30% down the screen
const lines = [...]
Creates an array of instruction strings to display to the player
for (let line of lines) { text(line, width / 2, y); y += 24; }
Loops through each instruction and draws it, incrementing y by 24 pixels for spacing
const blink = sin(frameCount * 0.1) * 0.5 + 0.5
Uses sine wave to create a value that oscillates between 0 and 1, creating a pulsing effect
push(); translate(width / 2, height * 0.7); scale(1 + blink * 0.05); text(...); pop()
Uses push/pop to save and restore transform state, applies translation and scaling to make the start text pulse
if (bestScore > 0) { ... }
Only displays best score if it's greater than 0 (player has completed at least one game)

drawGameOver()

drawGameOver() displays the game over screen with a dark overlay and game statistics. The overlay is drawn first so it appears behind the text. This function is called after the frozen game state is drawn, creating a layered effect.

function drawGameOver() {
  // Semi-transparent overlay
  fill(0, 0, 0, 180);
  rect(0, 0, width, height);

  textAlign(CENTER, CENTER);
  textFont('system-ui');

  textSize(40);
  fill(255, 120, 160);
  text('GAME OVER', width / 2, height * 0.35);

  textSize(20);
  fill(230);
  text('Score: ' + nf(score, 1, 1), width / 2, height * 0.45);
  fill(255, 215, 0);
  text('Best: ' + nf(bestScore, 1, 1), width / 2, height * 0.5);

  textSize(16);
  fill(220);
  text('Click or press any key to try again', width / 2, height * 0.6);
}

Line by Line:

fill(0, 0, 0, 180); rect(0, 0, width, height)
Draws a semi-transparent black overlay covering the entire screen, darkening the game state behind it
textSize(40); fill(255, 120, 160); text('GAME OVER', width / 2, height * 0.35)
Displays the large pink 'GAME OVER' text at 35% down the screen
text('Score: ' + nf(score, 1, 1), width / 2, height * 0.45)
Shows the player's final score with 1 decimal place formatting
text('Best: ' + nf(bestScore, 1, 1), width / 2, height * 0.5)
Shows the best score achieved across all games in gold color
text('Click or press any key to try again', width / 2, height * 0.6)
Displays instructions for restarting the game

backgroundGradient()

backgroundGradient() creates a smooth color gradient by drawing many thin horizontal strips with gradually changing colors. It uses lerpColor() to smoothly transition between colors, and the gradient changes based on difficulty level - adding more red/purple tones as the game gets harder. This is a common technique for creating smooth gradients in p5.js.

function backgroundGradient() {
  noFill();
  const steps = 40;
  for (let i = 0; i < steps; i++) {
    const t = i / (steps - 1);
    const y = lerp(0, height, t);
    const h = height / steps + 2;

    const base = map(difficultyLevel, 1, 10, 0, 1, true);
    // Top color
    const c1 = lerpColor(color(8, 12, 32), color(50, 0, 60), base);
    // Bottom color
    const c2 = lerpColor(color(10, 5, 25), color(10, 1, 30), base);
    const c = lerpColor(c1, c2, t);
    c.setAlpha(255);
    fill(c);
    rect(0, y, width + 1, h);
  }
}

๐Ÿ”ง Subcomponents:

for-loop Gradient Strip Loop for (let i = 0; i < steps; i++) { ... }

Creates 40 horizontal strips with gradually changing colors to form a smooth gradient

calculation Difficulty-Based Color Shift const base = map(difficultyLevel, 1, 10, 0, 1, true)

Maps difficulty level to a 0-1 value that controls how much the colors shift toward red/purple

Line by Line:

const steps = 40
Divides the background into 40 horizontal strips for smooth gradient effect
const t = i / (steps - 1)
Creates a value from 0 to 1 representing position through the gradient
const y = lerp(0, height, t)
Calculates the y position of this strip by interpolating from 0 to screen height
const base = map(difficultyLevel, 1, 10, 0, 1, true)
Maps difficulty (1-10) to a 0-1 range that controls color shift intensity
const c1 = lerpColor(color(8, 12, 32), color(50, 0, 60), base)
Interpolates the top color between dark blue and dark purple based on difficulty
const c2 = lerpColor(color(10, 5, 25), color(10, 1, 30), base)
Interpolates the bottom color between two dark shades based on difficulty
const c = lerpColor(c1, c2, t)
Interpolates between top and bottom colors for this strip's position
rect(0, y, width + 1, h)
Draws a horizontal rectangle strip with the calculated color

setupAudio()

setupAudio() initializes the p5.sound library oscillators for ambient background sound. Oscillators are started silently (amp = 0) and will be faded in when the user first interacts with the page. Using two oscillators at different frequencies creates a richer, more interesting ambient sound than a single tone.

function setupAudio() {
  // Soft background space hum
  ambientOsc1 = new p5.Oscillator('sine');
  ambientOsc1.freq(80);
  ambientOsc1.amp(0);
  ambientOsc1.start();

  ambientOsc2 = new p5.Oscillator('triangle');
  ambientOsc2.freq(160);
  ambientOsc2.amp(0);
  ambientOsc2.start();
}

Line by Line:

ambientOsc1 = new p5.Oscillator('sine')
Creates a sine wave oscillator for the first ambient sound layer
ambientOsc1.freq(80)
Sets the frequency to 80 Hz, a low rumbling tone
ambientOsc1.amp(0)
Sets amplitude to 0 (silent) initially - will fade in when game starts
ambientOsc1.start()
Starts the oscillator running (but silently since amplitude is 0)
ambientOsc2 = new p5.Oscillator('triangle')
Creates a triangle wave oscillator for the second ambient sound layer with a different tone quality
ambientOsc2.freq(160)
Sets the frequency to 160 Hz, exactly one octave higher than the first oscillator

startAudioIfNeeded()

startAudioIfNeeded() handles the browser's audio context requirement. Modern browsers require user interaction before playing sound. This function is called on the first mouse click or key press, unlocking audio for the rest of the game.

function startAudioIfNeeded() {
  if (!audioStarted) {
    userStartAudio(); // unlock audio context
    audioStarted = true;
    startAmbient();
  }
}

Line by Line:

if (!audioStarted)
Checks if audio has already been started to avoid starting it multiple times
userStartAudio()
p5.sound function that unlocks the browser's audio context (required by modern browsers)
audioStarted = true
Sets the flag to true so this function won't try to start audio again
startAmbient()
Calls the function that fades in the ambient background sound

startAmbient()

startAmbient() fades in the ambient background sound over 3 seconds. The second parameter to amp() is the time in seconds to fade. This creates a smooth, non-jarring entrance of the background sound.

function startAmbient() {
  if (!ambientOsc1 || !ambientOsc2) return;
  // Fade in quiet ambient hum
  ambientOsc1.amp(0.03, 3.0);
  ambientOsc2.amp(0.02, 3.0);
}

Line by Line:

if (!ambientOsc1 || !ambientOsc2) return
Safety check to ensure oscillators exist before trying to use them
ambientOsc1.amp(0.03, 3.0)
Fades the first oscillator to amplitude 0.03 over 3 seconds (creates smooth fade-in)
ambientOsc2.amp(0.02, 3.0)
Fades the second oscillator to amplitude 0.02 over 3 seconds (slightly quieter)

updateAmbient()

updateAmbient() is called every frame to adjust the ambient sound pitch based on difficulty level. As the game gets harder, the background sound gets higher in pitch, creating audio feedback that reinforces the increasing tension.

function updateAmbient() {
  if (!ambientOsc1 || !ambientOsc2 || !audioStarted) return;
  const base1 = 60 + difficultyLevel * 3;
  const base2 = 90 + difficultyLevel * 4;
  ambientOsc1.freq(base1);
  ambientOsc2.freq(base2);
}

Line by Line:

if (!ambientOsc1 || !ambientOsc2 || !audioStarted) return
Safety check to ensure audio is initialized and started before updating
const base1 = 60 + difficultyLevel * 3
Calculates frequency for first oscillator: starts at 60 Hz, increases by 3 Hz per difficulty level
const base2 = 90 + difficultyLevel * 4
Calculates frequency for second oscillator: starts at 90 Hz, increases by 4 Hz per difficulty level
ambientOsc1.freq(base1); ambientOsc2.freq(base2)
Updates both oscillator frequencies to create a rising pitch as difficulty increases

playBeep()

playBeep() is a flexible sound effect generator using ADSR envelopes. ADSR stands for Attack, Decay, Sustain, Release - the four stages of how a sound evolves. By passing different parameters, you can create different sound effects (beeps, whooshes, thumps). The pitch slide feature creates expressive sounds that change frequency over time.

function playBeep({ type = 'sine', startFreq = 440, endFreq = 440, attack = 0.01, decay = 0.1, sustain = 0.0, release = 0.1, amp = 0.4, sustainTime = 0.05 } = {}) {
  if (!audioStarted) return;
  const osc = new p5.Oscillator(type);
  osc.start();
  osc.freq(startFreq);
  osc.amp(0);

  const env = new p5.Envelope();
  env.setADSR(attack, decay, sustain, release);
  env.setRange(amp, 0);
  env.play(osc, 0, sustainTime);

  // Slide pitch
  if (startFreq !== endFreq) {
    osc.freq(endFreq, attack + decay + sustainTime + release);
  }

  // Stop after envelope
  const totalTime = attack + decay + sustainTime + release + 0.05;
  osc.stop(totalTime);
}

๐Ÿ”ง Subcomponents:

calculation ADSR Envelope Configuration env.setADSR(attack, decay, sustain, release); env.setRange(amp, 0)

Configures the amplitude envelope (Attack, Decay, Sustain, Release) for the sound

conditional Pitch Slide Effect if (startFreq !== endFreq) { osc.freq(endFreq, attack + decay + sustainTime + release) }

Creates a pitch slide effect if start and end frequencies are different

Line by Line:

function playBeep({ type = 'sine', startFreq = 440, ... } = {})
Uses destructuring with default parameters to accept an object with sound configuration options
if (!audioStarted) return
Safety check to prevent playing sounds before audio is unlocked
const osc = new p5.Oscillator(type)
Creates a new oscillator with the specified waveform type (sine, triangle, square, sawtooth)
osc.start(); osc.freq(startFreq); osc.amp(0)
Starts the oscillator, sets its initial frequency, and sets amplitude to 0 (silent until envelope plays)
const env = new p5.Envelope()
Creates an envelope object to control how the sound's volume changes over time
env.setADSR(attack, decay, sustain, release)
Configures the four stages of the envelope: Attack (fade in), Decay (drop), Sustain (hold), Release (fade out)
env.setRange(amp, 0)
Sets the envelope to go from amplitude 'amp' down to 0, creating a fade-out
env.play(osc, 0, sustainTime)
Plays the envelope on the oscillator, sustaining for 'sustainTime' seconds
osc.freq(endFreq, attack + decay + sustainTime + release)
Slides the pitch from startFreq to endFreq over the total duration of the sound
osc.stop(totalTime)
Stops the oscillator after the total duration to clean up resources

playStartGameSound()

playStartGameSound() creates a bright, rising 'whoosh' sound effect when the game starts. The sawtooth waveform and upward pitch slide give it an energetic, exciting quality.

function playStartGameSound() {
  // Bright rising whoosh
  playBeep({
    type: 'sawtooth',
    startFreq: 300,
    endFreq: 900,
    attack: 0.01,
    decay: 0.15,
    sustain: 0,
    release: 0.2,
    amp: 0.5,
    sustainTime: 0.1
  });
}

Line by Line:

type: 'sawtooth'
Uses a sawtooth waveform which has a bright, harsh quality suitable for a whoosh sound
startFreq: 300, endFreq: 900
Slides the pitch upward from 300 Hz to 900 Hz, creating a rising effect
attack: 0.01, decay: 0.15
Quick attack (10ms) followed by a 150ms decay creates a punchy sound
amp: 0.5, sustainTime: 0.1
Medium volume at 0.5 amplitude, sustains for 100ms before fading out

playCollectSound()

playCollectSound() creates a bright 'bling' sound when the player collects an orb. The frequency increases with difficulty level, so at higher difficulties the sound is higher-pitched, giving audio feedback about the game's intensity.

function playCollectSound() {
  // Fast, bright bling
  playBeep({
    type: 'triangle',
    startFreq: 700 + difficultyLevel * 30,
    endFreq: 1100 + difficultyLevel * 40,
    attack: 0.005,
    decay: 0.08,
    sustain: 0,
    release: 0.1,
    amp: 0.4,
    sustainTime: 0.02
  });
}

Line by Line:

type: 'triangle'
Uses a triangle waveform which is brighter than sine but smoother than sawtooth
startFreq: 700 + difficultyLevel * 30
Starting frequency increases with difficulty, making the sound higher-pitched as game gets harder
attack: 0.005, decay: 0.08
Very quick attack (5ms) and fast decay (80ms) creates a quick 'bling' effect
sustainTime: 0.02
Very short sustain time (20ms) makes the sound quick and punchy

playEnemySpawnSound()

playEnemySpawnSound() creates a low, falling 'thump' sound when enemies spawn. The downward pitch slide and square waveform give it a warning quality that alerts the player to danger.

function playEnemySpawnSound() {
  // Low thump / warning
  playBeep({
    type: 'square',
    startFreq: 180,
    endFreq: 90,
    attack: 0.005,
    decay: 0.15,
    sustain: 0,
    release: 0.15,
    amp: 0.35,
    sustainTime: 0.02
  });
}

Line by Line:

type: 'square'
Uses a square waveform which has a harsh, buzzy quality suitable for a warning sound
startFreq: 180, endFreq: 90
Slides the pitch downward from 180 Hz to 90 Hz, creating a falling warning effect
decay: 0.15, release: 0.15
Balanced decay and release create a thump sound that fades naturally
amp: 0.35
Slightly quieter than other sounds to avoid being too jarring

playGameOverSound()

playGameOverSound() creates a sad, falling tone when the game ends. The long decay and release times, combined with the dramatic downward pitch slide, create a sense of defeat and finality.

function playGameOverSound() {
  // Falling tone
  playBeep({
    type: 'sine',
    startFreq: 500,
    endFreq: 120,
    attack: 0.01,
    decay: 0.4,
    sustain: 0,
    release: 0.5,
    amp: 0.6,
    sustainTime: 0.1
  });
}

Line by Line:

type: 'sine'
Uses a smooth sine wave for a sad, mournful quality
startFreq: 500, endFreq: 120
Slides the pitch down dramatically from 500 Hz to 120 Hz, creating a falling, defeated effect
decay: 0.4, release: 0.5
Long decay and release times create a slow, lingering fade-out
sustainTime: 0.1
Sustains for 100ms before beginning the long fade-out
amp: 0.6
Louder than other sounds to make the game over moment impactful

endGame()

endGame() is called when the player collides with an enemy. It stops the game, updates the best score if needed, and plays the game over sound.

function endGame() {
  gameState = 'gameover';
  if (score > bestScore) {
    bestScore = score;
  }
  playGameOverSound();
}

Line by Line:

gameState = 'gameover'
Changes the game state to 'gameover', which stops the game loop and shows the game over screen
if (score > bestScore) { bestScore = score }
Updates the best score if the current score is higher
playGameOverSound()
Plays the game over sound effect

mousePressed()

mousePressed() is a p5.js built-in function that runs whenever the mouse is clicked. It handles starting the game from the menu or restarting after game over.

function mousePressed() {
  startAudioIfNeeded();

  if (gameState === 'menu' || gameState === 'gameover') {
    initGame();
    gameState = 'playing';
    playStartGameSound();
  }
}

Line by Line:

startAudioIfNeeded()
Unlocks the browser's audio context on first user interaction
if (gameState === 'menu' || gameState === 'gameover')
Checks if the game is on the menu or game over screen
initGame()
Resets all game objects and variables for a fresh game
gameState = 'playing'
Changes the game state to 'playing' to start the game loop
playStartGameSound()
Plays the start game sound effect

keyPressed()

keyPressed() is a p5.js built-in function that runs whenever any key is pressed. It provides an alternative way to start the game besides clicking the mouse.

function keyPressed() {
  startAudioIfNeeded();

  if (gameState === 'menu' || gameState === 'gameover') {
    initGame();
    gameState = 'playing';
    playStartGameSound();
  }
}

Line by Line:

startAudioIfNeeded()
Unlocks the browser's audio context on first user interaction
if (gameState === 'menu' || gameState === 'gameover')
Checks if the game is on the menu or game over screen
initGame(); gameState = 'playing'; playStartGameSound()
Resets the game, starts playing, and plays the start sound

windowResized()

windowResized() is a p5.js built-in function that runs whenever the browser window is resized. It resizes the canvas and regenerates the starfield to match the new dimensions, keeping the game responsive.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  // Rebuild stars to fit new size
  bgStars = [];
  const numStars = floor((width * height) / 15000);
  for (let i = 0; i < numStars; i++) {
    bgStars.push(new Star());
  }
}

๐Ÿ”ง Subcomponents:

for-loop Star Regeneration Loop for (let i = 0; i < numStars; i++) { bgStars.push(new Star()); }

Recreates stars proportional to the new canvas size

Line by Line:

resizeCanvas(windowWidth, windowHeight)
Resizes the p5.js canvas to match the new window dimensions
bgStars = []
Clears the old stars array
const numStars = floor((width * height) / 15000)
Recalculates the number of stars needed for the new canvas size
for (let i = 0; i < numStars; i++) { bgStars.push(new Star()) }
Creates new stars to fill the resized canvas

๐Ÿ“ฆ Key Variables

gameState string

Tracks the current game mode: 'menu' (start screen), 'playing' (active game), or 'gameover' (end screen)

let gameState = 'menu';
player object

Stores the Player object containing position, radius, and trail data

let player;
enemies array

Array of Enemy objects currently on screen that chase the player

let enemies = [];
orbs array

Array of OrbCollectible objects that the player can collect for points

let orbs = [];
score number

The player's current score in the active game

let score = 0;
bestScore number

The highest score achieved across all games played

let bestScore = 0;
startTime number

Stores the millisecond timestamp when the current game started, used to calculate elapsed time and difficulty

let startTime;
difficultyLevel number

The current difficulty multiplier (1 = easy, increases over time). Affects enemy spawn rate, orb spawn rate, and audio pitch

let difficultyLevel = 1;
bgStars array

Array of Star objects that create the scrolling starfield background

let bgStars = [];
PLAYER_BASE_RADIUS number

Constant defining the player orb's base radius in pixels

const PLAYER_BASE_RADIUS = 20;
PLAYER_MAX_SPEED number

Constant defining the maximum speed the player can move per frame

const PLAYER_MAX_SPEED = 12;
ENEMY_BASE_SPEED number

Constant defining the base speed at which enemies chase the player

const ENEMY_BASE_SPEED = 1.2;
ENEMY_SPAWN_INTERVAL number

Constant defining the frame interval at which enemies spawn (adjusted by difficulty)

const ENEMY_SPAWN_INTERVAL = 90;
lastEnemySpawn number

Stores the frameCount when the last enemy was spawned, used to control spawn timing

let lastEnemySpawn = 0;
ORB_SPAWN_INTERVAL number

Constant defining the frame interval at which collectible orbs spawn (adjusted by difficulty)

const ORB_SPAWN_INTERVAL = 70;
lastOrbSpawn number

Stores the frameCount when the last orb was spawned, used to control spawn timing

let lastOrbSpawn = 0;
audioStarted boolean

Flag tracking whether audio has been unlocked by user interaction

let audioStarted = false;
ambientOsc1 object

p5.Oscillator object for the first ambient background sound (sine wave)

let ambientOsc1;
ambientOsc2 object

p5.Oscillator object for the second ambient background sound (triangle wave)

let ambientOsc2;

๐Ÿงช Try This!

Experiment with the code by making these changes:

  1. Change PLAYER_BASE_RADIUS from 20 to 30 to make the player orb larger and easier to see. Notice how this also makes collision detection more forgiving.
  2. Modify the difficultyLevel calculation in updateGame() from '1 + elapsedSec / 15.0' to '1 + elapsedSec / 10.0' to make the game get harder faster. Watch how enemies spawn more frequently.
  3. In playCollectSound(), change the type from 'triangle' to 'square' to hear how different waveforms affect the sound quality of the collect sound effect.
  4. Adjust the ENEMY_SPAWN_INTERVAL from 90 to 120 to make enemies spawn less frequently at the start of the game, giving the player more time to react.
  5. Change the Player's trail maxTrailLength from 15 to 30 to create a longer, more dramatic trail effect behind the player orb.
  6. In backgroundGradient(), modify the color values like color(8, 12, 32) to different RGB values to change the base background colors and atmosphere.
  7. Try changing the Player's pulse effect in the draw() method from 'sin(frameCount * 0.15) * 3' to 'sin(frameCount * 0.05) * 5' to make the pulsing slower and more pronounced.
  8. Modify the enemy's bobbing effect from 'sin(frameCount * 0.06 + this.noiseOffset) * 0.3' to 'sin(frameCount * 0.1 + this.noiseOffset) * 0.8' to make enemies bob up and down more dramatically.
Open in Editor & Experiment โ†’

๐Ÿ”ง Potential Improvements

Here are some ways this code could be enhanced:

BUG Player.update()

The player can briefly become NaN if mouseX/mouseY are undefined, but the check only prevents movement - the player position could still become NaN in edge cases

๐Ÿ’ก Add additional bounds checking: if (isNaN(this.x) || isNaN(this.y)) { this.x = width/2; this.y = height/2; } at the end of the update method

PERFORMANCE backgroundGradient()

Creates 40 new color objects every frame using lerpColor(), which is computationally expensive for a background that could be cached

๐Ÿ’ก Create a graphics buffer (createGraphics) once and reuse it, only regenerating when difficulty level changes significantly

BUG Enemy.hitsPlayer()

Uses a forgiving collision (0.8 multiplier) but doesn't account for the player's pulsing radius, which can be up to 23 pixels instead of 20

๐Ÿ’ก Use the actual current radius: const d = dist(this.x, this.y, player.x, player.y); return d < this.radius + (player.radius + sin(frameCount * 0.15) * 3)

PERFORMANCE playBeep()

Creates new Oscillator and Envelope objects for every sound effect, which accumulates in memory if sounds are played frequently

๐Ÿ’ก Consider using a sound pool pattern where oscillators are reused, or ensure they're properly garbage collected by testing memory usage over long play sessions

STYLE Multiple functions

Sound functions (playCollectSound, playEnemySpawnSound, etc.) are repetitive and could be consolidated

๐Ÿ’ก Create a single playSound(soundType) function that returns the appropriate parameters based on soundType, reducing code duplication

FEATURE Game mechanics

No pause functionality - players can't pause the game during gameplay

๐Ÿ’ก Add a 'paused' game state and handle the spacebar to toggle pause, showing a pause menu overlay

BUG drawHUD()

The nf() function formats numbers but doesn't handle very large scores well - could overflow the HUD box at high scores

๐Ÿ’ก Add a check to abbreviate large scores (e.g., '1.2M' for 1.2 million) or increase the HUD box width dynamically

PERFORMANCE updateGame() - orb spawning

Orbs spawn more frequently at higher difficulty, potentially creating hundreds of orbs if the game runs long enough

๐Ÿ’ก Add a maximum orb count: if (orbs.length < 15) { ... } to prevent memory issues in very long games

Preview

orb-wars - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of orb-wars - Code flow showing setup, draw, initgame, updategame, drawgame, drawhud, drawmenu, drawgameover, backgroundgradient, setupaudio, startaudioifneeded, startambient, updateambient, playbeep, playstartgamesound, playcollectsound, playenemyspawnsound, playgameoversound, endgame, mousepressed, keypressed, windowresized
Code Flow Diagram