SpaceTeather

Abyssal Anchor is a whack-a-mole style arcade game where you swing a glowing lure on a physics-driven tether to smash jellyfish and sharks before they reach your core. A live 8-bit chiptune soundtrack, screen shake, and particle bursts sell every hit, all generated with p5.js and p5.sound instead of audio files.

🧪 Try This!

Experiment with the code by making these changes:

  1. Supersize the lure — The tether's radius controls both its collision size and how big the glowing lure looks on screen - making it huge turns the game into easy mode.
  2. Make snap-attacks nearly free — Lowering the stamina cost lets you spam snap-attacks almost constantly instead of saving them for tough moments.
  3. Speed up the chiptune soundtrack — TEMPO controls how many frames pass between each music step - a smaller number makes the whole song play noticeably faster.
Prefer the full editor? Open it there →

📖 About This Sketch

Abyssal Anchor turns the classic whack-a-mole idea into an underwater arcade game: you swing a glowing lure on an elastic tether to smash jellyfish and sharks before they reach your core, while a procedurally generated 8-bit soundtrack plays underneath. The tether isn't just a sprite that follows your cursor - it's driven by real spring physics (position, velocity, acceleration and drag) so it swings, overshoots, and needs a well-timed 'snap' attack to hit fast-moving threats. Every note of the music - the arpeggiated lead, sawtooth bass, and noise-burst drums - is triggered live from p5.Oscillator and p5.Noise objects, no audio files required. Screen shake, particle bursts, and a rotating core all layer on top to sell the impact of every hit.

Under the hood the sketch is organized into three sections: an 8-bit audio engine (setupAudio, updateMusic, playSFX) that runs a tiny step sequencer off arrays of note frequencies, a game logic section (updateGame, spawnManager, killEnemy, takeDamage) that handles spring physics and collisions, and a drawing section (drawGame, drawHUD, drawMenu) that renders everything with push/pop transforms. Studying this sketch is a great way to learn how to fake spring physics with velocity and drag, how to build a simple music sequencer from frameCount and modulo arithmetic, and how a finite state machine (START/PLAY/GAMEOVER) can drive an entire game's flow.

⚙️ How It Works

  1. On load, setup() creates a full-window canvas, restores any saved high score from localStorage, builds five silent oscillators/noise generators in setupAudio(), and calls resetGame() to place the tether and scatter 30 background bubbles.
  2. Every frame, draw() paints a translucent dark rectangle over the previous frame (creating soft motion trails) then branches on gameState to show the start menu, run the live game, or show the game-over screen.
  3. During PLAY, updateGame() pulls the tether toward the pointer with spring physics (velocity += acceleration, velocity *= drag, position += velocity), spawns enemies from spawnManager() that swim toward the central core, and checks distances to detect core hits and tether hits.
  4. Clicking or tapping calls triggerSnap(), which spends stamina to temporarily crank up the spring constant, making the tether snap violently toward the pointer for a few frames - this is the 'attack' that lets you one-shot fast enemies.
  5. In parallel, updateMusic() advances a 16-step sequencer every TEMPO frames, reading frequencies out of the MELODY and BASS arrays and briefly ramping oscillator amplitudes up and down to fake chiptune envelopes, while playDrum() bursts noise for kicks and hi-hats.
  6. Hits and damage trigger spawnParticles() and shakeScreen(), and takeDamage() ends the game - saving a new high score if beaten - when health reaches zero, returning gameState to GAMEOVER.

🎓 Concepts You'll Learn

p5.Oscillator and p5.Noise sound synthesisSpring physics (velocity, acceleration, drag)Finite state machine (START/PLAY/GAMEOVER)Particle systemsVector math with p5.Vector and createVectorStep-sequenced procedural musicScreen shake via random translatelocalStorage persistence

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. Here it wires up audio objects early (in a muted state) so that later calls to .amp() and .freq() don't fail, and it restores saved progress from localStorage before the game begins.

function setup() {
  createCanvas(windowWidth, windowHeight);
  pointerX = width / 2;
  pointerY = height / 2;

  // Load High Score
  if (typeof Storage !== "undefined") {
    let saved = localStorage.getItem("abyssalHighScore");
    if (saved) highScore = int(saved);
  }

  // Initialize Audio Objects (suspended state)
  setupAudio();
  
  resetGame();
  
  textFont("Courier New");
  textAlign(CENTER, CENTER);
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Load Saved High Score if (typeof Storage !== "undefined") {

Checks that the browser supports localStorage before trying to read a previously saved high score

createCanvas(windowWidth, windowHeight);
Makes the canvas fill the entire browser window, so the game works full-screen on desktop and mobile.
pointerX = width / 2;
Starts the pointer (where you aim the tether) at the horizontal center of the screen.
let saved = localStorage.getItem("abyssalHighScore");
Reads the previous high score string from the browser's persistent storage, if one exists.
if (saved) highScore = int(saved);
Converts the saved text back into a number using p5's int() and stores it in highScore.
setupAudio();
Creates all five oscillator/noise objects up front, in a silent state, so they're ready the instant the player interacts.
resetGame();
Initializes the tether, score, health, stamina, and bubbles for a fresh game.
textFont("Courier New");
Sets the monospace retro font used everywhere text is drawn.

setupAudio()

p5.sound's p5.Oscillator and p5.Noise objects are the building blocks of synthesized audio. Instead of loading sound files, this sketch builds every sound live from waveforms, which is the essence of chiptune/8-bit music production.

function setupAudio() {
  // 1. Lead Melody (Square wave = NES style)
  musicOscA = new p5.Oscillator('square');
  musicOscA.amp(0);
  musicOscA.start();

  // 2. Harmony/Arp (Square wave)
  musicOscB = new p5.Oscillator('square');
  musicOscB.amp(0);
  musicOscB.start();

  // 3. Bass (Sawtooth = gritty)
  bassOsc = new p5.Oscillator('sawtooth');
  bassOsc.amp(0);
  bassOsc.start();

  // 4. Noise (Drums + Explosions)
  noiseOsc = new p5.Noise('white');
  noiseOsc.amp(0);
  noiseOsc.start();
  
  // 5. SFX Oscillator
  sfxOsc = new p5.Oscillator('triangle');
  sfxOsc.amp(0);
  sfxOsc.start();
}
Line-by-line explanation (6 lines)
musicOscA = new p5.Oscillator('square');
Creates a square-wave oscillator, the classic buzzy tone used for NES-style lead melodies.
musicOscA.amp(0);
Starts the oscillator silent (0 amplitude) so it doesn't make sound until the sequencer explicitly triggers a note.
musicOscA.start();
Actually starts the oscillator running in the Web Audio graph - it's always 'on', just silent until amp() ramps it up.
bassOsc = new p5.Oscillator('sawtooth');
A sawtooth wave has a grittier, buzzier tone than a square wave, used here for the bass line.
noiseOsc = new p5.Noise('white');
p5.Noise generates random static rather than a pitched tone - perfect for drums and damage sounds.
sfxOsc = new p5.Oscillator('triangle');
A softer triangle wave oscillator reserved for one-off sound effects like snaps and hits.

ensureAudioStarted()

Modern browsers require a user interaction before allowing audio playback (autoplay policy). This function is the standard p5.js pattern for safely unlocking sound the first time the player clicks or taps.

function ensureAudioStarted() {
  if (!audioInit) {
    userStartAudio().then(() => {
      audioInit = true;
      console.log("Audio Context Started");
    });
  }
}
Line-by-line explanation (3 lines)
if (!audioInit) {
Only tries to start audio once - avoids repeatedly calling userStartAudio() on every click.
userStartAudio().then(() => {
Browsers block audio until a user gesture (click/tap) happens; this p5 function resumes the audio context in response to that gesture.
audioInit = true;
Flags that audio is now allowed to play, which updateMusic() and playSFX() check before making sound.

resetGame()

resetGame() is called both at startup and whenever the player restarts after dying, which is why all the mutable game state (score, health, entities) is reinitialized here rather than in setup().

function resetGame() {
  score = 0;
  health = 100;
  stamina = 100;
  enemies = [];
  particles = [];
  bubbles = [];
  
  tether = {
    x: width / 2,
    y: height / 2 + 100,
    vx: 0,
    vy: 0,
    radius: 18
  };

  // Background bubbles
  for(let i=0; i<30; i++) {
    bubbles.push({
      x: random(width),
      y: random(height),
      size: random(2, 6),
      speed: random(0.5, 2)
    });
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

for-loop Bubble Creation Loop for(let i=0; i<30; i++) {

Creates 30 background bubbles at random positions with random size and rise speed

enemies = [];
Empties the enemies array so no leftover enemies carry over into a new game.
tether = {
Rebuilds the tether object from scratch as a plain JavaScript object with position, velocity, and radius fields.
y: height / 2 + 100,
Starts the tether slightly below the screen's vertical center, hanging beneath the core.
for(let i=0; i<30; i++) {
Loops 30 times to scatter that many decorative bubbles across the screen.
speed: random(0.5, 2)
Gives each bubble its own random rise speed so they don't all move in unison.

draw()

draw() is p5's automatic animation loop, called ~60 times per second. Structuring it as a state machine (START/PLAY/GAMEOVER) is a common and powerful pattern for games - it keeps unrelated logic (menus vs. gameplay) cleanly separated.

🔬 This is the game's whole state machine in one glance. What happens if you comment out the updateGame(); line during PLAY - does the game freeze while the music and drawing keep going?

  if (gameState === "START") {
    drawMenu("ABYSSAL ANCHOR", isMobile ? "TAP TO DIVE" : "CLICK TO DIVE");
  } else if (gameState === "PLAY") {
    updateMusic(); // Play the 8-bit loop
    updateGame();
    drawGame();
  } else if (gameState === "GAMEOVER") {
function draw() {
  // Background with trails
  noStroke();
  fill(5, 10, 20, 80); 
  rect(0, 0, width, height);

  if (gameState === "START") {
    drawMenu("ABYSSAL ANCHOR", isMobile ? "TAP TO DIVE" : "CLICK TO DIVE");
  } else if (gameState === "PLAY") {
    updateMusic(); // Play the 8-bit loop
    updateGame();
    drawGame();
  } else if (gameState === "GAMEOVER") {
    silenceMusic(); // Stop music on death
    drawMenu("HULL BREACHED", "TAP TO RESURFACE");
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

conditional Game State Switch if (gameState === "START") {

Branches the whole frame's behavior based on whether the game is on the menu, actively playing, or showing game over

fill(5, 10, 20, 80);
A dark, semi-transparent fill drawn every frame instead of a fully opaque background - this leaves faint trails behind moving objects.
rect(0, 0, width, height);
Draws that translucent rectangle over the entire canvas, fading out the previous frame rather than erasing it instantly.
updateMusic(); // Play the 8-bit loop
Advances the music sequencer one tick, only while actively playing.
updateGame();
Runs all physics, collisions, and spawning logic for this frame.
drawGame();
Renders the tether, enemies, particles, and HUD based on the state updateGame() just calculated.
silenceMusic(); // Stop music on death
Fades all music oscillators to silence once the player dies.

updateMusic()

This is a step sequencer - the classic architecture behind trackers and drum machines. A repeating counter (seqStep) indexes into arrays of note data (MELODY, BASS), and modulo arithmetic decides which instruments fire on which steps.

🔬 The kick fires every 8 steps and the hi-hat every 4. What happens if you change seqStep % 8 to seqStep % 2, making the kick drum pound on almost every step?

    if (seqStep % 8 === 0) { // Kick
       playDrum(0.2); 
    } else if (seqStep % 4 === 2) { // Hi-hat
       playDrum(0.05);
    }
function updateMusic() {
  if (!audioInit) return;

  frameCounter++;
  
  // Metronome
  if (frameCounter % TEMPO === 0) {
    seqStep = (seqStep + 1) % 16;
    
    // 1. Melody (Fast Arps)
    let note = MELODY[seqStep % MELODY.length];
    if (note > 0) {
      musicOscA.freq(note);
      musicOscA.amp(0.08, 0.05); // Short decay
      musicOscA.amp(0, 0.1);
    }

    // 2. Bass (Slow steps)
    if (seqStep % 4 === 0) {
      let bassNote = BASS[(seqStep / 4) % BASS.length];
      bassOsc.freq(bassNote);
      bassOsc.amp(0.15, 0.05);
      bassOsc.amp(0, 0.4);
    }
    
    // 3. Drums (Noise bursts)
    if (seqStep % 8 === 0) { // Kick
       playDrum(0.2); 
    } else if (seqStep % 4 === 2) { // Hi-hat
       playDrum(0.05);
    }
  }
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

conditional Metronome Tick if (frameCounter % TEMPO === 0) {

Only advances the sequencer once every TEMPO frames, controlling the song's playback speed

conditional Melody Note Trigger if (note > 0) {

Skips playing a note on steps where the MELODY array holds 0, creating rhythmic gaps in the arpeggio

conditional Bass Step Trigger if (seqStep % 4 === 0) {

Plays the bass note only every 4th step, giving it a slower rhythm than the melody

conditional Drum Pattern if (seqStep % 8 === 0) { // Kick

Plays a loud kick drum every 8 steps and a quieter hi-hat every 4 steps, forming a basic drum beat

if (!audioInit) return;
Bails out immediately if the browser hasn't unlocked audio yet, so nothing crashes before the player interacts.
frameCounter++;
Counts every frame the game has been playing, used as the clock for the sequencer.
if (frameCounter % TEMPO === 0) {
Only every TEMPO-th frame actually advances the song - this is what turns 60fps into a musical tempo.
seqStep = (seqStep + 1) % 16;
Advances the step counter and wraps back to 0 after step 15, looping the 16-step pattern forever.
let note = MELODY[seqStep % MELODY.length];
Looks up which frequency to play this step from the MELODY array.
musicOscA.amp(0.08, 0.05); // Short decay
Ramps the melody oscillator's volume up to 0.08 over 0.05 seconds - a fast attack that mimics a plucked chiptune note.
musicOscA.amp(0, 0.1);
Immediately schedules the volume to fade back to 0 over 0.1 seconds, creating a short blip instead of a sustained tone.
let bassNote = BASS[(seqStep / 4) % BASS.length];
Picks the bass frequency for this quarter of the pattern from the BASS array.
if (seqStep % 8 === 0) { // Kick
Every 8 steps (twice per 16-step loop) triggers a louder noise burst for the kick drum.

playDrum()

Reusing a single noise oscillator for every drum hit (rather than creating new ones) is efficient and is a common p5.sound pattern for percussive one-shot sounds.

function playDrum(vol) {
  noiseOsc.setType('brown');
  noiseOsc.amp(vol, 0.01);
  noiseOsc.amp(0, 0.1);
}
Line-by-line explanation (3 lines)
noiseOsc.setType('brown');
Switches the noise generator to 'brown' noise, which is deeper and less hissy than white noise - good for a punchy drum sound.
noiseOsc.amp(vol, 0.01);
Very quickly ramps the noise volume up to the requested level (0.01 seconds is nearly instant), creating a percussive attack.
noiseOsc.amp(0, 0.1);
Schedules the volume back down to silence over 0.1 seconds, shaping a short drum hit rather than continuous noise.

playSFX()

Frequency sweeps (calling .freq() twice with a ramp time) are a classic 8-bit sound design trick used for lasers, jumps, and explosions in retro games - this function reuses that trick for three different feedback sounds.

function playSFX(type) {
  if (!audioInit) return;
  
  if (type === 'SNAP') {
    // Laser-like sweep
    sfxOsc.setType('square');
    sfxOsc.freq(800);
    sfxOsc.freq(100, 0.2);
    sfxOsc.amp(0.3, 0.01);
    sfxOsc.amp(0, 0.2);
  } else if (type === 'HIT') {
    // High ping
    sfxOsc.setType('sine');
    sfxOsc.freq(1200);
    sfxOsc.amp(0.2, 0.01);
    sfxOsc.amp(0, 0.1);
  } else if (type === 'DAMAGE') {
    // Low crunch
    noiseOsc.setType('white');
    noiseOsc.amp(0.4, 0.01);
    noiseOsc.amp(0, 0.3);
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Sound Effect Type Switch if (type === 'SNAP') {

Picks which waveform and frequency sweep to play depending on whether the event was a snap, a hit, or damage taken

sfxOsc.freq(800);
Sets the starting pitch of the snap sound effect to a high 800Hz.
sfxOsc.freq(100, 0.2);
Glides the frequency down to 100Hz over 0.2 seconds, creating a descending 'laser' sweep.
sfxOsc.setType('sine');
Switches to a smooth sine wave for the HIT sound, giving it a clean 'ping' quality rather than a buzzy tone.
noiseOsc.amp(0.4, 0.01);
Quickly spikes the noise volume for the DAMAGE sound, mimicking a harsh impact crunch.

silenceMusic()

Fading amplitude down over time (instead of snapping straight to zero) avoids audible clicks/pops and gives the music a graceful ending when you die.

function silenceMusic() {
  if (audioInit) {
    musicOscA.amp(0, 0.5);
    musicOscB.amp(0, 0.5);
    bassOsc.amp(0, 0.5);
    noiseOsc.amp(0, 0.5);
  }
}
Line-by-line explanation (2 lines)
if (audioInit) {
Only tries to silence oscillators if audio was actually started, avoiding errors.
musicOscA.amp(0, 0.5);
Fades the lead melody oscillator's volume down to 0 smoothly over half a second, rather than cutting it off abruptly.

updateGame()

This is the physics and collision heart of the game. It shows a full 'Euler integration' physics loop (acceleration -> velocity -> position) plus circle-circle distance checks for collision detection, two of the most reusable patterns in any 2D game.

🔬 The normal pull (0.03) is over 10x weaker than the snap pull (0.35). What happens if you make the snap value only 0.05 - does the attack still feel special?

  let springK = 0.03; 
  if (isSnapping) {
    springK = 0.35; // Very strong snap
    snapTimer--;
    if (snapTimer <= 0) isSnapping = false;
  }

🔬 Enemies only die if the tether is moving fast enough. What happens if you lower impact > 8 to impact > 1, making even the slowest bump lethal?

      let impact = mag(tether.vx, tether.vy);
      
      if (isSnapping || impact > 8) {
        playSFX('HIT');
        killEnemy(i, true);
function updateGame() {
  const coreX = width / 2;
  const coreY = height / 2;
  
  // Stamina
  if (stamina < 100) stamina += RECHARGE_RATE;
  
  // Physics
  let dx = pointerX - tether.x;
  let dy = pointerY - tether.y;
  
  // Snap Physics
  let springK = 0.03; 
  if (isSnapping) {
    springK = 0.35; // Very strong snap
    snapTimer--;
    if (snapTimer <= 0) isSnapping = false;
  }
  
  let ax = dx * springK;
  let ay = dy * springK;
  
  tether.vx += ax;
  tether.vy += ay;
  
  // Water Drag
  tether.vx *= WATER_DRAG;
  tether.vy *= WATER_DRAG;
  
  tether.x += tether.vx;
  tether.y += tether.vy;
  
  // Bubbles
  for (let b of bubbles) {
    b.y -= b.speed;
    b.x += sin(frameCount * 0.05 + b.y * 0.01) * 0.5;
    if (b.y < -10) {
      b.y = height + 10;
      b.x = random(width);
    }
  }

  // Enemies
  spawnManager();
  
  for (let i = enemies.length - 1; i >= 0; i--) {
    let e = enemies[i];
    
    // Move
    let dir = createVector(coreX - e.pos.x, coreY - e.pos.y);
    dir.normalize();
    
    if (e.type === 'JELLY') {
      let pulse = 1 + sin(frameCount * 0.1 + e.offset) * 0.2;
      e.pos.add(dir.mult(e.speed * pulse));
    } else {
      let wobble = createVector(-dir.y, dir.x).mult(sin(frameCount * 0.2) * 2);
      e.pos.add(dir.mult(e.speed).add(wobble));
    }

    // Core Hit
    let dCore = dist(e.pos.x, e.pos.y, coreX, coreY);
    if (dCore < 45) {
      takeDamage(15);
      killEnemy(i, false);
      continue;
    }

    // Tether Hit
    let dTether = dist(e.pos.x, e.pos.y, tether.x, tether.y);
    let hitRad = tether.radius + e.size/2;
    if (isSnapping) hitRad += 20; 

    if (dTether < hitRad) {
      let impact = mag(tether.vx, tether.vy);
      
      if (isSnapping || impact > 8) {
        playSFX('HIT');
        killEnemy(i, true);
        
        tether.vx *= -0.5;
        tether.vy *= -0.5;
        
        if (isSnapping) shakeScreen(10);
      } else {
        e.pos.x += tether.vx;
        e.pos.y += tether.vy;
      }
    }
  }

  // Particles
  for (let i = particles.length - 1; i >= 0; i--) {
    let p = particles[i];
    p.life -= 4;
    p.pos.add(p.vel);
    p.vel.mult(0.95);
    if (p.life <= 0) particles.splice(i, 1);
  }
  
  if (shaking > 0) shaking *= 0.8;
}
Line-by-line explanation (20 lines)

🔧 Subcomponents:

conditional Snap Spring Strength if (isSnapping) {

Temporarily swaps a weak steady spring pull for a very strong one while a snap-attack is active

for-loop Bubble Movement Loop for (let b of bubbles) {

Moves each background bubble upward with a gentle side-to-side sway, wrapping it to the bottom once it floats off-screen

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

Iterates backwards through enemies so entries can be safely removed mid-loop while moving them and checking collisions

conditional Jelly vs Shark Movement if (e.type === 'JELLY') {

Jellies pulse toward the core while sharks add a sideways wobble for a more erratic swimming pattern

conditional Core Collision Check if (dCore < 45) {

Damages the player's core and removes the enemy without reward if it reaches the center

conditional Tether Collision Check if (dTether < hitRad) {

Determines whether the swinging tether ball touches an enemy, and whether it's moving fast enough (or snapping) to kill it

for-loop Particle Fade Loop for (let i = particles.length - 1; i >= 0; i--) {

Ages each particle, moves it along its velocity, and removes it once its life reaches zero

if (stamina < 100) stamina += RECHARGE_RATE;
Slowly refills stamina every frame, capping implicitly since it stops adding once stamina hits 100.
let dx = pointerX - tether.x;
Finds the horizontal distance between where the player is pointing and where the tether ball currently is.
let springK = 0.03;
The spring constant - how strongly the tether accelerates toward the pointer. This small default value makes normal swinging feel loose.
springK = 0.35; // Very strong snap
During a snap-attack, the spring constant jumps to over 10x its normal strength, yanking the tether hard toward the pointer.
snapTimer--;
Counts down the snap's duration each frame.
if (snapTimer <= 0) isSnapping = false;
Ends the snap-attack once its short timer expires, returning to normal spring strength.
tether.vx += ax;
Applies the spring's pull as acceleration, adding it into the tether's velocity (classic Euler integration).
tether.vx *= WATER_DRAG;
Multiplies velocity by a number just under 1 every frame, simulating water resistance slowing the tether down over time.
tether.x += tether.vx;
Finally moves the tether's position by its velocity - this is what makes it actually swing across the screen.
let dir = createVector(coreX - e.pos.x, coreY - e.pos.y);
Builds a vector pointing from the enemy toward the core, which will be normalized into a pure direction.
let pulse = 1 + sin(frameCount * 0.1 + e.offset) * 0.2;
Creates a smoothly oscillating speed multiplier so jellyfish appear to pulse as they swim, each with its own phase via e.offset.
let dCore = dist(e.pos.x, e.pos.y, coreX, coreY);
Measures how close this enemy currently is to the core, used to detect a 'game over' style hit.
if (dCore < 45) {
If the enemy gets within 45 pixels of the core, it counts as breaking through and damaging the player.
let hitRad = tether.radius + e.size/2;
Combines both circles' radii to get the distance threshold at which the tether and enemy are considered to be touching.
if (isSnapping) hitRad += 20;
Snap-attacks get a forgiving hitbox bonus, making them easier to land than a normal swing.
let impact = mag(tether.vx, tether.vy);
Calculates the tether's current speed (magnitude of its velocity vector) to decide if the collision hit hard enough to count as a kill.
if (isSnapping || impact > 8) {
An enemy only dies if the player is snapping OR the tether was moving fast - a slow, gentle bump just pushes the enemy instead.
tether.vx *= -0.5;
Bounces the tether backward after a successful hit, like a recoil, at half its previous speed.
p.life -= 4;
Reduces each particle's remaining life every frame, which is used both to fade it out and to eventually delete it.
if (shaking > 0) shaking *= 0.8;
Gradually decays the screen-shake intensity back toward zero after an impact.

spawnManager()

This is a difficulty-scaling spawner: map() converts score into a frame interval, and frameCount % rate creates a repeating timer without needing separate timer variables. It's a compact, reusable pattern for pacing any wave-based game.

🔬 Sharks are rare (30%) but fast, jellies are common but slow. What happens if you flip the odds to random() < 0.8, making sharks the majority enemy?

    let type = random() < 0.3 ? 'SHARK' : 'JELLY';
    let size = type === 'JELLY' ? 30 : 20;
    let speed = type === 'JELLY' ? random(0.8, 1.5) : random(2.5, 4.0);
function spawnManager() {
  let rate = map(score, 0, 2000, 80, 25);
  if (frameCount % floor(rate) === 0) {
    let angle = random(TWO_PI);
    let rad = max(width, height) * 0.6;
    let pos = createVector(width/2 + cos(angle)*rad, height/2 + sin(angle)*rad);
    
    let type = random() < 0.3 ? 'SHARK' : 'JELLY';
    let size = type === 'JELLY' ? 30 : 20;
    let speed = type === 'JELLY' ? random(0.8, 1.5) : random(2.5, 4.0);
    
    enemies.push({
      pos: pos,
      type: type,
      size: size,
      speed: speed,
      offset: random(100),
      hp: 1
    });
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

conditional Spawn Timing Gate if (frameCount % floor(rate) === 0) {

Only spawns a new enemy on frames that line up with the current spawn rate, which speeds up as score increases

calculation Random Enemy Type let type = random() < 0.3 ? 'SHARK' : 'JELLY';

Rolls a 30% chance for a fast shark versus a 70% chance for a slower jellyfish

let rate = map(score, 0, 2000, 80, 25);
As score climbs from 0 to 2000, the spawn interval shrinks from every 80 frames down to every 25 frames, making the game harder over time.
let angle = random(TWO_PI);
Picks a completely random direction (in radians) around a full circle for the enemy to appear from.
let rad = max(width, height) * 0.6;
Places the spawn point well outside the visible screen, scaled to whichever dimension (width or height) is larger.
let pos = createVector(width/2 + cos(angle)*rad, height/2 + sin(angle)*rad);
Converts the random angle and radius into an actual x,y position using basic trigonometry (a point on a circle).
let type = random() < 0.3 ? 'SHARK' : 'JELLY';
Rolls a random number between 0 and 1; below 0.3 spawns a shark, otherwise spawns a jellyfish.
enemies.push({
Adds a brand-new enemy object with all its starting properties to the enemies array, where updateGame() will pick it up next frame.

killEnemy()

killEnemy() centralizes everything that should happen when an enemy is removed (particles, scoring, array cleanup) into one function, so both the 'died from reaching core' and 'died from being hit' code paths share the same cleanup logic.

function killEnemy(index, awardedPoints) {
  let e = enemies[index];
  let color = e.type === 'JELLY' ? [0, 255, 200] : [255, 100, 100];
  spawnParticles(e.pos.x, e.pos.y, color, 8);
  
  if (awardedPoints) {
    score += (e.type === 'SHARK' ? 25 : 10);
    if (isSnapping) score += 10;
  }
  
  enemies.splice(index, 1);
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Award Points Check if (awardedPoints) {

Only gives score if the enemy was actually killed by the player rather than by reaching the core

let color = e.type === 'JELLY' ? [0, 255, 200] : [255, 100, 100];
Picks a teal color for jellyfish death particles or a red color for shark death particles, matching each enemy's own hue.
spawnParticles(e.pos.x, e.pos.y, color, 8);
Bursts 8 small particles at the enemy's location as a visual death effect, regardless of why it died.
score += (e.type === 'SHARK' ? 25 : 10);
Sharks are worth more points than jellyfish since they're faster and harder to hit.
if (isSnapping) score += 10;
Rewards a bonus 10 points for kills landed specifically during a snap-attack, encouraging skillful timing.
enemies.splice(index, 1);
Removes the enemy from the array entirely, which is why the caller loop iterates backwards (so removing an index doesn't skip the next one).

takeDamage()

localStorage.setItem/getItem is the simplest way to persist data in a browser-based game with no server - it's plain text key/value storage that survives page refreshes and browser restarts.

function takeDamage(amt) {
  health = max(0, health - amt);
  shakeScreen(20);
  playSFX('DAMAGE');
  
  if (health <= 0) {
    gameState = "GAMEOVER";
    if (score > highScore) {
      highScore = score;
      localStorage.setItem("abyssalHighScore", highScore);
    }
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Death & High Score Check if (health <= 0) {

Ends the game and saves a new high score to localStorage if the player's health has dropped to zero or below

health = max(0, health - amt);
Subtracts damage from health but clamps it at 0, preventing health from going negative.
shakeScreen(20);
Triggers a strong screen shake to give visceral feedback for taking damage.
if (score > highScore) {
Only overwrites the saved high score if the current run actually beat the previous record.
localStorage.setItem("abyssalHighScore", highScore);
Persists the new high score in the browser so it survives page reloads.

drawGame()

This function combines several core p5.js drawing techniques in one place: push()/pop() for isolated transforms, translate/rotate for local coordinate systems, quadraticVertex for curved lines, and alpha blending for glow and fade effects.

🔬 This loop places 5 circles evenly around the core. What happens if you change the loop count and TWO_PI/5 to 3, making a triangular core instead?

  for(let i=0; i<5; i++) {
    let ang = (TWO_PI/5) * i;
    fill(255, 100, 150, dim);
    circle(cos(ang)*15, sin(ang)*15, 16);
  }
function drawGame() {
  push();
  if (shaking > 1) {
    translate(random(-shaking, shaking), random(-shaking, shaking));
  }
  
  // Bubbles
  noStroke();
  fill(100, 200, 255, 30);
  for (let b of bubbles) circle(b.x, b.y, b.size);
  
  // Tether
  noFill();
  stroke(100, 255, 200, 100);
  strokeWeight(3);
  let midX = (pointerX + tether.x) / 2 - tether.vx * 2; 
  let midY = (pointerY + tether.y) / 2 - tether.vy * 2;
  beginShape();
  vertex(pointerX, pointerY);
  quadraticVertex(midX, midY, tether.x, tether.y);
  endShape();
  
  // Lure
  noStroke();
  fill(0, 255, 200, 50);
  circle(tether.x, tether.y, tether.radius * 3 + sin(frameCount*0.2)*5);
  fill(200, 255, 255);
  circle(tether.x, tether.y, tether.radius * 2);
  
  // Core
  coreRotation += 0.01;
  push();
  translate(width/2, height/2);
  rotate(coreRotation);
  let dim = map(health, 0, 100, 50, 255);
  for(let i=0; i<5; i++) {
    let ang = (TWO_PI/5) * i;
    fill(255, 100, 150, dim);
    circle(cos(ang)*15, sin(ang)*15, 16);
  }
  noFill();
  stroke(255, 100, 150, dim * 0.5);
  strokeWeight(2);
  circle(0, 0, 55);
  pop();
  
  // Enemies
  for (let e of enemies) {
    push();
    translate(e.pos.x, e.pos.y);
    rotate(atan2(height/2 - e.pos.y, width/2 - e.pos.x));
    
    if (e.type === 'JELLY') {
      fill(0, 180, 255, 200);
      noStroke();
      arc(0, 0, e.size, e.size, PI, TWO_PI);
      stroke(0, 180, 255, 150);
      strokeWeight(2);
      let wiggle = sin(frameCount * 0.3 + e.offset) * 5;
      line(-5, 0, -5 - wiggle, 15);
      line(5, 0, 5 + wiggle, 15);
    } else {
      fill(255, 80, 80);
      noStroke();
      triangle(10, 0, -10, -8, -10, 8);
    }
    pop();
  }
  
  // Particles
  for (let p of particles) {
    fill(p.r, p.g, p.b, p.life);
    noStroke();
    circle(p.pos.x, p.pos.y, p.size);
  }
  
  pop();
  drawHUD();
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

conditional Screen Shake Offset if (shaking > 1) {

Randomly offsets the whole scene each frame while shaking is active, creating an impact-shake effect

for-loop Bubble Drawing Loop for (let b of bubbles) circle(b.x, b.y, b.size);

Draws every background bubble as a small translucent circle

for-loop Core Petal Loop for(let i=0; i<5; i++) {

Draws 5 evenly-spaced circles rotating around the core's center, forming a flower/gear shape

for-loop Enemy Drawing Loop for (let e of enemies) {

Draws each enemy rotated to face the core, rendering jellies as arcs with wiggling tentacles and sharks as triangles

for-loop Particle Drawing Loop for (let p of particles) {

Draws each particle as a circle whose alpha equals its remaining life, so it visibly fades away

if (shaking > 1) {
Only bothers shaking the scene if the shake amount is large enough to be noticeable, saving a tiny bit of work otherwise.
translate(random(-shaking, shaking), random(-shaking, shaking));
Nudges the entire canvas origin by a random small amount in both x and y, which is what creates the shaking effect - everything drawn after this shifts together.
let midX = (pointerX + tether.x) / 2 - tether.vx * 2;
Calculates a control point roughly between the pointer and tether, offset backward by the tether's velocity, to bend the tether line like a whip under motion.
quadraticVertex(midX, midY, tether.x, tether.y);
Draws a curved (quadratic Bezier) line from the pointer to the tether through that control point, instead of a straight line, making the tether look flexible.
circle(tether.x, tether.y, tether.radius * 3 + sin(frameCount*0.2)*5);
Draws a soft glowing halo behind the lure whose size gently pulses using a sine wave over time.
rotate(coreRotation);
Slowly spins the core's local coordinate system each frame, making the whole core visual rotate continuously.
let dim = map(health, 0, 100, 50, 255);
Converts current health into a brightness/alpha value - the core visibly dims as the player takes damage.
for(let i=0; i<5; i++) {
Loops 5 times to place 5 circles evenly around the core using angle math.
rotate(atan2(height/2 - e.pos.y, width/2 - e.pos.x));
Rotates each enemy to visually face the direction of the core, using atan2 to convert a direction vector into an angle.
arc(0, 0, e.size, e.size, PI, TWO_PI);
Draws only the bottom half of a circle (from PI to TWO_PI radians) to form the dome shape of a jellyfish.
fill(p.r, p.g, p.b, p.life);
Uses the particle's stored color plus its remaining life value as the alpha channel, so particles visibly fade as they die.

drawHUD()

This function shows how to escape p5.js's simplified drawing API and use the underlying HTML5 Canvas 2D context (drawingContext) directly for features p5 doesn't provide natively, like radial gradients.

function drawHUD() {
  // Vignette
  let grad = drawingContext.createRadialGradient(
    width/2, height/2, height*0.3, width/2, height/2, height*0.8
  );
  grad.addColorStop(0, "rgba(0,0,0,0)");
  grad.addColorStop(1, "rgba(0,0,5,0.8)"); 
  drawingContext.fillStyle = grad;
  drawingContext.fillRect(0,0,width,height);
  
  fill(255);
  noStroke();
  textSize(20);
  textAlign(LEFT, TOP);
  text("DEPTH: " + score + "m", 20, 20);
  
  // Stamina Bar
  let barW = 200, barH = 10;
  let barX = width/2 - barW/2, barY = height - 40;
  
  fill(0, 50, 50);
  rect(barX, barY, barW, barH, 5);
  let fillW = map(stamina, 0, 100, 0, barW);
  fill(stamina >= SNAP_COST ? color(0, 255, 255) : color(255, 50, 50));
  rect(barX, barY, fillW, barH, 5);
  
  textAlign(CENTER, BOTTOM);
  textSize(14);
  fill(150, 200, 200);
  text(isMobile ? "DRAG to Swing • TAP to Snap" : "MOUSE to Swing • CLICK to Snap", width/2, barY - 5);
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Stamina Bar Color fill(stamina >= SNAP_COST ? color(0, 255, 255) : color(255, 50, 50));

Colors the stamina bar cyan when a snap-attack is affordable, or red when there isn't enough stamina to snap

let grad = drawingContext.createRadialGradient(
Reaches into the raw HTML5 Canvas API (drawingContext) to build a radial gradient, since p5.js doesn't have a built-in gradient function.
grad.addColorStop(1, "rgba(0,0,5,0.8)");
Makes the outer edge of the gradient nearly opaque dark blue, darkening the corners of the screen - a classic 'vignette' effect.
text("DEPTH: " + score + "m", 20, 20);
Displays the current score in the top-left corner, framed as a diving 'depth' readout to match the theme.
let fillW = map(stamina, 0, 100, 0, barW);
Converts the stamina value (0-100) into a pixel width for the filled portion of the stamina bar.
fill(stamina >= SNAP_COST ? color(0, 255, 255) : color(255, 50, 50));
Turns the bar red as a warning when there isn't enough stamina left to afford a snap-attack.

drawMenu()

drawMenu() is reused for both the START and GAMEOVER screens by simply passing in different title/subtitle strings - a good example of writing one flexible function instead of duplicating nearly identical drawing code.

function drawMenu(title, subtitle) {
  fill(200, 255, 255);
  textSize(width < 600 ? 32 : 64);
  drawingContext.shadowBlur = 30;
  drawingContext.shadowColor = "#00FFFF";
  text(title, width/2, height/2 - 50);
  
  drawingContext.shadowBlur = 0;
  textSize(20);
  fill(255);
  text(subtitle, width/2, height/2 + 50);
  
  if (highScore > 0) {
    fill(100, 255, 150);
    text("DEEPEST DIVE: " + highScore + "m", width/2, height/2 + 90);
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Responsive Title Size textSize(width < 600 ? 32 : 64);

Uses a smaller title font on narrow (mobile) screens and a larger one on wide desktop screens

conditional High Score Display if (highScore > 0) {

Only shows the 'deepest dive' high score text once the player has actually set a record

drawingContext.shadowBlur = 30;
Adds a soft glow blur behind the next drawn text, using the raw canvas shadow properties.
drawingContext.shadowColor = "#00FFFF";
Sets that glow's color to cyan, matching the game's neon deep-sea aesthetic.
drawingContext.shadowBlur = 0;
Turns the glow effect back off so it doesn't also apply to the subtitle text below.
text("DEEPEST DIVE: " + highScore + "m", width/2, height/2 + 90);
Displays the saved high score below the subtitle, only when one exists.

spawnParticles()

p5.Vector.random2D() is a quick way to get a random direction (angle) as a unit vector, which combined with .mult() for speed is the standard recipe for particle explosion effects.

function spawnParticles(x, y, rgb, count) {
  for(let i=0; i<count; i++) {
    particles.push({
      pos: createVector(x, y),
      vel: p5.Vector.random2D().mult(random(2, 6)),
      size: random(2, 6),
      life: 255,
      r: rgb[0], g: rgb[1], b: rgb[2]
    });
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

for-loop Particle Burst Loop for(let i=0; i<count; i++) {

Creates 'count' individual particles, each with its own random velocity direction and speed, forming an explosion burst

vel: p5.Vector.random2D().mult(random(2, 6)),
Creates a random unit direction vector, then scales it by a random speed between 2 and 6, so particles fly outward in all directions at varying speeds.
life: 255,
Starts every particle at full life (255), which doubles as its starting alpha transparency value.
r: rgb[0], g: rgb[1], b: rgb[2]
Stores the passed-in color as individual r/g/b fields on the particle so drawGame() can fill() it later.

shakeScreen()

A tiny one-line helper like this keeps the 'trigger a shake' intent readable at every call site (shakeScreen(20) reads clearer than shaking = 20 scattered everywhere).

function shakeScreen(amt) {
  shaking = amt;
}
Line-by-line explanation (1 lines)
shaking = amt;
Sets the global shake intensity, which drawGame() uses to randomly offset the scene and which decays back to 0 in updateGame().

triggerSnap()

This function models a simple resource-gated ability, a pattern used everywhere in games (mana costs, cooldowns, ammo). Checking the cost before spending it prevents stamina from going negative.

function triggerSnap() {
  if (stamina >= SNAP_COST) {
    stamina -= SNAP_COST;
    isSnapping = true;
    snapTimer = 8;
    playSFX('SNAP');
    shakeScreen(5);
    spawnParticles(pointerX, pointerY, [255, 255, 255], 10);
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Stamina Availability Check if (stamina >= SNAP_COST) {

Prevents snapping unless the player has enough stamina saved up, enforcing a cooldown-like resource system

if (stamina >= SNAP_COST) {
Only allows the snap-attack to happen if there's enough stamina banked, acting as a resource gate like mana or ammo.
stamina -= SNAP_COST;
Spends the stamina cost immediately when the snap is triggered.
isSnapping = true;
Flips the flag that updateGame() checks to switch to the strong spring physics.
snapTimer = 8;
Sets how many frames the snap's strong pull lasts before automatically turning off.
spawnParticles(pointerX, pointerY, [255, 255, 255], 10);
Adds a small white particle burst right at the pointer to visually mark the moment a snap begins.

handleInteract()

This function is the single entry point shared by mouse clicks and touch taps, keeping the 'what does a click mean right now' logic in one place instead of duplicated across input handlers.

function handleInteract() {
  ensureAudioStarted();
  
  if (gameState !== "PLAY") {
    if (gameState === "GAMEOVER") resetGame();
    gameState = "PLAY";
  } else {
    triggerSnap();
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Menu vs Gameplay Branch if (gameState !== "PLAY") {

Decides whether a click/tap should start or restart the game, or count as a snap-attack during active play

ensureAudioStarted();
Every interaction attempts to unlock audio, since the very first click is usually what a browser requires to allow sound.
if (gameState !== "PLAY") {
If we're on the START or GAMEOVER screen, this click should begin the game rather than trigger a snap.
if (gameState === "GAMEOVER") resetGame();
Specifically resets all game state before starting again if the player is restarting after dying.
gameState = "PLAY";
Switches the state machine into active gameplay mode.
triggerSnap();
If already playing, the same click/tap instead attempts to perform a snap-attack.

mousePressed()

mousePressed() is a p5.js callback automatically invoked once whenever a mouse button goes down, distinct from mouseDragged() which fires continuously while moving with the button held.

function mousePressed() {
  if (isMobile) return;
  handleInteract();
  pointerX = mouseX;
  pointerY = mouseY;
}
Line-by-line explanation (3 lines)
if (isMobile) return;
Skips mouse handling entirely on detected mobile devices, since touchStarted() handles those instead.
handleInteract();
Processes the click as either 'start/restart game' or 'trigger snap' depending on current state.
pointerX = mouseX;
Updates the aim target to wherever the mouse was clicked.

mouseDragged()

p5.js calls mouseDragged() every frame the mouse moves with a button held down, making it ideal for click-and-drag aiming controls.

function mouseDragged() {
  if (isMobile) return;
  pointerX = mouseX;
  pointerY = mouseY;
}
Line-by-line explanation (1 lines)
pointerX = mouseX;
Continuously updates the aim point while the mouse button is held and moving, so dragging swings the tether.

mouseMoved()

Combining mouseMoved() and mouseDragged() to both update pointerX/pointerY means desktop players can aim the tether just by moving the mouse, no clicking required, while clicking is reserved for the snap-attack.

function mouseMoved() {
  if (isMobile) return;
  pointerX = mouseX;
  pointerY = mouseY;
}
Line-by-line explanation (1 lines)
pointerX = mouseX;
Updates the aim point simply by moving the mouse, even without clicking - so the tether follows the cursor freely on desktop.

touchStarted()

touchStarted() is p5.js's mobile equivalent of mousePressed(), fired once when a finger first touches the screen. Returning false is the standard way to suppress default browser gestures during gameplay.

function touchStarted() {
  if (touches.length > 0) {
    pointerX = touches[0].x;
    pointerY = touches[0].y;
  }
  handleInteract();
  return false; // Prevents scrolling
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Touch Position Guard if (touches.length > 0) {

Makes sure there's an actual touch point before reading its coordinates, avoiding errors

if (touches.length > 0) {
Guards against reading touches[0] when there are no active touches, which would otherwise throw an error.
pointerX = touches[0].x;
Reads the first finger's x position to aim the tether, using p5's touches array.
handleInteract();
Treats the very start of a tap as either 'begin game' or 'snap-attack', mirroring mousePressed().
return false; // Prevents scrolling
Tells the browser not to perform its default touch behavior (like scrolling or zooming), keeping the game fully in control of touch input.

touchMoved()

This is the mobile counterpart to mouseDragged(), keeping desktop and touch controls functionally equivalent despite using different underlying p5.js event APIs.

function touchMoved() {
  if (touches.length > 0) {
    pointerX = touches[0].x;
    pointerY = touches[0].y;
  }
  return false; // Prevents scrolling
}
Line-by-line explanation (2 lines)
pointerX = touches[0].x;
Continuously updates the aim point as the finger drags across the screen.
return false; // Prevents scrolling
Stops the page from scrolling or refreshing while the player drags their finger to aim.

windowResized()

windowResized() is a p5.js callback fired automatically whenever the browser window changes size. Recentering game objects here prevents them from being stranded outside the visible area on resize.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  tether.x = width/2; 
  tether.y = height/2 + 100;
  pointerX = width/2;
  pointerY = height/2;
}
Line-by-line explanation (3 lines)
resizeCanvas(windowWidth, windowHeight);
Resizes the p5.js canvas to match the browser window's new dimensions, e.g. after rotating a mobile device.
tether.x = width/2;
Re-centers the tether relative to the new canvas size so it doesn't end up off-screen after a resize.
pointerX = width/2;
Resets the aim point back to center as well, keeping everything visually consistent after resizing.

📦 Key Variables

gameState string

Tracks which screen the game is on - "START", "PLAY", or "GAMEOVER" - and drives the state machine in draw().

let gameState = "START";
score number

The player's current 'depth' score, increased by killing enemies and used to scale difficulty.

let score = 0;
highScore number

The best score ever achieved, loaded from and saved to localStorage.

let highScore = 0;
health number

The core's remaining health (0-100); reaching 0 ends the game.

let health = 100;
stamina number

A resource that recharges over time and is spent to perform snap-attacks.

let stamina = 100;
tether object

Holds the tether ball's position, velocity, and radius - the physics object the player controls.

let tether = { x: 0, y: 0, vx: 0, vy: 0, radius: 18 };
coreRotation number

A slowly-increasing angle used to spin the visual core each frame.

let coreRotation = 0;
enemies array

Stores every active enemy object (jellies and sharks) currently swimming toward the core.

let enemies = [];
particles array

Stores every active visual particle used for explosion/impact effects.

let particles = [];
bubbles array

Stores decorative background bubbles that continuously rise and wrap around the screen.

let bubbles = [];
WATER_DRAG number

Constant multiplier applied to the tether's velocity each frame to simulate water resistance.

const WATER_DRAG = 0.92;
SNAP_COST number

Constant amount of stamina consumed by each snap-attack.

const SNAP_COST = 25;
RECHARGE_RATE number

Constant amount stamina refills by each frame.

const RECHARGE_RATE = 0.6;
audioInit boolean

Tracks whether the browser's audio context has been unlocked by a user gesture yet.

let audioInit = false;
musicOscA, musicOscB, bassOsc, noiseOsc object

p5.Oscillator/p5.Noise instances used for the lead melody, harmony, bass, and drum/noise layers of the music.

let musicOscA, musicOscB, bassOsc, noiseOsc;
sfxOsc object

A dedicated p5.Oscillator reused for one-off sound effects like snaps, hits, and damage.

let sfxOsc;
seqStep number

The current position (0-15) in the 16-step music sequencer loop.

let seqStep = 0;
frameCounter number

Counts frames elapsed during play, used as the clock that advances the music sequencer every TEMPO frames.

let frameCounter = 0;
TEMPO number

Constant number of frames between each music sequencer step - controls playback speed.

const TEMPO = 10;
MELODY array

Constant array of note frequencies (with 0 meaning silence) that make up the arpeggiated lead melody.

const MELODY = [220, 0, 261, 0, ...];
BASS array

Constant array of bass note frequencies played on the slower bass line.

const BASS = [55, 55, 55, 55, 65, 65, 49, 49];
pointerX, pointerY number

The current aim target (mouse or touch position) that the tether's spring physics pulls toward.

let pointerX, pointerY;
isMobile boolean

Detected once via a user-agent regex, used to switch between mouse and touch input handling and adjust UI text/size.

let isMobile = /Mobi|Android|iP(ad|hone|od)|Tablet/i.test(navigator.userAgent);
shaking number

Current screen-shake intensity, decaying toward zero each frame and randomly offsetting the drawing transform while active.

let shaking = 0;
isSnapping boolean

True while a snap-attack's strong spring pull is active.

let isSnapping = false;
snapTimer number

Counts down the remaining frames of the current snap-attack before it ends.

let snapTimer = 0;

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

BUG spawnManager()

`let rate = map(score, 0, 2000, 80, 25);` isn't clamped, so once score exceeds 2000 the extrapolated rate keeps shrinking and can eventually reach 0 or a negative number, making `frameCount % floor(rate)` throw or behave erratically (division/modulo by zero).

💡 Call map() with its optional 5th argument set to true to clamp the output, e.g. `map(score, 0, 2000, 80, 25, true)`, or wrap the result with `max(10, rate)`.

BUG setupAudio() / updateMusic()

musicOscB is created and started but is never given a frequency or amplitude anywhere in updateMusic() - the intended 'Harmony/Arp' layer is silent and effectively dead code.

💡 Add a harmony step inside updateMusic() (e.g. play a third above the current MELODY note through musicOscB) to actually use the oscillator, or remove it if it's not needed.

PERFORMANCE drawHUD()

`fill(stamina >= SNAP_COST ? color(0, 255, 255) : color(255, 50, 50));` constructs a brand-new p5.Color object every single frame just to pick between two fixed colors.

💡 Predefine `const STAMINA_READY = color(0,255,255);` and `const STAMINA_LOW = color(255,50,50);` once outside draw(), then just reference them here.

STYLE updateGame()

Collision thresholds like `45` (core hit radius), `8` (impact speed needed to kill), and `20` (snap hitbox bonus) are magic numbers scattered through the function, making balance tuning harder to find and adjust.

💡 Pull these into named constants near the top of the file (e.g. CORE_HIT_RADIUS, KILL_IMPACT_SPEED, SNAP_HITBOX_BONUS) so gameplay tuning doesn't require hunting through physics code.

🔄 Code Flow

Code flow showing setup, setupaudio, ensureaudiostarted, resetgame, draw, updatemusic, playdrum, playsfx, silencemusic, updategame, spawnmanager, killenemy, takedamage, drawgame, drawhud, drawmenu, spawnparticles, shakescreen, triggersnap, handleinteract, mousepressed, mousedragged, mousemoved, touchstarted, touchmoved, windowresized

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

graph TD start[Start] --> setup[setup] setup --> setupaudio[setupaudio] setup --> ensureaudiostarted[ensureaudiostarted] setup --> resetgame[resetgame] setup --> draw[draw loop] click setup href "#fn-setup" click setupaudio href "#fn-setupaudio" click ensureaudiostarted href "#fn-ensureaudiostarted" click resetgame href "#fn-resetgame" draw --> state-switch[state-switch] state-switch -->|Menu| drawmenu[drawmenu] state-switch -->|Play| updategame[updategame] state-switch -->|Game Over| drawmenu click draw href "#fn-draw" click state-switch href "#sub-state-branch" click drawmenu href "#fn-drawmenu" click updategame href "#fn-updategame" updategame --> spawnmanager[spawnmanager] updategame --> enemy-loop[enemy-loop] enemy-loop --> enemy-type-movement[enemy-type-movement] enemy-loop --> core-hit-check[core-hit-check] enemy-loop --> tether-hit-check[tether-hit-check] click spawnmanager href "#fn-spawnmanager" click enemy-loop href "#sub-enemy-loop" click enemy-type-movement href "#sub-enemy-type-movement" click core-hit-check href "#sub-core-hit-check" click tether-hit-check href "#sub-tether-hit-check" updategame --> particle-update-loop[particle-update-loop] particle-update-loop -->|Ages each particle| particle-draw-loop[particle-draw-loop] click particle-update-loop href "#sub-particle-update-loop" click particle-draw-loop href "#sub-particle-draw-loop" draw --> updatemusic[updatemusic] updatemusic --> metronome-check[metronome-check] metronome-check --> melody-note[melody-note] metronome-check --> bass-step[bass-step] metronome-check --> drum-pattern[drum-pattern] click updatemusic href "#fn-updatemusic" click metronome-check href "#sub-metronome-check" click melody-note href "#sub-melody-note" click bass-step href "#sub-bass-step" click drum-pattern href "#sub-drum-pattern" draw --> drawgame[drawgame] drawgame --> bubble-draw-loop[bubble-draw-loop] drawgame --> core-petal-loop[core-petal-loop] drawgame --> enemy-draw-loop[enemy-draw-loop] click drawgame href "#fn-drawgame" click bubble-draw-loop href "#sub-bubble-draw-loop" click core-petal-loop href "#sub-core-petal-loop" click enemy-draw-loop href "#sub-enemy-draw-loop" draw --> drawhud[drawhud] drawhud --> stamina-color-check[stamina-color-check] drawhud --> highscore-display[highscore-display] click drawhud href "#fn-drawhud" click stamina-color-check href "#sub-stamina-color-check" click highscore-display href "#sub-highscore-display" draw --> shakescreen[shakescreen] click shakescreen href "#fn-shakescreen" draw --> spawnparticles[spawnparticles] click spawnparticles href "#fn-spawnparticles" handleinteract[handleinteract] --> mousepressed[mousepressed] handleinteract --> mousedragged[mousedragged] handleinteract --> mousemoved[mousemoved] handleinteract --> touchstarted[touchstarted] handleinteract --> touchmoved[touchmoved] click handleinteract href "#fn-handleinteract" click mousepressed href "#fn-mousepressed" click mousedragged href "#fn-mousedragged" click mousemoved href "#fn-mousemoved" click touchstarted href "#fn-touchstarted" click touchmoved href "#fn-touchmoved" windowresized[windowresized] -->|Recenter objects| draw click windowresized href "#fn-windowresized"

Preview

SpaceTeather - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of SpaceTeather - Code flow showing setup, setupaudio, ensureaudiostarted, resetgame, draw, updatemusic, playdrum, playsfx, silencemusic, updategame, spawnmanager, killenemy, takedamage, drawgame, drawhud, drawmenu, spawnparticles, shakescreen, triggersnap, handleinteract, mousepressed, mousedragged, mousemoved, touchstarted, touchmoved, windowresized
Code Flow Diagram