Black screen is gone

This is a multi-level diamond hunting game where players pan across vast procedurally-generated towns at increasing depths to find and tap a hidden cyan diamond (or UFO, football, or cosmic object depending on the level). As difficulty increases across 9 levels, the diamond becomes smaller, the town denser, and special mechanics like digging, fishing, and asteroid-drifting are introduced.

🧪 Try This!

Experiment with the code by making these changes:

  1. Increase diamond depth to ultra-hard — Change the Level 1 diamond depth to force the player to pan much further to find it—teaches how difficulty is tuned.
  2. Make the diamond huge
  3. Fill the town with people — Set personDensityThreshold to 0.01 to ensure almost every empty space has a person—see how crowded Level 5+ can get.
  4. Change the win message text — Edit the draw() win message to see a custom celebration when the diamond is found.
  5. Disable the chasing UFO
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch creates an engaging multi-level game where the player hunts for a hidden diamond (or themed objects) in procedurally-generated towns. It demonstrates several core p5.js techniques: camera/viewport control through translate(), touch-based panning and interaction via touchStarted() and touchMoved(), collision detection using distance calculations, and procedural generation using noise-like randomization seeded by grid coordinates. The game spans 9 levels of increasing difficulty, each introducing new mechanics—from simple tapping in Level 1 to fishing in Level 8 and cosmic drifting in Level 9.

The code is organized into setup() which initializes the canvas and UI, draw() which runs the main game loop rendering the town and handling animations, and specialized functions like generateBlock() for procedural content, checkAndGenerateNewBlocks() for viewport-based generation, and level-specific handlers like triggerLevel8Win(). By studying it, you will learn how to build scalable interactive experiences with procedural content, manage complex game state across multiple levels, implement smooth camera systems, and create satisfying game feel through sound and particle effects.

⚙️ How It Works

  1. When the sketch loads, setup() creates a full-window canvas, initializes p5.sound oscillators for audio feedback, loads the current level from browser storage, and calls resetGame() to configure difficulty parameters and generate the initial town around a randomly-placed hidden diamond.
  2. On every frame, draw() clears the background, applies a camera offset (translate(camX, camY)) to simulate panning, and calls checkAndGenerateNewBlocks() to procedurally generate town blocks (buildings and people) only for the currently visible region and a small buffer around it.
  3. When the player touches the screen, touchStarted() checks whether they tapped near the diamond's location using distance calculation (dist()). If close enough (within tapTolerance), the diamond is marked as found, a sound plays, and the game advances to the next level after a 2-second delay.
  4. During touch drag, touchMoved() updates camX and camY based on finger movement, panning the view around the town. For Level 9, it also stores pan velocity to create a drift effect after the touch ends.
  5. As levels progress, generateBlock() creates denser towns with smaller diamonds and tighter tap tolerances. Special levels (6 = UFO with chasing enemy, 7 = football with digging animation, 8 = fishing with rod casting, 9 = asteroid field with drifting) introduce unique mechanics and visuals.
  6. When the diamond is found, a confetti particle system bursts, and for some levels (6 and 7), nearby townspeople animate toward the found object before leaving the screen, creating a celebratory post-win sequence.

🎓 Concepts You'll Learn

Procedural generation with grid-based seedingCamera systems and viewport translationTouch input and gesture panningCollision detection with distance calculationsMulti-level game state managementParticle systems for visual feedbackAudio synthesis with oscillatorsConditional rendering based on level

📝 Code Breakdown

setup()

setup() runs once at sketch startup. Use it to initialize your canvas, load resources, set up variables, and attach event listeners. This sketch demonstrates how to integrate p5.js with HTML DOM elements (buttons, modals) and the browser's localStorage API for persistence.

function setup() {
  createCanvas(windowWidth, windowHeight);
  noSmooth();

  createLevelButtons();

  hintButton = select('#hint-button');
  if (hintButton) {
    hintButton.elt.addEventListener('touchend', (event) => {
      event.stopPropagation();
      showHint();
    });
  }

  let savedLevel = localStorage.getItem('biggestTownDiamondHuntLevel');
  if (savedLevel) {
    currentLevel = parseInt(savedLevel);
    if (currentLevel < 1 || currentLevel > 9) {
      currentLevel = 1;
    }
  }

  resetGame(currentLevel);

  howToPlayModal = select('#how-to-play-modal');
  let closeButton = select('#close-how-to-play');

  if (closeButton) {
    closeButton.elt.addEventListener('touchend', (event) => {
      event.stopPropagation();
      closeHowToPlayModal();
    });
  }

  if (howToPlayModal) {
    howToPlayModal.elt.addEventListener('touchend', (event) => {
      if (event.target === howToPlayModal.elt) {
        closeHowToPlayModal();
      }
    });
  }

  showHowToPlayModal();
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

calculation Canvas Setup createCanvas(windowWidth, windowHeight);

Creates a full-window canvas that fills the entire browser viewport

conditional Load Saved Level from Storage let savedLevel = localStorage.getItem('biggestTownDiamondHuntLevel');

Retrieves the player's last completed level from browser local storage so the game resumes where they left off

calculation Modal and Button Initialization howToPlayModal = select('#how-to-play-modal');

Selects HTML elements and attaches event listeners for modal display and level selection buttons

createCanvas(windowWidth, windowHeight);
Creates a p5.js canvas that fills the entire browser window, making the game responsive to different screen sizes
noSmooth();
Disables anti-aliasing for a small performance boost—pixels render crisply without blur
let savedLevel = localStorage.getItem('biggestTownDiamondHuntLevel');
Retrieves the player's last completed level from the browser's localStorage, allowing progress to persist between page reloads
if (currentLevel < 1 || currentLevel > 9) { currentLevel = 1; }
Validates the saved level is between 1 and 9; if not, defaults to Level 1 to prevent errors
resetGame(currentLevel);
Initializes the game state, difficulty settings, and town generation for the selected level
showHowToPlayModal();
Displays the 'How to Play' instruction modal automatically when the game first loads

draw()

draw() is the game loop—it runs ~60 times per second and is where all animation, collision checking, and rendering happens. This sketch shows how to organize a complex multi-level game: conditionals check the current level, a camera system (translate) enables panning, and viewport-based rendering (checking visible blocks) keeps performance fast. Study how the diamond position changes based on level, and how post-win animations (confetti, grabbing people) are coordinated.

function draw() {
  if (currentLevel === 9) {
    background(0);
  } else {
    background(220);
  }

  textAlign(LEFT, TOP);
  textSize(32);
  fill(0);
  noStroke();
  text(`Level: ${currentLevel}`, 10, 10);
  if (currentLevel === 8) {
    text(`Fish Caught: ${fishCaughtCount}`, 10, 40);
  }

  if (currentLevel !== 8) {
    translate(camX, camY);
  }
  
  if (!panning && currentLevel === 9 && !diamondFound && !gameCompleted) {
    camX += driftVelocityX;
    camY += driftVelocityY;
    driftVelocityX *= 0.95;
    driftVelocityY *= 0.95;
    if (abs(driftVelocityX) < 0.1) driftVelocityX = 0;
    if (abs(driftVelocityY) < 0.1) driftVelocityY = 0;
    camX = constrain(camX, -(TOWN_WIDTH - width), 0);
  }

  checkAndGenerateNewBlocks();

  if (currentLevel === 9) {
    for (let nebula of nebulae) {
      fill(nebula.color);
      noStroke();
      ellipse(nebula.x, nebula.y, nebula.size, nebula.size);
    }

    for (let asteroid of asteroids) {
      fill(asteroid.color);
      stroke(asteroid.color.levels[0] + 50);
      strokeWeight(1);
      ellipse(asteroid.x, asteroid.y, asteroid.size, asteroid.size);
      asteroid.x += asteroid.speedX;
      asteroid.y += asteroid.speedY;
      if (asteroid.x > TOWN_WIDTH) asteroid.x = -asteroid.size;
      if (asteroid.x < -asteroid.size) asteroid.x = TOWN_WIDTH;
      let screenTop = -camY;
      let screenBottom = -camY + height;
      if (asteroid.y > screenBottom + height) asteroid.y = screenTop - height;
      if (asteroid.y < screenTop - height) asteroid.y = screenBottom + height;
    }

  } else if (currentLevel !== 8) {
    let visibleStartX = -camX;
    let visibleStartY = -camY;
    let visibleEndX = visibleStartX + width;
    let visibleEndY = visibleStartY + height;

    let buffer = BLOCK_SIZE * 2;
    visibleStartX -= buffer;
    visibleStartY -= buffer;
    visibleEndX += buffer;
    visibleEndY += buffer;

    let startDrawBlockX = floor(visibleStartX / BLOCK_SIZE);
    let startDrawBlockY = floor(visibleStartY / BLOCK_SIZE);
    let endDrawBlockX = ceil(visibleEndX / BLOCK_SIZE);
    let endDrawBlockY = ceil(visibleEndY / BLOCK_SIZE);

    stroke(100);
    strokeWeight(2);
    for (let bx = startDrawBlockX; bx < endDrawBlockX; bx++) {
      for (let by = startDrawBlockY; by < endDrawBlockY; by++) {
        if (bx >= 0 && bx < TOWN_WIDTH / BLOCK_SIZE) {
          let blockData = generatedBlocks.get(`${bx},${by}`);
          if (blockData) {
            let x = bx * BLOCK_SIZE;
            let y = by * BLOCK_SIZE;
            line(x, y, x + BLOCK_SIZE, y);
            line(x + BLOCK_SIZE, y, x + BLOCK_SIZE, y + BLOCK_SIZE);
            line(x, y + BLOCK_SIZE, x + BLOCK_SIZE, y + BLOCK_SIZE);
            line(x, y, x, y + BLOCK_SIZE);
          }
        }
      }
    }

    noStroke();

    for (let bx = startDrawBlockX; bx < endDrawBlockX; bx++) {
      for (let by = startDrawBlockY; by < endDrawBlockY; by++) {
        if (bx >= 0 && bx < TOWN_WIDTH / BLOCK_SIZE) {
          let blockData = generatedBlocks.get(`${bx},${by}`);
          if (blockData) {
            for (let person of blockData.people) {
              fill(person.color);
              rect(person.x, person.y, person.bodyW, person.bodyH);
              ellipse(person.x + person.bodyW / 2, person.y - person.headSize / 2, person.headSize, person.headSize);
            }
          }
        }
      }
    }

    for (let bx = startDrawBlockX; bx < endDrawBlockX; bx++) {
      for (let by = startDrawBlockY; by < endDrawBlockY; by++) {
        if (bx >= 0 && bx < TOWN_WIDTH / BLOCK_SIZE) {
          let blockData = generatedBlocks.get(`${bx},${by}`);
          if (blockData) {
            for (let building of blockData.buildings) {
              fill(building.color);
              rect(building.x, building.y, building.w, building.h);
            }
          }
        }
      }
    }
  }

  if (currentLevel === 6 && !diamondFound && chasingUFO) {
    let targetX = -camX + width / 2;
    let targetY = -camY + height / 2;
    let moveVec = createVector(targetX - chasingUFO.x, targetY - chasingUFO.y);
    moveVec.normalize().mult(chasingUFO.speed);
    chasingUFO.x += moveVec.x;
    chasingUFO.y += moveVec.y;
    chasingUFO.x += sin(frameCount * 0.05) * 5;
    chasingUFO.y += cos(frameCount * 0.04) * 5;
    drawUFO(chasingUFO.x, chasingUFO.y, chasingUFO.size, color(255, 0, 0, 180), color(255, 255, 0));
  }

  if (currentLevel === 6 && petUFO) {
    let targetX = -camX + width / 2;
    let targetY = -camY + height / 2;
    petUFO.x = lerp(petUFO.x, targetX, 0.05);
    petUFO.y = lerp(petUFO.y, targetY, 0.05);
    drawUFO(petUFO.x, petUFO.y, petUFO.size, petUFO.bodyColor, petUFO.lightColor);
  }

  if (!diamondFound) {
    if (currentLevel === 8) {
      fill(50, 150, 255);
      noStroke();
      rect(-camX, height / 2, width, height / 2);
      stroke(100);
      strokeWeight(4);
      line(width / 2 - 50, 0, width / 2 + 50, height / 2 - 100);
      fill(150);
      strokeWeight(2);
      rect(width / 2 - 60, -10, 20, 20);
      stroke(0);
      strokeWeight(1);
      line(width / 2 + 50, height / 2 - 100, diamondX, fishingBobberY + fishingBobberJiggleOffset);
      fill(255, 0, 0);
      noStroke();
      ellipse(diamondX, fishingBobberY + fishingBobberJiggleOffset, 10, 15);

      if (fishingRodActive) {
        let elapsed = millis() - fishingRodCastTime;
        if (elapsed > fishingCooldown && !fishingCatch) {
          fishingBobberJiggleOffset = sin(frameCount * 0.3) * 5;
        } else {
          fishingBobberJiggleOffset = 0;
        }
      }

      if (fishingCatch) {
        if (fishingCatch.y < height / 2 - 80) {
          fishingCatch.y = height / 2 - 80;
        } else {
          fishingCatch.y = lerp(fishingCatch.y, height / 2 - 80, 0.1);
        }
      }

      if (fishingCatch) {
        if (fishingCatch.type === 'fish') drawFish(fishingCatch.x, fishingCatch.y, fishingCatch.size);
        else if (fishingCatch.type === 'sushi') drawSushi(fishingCatch.x, fishingCatch.y, fishingCatch.size);
        else if (fishingCatch.type === 'diamond') {
          push();
          translate(fishingCatch.x, fishingCatch.y);
          rotate(frameCount * 0.1);
          fill(0, 255, 255, 200);
          noStroke();
          ellipse(0, 0, fishingCatch.size, fishingCatch.size);
          pop();
        }
      }
    } else if (currentLevel === 7) {
      if (diggingAnimationActive) {
        let elapsed = millis() - diggingStartTime;
        let alpha = map(elapsed, 0, diggingDuration, 200, 0);
        let digSize = map(elapsed, 0, diggingDuration, 10, diamondSize * 1.5);
        fill(139, 69, 19, alpha);
        noStroke();
        ellipse(diamondX, diamondY + diamondSize / 4, digSize, digSize / 2);

        if (elapsed > diggingDuration) {
          diggingAnimationActive = false;
          diamondFound = true;
          diamondOsc.start(0, 880, 0.5);
          diamondOsc.amp(0, 0.5, 0.2);
          diamondOsc.stop(0.7);

          setTimeout(() => {
            triggerLevel7Win();
          }, 2000);
        }
      } else {
        drawFootballDiamond(diamondX, diamondY, diamondSize);
      }
    } else if (currentLevel === 6) {
      drawUFO(diamondX, diamondY, diamondSize, color(0, 255, 255), color(255, 255, 0));
    } else if (currentLevel === 9) {
      fill(0, 255, 255);
      noStroke();
      ellipse(diamondX, diamondY, diamondSize, diamondSize);
    } else {
      fill(0, 255, 255);
      noStroke();
      ellipse(diamondX, diamondY, diamondSize, diamondSize);
    }
  } else {
    textAlign(CENTER, CENTER);
    textSize(64);
    fill(0);
    if (gameCompleted) {
      text("Game Over, You Won!", -camX + width / 2, -camY + height / 2);

      if (diamondRock) {
        if (currentLevel === 6) {
          drawUFO(diamondRock.x, diamondRock.y, diamondRock.size, color(0, 255, 255), color(255, 255, 0));
        } else if (currentLevel === 7) {
          drawFootballDiamond(diamondRock.x, diamondRock.y, diamondRock.size);
        } else if (currentLevel === 8) {
          drawSushi(diamondRock.x, diamondRock.y, diamondRock.size);
        } else if (currentLevel === 9) {
          push();
          translate(diamondRock.x, diamondRock.y);
          rotate(frameCount * 0.1);
          fill(0, 255, 255, 200);
          noStroke();
          ellipse(0, 0, diamondRock.size, diamondRock.size);
          pop();
        }
      }
    } else {
      text("You Found the Diamond!", -camX + width / 2, -camY + height / 2);
    }
  }

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

  if (postWinAnimationActive) {
    for (let i = grabbingPeople.length - 1; i >= 0; i--) {
      let person = grabbingPeople[i];

      if (person.state === 'movingToRock') {
        let moveVec = createVector(person.targetX - person.x, person.targetY - person.y);
        moveVec.normalize().mult(person.speed);
        person.x += moveVec.x;
        person.y += moveVec.y;

        let d = dist(person.x, person.y, person.targetX, person.targetY);
        if (d < person.speed * 2) {
          person.state = 'grabbing';
          if (diamondRock) {
            diamondRock.size *= 0.9;
            if (diamondRock.size < 5) diamondRock = undefined;
          }
          person.targetX = random(0, TOWN_WIDTH);
          person.targetY = -camY - height;
          person.speed = random(5, 10);
        }
      } else if (person.state === 'grabbing') {
        person.state = 'leaving';
      } else if (person.state === 'leaving') {
        let moveVec = createVector(person.targetX - person.x, person.targetY - person.y);
        moveVec.normalize().mult(person.speed);
        person.x += moveVec.x;
        person.y += moveVec.y;

        if (person.y < -camY - BLOCK_SIZE || person.y > -camY + height + BLOCK_SIZE ||
            person.x < -camX - BLOCK_SIZE || person.x > -camX + width + BLOCK_SIZE) {
          grabbingPeople.splice(i, 1);
        }
      }

      if (person) {
        fill(person.color);
        noStroke();
        rect(person.x, person.y, person.bodyW, person.bodyH);
        ellipse(person.x + person.bodyW / 2, person.y - person.headSize / 2, person.headSize, person.headSize);
      }
    }

    if (grabbingPeople.length === 0) {
      postWinAnimationActive = false;
    }
  }

  if (currentLevel === 7 && hintCircleActive) {
    let elapsed = millis() - hintCircleStartTime;
    let alpha = map(elapsed, 0, hintCircleDuration, 255, 0);
    let circleSize = map(elapsed, 0, hintCircleDuration, diamondSize + tapTolerance * 2, diamondSize + tapTolerance * 3);

    push();
    noFill();
    stroke(255, 0, 0, alpha);
    strokeWeight(3);
    ellipse(diamondX, diamondY, circleSize, circleSize);
    pop();

    if (elapsed > hintCircleDuration) {
      hintCircleActive = false;
      resetGame(currentLevel + 1);
    }
  }
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

conditional Background Color Selection if (currentLevel === 9) { background(0); } else { background(220); }

Sets background to black for space Level 9, light gray for all other levels

calculation Camera Translation if (currentLevel !== 8) { translate(camX, camY); }

Applies the camera offset to simulate panning around the town (ocean Level 8 has fixed screen view)

calculation Drift Velocity Update (Level 9) if (!panning && currentLevel === 9 && !diamondFound && !gameCompleted) { camX += driftVelocityX; camY += driftVelocityY; driftVelocityX *= 0.95; driftVelocityY *= 0.95; }

Continues camera movement after touch ends on Level 9, gradually decelerating for a smooth drift effect

for-loop Town Rendering Loop for (let bx = startDrawBlockX; bx < endDrawBlockX; bx++) { for (let by = startDrawBlockY; by < endDrawBlockY; by++) { ... } }

Iterates through visible blocks to draw roads, people, and buildings only in the current viewport (optimization)

conditional Diamond Drawing Logic if (!diamondFound) { ... } else { ... }

Draws the diamond (or level-specific object) if not found; otherwise displays win message and giant rock animation

for-loop Confetti Particle Update for (let i = confetti.length - 1; i >= 0; i--) { confetti[i].update(); confetti[i].display(); ... }

Updates and displays confetti particles, removing them when they fall off-screen

if (currentLevel === 9) { background(0); } else { background(220); }
Clears the canvas each frame with black for space, light gray for town levels—prevents trails and prepares for new drawing
text(`Level: ${currentLevel}`, 10, 10);
Displays the current level number in the top-left corner of the screen (fixed position, not affected by camera)
if (currentLevel !== 8) { translate(camX, camY); }
Shifts all subsequent drawings by the camera offset, simulating panning; skipped for ocean Level 8 which has a fixed view
if (!panning && currentLevel === 9 && !diamondFound && !gameCompleted) { camX += driftVelocityX; ... driftVelocityX *= 0.95; }
For Level 9, after the player releases their touch, the camera continues drifting and gradually slows down (0.95 decay factor)
checkAndGenerateNewBlocks();
Generates procedural town blocks around the current camera position if they haven't been generated yet
for (let bx = startDrawBlockX; bx < endDrawBlockX; bx++) { for (let by = startDrawBlockY; by < endDrawBlockY; by++) { ... } }
Loops through all visible blocks to draw roads, people, and buildings—only draws what's on screen, improving performance
if (currentLevel === 6 && !diamondFound && chasingUFO) { let targetX = -camX + width / 2; let moveVec = createVector(...); chasingUFO.x += moveVec.x; }
For Level 6, calculates the center of the screen in virtual coordinates and moves the red chasing UFO toward it with normalized velocity
drawUFO(diamondX, diamondY, diamondSize, color(0, 255, 255), color(255, 255, 0));
Draws the diamond as a cyan/yellow UFO (Level 6) using the helper function with custom colors
if (fishingCatch) { fishingCatch.y = lerp(fishingCatch.y, height / 2 - 80, 0.1); }
For Level 8, uses lerp() to smoothly reel in the caught fish/sushi/diamond toward the rod tip at the top of the screen
for (let i = confetti.length - 1; i >= 0; i--) { confetti[i].update(); confetti[i].display(); if (confetti[i].isOffScreen()) { confetti.splice(i, 1); } }
Loops through confetti particles in reverse order, updates and displays them, and removes any that fall off-screen (reverse loop prevents index issues when splicing)

generateBlock(blockX, blockY)

generateBlock() is the procedural generation engine—it creates content for a single grid cell based on the current level and difficulty. The clever part is the density thresholds: by using `random() > threshold`, the code makes generation feel organic and random while being deterministic (same block generated twice has the same content). Notice how Level 9 generates asteroids instead of buildings, and Levels 5+ add people. This pattern scales easily to add new content types (vehicles, trees, etc.).

function generateBlock(blockX, blockY) {
  let startX = blockX * BLOCK_SIZE;
  let startY = blockY * BLOCK_SIZE;

  if (currentLevel === 9) {
    for (let i = 0; i < random(1, 4); i++) {
      let size = random(BLOCK_SIZE * 0.2, BLOCK_SIZE * 0.8);
      let col = color(random(80, 150));
      asteroids.push({
        x: startX + random(BLOCK_SIZE - size),
        y: startY + random(BLOCK_SIZE - size),
        size: size,
        color: col,
        speedX: random(-0.5, 0.5),
        speedY: random(-0.5, 0.5),
        blockX, blockY
      });
    }

    if (random() < 0.2) {
      let size = random(BLOCK_SIZE * 1.5, BLOCK_SIZE * 3);
      let col = color(random(50, 150), random(50, 150), random(150, 255), random(50, 100));
      nebulae.push({
        x: startX + random(BLOCK_SIZE - size),
        y: startY + random(BLOCK_SIZE - size),
        size: size,
        color: col,
        blockX, blockY
      });
    }
  } else {
    let blockData = { buildings: [], people: [] };

    for (let i = 0; i < 3; i++) {
      if (random() > buildingDensityThreshold) {
        let w = random(BLOCK_SIZE * 0.4, BLOCK_SIZE * 0.8);
        let h = random(BLOCK_SIZE * 0.4, BLOCK_SIZE * 0.8);
        let col = color(random(100, 180), random(100, 180), random(100, 180));
        blockData.buildings.push({
          x: startX + random(BLOCK_SIZE - w),
          y: startY + random(BLOCK_SIZE - h),
          w, h, color: col,
          blockX, blockY
        });
      }
    }

    if (currentLevel >= 5) {
      for (let i = 0; i < 5; i++) {
        if (random() > personDensityThreshold) {
          let bodyW = random(10, 20);
          let bodyH = random(20, 35);
          let headSize = random(8, 12);
          let col = color(random(50, 150), random(50, 150), random(50, 150));
        blockData.people.push({
            x: startX + random(BLOCK_SIZE - bodyW),
            y: startY + random(BLOCK_SIZE - bodyH),
            bodyW, bodyH, headSize, color: col,
            blockX, blockY
          });
        }
      }
    }
    generatedBlocks.set(`${blockX},${blockY}`, blockData);
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

calculation Grid to Virtual Coordinates let startX = blockX * BLOCK_SIZE; let startY = blockY * BLOCK_SIZE;

Converts block grid indices to the virtual world coordinates where buildings/asteroids are placed

for-loop Asteroid Generation Loop for (let i = 0; i < random(1, 4); i++) { asteroids.push({...}); }

Generates 1–3 random asteroids per block for Level 9, each with position, size, and drift velocity

for-loop Building Generation Loop for (let i = 0; i < 3; i++) { if (random() > buildingDensityThreshold) { blockData.buildings.push({...}); } }

Generates up to 3 buildings per block for town levels, controlled by difficulty-based density threshold

for-loop People Generation Loop if (currentLevel >= 5) { for (let i = 0; i < 5; i++) { if (random() > personDensityThreshold) { blockData.people.push({...}); } } }

Generates people (5 potential slots) only for Levels 5+, using density threshold to control population

let startX = blockX * BLOCK_SIZE;
Converts the block's grid X coordinate to the top-left X position of that block in virtual world space
for (let i = 0; i < random(1, 4); i++) {
Loops 1 to 3 times (random whole number), generating that many asteroids per block for Level 9
let size = random(BLOCK_SIZE * 0.2, BLOCK_SIZE * 0.8);
Randomly sizes each asteroid between 20% and 80% of a block's size for visual variety
asteroids.push({ x: startX + random(BLOCK_SIZE - size), y: startY + random(BLOCK_SIZE - size), ... });
Places the asteroid at a random position within the block, ensuring it doesn't extend beyond the block boundary (random(BLOCK_SIZE - size) keeps it in bounds)
if (random() > buildingDensityThreshold) {
Uses a random threshold check: if random() generates a number larger than the threshold (e.g., 0.3), a building is created; higher thresholds = fewer buildings
blockData.buildings.push({...});
Adds the building object (with position, size, color, and grid reference) to the current block's buildings array
if (currentLevel >= 5) { for (let i = 0; i < 5; i++) { if (random() > personDensityThreshold) { blockData.people.push({...}); } } }
For Levels 5+, generates up to 5 people per block using the same density threshold approach, filling remaining empty spaces with townspeople
generatedBlocks.set(`${blockX},${blockY}`, blockData);
Stores the block's buildings and people in a Map using a string key (e.g., '5,12') for fast lookup during rendering

checkAndGenerateNewBlocks()

This function is the engine of viewport-culled procedural generation. It calculates which grid cells are visible and generates only those, keeping memory and computation low. The buffer zone pre-generates blocks before they scroll fully on-screen, eliminating visible pop-in. This is a key optimization pattern used in games like Minecraft—instead of generating the entire infinite world upfront, generate only what's needed. Study how the Map (generatedBlocks) prevents regenerating the same block twice.

function checkAndGenerateNewBlocks() {
  if (currentLevel === 8) {
    return;
  }

  let visibleStartX = -camX;
  let visibleStartY = -camY;
  let visibleEndX = visibleStartX + width;
  let visibleEndY = visibleStartY + height;

  let buffer = BLOCK_SIZE * 2;
  visibleStartX -= buffer;
  visibleStartY -= buffer;
  visibleEndX += buffer;
  visibleEndY += buffer;

  let startBlockX = floor(visibleStartX / BLOCK_SIZE);
  let startBlockY = floor(visibleStartY / BLOCK_SIZE);
  let endBlockX = ceil(visibleEndX / BLOCK_SIZE);
  let endBlockY = ceil(visibleEndY / BLOCK_SIZE);

  for (let bx = startBlockX; bx < endBlockX; bx++) {
    for (let by = startBlockY; by < endBlockY; by++) {
      if (bx >= 0 && bx < TOWN_WIDTH / BLOCK_SIZE) {
        if (currentLevel === 9) {
          if (!generatedSpaceBlocks.has(`${bx},${by}`)) {
            generateBlock(bx, by);
            generatedSpaceBlocks.add(`${bx},${by}`);
          }
        } else if (!generatedBlocks.has(`${bx},${by}`)) {
          generateBlock(bx, by);
        }
      }
    }
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

calculation Calculate Visible Area let visibleStartX = -camX; let visibleStartY = -camY; let visibleEndX = visibleStartX + width; let visibleEndY = visibleStartY + height;

Converts the camera offset and canvas size into virtual world coordinates that represent what's currently on screen

calculation Expand Visible Area with Buffer let buffer = BLOCK_SIZE * 2; visibleStartX -= buffer; ... visibleEndY += buffer;

Adds a 2-block margin around the visible area to pre-generate content before it scrolls into view, avoiding pop-in

calculation Convert to Block Coordinates let startBlockX = floor(visibleStartX / BLOCK_SIZE); ... let endBlockY = ceil(visibleEndY / BLOCK_SIZE);

Converts virtual coordinates back to block grid indices to determine which grid cells need generation

for-loop Generate Visible Blocks for (let bx = startBlockX; bx < endBlockX; bx++) { for (let by = startBlockY; by < endBlockY; by++) { if (!generatedBlocks.has(...)) { generateBlock(...); } } }

Iterates through all blocks in the visible range and calls generateBlock() for any not yet generated

if (currentLevel === 8) { return; }
Skips generation entirely for ocean Level 8, which has no procedural town blocks
let visibleStartX = -camX;
Converts the camera offset into the top-left corner's virtual X coordinate (negative camX means we've moved right, so visible area starts at -camX)
let buffer = BLOCK_SIZE * 2; visibleStartX -= buffer;
Extends the generation zone by 2 blocks in each direction to pre-generate content before it appears on screen, preventing pop-in
let startBlockX = floor(visibleStartX / BLOCK_SIZE);
Divides the virtual coordinate by block size and floors it to find the leftmost block index in the visible area
if (bx >= 0 && bx < TOWN_WIDTH / BLOCK_SIZE) {
Bounds-checks the block X coordinate to ensure it's within the valid town width (prevents generating off-world blocks)
if (!generatedBlocks.has(`${bx},${by}`)) { generateBlock(bx, by); }
Checks if the block has already been generated using a Map lookup; if not, calls generateBlock() to create it and avoid redundant generation

drawUFO(x, y, size, bodyColor, lightColor)

drawUFO() is a simple shape-compositing function that combines three ellipses to create a UFO silhouette. It demonstrates how parameterized helper functions make code reusable: the same function draws the cyan diamond UFO, the red chasing UFO, and the green pet UFO by passing different colors. Notice how all measurements are scaled relative to the `size` parameter—doubling size makes the UFO exactly twice as large.

function drawUFO(x, y, size, bodyColor, lightColor) {
  fill(bodyColor);
  noStroke();
  ellipse(x, y, size * 2, size * 0.75);
  ellipse(x, y - size * 0.3, size * 0.8, size * 0.5);
  fill(lightColor);
  ellipse(x, y - size * 0.3, size * 0.4, size * 0.2);
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Draw UFO Body ellipse(x, y, size * 2, size * 0.75);

Draws the main UFO disc as a wide, flat ellipse

calculation Draw UFO Dome ellipse(x, y - size * 0.3, size * 0.8, size * 0.5);

Draws the control dome on top of the UFO, slightly offset upward

calculation Draw UFO Light Window fill(lightColor); ellipse(x, y - size * 0.3, size * 0.4, size * 0.2);

Draws a glowing window/light on the dome to make it look functional

fill(bodyColor);
Sets the fill color to the body color (e.g., cyan for the diamond UFO, red for the chasing UFO)
noStroke();
Disables the outline so the UFO looks smooth and filled
ellipse(x, y, size * 2, size * 0.75);
Draws the main saucer disc: width is 2× the size parameter, height is 0.75× to make it look flat and wide
ellipse(x, y - size * 0.3, size * 0.8, size * 0.5);
Draws the dome on top, positioned 0.3× size higher (y - size * 0.3), with width 0.8× and height 0.5× size
fill(lightColor); ellipse(x, y - size * 0.3, size * 0.4, size * 0.2);
Fills with the light color and draws a small glowing window inside the dome to make it look like a lit porthole

touchStarted()

touchStarted() is called when the player first touches the screen. This sketch shows how to handle multi-level interaction: checking the modal state, initializing audio, calculating virtual coordinates from screen space, and dispatching different logic per level (fishing vs. diamond tap). The collision detection using dist() is crucial—it's the same pattern used in thousands of games. Notice how `touches[0]` accesses the first touch point; the code also validates that at least one touch exists.

function touchStarted() {
  if (howToPlayModal.elt.style.display === 'flex') {
    return false;
  }

  if (!audioStarted) {
    userStartAudio();
    audioStarted = true;
  }

  if (!diamondFound && !gameCompleted && !(currentLevel === 7 && hintCircleActive)) {
    if (touches.length > 0) {
      let tapVirtualX = touches[0].x - camX;
      let tapVirtualY = touches[0].y - camY;

      if (currentLevel === 8) {
        if (fishingRodActive) {
          if (millis() - fishingRodCastTime > fishingCooldown) {
            let catchRoll = random();
            if (catchRoll < 0.05) {
              fishingCatch = { type: 'diamond', x: diamondX, y: fishingBobberY, size: diamondSize };
            } else if (catchRoll < 0.20) {
              fishingCatch = { type: 'sushi', x: diamondX, y: fishingBobberY, size: diamondSize };
            } else {
              fishingCatch = { type: 'fish', x: diamondX, y: fishingBobberY, size: diamondSize };
            }

            reelInOsc.start(0, 300, 0.2);
            reelInOsc.amp(0, 0.5, 0.2);
            reelInOsc.stop(0.7);

            fishingRodActive = false;
          }
        } else {
          if (fishingCatch) {
            let catchDist = dist(tapVirtualX, tapVirtualY, fishingCatch.x, fishingCatch.y);
            if (catchDist < fishingCatch.size / 2 + tapTolerance) {
              if (fishingCatch.type === 'fish' || fishingCatch.type === 'sushi') {
                fishCaughtCount++;
              } else if (fishingCatch.type === 'diamond') {
                triggerLevel8Win();
              }
              fishingCatch = undefined;
            }
          } else {
            fishingRodActive = true;
            fishingRodCastTime = millis();
            fishingCooldown = random(1000, 3000);
            fishingBobberY = height / 2 + 100;

            castOsc.start(0, 150, 0.2);
            castOsc.amp(0, 0.5, 0.2);
            castOsc.stop(0.7);
          }
        }
      } else {
        panning = true;
        prevTouchX = touches[0].x;
        prevTouchY = touches[0].y;

        let d = dist(tapVirtualX, tapVirtualY, diamondX, diamondY);

        if (currentLevel === 7) {
          if (d < diamondSize / 2 + tapTolerance && !diggingAnimationActive) {
            diggingAnimationActive = true;
            diggingStartTime = millis();
          }
        } else if (currentLevel === 6) {
          if (d < diamondSize / 2 + tapTolerance) {
            diamondFound = true;
            diamondOsc.start(0, 880, 0.5);
            diamondOsc.amp(0, 0.5, 0.2);
            diamondOsc.stop(0.7);

            chasingUFO = undefined;

            setTimeout(() => {
              resetGame(currentLevel + 1);
            }, 2000);
          }
        } else if (currentLevel === 9) {
          if (d < diamondSize / 2 + tapTolerance) {
            diamondFound = true;
            diamondOsc.start(0, 880, 0.5);
            diamondOsc.amp(0, 0.5, 0.2);
            diamondOsc.stop(0.7);
            
            setTimeout(() => {
              triggerLevel9Win();
            }, 2000);
          }
        } else {
          if (d < diamondSize / 2 + tapTolerance) {
            diamondFound = true;
            diamondOsc.start(0, 880, 0.5);
            diamondOsc.amp(0, 0.5, 0.2);
            diamondOsc.stop(0.7);

            setTimeout(() => {
              resetGame(currentLevel + 1);
            }, 2000);
          }
        }
      }
    }
  }
  return false;
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional Modal Open Check if (howToPlayModal.elt.style.display === 'flex') { return false; }

Blocks all canvas interactions while the How to Play modal is open

conditional Audio Context Initialization if (!audioStarted) { userStartAudio(); audioStarted = true; }

Starts the Web Audio context on first user interaction (required by browser security policies)

calculation Convert Screen to Virtual Coordinates let tapVirtualX = touches[0].x - camX; let tapVirtualY = touches[0].y - camY;

Converts screen-space tap coordinates to virtual world coordinates by subtracting camera offset

conditional Level 8 Fishing Logic if (currentLevel === 8) { ... }

Handles fishing rod casting, catch detection, and reward collection

conditional Diamond Tap Detection let d = dist(tapVirtualX, tapVirtualY, diamondX, diamondY); if (d < diamondSize / 2 + tapTolerance) { ... }

Uses distance calculation to check if tap is within tap tolerance radius of the diamond

if (howToPlayModal.elt.style.display === 'flex') { return false; }
Checks if the modal is currently visible; if so, returns false to ignore the touch and prevent canvas interaction
if (!audioStarted) { userStartAudio(); audioStarted = true; }
p5.sound requires the Web Audio context to be started by user interaction; this starts it on first touch to enable sound effects
let tapVirtualX = touches[0].x - camX;
Subtracts the camera offset from the screen-space tap X to get the virtual world X coordinate where the tap actually occurred
if (currentLevel === 8) { ... fishingRodActive = true; fishingRodCastTime = millis(); }
For Level 8, the first tap casts the fishing rod and records the cast time; subsequent taps after a cooldown reel in the catch
let d = dist(tapVirtualX, tapVirtualY, diamondX, diamondY);
Calculates the Euclidean distance between the tap position and the diamond's position
if (d < diamondSize / 2 + tapTolerance) {
If the distance is less than the diamond's radius plus the tap tolerance, the tap hit the diamond
diamondOsc.start(0, 880, 0.5); diamondOsc.amp(0, 0.5, 0.2); diamondOsc.stop(0.7);
Plays a high-pitched sine wave beep (880 Hz, ~A5 note) for 0.5 seconds with a fade-in and fade-out envelope
setTimeout(() => { resetGame(currentLevel + 1); }, 2000);
Schedules the game to advance to the next level after a 2-second delay, allowing time for win animations to play

touchMoved()

touchMoved() handles continuous panning as the finger drags across the screen. It calculates the delta (change) from the previous position and updates the camera offset. The constrain() call is key—it prevents panning beyond the town boundaries, giving the game solid boundaries. For Level 9, it stores the velocity for post-touch drift. This is a classic mobile game pattern: dragging to pan, with optional momentum on release.

function touchMoved() {
  if (howToPlayModal.elt.style.display === 'flex') {
    return false;
  }

  if (panning && !diamondFound && !gameCompleted && !diggingAnimationActive && currentLevel !== 8) {
    if (touches.length > 0) {
      let dx = touches[0].x - prevTouchX;
      let dy = touches[0].y - prevTouchY;

      camX += dx;
      camY += dy;

      camX = constrain(camX, -(TOWN_WIDTH - width), 0);

      prevTouchX = touches[0].x;
      prevTouchY = touches[0].y;

      if (currentLevel === 9) {
        lastPanDriftX = dx;
        lastPanDriftY = dy;
      }
    }
  }
  return false;
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Modal Open Check if (howToPlayModal.elt.style.display === 'flex') { return false; }

Prevents panning while the modal is open

conditional Camera Update Logic if (panning && !diamondFound && !gameCompleted && !diggingAnimationActive && currentLevel !== 8) { camX += dx; camY += dy; }

Updates camera position based on finger movement, but only if conditions allow panning

conditional Drift Velocity Storage (Level 9) if (currentLevel === 9) { lastPanDriftX = dx; lastPanDriftY = dy; }

Records the last pan velocity for Level 9's drift-on-release effect

let dx = touches[0].x - prevTouchX;
Calculates how far horizontally the finger moved since the last touchMoved event
camX += dx;
Updates the camera X offset by the movement amount, panning the view right if finger moved right
camX = constrain(camX, -(TOWN_WIDTH - width), 0);
Clamps the camera X so it never pans past the left edge (negative camX minimum) or past the right edge (camX maximum 0) of the town
prevTouchX = touches[0].x;
Saves the current touch X position so the next touchMoved event can calculate the new delta from this point
if (currentLevel === 9) { lastPanDriftX = dx; lastPanDriftY = dy; }
For Level 9, records the last frame's movement so touchEnded() can apply drift momentum

triggerLevel8Win()

triggerLevel8Win() is a level-specific win handler. Unlike Levels 6 and 7 which animate people grabbing the prize, Level 8 (ocean fishing) has no people—just a celebration burst. The pattern is consistent across all win handlers: set flags, spawn confetti, create a giant rock, schedule the next level. Notice how it uses setTimeout() to defer the level reset, giving animations time to play.

function triggerLevel8Win() {
  console.log("Triggering Level 8 Win!");
  currentLevel = 8;
  diamondFound = true;
  gameCompleted = true;

  for (let i = 0; i < 200; i++) {
    confetti.push(new ConfettiParticle(width / 2, height / 2));
  }

  diamondRock = {
    x: width / 2,
    y: height / 2 - 100,
    size: 250
  };

  setTimeout(() => {
    resetGame(currentLevel + 1);
  }, 2000);
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

calculation Set Win State Flags diamondFound = true; gameCompleted = true;

Marks the diamond as found and the level as completed, triggering win-state behavior in draw()

for-loop Spawn Confetti Burst for (let i = 0; i < 200; i++) { confetti.push(new ConfettiParticle(width / 2, height / 2)); }

Creates 200 confetti particles at screen center, bursting outward with gravity

calculation Create Giant Sushi Rock diamondRock = { x: width / 2, y: height / 2 - 100, size: 250 };

Creates a giant sushi object that will be drawn as the celebratory prize

console.log("Triggering Level 8 Win!");
Logs a message to the browser console for debugging, confirming the win was triggered
diamondFound = true; gameCompleted = true;
Sets the game state flags that trigger draw() to display the win message and giant rock instead of the hidden object
for (let i = 0; i < 200; i++) { confetti.push(new ConfettiParticle(...)); }
Creates 200 confetti particles at screen center; each one initializes with random velocity, rotation, and color
setTimeout(() => { resetGame(currentLevel + 1); }, 2000);
After 2 seconds, resets the game to Level 9, allowing the player to see the celebration before advancing

ConfettiParticle class

ConfettiParticle is a classic particle system class. Each instance tracks position, velocity, acceleration, color, and rotation—the core data for a physics-driven animation. The update() method uses Euler integration (a simple physics solver), and display() uses push()/pop() to safely apply transformations without affecting other drawings. This pattern powers particle systems in engines from Unity to Godot. Notice how size, color, and rotation are randomized in the constructor—this is why the confetti feels organic and varied.

🔬 This applies gravity each frame. What happens if you change this.acc from createVector(0, 0.5) to createVector(0, 0) in the constructor? Or to createVector(0, -0.5)? What if you comment out this.vel.add(this.acc)?

  update() {
    this.vel.add(this.acc);
    this.pos.add(this.vel);
    this.rotation += this.rotationSpeed;
  }
class ConfettiParticle {
  constructor(x, y) {
    this.pos = createVector(x, y);
    this.vel = createVector(random(-5, 5), random(-15, -5));
    this.acc = createVector(0, 0.5);
    this.color = color(random(255), random(255), random(255), random(150, 255));
    this.size = random(8, 16);
    this.rotation = random(TWO_PI);
    this.rotationSpeed = random(-0.1, 0.1);
  }

  update() {
    this.vel.add(this.acc);
    this.pos.add(this.vel);
    this.rotation += this.rotationSpeed;
  }

  display() {
    push();
    translate(this.pos.x, this.pos.y);
    rotate(this.rotation);
    fill(this.color);
    noStroke();
    rect(0, 0, this.size, this.size / 2);
    pop();
  }

  isOffScreen() {
    return this.pos.y > -camY + height;
  }
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

calculation Initialize Particle this.vel = createVector(random(-5, 5), random(-15, -5));

Gives each confetti particle a random initial velocity, upward (-15 to -5 Y) and sideways (-5 to 5 X)

calculation Set Gravity Acceleration this.acc = createVector(0, 0.5);

Applies constant downward acceleration (gravity) to pull confetti down over time

calculation Update Physics this.vel.add(this.acc); this.pos.add(this.vel);

Each frame, adds acceleration to velocity, then adds velocity to position (Euler physics integration)

calculation Draw Rotated Particle push(); translate(this.pos.x, this.pos.y); rotate(this.rotation); rect(0, 0, this.size, this.size / 2); pop();

Draws a small rotating rectangle at the particle's position using matrix transformations

this.pos = createVector(x, y);
Stores the particle's position as a p5.Vector, making vector operations (add, sub, etc.) easy
this.vel = createVector(random(-5, 5), random(-15, -5));
Initializes velocity with upward (negative Y) and random horizontal (X) components, so confetti bursts outward and upward
this.acc = createVector(0, 0.5);
Sets constant acceleration of 0.5 pixels/frame² downward, simulating gravity pulling the confetti down
this.color = color(random(255), random(255), random(255), random(150, 255));
Randomizes RGB values for color variety, and sets alpha (transparency) to 150–255 for semi-opaque confetti
this.vel.add(this.acc);
Adds acceleration to velocity each frame, increasing downward speed—classic Newtonian physics
this.pos.add(this.vel);
Updates position by adding velocity, moving the particle to its new location
translate(this.pos.x, this.pos.y); rotate(this.rotation); rect(0, 0, this.size, this.size / 2);
Translates to the particle's position, rotates by its angle, then draws a small rectangle at the origin so it appears rotated at the correct position
push(); ... pop();
push() saves the current transformation matrix, and pop() restores it, so the rotation doesn't affect other drawings
isOffScreen() { return this.pos.y > -camY + height; }
Returns true if the particle has fallen below the visible area, so draw() can remove it from the confetti array

📦 Key Variables

TOWN_WIDTH number

The horizontal span of the procedurally-generated town in pixels; constrains horizontal panning and block generation

const TOWN_WIDTH = 4000;
BLOCK_SIZE number

The grid cell size for procedural generation; larger blocks create fewer, bigger buildings; smaller blocks create denser towns

const BLOCK_SIZE = 120;
currentLevel number

Tracks which of the 9 levels the player is on; controls difficulty, game mechanics, and visual appearance

let currentLevel = 1;
camX number

Camera's horizontal offset from the origin; negative values pan right, positive values pan left (used in translate())

let camX = 0;
camY number

Camera's vertical offset from the origin; negative values pan down, positive values pan up (used in translate())

let camY = 0;
diamondX number

Virtual world X coordinate of the hidden diamond (or UFO/football/fishing bobber depending on level)

let diamondX = random(TOWN_WIDTH * 0.2, TOWN_WIDTH * 0.8);
diamondY number

Virtual world Y coordinate of the hidden diamond; set to initialDiamondDepth for town levels, or fixed for special levels

let diamondY = initialDiamondDepth + random(BLOCK_SIZE * 0.2, BLOCK_SIZE * 0.8);
diamondFound boolean

Flag indicating whether the player has found and tapped the diamond; when true, draw() displays win message

let diamondFound = false;
generatedBlocks object

A Map storing all generated town blocks; key is 'blockX,blockY' string, value is {buildings: [], people: []}

let generatedBlocks = new Map();
asteroids array

Global array of asteroid objects for Level 9; each has position, size, color, and velocity for drifting

let asteroids = [];
confetti array

Array of ConfettiParticle instances spawned when the player wins; updated and drawn each frame until off-screen

let confetti = [];
initialDiamondDepth number

How deep (in virtual pixels) the diamond is buried, set per difficulty level in resetGame()

let initialDiamondDepth;
diamondSize number

Diameter of the diamond ellipse in pixels; smaller on harder levels; also used for tap collision detection

let diamondSize;
tapTolerance number

Extra radius in pixels added to the diamond's collision zone; makes tapping easier; decreases on harder levels

let tapTolerance;
panning boolean

Flag set when the player begins a touch drag; remains true until touchEnded() is called

let panning = false;
fishingRodActive boolean

For Level 8, true when the fishing rod has been cast and is waiting for a bite; false when reeling or ready to cast

let fishingRodActive = false;
fishingCatch object

For Level 8, stores the caught item's data {type, x, y, size} after a bite; undefined when no catch is active

let fishingCatch = undefined;
gameCompleted boolean

Flag indicating the current level has been won; when true, draw() shows 'Game Over, You Won!' and giant rock animation

let gameCompleted = false;
postWinAnimationActive boolean

For Levels 6 and 7, true while nearby townspeople are animating toward the found diamond; false when animation completes

let postWinAnimationActive = false;
chasingUFO object

For Level 6, stores the red chasing UFO's data {x, y, size, speed}; undefined on other levels

let chasingUFO;
petUFO object

For Level 6, stores the friendly green pet UFO's data {x, y, size, speed, bodyColor, lightColor}; follows player smoothly

let petUFO;
driftVelocityX number

For Level 9, stores the camera's horizontal drift speed after the player releases their touch; decays each frame

let driftVelocityX = 0;
driftVelocityY number

For Level 9, stores the camera's vertical drift speed after the player releases their touch; decays each frame

let driftVelocityY = 0;
diamondRock object

After winning, stores the giant celebratory rock's data {x, y, size}; shrinks as townspeople grab it

let diamondRock;

🔧 Potential Improvements (6)

Here are some ways this code could be enhanced:

BUG touchStarted() and touchMoved()

The code does not validate that touches.length > 0 before accessing touches[0] in some code paths, which could cause a runtime error if a touch somehow ends before being processed.

💡 Always check `if (touches.length > 0)` before accessing touches[0]; alternatively, use `touches[0]?.x` optional chaining for safety.

PERFORMANCE draw() - town rendering loops

The code loops through all visible blocks three times (once for roads, once for people, once for buildings), iterating over the same block list. This is redundant and could be optimized.

💡 Combine the three rendering passes into a single loop that draws roads, people, and buildings for each block in one pass, reducing iteration overhead.

STYLE resetGame()

The switch statement has 9 cases with large amounts of duplicated code (buildings, people, UFOs, etc.). The pattern is repetitive.

💡 Create a levels data object (e.g., `const LEVEL_CONFIG = { 1: {...}, 2: {...}, ...}`) to eliminate switch duplication and make adding new levels easier.

BUG confetti.splice() in draw()

While iterating through confetti in reverse (for (let i = confetti.length - 1; ...) is correct, there's a potential issue if confetti is modified elsewhere during draw().

💡 Use `confetti = confetti.filter(p => !p.isOffScreen())` instead for cleaner functional code that's less error-prone.

FEATURE fishing mechanic (Level 8)

The fishing rod UI is drawn fixed on the screen but the catch mechanics use screen-space coordinates, which can feel disconnected if the player resizes the window.

💡 Store the rod position as a named constant or derive it from window dimensions at the start of draw() so any resize updates the entire fishing UI consistently.

PERFORMANCE generateBlock() and checkAndGenerateNewBlocks()

Every frame checkAndGenerateNewBlocks() recalculates visible block ranges even if the camera hasn't moved, wastefully re-checking already-generated blocks.

💡 Cache the last generated block range and only regenerate if the camera moves enough to scroll into a new block (e.g., when camX or camY crosses a BLOCK_SIZE boundary).

🔄 Code Flow

Code flow showing setup, draw, generateblock, checkandgeneratenewblocks, drawegg, touchstarted, touchmoved, triggerlevel8win, confettiparticle

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

graph TD start[Start] --> setup[setup] setup --> canvas-creation[Canvas Setup] setup --> level-loading[Load Saved Level from Storage] setup --> modal-init[Modal and Button Initialization] setup --> audio-init[Audio Context Initialization] setup --> draw[draw loop] click setup href "#fn-setup" click canvas-creation href "#sub-canvas-creation" click level-loading href "#sub-level-loading" click modal-init href "#sub-modal-init" click audio-init href "#sub-audio-init" draw --> bg-select[Background Color Selection] draw --> visible-bounds[Calculate Visible Area] draw --> buffer-zone[Expand Visible Area with Buffer] draw --> generation-loop[Generate Visible Blocks] generation-loop --> generateblock[generateBlock()] click generation-loop href "#sub-generation-loop" click generateblock href "#fn-generateblock" draw --> camera-translate[Camera Translation] draw --> town-rendering[Town Rendering Loop] town-rendering --> diamond-draw[Diamond Drawing Logic] click town-rendering href "#sub-town-rendering" click diamond-draw href "#sub-diamond-draw" draw --> confetti-loop[Confetti Particle Update] click confetti-loop href "#sub-confetti-loop" draw --> touchstarted[touchStarted()] touchstarted --> virtual-coord-calc[Convert Screen to Virtual Coordinates] touchstarted --> modal-check[Modal Open Check] touchstarted --> diamond-collision[Diamond Tap Detection] touchstarted --> level8-fishing[Level 8 Fishing Logic] click touchstarted href "#fn-touchstarted" click virtual-coord-calc href "#sub-virtual-coord-calc" click modal-check href "#sub-modal-check" click diamond-collision href "#sub-diamond-collision" click level8-fishing href "#sub-level8-fishing" draw --> touchmoved[touchMoved()] touchmoved --> panning-update[Camera Update Logic] touchmoved --> drift-storage[Drift Velocity Storage] touchmoved --> drift-update[Drift Velocity Update] click touchmoved href "#fn-touchmoved" click panning-update href "#sub-panning-update" click drift-storage href "#sub-drift-storage" click drift-update href "#sub-drift-update" draw --> triggerlevel8win[triggerLevel8Win()] triggerlevel8win --> win-state[Set Win State Flags] triggerlevel8win --> confetti-spawn[Spawn Confetti Burst] triggerlevel8win --> diamond-rock[Create Giant Sushi Rock] click triggerlevel8win href "#fn-triggerlevel8win" click win-state href "#sub-win-state" click confetti-spawn href "#sub-confetti-spawn" click diamond-rock href "#sub-diamond-rock" confetti-loop --> constructor[Initialize Particle] constructor --> gravity[Set Gravity Acceleration] gravity --> update[Update Physics] update --> display[Draw Rotated Particle] click constructor href "#sub-constructor" click gravity href "#sub-gravity" click update href "#sub-update" click display href "#sub-display"

❓ Frequently Asked Questions

What visual elements can I expect to see in the 'Black screen is gone' p5.js sketch?

The sketch creates a sprawling virtual town with various buildings and roads, along with dynamic elements like asteroids and nebulae in space environments.

How can I interact with the 'Black screen is gone' sketch?

Users can pan across the town map by dragging their mouse or finger, and there are game mechanics involving finding diamonds and interacting with characters.

What creative coding techniques does the 'Black screen is gone' sketch showcase?

This sketch demonstrates procedural generation of a town layout, camera panning for exploration, and the use of animation for gameplay elements.

Preview

Black screen is gone - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Black screen is gone - Code flow showing setup, draw, generateblock, checkandgeneratenewblocks, drawegg, touchstarted, touchmoved, triggerlevel8win, confettiparticle
Code Flow Diagram