Sketch 2026-04-17 16:24

This is a rhythm game where players click or press keys to hit falling beats as they pass through a red hit zone at the bottom of the screen, similar to Guitar Hero or Beat Saber. The game uses Tone.js for precise audio scheduling and features a creative twist: each time you lose, the crowd changes from people to dogs to water, creating a humorous progression.

🧪 Try This!

Experiment with the code by making these changes:

  1. Increase beat difficulty — Higher beatSpeed makes beats fall faster, requiring quicker reflexes—the game becomes harder almost immediately
  2. Change the rhythm pattern — Modify the pattern array to create a completely different rhythm—use 1 for beats and 0 for rests
  3. Make hits worth more points — Increase the score reward to make the player feel accomplished faster—the final score will grow quickly
  4. Give players more health — Increase starting health so players can miss more beats before losing—makes the game more forgiving
  5. Make the hit zone more forgiving — Change the hit window radius to accept hits farther from the red line—easier timing makes the game more fun for casual players
  6. Play a different note when beats spawn — Change the C4 note to a different pitch like E4 or A3—the game's audio melody changes
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch creates an interactive rhythm game that combines music, precise timing, and visual feedback. Players watch beats fall down the screen and must click or press a key when each beat reaches the red hit zone at the bottom. The game teaches three essential p5.js ideas: the draw loop for animation, collision detection for hit validation, and game state management to switch between screens.

The code is organized around three game states (start screen, playing, game over) and uses Tone.js—a separate library for audio—to schedule beats at exact musical intervals. The sketch also features a creative progression system: after your first loss, the crowd becomes dogs; after the second loss, it becomes water. By studying this code, you will learn how to combine p5.js with external audio libraries, manage multiple game states, and create visual feedback that responds to player actions.

⚙️ How It Works

  1. When the sketch loads, setup() creates a canvas and initializes Tone.js with a synthesizer and a Loop that fires at precise eighth-note intervals. A rhythm pattern (array of 1s and 0s) determines when beats should appear.
  2. The player sees a start screen until they press any key or click the mouse, which calls startGame() to begin playing.
  3. Every frame, draw() calls drawGamePlaying(), which clears the background, draws a red hit zone line, and displays all falling beats on screen.
  4. Tone.js's scheduleBeat() function fires every eighth note: if the current pattern step is a 1, it adds a new beat object to the beats array and plays a synth sound.
  5. Each beat falls downward because beat.y increases by beatSpeed every frame. When the player presses a key or clicks, checkHit() finds the closest beat within the hit zone and either awards 10 points (PERFECT!) or removes health (MISS!).
  6. If a beat falls past the hit zone without being hit, handleMiss() reduces health by 1. When health reaches 0, gameOver becomes true and the crowd animation begins—emojis flood the screen from below, eventually covering it completely before the game-over message appears.

🎓 Concepts You'll Learn

Game state managementCollision detection and hit zonesAudio scheduling with Tone.jsRhythm patterns and timingFeedback and visual responseEmoji rendering and animationScore and health systems

📝 Code Breakdown

preload()

preload() is a special p5.js function that runs before setup(). Use it to load files (fonts, images, sounds) that your sketch needs immediately. Without it, your sketch might try to use a font that hasn't loaded yet.

function preload() {
  gameFont = loadFont('https://cdn.jsdelivr.net/npm/@fontsource/roboto@latest/files/roboto-latin-400-normal.woff');
}
Line-by-line explanation (1 lines)
gameFont = loadFont('https://cdn.jsdelivr.net/npm/@fontsource/roboto@latest/files/roboto-latin-400-normal.woff');
Loads a custom font from the internet before setup() runs, ensuring the font is available for all text display

setup()

setup() runs once when the sketch starts. It initializes the canvas, configures text rendering, creates a Tone.js synthesizer for audio, sets up audio scheduling via a Tone.js Loop, and generates the initial crowd. This function is where you prepare everything your sketch needs to run.

function setup() {
  createCanvas(windowWidth, windowHeight);
  hitZoneY = height - 100; // Set hit zone near the bottom

  // Set default font and text size for game UI
  textFont(gameFont);
  textSize(24);
  textAlign(CENTER, CENTER);

  // Initialize Tone.js synthesizer
  synth = new Tone.PolySynth(Tone.Synth, {
    oscillator: {
      type: "triangle" // Simple waveform for beat sounds
    }
  }).toDestination(); // Send output to the speakers

  // Schedule beats using Tone.Loop
  // This loop will call scheduleBeat every "8n" (eighth note)
  beatLoop = new Tone.Loop(scheduleBeat, "8n").start(0);
  beatLoop.probability = 1; // Always trigger if pattern has a beat

  // Create initial crowd elements (start off-screen below)
  // Initially, the crowd is always 'person' on the first game attempt
  generateCrowd('person');
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

calculation Canvas and UI initialization createCanvas(windowWidth, windowHeight);

Creates a full-screen canvas and sets up text rendering properties for the game UI

calculation Tone.js synthesizer setup synth = new Tone.PolySynth(Tone.Synth, { oscillator: { type: "triangle" } }).toDestination();

Creates a synthesizer that can play multiple notes simultaneously and sends the audio to speakers

calculation Audio scheduling loop beatLoop = new Tone.Loop(scheduleBeat, "8n").start(0);

Sets up a Tone.js loop that calls scheduleBeat() at precise musical intervals (every eighth note)

createCanvas(windowWidth, windowHeight);
Creates a canvas that fills the entire browser window
hitZoneY = height - 100;
Sets the y-coordinate of the red hit zone 100 pixels from the bottom of the screen—this is where players must hit beats
textFont(gameFont);
Tells p5.js to use the custom font we loaded in preload() for all text
synth = new Tone.PolySynth(Tone.Synth, { oscillator: { type: "triangle" } }).toDestination();
Creates a Tone.js synthesizer that can play triangle-wave sounds and sends the audio output to the speakers
beatLoop = new Tone.Loop(scheduleBeat, "8n").start(0);
Creates a Tone.js Loop that calls scheduleBeat() every eighth note (a sixteenth of a measure at 120 BPM). The .start(0) begins it immediately when audio starts
generateCrowd('person');
Creates 150 emoji crowd elements (people) positioned randomly off-screen below—they will animate upward on game over

scheduleBeat()

scheduleBeat() is a callback function called by Tone.js at precise audio intervals. It runs independently of the draw loop, allowing music to stay perfectly timed even if drawing slows down. This function is the bridge between audio scheduling and visual beat creation.

🔬 This code only adds beats when pattern[patternIndex] is 1. What happens if you change the condition to === 0 (rest steps become beats and beat steps become rests)? What does the rhythm sound like now?

    if (pattern[patternIndex] === 1) { // If the current pattern step is a beat (1)
      beats.push({
        x: width / 2, // Beats start in the middle top
        y: 0,
        time: time // Store the scheduled time for potential future use
      });
function scheduleBeat(time) {
  if (gameStarted && !gameOver) {
    if (pattern[patternIndex] === 1) { // If the current pattern step is a beat (1)
      beats.push({
        x: width / 2, // Beats start in the middle top
        y: 0,
        time: time // Store the scheduled time for potential future use
      });
      synth.triggerAttackRelease("C4", "8n", time); // Play a sound when a beat is added
    }
    patternIndex = (patternIndex + 1) % pattern.length; // Move to the next step in the pattern, looping at the end
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Pattern-based beat creation if (pattern[patternIndex] === 1) {

Only adds a beat to the screen if the current step in the rhythm pattern is a 1 (beat) rather than a 0 (rest)

calculation Beat object creation beats.push({ x: width / 2, y: 0, time: time });

Creates a new beat object with x/y position and scheduled time, then adds it to the beats array so it appears on screen

calculation Pattern advancement patternIndex = (patternIndex + 1) % pattern.length;

Moves to the next step in the rhythm pattern, looping back to 0 when reaching the end

if (gameStarted && !gameOver) {
Only schedule beats if the game is currently playing (not on start screen or game over screen)
if (pattern[patternIndex] === 1) {
Checks if the current position in the pattern array is a 1 (a beat to add) versus a 0 (a rest with no beat)
beats.push({
Adds a new beat object to the beats array so it appears on screen
x: width / 2,
Positions the new beat horizontally at the center of the screen
y: 0,
Positions the new beat at the top of the screen, where it will fall downward
synth.triggerAttackRelease("C4", "8n", time);
Plays a C note on the synthesizer at the exact scheduled time, with an eighth-note duration
patternIndex = (patternIndex + 1) % pattern.length;
Increments patternIndex and uses modulo (%) to loop back to 0 when reaching the end of the pattern

draw()

draw() runs 60 times per second and is the heartbeat of the sketch. Here it clears the canvas and routes to the correct drawing function based on game state. This is a classic pattern in p5.js games: use a state variable to determine what to draw each frame.

function draw() {
  background(20, 20, 40); // Dark background

  if (!gameStarted) {
    drawStartScreen(); // Show start screen
  } else if (gameOver) {
    drawGameOverScreen(); // Show game over screen with crowd animation
  } else {
    drawGamePlaying(); // Draw game elements during gameplay
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Game state routing if (!gameStarted) { drawStartScreen(); } else if (gameOver) { drawGameOverScreen(); } else { drawGamePlaying(); }

Routes to the correct drawing function based on the current game state, ensuring only one screen is shown at a time

background(20, 20, 40);
Clears the canvas with a dark blue-gray color every frame, preventing trails and ensuring a clean slate
if (!gameStarted) {
If the game hasn't started yet, show the start screen
} else if (gameOver) {
If the game is over, show the game over screen (with crowd animation)
} else {
Otherwise, the game is actively playing, so show the game playing screen

drawStartScreen()

This function displays the start screen. It's a simple example of how to use textSize() and text() to create multi-line, multi-sized UI. The positions use height/width math to stay centered regardless of screen size.

function drawStartScreen() {
  fill(255);
  textSize(48);
  text("Poison Rhythm Game", width / 2, height / 2 - 50);
  textSize(24);
  text("Hit the beats as they pass the red line!", width / 2, height / 2);
  textSize(18);
  text("Press any key or click to start", width / 2, height / 2 + 50);
}
Line-by-line explanation (7 lines)
fill(255);
Sets the text color to white
textSize(48);
Sets the title to a large font size of 48 pixels
text("Poison Rhythm Game", width / 2, height / 2 - 50);
Draws the game title centered horizontally and positioned above the middle of the screen
textSize(24);
Reduces font size to 24 pixels for the instructions
text("Hit the beats as they pass the red line!", width / 2, height / 2);
Draws the game instructions centered on the screen
textSize(18);
Reduces font size to 18 pixels for the smallest text
text("Press any key or click to start", width / 2, height / 2 + 50);
Draws the start instruction centered below the middle of the screen

drawGameOverScreen()

This function animates the crowd as it covers the screen on game over. It uses a loop to update each crowd element's position and opacity, then selects the appropriate emoji based on the crowd type (people, dogs, or water). The crowdCoverProgress variable controls the overall animation, and once it reaches 1, the final game over message appears.

🔬 This code picks a random emoji from an array based on crowd type. What happens if you add an else clause (else { emoji = '🤖'; }) to handle unknown types? Which emoji would appear if a fourth type was added?

    if (c.type === 'person') {
      emoji = random(peopleEmojis);
    } else if (c.type === 'dog') {
      emoji = random(dogEmojis);
    } else if (c.type === 'water') {
      emoji = random(waterEmojis);
    }
function drawGameOverScreen() {
  // Animate crowd covering the screen
  push();
  textAlign(CENTER, CENTER); // Ensure emojis are centered
  for (let i = 0; i < crowd.length; i++) {
    let c = crowd[i];
    // Move crowd elements upwards until they cover their target y-position
    if (c.y > height * crowdCoverProgress) {
      c.y -= crowdCoverSpeed * height;
      // Fade in crowd elements as they move up
      c.alpha = map(c.y, height * crowdCoverProgress, height, 255, 0);
      c.alpha = constrain(c.alpha, 0, 255);
    } else {
      c.y = constrain(c.y, 0, height);
      c.alpha = 255;
    }

    fill(255, c.alpha); // Emojis generally render in their own colors, but this sets the alpha

    let emoji;
    if (c.type === 'person') {
      emoji = random(peopleEmojis);
    } else if (c.type === 'dog') {
      emoji = random(dogEmojis);
    } else if (c.type === 'water') {
      emoji = random(waterEmojis);
    }
    textSize(c.size * 1.5); // Increase size for better emoji visibility
    text(emoji, c.x, c.y);
  }
  pop();

  // Update crowd cover progress
  crowdCoverProgress += crowdCoverSpeed;
  crowdCoverProgress = constrain(crowdCoverProgress, 0, 1);

  // Once the crowd fully covers the screen, display the final game over message
  if (crowdCoverProgress >= 1) {
    fill(0, 100); // Semi-transparent black overlay
    rect(0, 0, width, height);

    textFont(gameFont);
    textSize(64);
    fill(255, 0, 0); // Red "Game Over"
    text("GAME OVER", width / 2, height / 2 - 50);
    textSize(36);
    fill(255);
    text("Final Score: " + score, width / 2, height / 2);
    textSize(24);
    text("Press any key or click to restart", width / 2, height / 2 + 50);
  }
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

for-loop Crowd animation loop for (let i = 0; i < crowd.length; i++) {

Iterates through every crowd element, updating its position, opacity, and drawing it with the correct emoji type

conditional Crowd movement logic if (c.y > height * crowdCoverProgress) { c.y -= crowdCoverSpeed * height;

Moves crowd elements upward only if they haven't reached their target y-position yet

conditional Emoji type selection if (c.type === 'person') { emoji = random(peopleEmojis); } else if (c.type === 'dog') { emoji = random(dogEmojis); } else if (c.type === 'water') { emoji = random(waterEmojis); }

Selects a random emoji from the appropriate array based on the crowd element's type

conditional Final game over message if (crowdCoverProgress >= 1) {

Only displays the 'GAME OVER' text and final score after the crowd has fully covered the screen

push();
Saves the current drawing state so text alignment changes won't affect other text
for (let i = 0; i < crowd.length; i++) {
Loops through every crowd element to update and draw it
if (c.y > height * crowdCoverProgress) {
Only animate this crowd element if it's still below its target position
c.y -= crowdCoverSpeed * height;
Moves the crowd element upward by subtracting from its y-coordinate
c.alpha = map(c.y, height * crowdCoverProgress, height, 255, 0);
Maps the element's y-position to an alpha (opacity) value: as it moves up, it fades in from invisible to opaque
c.alpha = constrain(c.alpha, 0, 255);
Clamps the alpha value between 0 and 255 so it never goes out of range
fill(255, c.alpha);
Sets the fill color to white with the element's current alpha, controlling how transparent it is
if (c.type === 'person') { emoji = random(peopleEmojis);
If this crowd element is a person, pick a random person emoji from the array
crowdCoverProgress += crowdCoverSpeed;
Slowly advances the overall progress of the crowd covering animation
if (crowdCoverProgress >= 1) {
Once the crowd has fully covered the screen (progress reaches 1), show the game over message
text("GAME OVER", width / 2, height / 2 - 50);
Displays the 'GAME OVER' text in red at the center of the screen

drawGamePlaying()

drawGamePlaying() is the main gameplay screen. It draws the hit zone line, animates falling beats, detects misses, displays score and health, and animates feedback text. The backward loop through beats is important: it allows safe removal of beats without skipping elements.

🔬 This loop draws each beat and moves it downward by adding beatSpeed to y. What happens if you change '+=' to '-=' so beats move upward instead? How does that change the gameplay?

  for (let i = beats.length - 1; i >= 0; i--) {
    let beat = beats[i];
    ellipse(beat.x, beat.y, beatSize, beatSize);
    beat.y += beatSpeed;
function drawGamePlaying() {
  // Draw hit zone line
  stroke(255, 0, 0);
  strokeWeight(4);
  line(0, hitZoneY, width, hitZoneY);

  // Draw beats and update their positions
  noStroke();
  fill(255);
  for (let i = beats.length - 1; i >= 0; i--) {
    let beat = beats[i];
    ellipse(beat.x, beat.y, beatSize, beatSize);
    beat.y += beatSpeed; // Move beat downwards

    // Remove beats that go off-screen below the hit zone (misses)
    if (beat.y > hitZoneY + beatSize / 2) {
      handleMiss(); // Player missed this beat
      beats.splice(i, 1); // Remove the beat
      feedbackText = "MISS!";
      feedbackAlpha = 255;
      feedbackX = beat.x;
      feedbackY = beat.y - beatSize; // Position feedback above the missed beat
    }
  }

  // Draw score and health UI
  fill(255);
  textSize(24);
  textAlign(LEFT, TOP);
  text("Score: " + score, 20, 20);
  textAlign(RIGHT, TOP);
  text("Health: " + health, width - 20, 20);

  // Draw feedback text (PERFECT, MISS, TOO EARLY/LATE)
  if (feedbackAlpha > 0) {
    fill(255, feedbackAlpha);
    textSize(36);
    textAlign(CENTER, BOTTOM);
    text(feedbackText, feedbackX, feedbackY);
    feedbackAlpha -= 5; // Fade out feedback text
  }
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

calculation Hit zone visualization stroke(255, 0, 0); strokeWeight(4); line(0, hitZoneY, width, hitZoneY);

Draws a thick red horizontal line showing where players must hit beats

for-loop Beat animation and collision loop for (let i = beats.length - 1; i >= 0; i--) {

Loops backward through all beats, updating positions and detecting misses

conditional Miss detection if (beat.y > hitZoneY + beatSize / 2) { handleMiss(); beats.splice(i, 1);

Removes beats that pass below the hit zone, triggering a miss and reducing health

conditional Feedback text fading if (feedbackAlpha > 0) { fill(255, feedbackAlpha); textSize(36); textAlign(CENTER, BOTTOM); text(feedbackText, feedbackX, feedbackY); feedbackAlpha -= 5;

Draws feedback text (PERFECT!, MISS!) that fades out by reducing its alpha value each frame

stroke(255, 0, 0);
Sets the stroke color to red for the hit zone line
strokeWeight(4);
Makes the line 4 pixels thick so it's clearly visible
line(0, hitZoneY, width, hitZoneY);
Draws a horizontal red line across the entire width of the screen at the hit zone position
noStroke();
Removes the stroke for beat circles so they appear as solid shapes
fill(255);
Sets the beat color to white
for (let i = beats.length - 1; i >= 0; i--) {
Loops backward through the beats array (from last to first) so removing elements doesn't skip any
ellipse(beat.x, beat.y, beatSize, beatSize);
Draws a white circle (beat) at its current position with diameter equal to beatSize
beat.y += beatSpeed;
Increases the beat's y-coordinate each frame, making it fall downward
if (beat.y > hitZoneY + beatSize / 2) {
Checks if the beat has passed the hit zone (bottom of the circle below the red line)
beats.splice(i, 1);
Removes the beat from the array so it disappears from the screen
feedbackAlpha -= 5;
Decreases the feedback text's opacity each frame, making it fade out gradually

handleMiss()

This small but important function handles what happens when a player misses a beat. It reduces health and checks for game over. The lossCount increment is key to the progressive difficulty feature: each loss changes the crowd type.

function handleMiss() {
  health--;
  if (health <= 0) {
    gameOver = true;
    lossCount++; // Increment loss count when the game ends
    Tone.Transport.stop(); // Stop audio scheduling
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Game over condition if (health <= 0) {

Checks if health has reached 0 and triggers game over if true

health--;
Decreases health by 1 when the player misses a beat
if (health <= 0) {
Checks if health has dropped to 0 or below, meaning the player has lost
gameOver = true;
Sets the gameOver flag to true, which will trigger the game over screen on the next draw() call
lossCount++;
Increments the loss counter, which determines which crowd type (people, dogs, or water) appears on the next game
Tone.Transport.stop();
Stops the Tone.js audio transport, which halts beat scheduling and audio playback

keyPressed()

keyPressed() is a built-in p5.js function called whenever a key is pressed. This implementation routes the key press to the appropriate action based on game state, then returns false to prevent the browser from handling the key (avoiding unwanted scrolling or text selection).

function keyPressed() {
  if (!gameStarted) {
    startGame(); // Start game if not already started
  } else if (gameOver) {
    restartGame(); // Restart game if game over
  } else {
    checkHit(); // Check for a hit during gameplay
  }
  return false; // Prevent default browser behavior (e.g., scrolling)
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Game state routing on key press if (!gameStarted) { startGame(); } else if (gameOver) { restartGame(); } else { checkHit(); }

Routes key presses to the appropriate action based on the current game state

if (!gameStarted) {
If the game hasn't started yet, interpret the key press as a start command
startGame();
Begins the game, initializing audio and resetting all game variables
} else if (gameOver) {
If the game is over, interpret the key press as a restart command
restartGame();
Restarts the game
} else {
Otherwise, the game is actively playing, so check if this key press hit a beat
checkHit();
Checks if there's a beat in the hit zone to be hit
return false;
Prevents the browser's default action for this key (e.g., scrolling), keeping focus on the game

mousePressed()

mousePressed() is called whenever the mouse is clicked anywhere on the canvas. It uses identical logic to keyPressed() but is triggered by mouse clicks instead of key presses, giving players two input methods.

function mousePressed() {
  if (!gameStarted) {
    startGame(); // Start game if not already started
  } else if (gameOver) {
    restartGame(); // Restart game if game over
  } else {
    checkHit(); // Check for a hit during gameplay
  }
  return false; // Prevent default browser behavior (e.g., context menu)
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Game state routing on mouse click if (!gameStarted) { startGame(); } else if (gameOver) { restartGame(); } else { checkHit(); }

Routes mouse clicks to the appropriate action based on the current game state

if (!gameStarted) {
If the game hasn't started, interpret the click as a start command
startGame();
Begins the game
} else if (gameOver) {
If the game is over, interpret the click as a restart command
restartGame();
Restarts the game
} else {
Otherwise, the game is playing, so check for a beat hit
checkHit();
Checks if there's a beat in the hit zone to be hit
return false;
Prevents the browser's default mouse action (right-click context menu), keeping focus on the game

startGame()

startGame() resets all game variables and initializes audio. The 'async' keyword and 'await' allow it to properly start the Tone.js audio context, which requires user interaction. The crowd type selection based on lossCount creates the progressive difficulty feature.

🔬 This code creates a progression: people → dogs → water. What happens if you change the numbers so the progression happens faster (e.g., lossCount >= 2 for dogs, >= 1 for water)? Or slower (e.g., lossCount >= 4 for dogs, >= 6 for water)?

  if (lossCount >= 3) { // 3rd loss and beyond
    crowdType = 'water';
  } else if (lossCount >= 2) { // 2nd loss
    crowdType = 'dog';
  } else { // 0th or 1st loss
    crowdType = 'person';
  }
async function startGame() {
  gameStarted = true;
  gameOver = false;
  score = 0;
  health = 5;
  beats = [];
  patternIndex = 0;
  crowdCoverProgress = 0; // Reset crowd animation

  // Clear existing crowd and generate new one based on lossCount
  // If lossCount is 0 or 1, generate 'person'.
  // If lossCount is 2, generate 'dog'.
  // If lossCount is 3 or more, generate 'water'.
  let crowdType;
  if (lossCount >= 3) { // 3rd loss and beyond
    crowdType = 'water';
  } else if (lossCount >= 2) { // 2nd loss
    crowdType = 'dog';
  } else { // 0th or 1st loss
    crowdType = 'person';
  }
  generateCrowd(crowdType);

  // Ensure audio context starts on user interaction
  await Tone.start();
  Tone.Transport.bpm.value = 120; // Set BPM (beats per minute)
  Tone.Transport.start(); // Start Tone.js transport
}
Line-by-line explanation (15 lines)

🔧 Subcomponents:

calculation Game state reset gameStarted = true; gameOver = false; score = 0; health = 5; beats = []; patternIndex = 0; crowdCoverProgress = 0;

Resets all game variables to their initial values for a fresh game

conditional Progressive difficulty crowd selection if (lossCount >= 3) { crowdType = 'water'; } else if (lossCount >= 2) { crowdType = 'dog'; } else { crowdType = 'person'; }

Determines which crowd type to generate based on how many times the player has lost

calculation Audio initialization await Tone.start(); Tone.Transport.bpm.value = 120; Tone.Transport.start();

Starts the Tone.js audio context, sets the tempo to 120 BPM, and begins beat scheduling

async function startGame() {
The 'async' keyword allows this function to use 'await' for asynchronous operations like starting Tone.js audio
gameStarted = true;
Sets the game state to started, which stops displaying the start screen
gameOver = false;
Clears the game over state to begin playing
score = 0;
Resets the score to 0 for a fresh game
health = 5;
Resets health to 5, giving the player 5 chances to miss beats before losing
beats = [];
Clears any remaining beats from the previous game
patternIndex = 0;
Resets the pattern index to the beginning, so the rhythm starts fresh
crowdCoverProgress = 0;
Resets the crowd animation progress so it starts off-screen on the next game over
if (lossCount >= 3) {
If the player has lost 3 or more times, set crowd type to water
} else if (lossCount >= 2) {
If the player has lost 2 times, set crowd type to dog
} else {
Otherwise (0 or 1 losses), set crowd type to person
generateCrowd(crowdType);
Creates the appropriate crowd based on the selected type
await Tone.start();
Starts the Tone.js audio context, which requires user interaction (key or click). The 'await' keyword pauses until this completes
Tone.Transport.bpm.value = 120;
Sets the tempo to 120 beats per minute
Tone.Transport.start();
Starts the Tone.js transport, which begins executing the beatLoop at precise intervals

generateCrowd()

generateCrowd() creates an array of crowd objects. Each object stores x, y, size, alpha, and type. This function is called once at startup and then again whenever a new game starts, allowing the crowd type to change based on the player's loss count.

🔬 This loop creates crowdDensity elements with random positions. What happens if you change the y range to random(0, height) (top half of screen) instead of random(height, height * 2) (below screen)? Where would the crowd start?

  for (let i = 0; i < crowdDensity; i++) {
    crowd.push({
      x: random(width),
      y: random(height, height * 2),
function generateCrowd(type) {
  crowd = []; // Clear existing crowd
  for (let i = 0; i < crowdDensity; i++) {
    crowd.push({
      x: random(width),
      y: random(height, height * 2), // Start below the canvas
      size: random(16, 32),
      alpha: 0,
      type: type // Store the type of crowd member ('person', 'dog', or 'water')
    });
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

for-loop Crowd element loop for (let i = 0; i < crowdDensity; i++) {

Creates crowdDensity number of crowd elements with random positions, sizes, and the specified type

crowd = [];
Clears the existing crowd array, removing all previous crowd elements
for (let i = 0; i < crowdDensity; i++) {
Loops crowdDensity (150) times to create that many crowd elements
x: random(width),
Positions each crowd element randomly across the width of the screen
y: random(height, height * 2),
Positions each crowd element randomly below the canvas (between height and height*2)
size: random(16, 32),
Gives each element a random size between 16 and 32 pixels for visual variety
alpha: 0,
Sets initial opacity to 0 (fully transparent) so elements fade in as they animate
type: type
Stores the type passed to the function ('person', 'dog', or 'water') so the correct emoji is drawn later

restartGame()

restartGame() is a wrapper function that calls startGame(). It exists for clarity in the code structure, showing that the game over screen transitions directly to a fresh game start.

function restartGame() {
  startGame();
}
Line-by-line explanation (1 lines)
startGame();
Simply calls startGame() to reset and begin a fresh game

checkHit()

checkHit() is called when the player presses a key or clicks the mouse. It searches for a beat within the hit zone (a circle of radius beatSize/2 around the red line), finds the closest one, and either awards points or reduces health. This is the core of the hit detection system.

🔬 This loop finds the beat closest to the red line. What happens if you change distance < minDistance to distance < minDistance * 2? The hit zone would be twice as large—how would that change gameplay?

  // Find the closest beat within the hit window
  for (let i = 0; i < beats.length; i++) {
    let beat = beats[i];
    let distance = abs(beat.y - hitZoneY);
    if (distance < minDistance) {
      minDistance = distance;
      closestBeatIndex = i;
function checkHit() {
  let hit = false;
  let closestBeatIndex = -1;
  let minDistance = beatSize / 2; // Hit window radius around the red line
  let hitBeatX = width / 2; // Default x-position for feedback

  // Find the closest beat within the hit window
  for (let i = 0; i < beats.length; i++) {
    let beat = beats[i];
    let distance = abs(beat.y - hitZoneY);
    if (distance < minDistance) {
      minDistance = distance;
      closestBeatIndex = i;
      hit = true;
      hitBeatX = beat.x; // Store x-position of the hit beat
    }
  }

  if (hit) {
    // Perfect hit!
    score += 10;
    beats.splice(closestBeatIndex, 1); // Remove the hit beat
    feedbackText = "PERFECT!";
    feedbackAlpha = 255;
    feedbackX = hitBeatX; // Use the stored x
    feedbackY = hitZoneY - beatSize;
    synth.triggerAttackRelease("G4", "16n"); // Play a different sound for hit
  } else {
    // Missed hit or hit too early/late
    handleMiss();
    feedbackText = "TOO EARLY/LATE!";
    feedbackAlpha = 255;
    feedbackX = hitBeatX; // Use default or last known beat x for feedback placement
    feedbackY = hitZoneY - beatSize;
  }
}
Line-by-line explanation (14 lines)

🔧 Subcomponents:

conditional Distance check if (distance < minDistance) {

Updates the closest beat if a beat is closer to the hit zone than the previous closest

conditional Hit success logic if (hit) { score += 10; beats.splice(closestBeatIndex, 1);

If a beat was found in the hit zone, award points and remove the beat

conditional Hit failure logic } else { handleMiss();

If no beat was in the hit zone, reduce health as if a beat was missed

let hit = false;
Tracks whether the player successfully hit a beat
let minDistance = beatSize / 2;
Sets the hit window to half the beat size (radius), creating a forgiving hit zone
for (let i = 0; i < beats.length; i++) {
Loops through all falling beats
let distance = abs(beat.y - hitZoneY);
Calculates how far the beat is from the hit zone line (vertical distance)
if (distance < minDistance) {
If the beat is close enough to the hit zone, record it as a candidate
minDistance = distance;
Updates minDistance to this closer value so we find the closest beat
closestBeatIndex = i;
Records the index of the closest beat so we can remove it later
hit = true;
Marks that we found a beat in the hit zone
if (hit) {
If we found a beat in the hit zone, award points
score += 10;
Adds 10 points to the score for a successful hit
beats.splice(closestBeatIndex, 1);
Removes the hit beat from the beats array so it disappears
synth.triggerAttackRelease("G4", "16n");
Plays a G note on the synthesizer as audio feedback for a successful hit
} else {
If no beat was in the hit zone, the player clicked/pressed too early or too late
handleMiss();
Calls handleMiss() to reduce health and check for game over

windowResized()

windowResized() is a built-in p5.js function called whenever the browser window is resized. This sketch resizes the canvas to fill the window and adjusts the hit zone position accordingly, ensuring the game works on any screen size.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  hitZoneY = height - 100; // Adjust hit zone position
}
Line-by-line explanation (2 lines)
resizeCanvas(windowWidth, windowHeight);
Resizes the p5.js canvas to match the browser window's new dimensions
hitZoneY = height - 100;
Recalculates the hit zone position so it stays 100 pixels from the bottom on the new canvas size

📦 Key Variables

beats array

Stores an array of beat objects, each with x, y, and time properties. Updated as beats are added by Tone.js and removed when hit or missed.

let beats = [];
beatSpeed number

Controls how many pixels beats move downward each frame. Higher values make the game faster.

let beatSpeed = 5;
hitZoneY number

Y-coordinate of the red hit zone line. Set to height - 100 so the zone is near the bottom.

let hitZoneY;
beatSize number

The diameter of beat circles in pixels. Affects both visual size and hit zone radius.

let beatSize = 40;
score number

Tracks the player's current score. Increases by 10 for each perfect hit.

let score = 0;
health number

Tracks the player's remaining chances to miss. Decreases by 1 on each miss; game ends when it reaches 0.

let health = 5;
gameStarted boolean

Game state flag: true = playing, false = start screen. Controls which screen is drawn.

let gameStarted = false;
gameOver boolean

Game state flag: true = game over screen, false = still playing. Controls which screen is drawn.

let gameOver = false;
crowd array

Array of crowd objects, each with x, y, size, alpha, and type. Rendered on the game over screen.

let crowd = [];
crowdDensity number

Number of crowd elements to generate. Controls how full the crowd appears.

let crowdDensity = 150;
crowdCoverProgress number

Tracks animation progress of crowd covering the screen (0 to 1). Controls crowd position and opacity.

let crowdCoverProgress = 0;
crowdCoverSpeed number

How quickly the crowd animates upward to cover the screen on game over.

let crowdCoverSpeed = 0.005;
gameFont object

Stores the loaded font for all text rendering. Loaded in preload().

let gameFont;
synth object

Tone.js PolySynth object that plays audio sounds for beats and hits.

let synth;
beatLoop object

Tone.js Loop object that schedules beat creation at precise musical intervals.

let beatLoop;
pattern array

Array of 1s and 0s representing the rhythm pattern. 1 = beat, 0 = rest.

let pattern = [1, 0, 1, 0, 1, 1, 0, 1];
patternIndex number

Current position in the rhythm pattern. Increments each beat and loops at the end.

let patternIndex = 0;
feedbackText string

Text to display as feedback (PERFECT!, MISS!, TOO EARLY/LATE!). Fades out each frame.

let feedbackText = '';
feedbackAlpha number

Opacity of feedback text (0-255). Decreases by 5 each frame to create a fade-out effect.

let feedbackAlpha = 0;
feedbackX number

Horizontal position of feedback text. Set to the x-coordinate of the hit or missed beat.

let feedbackX;
feedbackY number

Vertical position of feedback text. Set to just above the hit zone.

let feedbackY;
lossCount number

Tracks how many times the player has lost. Determines which crowd type (person, dog, water) appears.

let lossCount = 0;
peopleEmojis array

Array of people and celebration emojis used for the first crowd appearance.

const peopleEmojis = ['🧑‍🤝‍🧑', '👨‍👩‍👧‍👦', ...];
dogEmojis array

Array of dog emojis used for the second crowd appearance (after first loss).

const dogEmojis = ['🐕', '🐶', ...];
waterEmojis array

Array of water and animal emojis used for the third crowd appearance (after second loss).

const waterEmojis = ['🌊', '💧', ...];

🔧 Potential Improvements (6)

Here are some ways this code could be enhanced:

BUG checkHit() - hit window logic

If no beats are on screen, hitBeatX remains at width/2, potentially placing feedback text far from where the player expects it

💡 Use mouseX or the center of the screen as default instead of hardcoding width/2, or store the feedback position at the time of input

BUG drawGamePlaying() - beat collision detection

The miss detection uses beat.y > hitZoneY + beatSize/2, but the hit zone actually extends from hitZoneY - beatSize/2 to hitZoneY + beatSize/2. A beat could be considered missed while still overlapping the hit zone visually

💡 Change the miss condition to beat.y > hitZoneY + beatSize to better match the visual hit zone, or recalculate the hit zone boundaries more carefully

PERFORMANCE drawGameOverScreen() - emoji selection

random(peopleEmojis) is called every frame for every crowd element, causing constant new emoji selections and unnecessarily calling random() hundreds of times per frame

💡 Pre-select emojis when crowd elements are created in generateCrowd(), storing the chosen emoji in the crowd object's properties instead of regenerating them each frame

STYLE Emoji arrays definition

Emoji constants are defined in the global scope but not in const form (should be const, not let). Variable naming doesn't follow camelCase conventions (should be peopleEmojis but written as `peopleEmojis`, which is correct)

💡 Ensure all constant arrays use const instead of let for clarity. Consider grouping all emoji constants in a single object for better organization: const emojis = { people: [...], dogs: [...], water: [...] }

FEATURE Game mechanics

The game has no difficulty progression beyond crowd visual changes—beat speed, pattern complexity, and hit zone don't change with loss count

💡 Progressively increase beatSpeed or crowdDensity with each loss, or introduce harder patterns after losses to create gameplay difficulty progression beyond the visual aesthetic changes

FEATURE Feedback system

Hit feedback only shows PERFECT, TOO EARLY/LATE, and MISS, but doesn't distinguish between early and late or provide timing precision feedback

💡 Calculate whether the hit was early or late and show 'TOO EARLY!' or 'TOO LATE!' separately. Consider showing the distance (pixels) from the perfect hit zone for skilled players to improve

🔄 Code Flow

Code flow showing preload, setup, scheduleBeat, draw, drawStartScreen, drawGameOverScreen, drawGamePlaying, handleMiss, keyPressed, mousePressed, startGame, generateCrowd, restartGame, checkHit, windowResized

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

graph TD start[Start] --> setup[setup] setup --> canvas-setup[Canvas and UI initialization] setup --> tone-synth[Tone.js synthesizer setup] setup --> audio-init[Audio initialization] setup --> beat-loop[Audio scheduling loop] setup --> generateCrowd[generateCrowd] setup --> draw[draw loop] click setup href "#fn-setup" click canvas-setup href "#sub-canvas-setup" click tone-synth href "#sub-tone-synth" click audio-init href "#sub-audio-init" click beat-loop href "#sub-beat-loop" click generateCrowd href "#fn-generateCrowd" draw --> state-routing[Game state routing] state-routing --> drawStartScreen[drawStartScreen] state-routing --> drawGameOverScreen[drawGameOverScreen] state-routing --> drawGamePlaying[drawGamePlaying] click draw href "#fn-draw" click state-routing href "#sub-state-routing" click drawStartScreen href "#fn-drawStartScreen" click drawGameOverScreen href "#fn-drawGameOverScreen" click drawGamePlaying href "#fn-drawGamePlaying" drawGamePlaying --> hit-zone-line[Hit zone visualization] drawGamePlaying --> beat-loop[Beat animation and collision loop] beat-loop --> miss-detection[Miss detection] beat-loop --> feedback-animation[Feedback text fading] beat-loop --> game-over-check[Game over condition] click hit-zone-line href "#sub-hit-zone-line" click beat-loop href "#sub-beat-loop" click miss-detection href "#sub-miss-detection" click feedback-animation href "#sub-feedback-animation" click game-over-check href "#sub-game-over-check" beat-loop --> pattern-check[Pattern-based beat creation] pattern-check --> beat-object[Beat object creation] pattern-check --> pattern-advance[Pattern advancement] click pattern-check href "#sub-pattern-check" click beat-object href "#sub-beat-object" click pattern-advance href "#sub-pattern-advance" drawGameOverScreen --> crowd-loop[Crowd animation loop] crowd-loop --> crowd-movement[Crowd movement logic] crowd-loop --> emoji-selection[Emoji type selection] crowd-loop --> final-message[Final game over message] click crowd-loop href "#sub-crowd-loop" click crowd-movement href "#sub-crowd-movement" click emoji-selection href "#sub-emoji-selection" click final-message href "#sub-final-message" keyPressed --> state-routing-keys[Game state routing on key press] mousePressed --> state-routing-mouse[Game state routing on mouse click] click keyPressed href "#fn-keyPressed" click state-routing-keys href "#sub-state-routing-keys" click mousePressed href "#fn-mousePressed" click state-routing-mouse href "#sub-state-routing-mouse" startGame --> variable-reset[Game state reset] startGame --> crowd-type-selection[Crowd type selection] click startGame href "#fn-startGame" click variable-reset href "#sub-variable-reset" click crowd-type-selection href "#sub-crowd-type-selection" checkHit --> beat-search[Closest beat search] beat-search --> distance-calc[Distance check] distance-calc --> hit-success[Hit success logic] distance-calc --> hit-failure[Hit failure logic] click checkHit href "#fn-checkHit" click beat-search href "#sub-beat-search" click distance-calc href "#sub-distance-calc" click hit-success href "#sub-hit-success" click hit-failure href "#sub-hit-failure" windowResized --> canvas-setup click windowResized href "#fn-windowResized"

❓ Frequently Asked Questions

What visual elements does the p5.js sketch create?

The sketch features animated beat circles that fall from the top of the screen, a crowd represented by various emojis, and dynamic feedback text indicating player performance.

How can users interact with the p5.js creative coding sketch?

Users can interact by clicking to hit the falling beats, aiming to score points while managing their health through misses.

What creative coding techniques does this sketch demonstrate?

This sketch showcases rhythm-based gameplay mechanics, real-time visual feedback, and the integration of audio synthesis using Tone.js.

Preview

Sketch 2026-04-17 16:24 - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Sketch 2026-04-17 16:24 - Code flow showing preload, setup, scheduleBeat, draw, drawStartScreen, drawGameOverScreen, drawGamePlaying, handleMiss, keyPressed, mousePressed, startGame, generateCrowd, restartGame, checkHit, windowResized
Code Flow Diagram