20 level is easy

This is a 20-level diamond hunting game built in p5.js where you pan through procedurally generated towns to find increasingly hidden diamonds. Each level increases difficulty through deeper depths, smaller targets, denser towns, and special mechanics like fishing, digging, and drifting through space.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make Level 1 impossible
  2. Make the town twice as wide — Doubling TOWN_WIDTH makes the horizontal panning area much larger, requiring more searching.
  3. Make buildings huge — Increase the building size range in generateBlock() so buildings take up much more space and hide the diamond better.
  4. Make all buildings red — Override the building color in generateBlock() to always be red, making the town look very different.
  5. Speed up Level 9 drift decay
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch creates an addictive diamond-hunting game across 20 escalating difficulty levels. You pan through infinite procedurally generated towns using touch controls, spotting tiny cyan diamonds (or UFOs, footballs, and pearls on themed levels) hidden among buildings and people. It demonstrates procedural generation using grid-based block creation, camera panning with momentum effects, collision detection with tap tolerance, and state-based level progression—all running at 60 fps on mobile.

The code is organized into a massive draw loop that handles level-specific graphics, a touch system for panning and tapping, dynamic block generation only when needed for performance, and a series of helper functions for drawing themed diamonds (lava crystals, alien artifacts, mirror orbs). By studying it, you'll learn how to build games with procedural content, manage complex game state across many levels, and create smooth mobile interactions with p5.js.

⚙️ How It Works

  1. When the sketch loads, setup() creates the canvas, loads the current level from browser storage (so progress is saved), and calls resetGame() to initialize difficulty parameters, place the diamond at a random deep location, and hide the 'How to Play' modal.
  2. On every frame, draw() clears the background with a level-appropriate color, applies camera offset to show the panned town, and dynamically generates building blocks only in the visible area plus a buffer zone using checkAndGenerateNewBlocks().
  3. The draw loop renders the town's grid of buildings and people, then draws the target diamond (or special objects for themed levels like fishing rods, asteroids, or lava flows) at its hidden location.
  4. When you touch and drag the screen, touchMoved() updates the camera offset to pan the view, with optional drift decay on Level 9 or slippery ice physics on Level 18.
  5. When you tap on the diamond (within a level-dependent tap tolerance), touchStarted() checks collision, plays a victory sound, triggers confetti and animations, and advances to the next level after a 2-second delay.
  6. Special levels have unique mechanics: Level 7 has a digging animation, Levels 8 and 13 use a fishing rod system with cooldowns and random catches, Level 9 applies camera drift after panning, and Level 20 fixes the massive diamond at screen center rather than panning it.

🎓 Concepts You'll Learn

Procedural generation and grid-based generationCamera panning with constrained boundariesCollision detection with tap toleranceLevel progression and game state managementTouch controls (tap, drag, touchMoved, touchEnded)Performance optimization through lazy block generationTheme-specific mechanics and asset drawingParticle effects and confetti animationsSound synthesis with p5.sound oscillatorsLocalStorage for persistent game progress

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. It initializes the canvas, retrieves saved progress from localStorage, attaches event listeners to HTML buttons, and calls resetGame() to begin play. This is where all one-time setup happens before the draw loop starts.

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 > 20) {
      currentLevel = 1;
    }
  }
  resetGame(currentLevel);
  howToPlayModal = select('#how-to-play-modal');
  let closeHowToPlayButton = select('#close-how-to-play');
  if (closeHowToPlayButton) {
    closeHowToPlayButton.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 (8 lines)

🔧 Subcomponents:

calculation Canvas Creation createCanvas(windowWidth, windowHeight);

Fills the entire browser window with the p5.js canvas for the game viewport

conditional Level Recovery from Storage if (savedLevel) { currentLevel = parseInt(savedLevel); }

Loads the player's previously reached level from browser localStorage so progress is saved between sessions

calculation Modal Event Listeners howToPlayModal.elt.addEventListener('touchend', (event) => {

Attaches touch handlers to close the How to Play modal when the player taps outside it or on the close button

createCanvas(windowWidth, windowHeight);
Creates a p5.js canvas that fills the entire browser window, used as the game viewport
noSmooth();
Disables anti-aliasing for performance boost on mobile devices
createLevelButtons();
Generates the 20 difficulty level buttons and displays them in the level-buttons div
hintButton = select('#hint-button');
Gets a reference to the HTML hint button element so it can be shown/hidden per level
let savedLevel = localStorage.getItem('biggestTownDiamondHuntLevel');
Retrieves the player's highest reached level from browser storage (persists across page reloads)
if (currentLevel < 1 || currentLevel > 20) { currentLevel = 1; }
Validates the loaded level is within 1-20 range; defaults to Level 1 if invalid
resetGame(currentLevel);
Initializes the game with difficulty parameters for the current level and generates the initial town
showHowToPlayModal();
Displays the How to Play instruction modal when the game first loads

resetGame(level)

resetGame() is the master reset function called whenever you select a new level. It clears all state variables, sets difficulty parameters via a huge switch statement (each case represents a level), saves progress to localStorage, and then calls generateInitialTown() to place the diamond. Understanding this function is key to adding new levels or tweaking difficulty.

🔬 This sets up Level 1's difficulty. What happens if you change tapTolerance to 100? Or diamondSize to 50? Tap into the Level 1 button to see the result.

    case 1: // Easy
      initialDiamondDepth = 1000;
      diamondSize = 12;
      tapTolerance = 30;
      buildingDensityThreshold = 0.3;
function resetGame(level) {
  currentLevel = level;
  diamondFound = false;
  generatedBlocks.clear();
  generatedSpaceBlocks.clear();
  confetti = [];
  diamondRock = undefined;
  postWinAnimationActive = false;
  grabbingPeople = [];
  camX = 0;
  camY = 0;
  gameCompleted = false;
  chasingUFO = undefined;
  petUFO = undefined;
  diggingAnimationActive = false;
  fishingRodActive = false;
  fishingRodCastTime = 0;
  fishingCatch = undefined;
  fishingCooldown = 0;
  fishingLineLength = 0;
  fishingBobberY = height / 2;
  fishingBobberJiggleOffset = 0;
  fishCaughtCount = 0;
  hintCircleActive = false;
  driftVelocityX = 0;
  driftVelocityY = 0;
  lastPanDriftX = 0;
  lastPanDriftY = 0;
  slipperyPanVelocityX = 0;
  slipperyPanPanVelocityY = 0;
  lavaFlows = [];
  jungleFoliage = [];
  deepSeaCreatures = [];
  alienObjects = [];
  timeWarpActive = false;
  mirrorActive = false;
  ashParticles = [];
  snowParticles = [];
  glitchActive = false;
  localStorage.setItem('biggestTownDiamondHuntLevel', currentLevel);
  switch (currentLevel) {
    case 1:
      initialDiamondDepth = 1000;
      diamondSize = 12;
      tapTolerance = 30;
      buildingDensityThreshold = 0.3;
      personDensityThreshold = 1.0;
      break;
    // ... cases 2-20 omitted for brevity, follow same pattern
    case 20:
      initialDiamondDepth = 0;
      diamondSize = 300;
      tapTolerance = 100;
      buildingDensityThreshold = 1.0;
      personDensityThreshold = 1.0;
      diamondX = width / 2;
      diamondY = height / 2;
      camX = 0;
      camY = 0;
      break;
  }
  generateInitialTown();
  createLevelButtons();
  if (hintButton) {
    if (currentLevel === 7 || currentLevel === 15) {
      hintButton.removeClass('hidden');
    } else {
      hintButton.addClass('hidden');
    }
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

calculation Global State Reset diamondFound = false; generatedBlocks.clear(); confetti = [];

Clears all game state variables from the previous level so the new level starts fresh

switch-case Difficulty Parameter Switch switch (currentLevel) { case 1: initialDiamondDepth = 1000; ... }

Sets level-specific difficulty parameters like diamond depth, size, and tap tolerance based on the selected level number

calculation Progress Save localStorage.setItem('biggestTownDiamondHuntLevel', currentLevel);

Saves the current level to browser storage so the player resumes at this level on their next visit

conditional Hint Button Toggle if (currentLevel === 7 || currentLevel === 15) { hintButton.removeClass('hidden'); }

Shows the hint button only for Level 7 (digging) and Level 15 (void), hides it for all other levels

currentLevel = level;
Sets the global currentLevel variable to the newly selected difficulty level
diamondFound = false;
Resets the diamond-found flag so the diamond is hidden and needs to be searched for again
generatedBlocks.clear();
Empties the map of all previously generated building blocks so the new level generates fresh content
localStorage.setItem('biggestTownDiamondHuntLevel', currentLevel);
Saves the current level number to browser storage, allowing the player to resume from this level next time
switch (currentLevel) { case 1: initialDiamondDepth = 1000; ... }
A massive switch statement that sets all difficulty parameters (depth, size, tap tolerance, building density) based on the level number, with each level harder than the last
generateInitialTown();
Calls the function that places the diamond at a random location and initializes the camera view for this level
createLevelButtons();
Recreates the level selection buttons with updated styling to highlight the currently active level
if (currentLevel === 7 || currentLevel === 15) { hintButton.removeClass('hidden'); }
Shows the hint button only for Level 7 and 15, hiding it for all other levels since only those need hints

draw()

draw() runs 60 times per second and is the heart of the game. It manages the background, applies camera panning/drift, dynamically generates visible blocks, draws the themed level content (asteroids for space, lava for caves, etc.), draws the diamond or special objects, handles win animations with confetti and people grabbing the diamond, and displays UI text. The heavy conditional logic handles each of the 20 levels' unique visuals and mechanics.

function draw() {
  if (currentLevel === 9 || currentLevel === 13 || currentLevel === 15 || currentLevel === 20) {
    background(0);
  } else if (currentLevel === 8) {
    background(220);
  } else if (currentLevel === 11) {
    background(50);
  } else if (currentLevel === 12) {
    background(34, 139, 34);
  } else if (currentLevel === 14) {
    background(100, 50, 150);
  } else if (currentLevel === 17) {
    background(100);
  } else if (currentLevel === 18) {
    background(200, 220, 255);
  } 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 && currentLevel !== 13 && currentLevel !== 20) {
    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);
  }
  if (!panning && currentLevel === 18 && !diamondFound && !gameCompleted) {
    camX += slipperyPanVelocityX;
    camY += slipperyPanPanVelocityY;
    slipperyPanVelocityX *= 0.98;
    slipperyPanPanVelocityY *= 0.98;
    if (abs(slipperyPanVelocityX) < 0.1) slipperyPanVelocityX = 0;
    if (abs(slipperyPanPanVelocityY) < 0.1) slipperyPanPanVelocityY = 0;
    camX = constrain(camX, -(TOWN_WIDTH - width), 0);
  }
  if (currentLevel !== 8 && currentLevel !== 13 && currentLevel !== 15 && currentLevel !== 20) {
    checkAndGenerateNewBlocks();
  }
  // Drawing logic for each level type (Level 9 asteroids, Level 11 lava, etc.)
  if (currentLevel === 9) {
    // Draw nebulae and asteroids
    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 === 11) {
    drawLavaFlows();
    drawTownElements();
  } else if (currentLevel === 12) {
    drawJungleFoliage();
    drawTownElements();
  } else if (currentLevel === 13) {
    drawDeepSeaCreatures();
  } else if (currentLevel === 14) {
    drawAlienObjects();
    drawTownElements();
  } else if (currentLevel === 17) {
    drawTownElements();
    drawAshParticles();
  } else if (currentLevel === 18) {
    drawTownElements();
    drawSnowParticles();
  } else if (currentLevel === 19) {
    drawTownElements();
    if (glitchActive) {
      push();
      noFill();
      stroke(random(255), random(255), random(255), 100);
      strokeWeight(random(1, 5));
      line(random(TOWN_WIDTH), random(-camY, -camY + height), random(TOWN_WIDTH), random(-camY, -camY + height));
      pop();
    }
  } else if (currentLevel !== 8 && currentLevel !== 20 && currentLevel !== 15) {
    drawTownElements();
  }
  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) {
      drawOceanFishingElements();
    } 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 if (currentLevel === 20) {
      push();
      translate(-camX + width / 2, -camY + height / 2);
      drawInfiniteVoidDiamond(0, 0, diamondSize);
      pop();
    } else {
      fill(0, 255, 255);
      noStroke();
      ellipse(diamondX, diamondY, diamondSize, diamondSize);
    }
  } else {
    textAlign(CENTER, CENTER);
    textSize(64);
    fill(0);
    if (gameCompleted) {
      text("You Won the Ultimate Diamond Hunt!", -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 {
      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;
    }
  }
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

conditional Level-Specific Background if (currentLevel === 9 || currentLevel === 13 || ...) { background(0); }

Sets the background color based on the current level theme (black for space/void, brown for caves, etc.)

calculation Camera Drift Physics (Level 9) camX += driftVelocityX; driftVelocityX *= 0.95;

On Level 9, applies momentum to the camera after panning stops, creating a drifting effect with gradual decay

conditional Diamond Drawing Logic if (!diamondFound) { if (currentLevel === 8) { drawOceanFishingElements(); } ... }

Draws the level-specific diamond (or fishing rod, asteroids, etc.) in the correct style for each level

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

Updates and renders each confetti particle, removing it if it falls off-screen

for-loop Grabbing People Animation for (let i = grabbingPeople.length - 1; i >= 0; i--) { let person = grabbingPeople[i]; ... }

Animates people moving toward the diamond, grabbing it, and leaving during the post-win celebration

if (currentLevel === 9 || currentLevel === 13 || currentLevel === 15 || currentLevel === 20) { background(0); }
For space/void/deep sea levels, sets the background to pure black to create a dark, mysterious atmosphere
textSize(32); fill(0); text(`Level: ${currentLevel}`, 10, 10);
Draws the current level number as a fixed label in the top-left corner so the player always knows which level they're on
if (currentLevel !== 8 && currentLevel !== 13 && currentLevel !== 20) { translate(camX, camY); }
Applies the camera offset to pan the town view—except for fishing (Level 8), deep sea (13), and void (20) which use fixed cameras
if (!panning && currentLevel === 9 && !diamondFound && !gameCompleted) { camX += driftVelocityX; driftVelocityX *= 0.95; }
On Level 9, after panning stops, the camera continues to drift with exponential decay, creating an inertia effect in space
checkAndGenerateNewBlocks();
Dynamically generates building blocks only for areas currently visible on screen, improving performance
if (currentLevel === 9) { // Draw asteroids and nebulae }
Level 9 skips town generation and instead draws asteroids and nebulae that wrap around the screen and drift
if (!diamondFound) { if (currentLevel === 8) { drawOceanFishingElements(); } ... }
Draws the diamond (or level-specific variant like UFO, football, fishing rod) in the correct style for each level
if (gameCompleted) { confetti[i].update(); confetti[i].display(); }
After winning, updates and displays each confetti particle, removing them when they fall off-screen
if (postWinAnimationActive) { person.state = 'movingToRock'; ... }
During the post-win animation, animates people moving to the diamond, grabbing it, and leaving in a celebratory scene

checkAndGenerateNewBlocks()

This is a classic optimization pattern: instead of generating the entire infinite town at startup (which would be slow and use tons of memory), you only generate visible blocks as the camera pans through. The function converts camera coordinates to block grid coordinates, adds a buffer to generate ahead of time, and calls generateBlock() only for blocks that haven't been generated yet. This is why the game stays fast even with a 4000-pixel-wide town.

function checkAndGenerateNewBlocks() {
  if (currentLevel === 8 || currentLevel === 13 || currentLevel === 15 || currentLevel === 20) {
    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 (!generatedBlocks.has(`${bx},${by}`)) {
          generateBlock(bx, by);
        }
      }
    }
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Level-Specific Skip if (currentLevel === 8 || currentLevel === 13 || currentLevel === 15 || currentLevel === 20) { return; }

Skips block generation for levels with no town (fishing, deep sea, void)

calculation Visible Area Calculation let visibleStartX = -camX; let visibleEndX = visibleStartX + width;

Converts camera offset to virtual coordinates to determine what part of the infinite town is currently visible

calculation Buffer Zone Expansion let buffer = BLOCK_SIZE * 2; visibleStartX -= buffer;

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

for-loop Block Grid Iteration for (let bx = startBlockX; bx < endBlockX; bx++) { for (let by = startBlockY; by < endBlockY; by++) {

Iterates through every block coordinate in the visible area (plus buffer) and generates it if it hasn't been generated yet

if (currentLevel === 8 || currentLevel === 13 || currentLevel === 15 || currentLevel === 20) { return; }
Exits early for levels with no town (fishing ocean, deep sea, void), avoiding unnecessary block generation
let visibleStartX = -camX; let visibleStartY = -camY;
Converts camera offset (camX, camY) into virtual world coordinates to determine the visible area
let buffer = BLOCK_SIZE * 2;
Creates a 2-block-sized buffer zone around the visible area to generate content ahead of time, preventing sudden pop-in
let startBlockX = floor(visibleStartX / BLOCK_SIZE);
Converts the visible pixel coordinates to block grid coordinates (dividing by BLOCK_SIZE and flooring)
if (!generatedBlocks.has(`${bx},${by}`)) { generateBlock(bx, by); }
Checks if this block has already been generated; if not, calls generateBlock() to populate it with buildings and people

generateBlock(blockX, blockY)

generateBlock() is called by checkAndGenerateNewBlocks() for each visible block and creates the procedurally generated content for that block. The function branches based on currentLevel: for Level 9 (space), it generates asteroids and nebulae; for town levels (1-7, 10-12, 14, 16-19), it generates buildings and people using density thresholds. Each block is stored in a Map so draw() can quickly retrieve and render it. This is the core of the procedural generation system.

🔬 These lines set building width, height, and color. What happens if you change the width range to (BLOCK_SIZE * 0.1, BLOCK_SIZE * 0.3) for smaller buildings? Or make col always bright red with color(255, 0, 0)?

        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));
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 if (currentLevel === 13) {
    // No generation for deep sea
  } else if (currentLevel === 20 || currentLevel === 15) {
    // No generation for void
  } 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));
        if (currentLevel === 11) col = color(random(80, 120));
        else if (currentLevel === 12) col = color(random(70, 110), random(70, 110), random(50, 90));
        else if (currentLevel === 14) col = color(random(100, 180), random(50, 100), random(150, 220));
        else if (currentLevel === 17) col = color(random(100, 180), random(100, 180), random(100, 180), 200);
        else if (currentLevel === 18) col = color(random(180, 220));
        else if (currentLevel === 19) col = color(random(255), random(255), random(255));
        blockData.buildings.push({
          x: startX + random(BLOCK_SIZE - w),
          y: startY + random(BLOCK_SIZE - h),
          w, h, color: col,
          blockX, blockY
        });
      }
    }
    if (currentLevel >= 5 && currentLevel !== 7 && currentLevel !== 8 && currentLevel !== 9 && currentLevel !== 13 && currentLevel !== 15 && currentLevel !== 20) {
      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));
          if (currentLevel === 11) col = color(random(0, 50));
          else if (currentLevel === 12) col = color(random(50, 100), random(100, 150), random(50, 100));
          else if (currentLevel === 14) col = color(random(100, 150), random(100, 150), random(200, 255));
          else if (currentLevel === 19) col = color(random(255), random(255), random(255));
          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 (7 lines)

🔧 Subcomponents:

calculation Block to Virtual Coordinate Conversion let startX = blockX * BLOCK_SIZE; let startY = blockY * BLOCK_SIZE;

Converts grid block coordinates (blockX, blockY) to virtual pixel coordinates (startX, startY) for positioning content within the block

conditional Level-Specific Generation if (currentLevel === 9) { ... } else if (currentLevel === 13) { ... }

Branches to different generation logic: asteroids for space, nothing for deep sea/void, buildings/people for town levels

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

For Level 9, generates 1-3 random asteroids per block with random sizes and velocities

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

For town levels, generates up to 3 buildings per block with density controlled by buildingDensityThreshold

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

For levels 5+, generates up to 5 people per block with density controlled by personDensityThreshold

let startX = blockX * BLOCK_SIZE; let startY = blockY * BLOCK_SIZE;
Converts block grid coordinates to pixel coordinates within the virtual world
if (currentLevel === 9) { for (let i = 0; i < random(1, 4); i++) { asteroids.push(...); } }
For Level 9 (space), generates 1-3 asteroids per block, each with random size, color, and drifting velocity
if (random() > buildingDensityThreshold) { ... blockData.buildings.push(...); }
Uses a random check against buildingDensityThreshold to decide whether to place a building—lower thresholds create denser towns
let col = color(random(100, 180), random(100, 180), random(100, 180));
Generates a random grayscale color for the building
if (currentLevel === 11) col = color(random(80, 120));
For themed levels, overrides the color with level-specific shades (darker for caves, different hues for jungles, etc.)
if (currentLevel >= 5 && currentLevel !== 7 && currentLevel !== 8 && ...) { for (let i = 0; i < 5; i++) { if (random() > personDensityThreshold) { blockData.people.push(...); } } }
For levels 5+, except special levels, generates up to 5 people per block using the personDensityThreshold to control density
generatedBlocks.set(`${blockX},${blockY}`, blockData);
Stores the generated buildings and people in a Map using a string key, so they can be quickly retrieved during drawing

touchStarted()

touchStarted() is called when the player first touches the screen. It is responsible for three main jobs: (1) initializing audio on first touch, (2) handling fishing mechanics for Levels 8 and 13 (casting, waiting, reeling), and (3) for other levels, starting panning and checking if the tap hit the diamond. The function converts screen coordinates to virtual world coordinates using the camera offset and applies level-specific logic for different behaviors (e.g., digging on Level 7, fixed-center tapping on Level 20).

function touchStarted() {
  if (howToPlayModal.elt.style.display === 'flex') {
    return false;
  }
  if (!audioStarted) {
    userStartAudio();
    audioStarted = true;
  }
  if (!diamondFound && !gameCompleted && (currentLevel === 7 || currentLevel === 15) && hintCircleActive) {
    return false;
  }
  if (!diamondFound && !gameCompleted) {
    if (touches.length > 0) {
      let tapVirtualX = touches[0].x - camX;
      let tapVirtualY = touches[0].y - camY;
      if (currentLevel === 8 || currentLevel === 13) {
        if (fishingRodActive) {
          if (millis() - fishingRodCastTime > fishingCooldown) {
            let catchRoll = random();
            if (currentLevel === 8) {
              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 };
              }
            } else if (currentLevel === 13) {
              if (catchRoll < 0.07) {
                fishingCatch = { type: 'diamond', x: diamondX, y: fishingBobberY, size: diamondSize };
              } else if (catchRoll < 0.30) {
                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') {
                if (currentLevel === 8) triggerLevel8Win();
                else if (currentLevel === 13) triggerLevel13Win();
              }
              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(() => { triggerLevel6Win(); }, 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(() => { resetGame(currentLevel + 1); }, 2000);
          }
        } else if (currentLevel === 10) {
          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(() => { triggerLevel10Win(); }, 2000);
          }
        } else if (currentLevel === 20) {
          if (dist(touches[0].x, touches[0].y, width / 2, height / 2) < diamondSize / 2 + tapTolerance) {
            diamondFound = true;
            diamondOsc.start(0, 880, 0.5);
            diamondOsc.amp(0, 0.5, 0.2);
            diamondOsc.stop(0.7);
            setTimeout(() => { triggerLevel20Win(); }, 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 (9 lines)

🔧 Subcomponents:

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

If the How to Play modal is open, prevents any canvas interaction until it's closed

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

Starts the p5.sound audio context on first user interaction (required for mobile browsers)

conditional Fishing Mechanics (Levels 8 & 13) if (currentLevel === 8 || currentLevel === 13) { if (fishingRodActive) { ... } ... }

Handles the three-state fishing cycle: cast the rod, wait for a bite, reel in the catch

conditional Panning and Diamond Tap (Other Levels) } else { panning = true; let d = dist(tapVirtualX, tapVirtualY, diamondX, diamondY);

For non-fishing levels, starts panning and checks if the tap hit the diamond

conditional Level-Specific Tap Handling if (currentLevel === 7) { if (d < diamondSize / 2 + tapTolerance && !diggingAnimationActive) { diggingAnimationActive = true; } }

Branches to different tap behavior for each level (e.g., Level 7 starts digging, Level 20 uses screen-center coordinates)

if (howToPlayModal.elt.style.display === 'flex') { return false; }
Prevents any canvas interactions if the How to Play modal is open, blocking panning and tapping
if (!audioStarted) { userStartAudio(); audioStarted = true; }
Initializes the p5.sound audio context on first touch (required for mobile browsers; sound won't work without this)
let tapVirtualX = touches[0].x - camX; let tapVirtualY = touches[0].y - camY;
Converts screen touch coordinates to virtual world coordinates by subtracting the camera offset
if (currentLevel === 8 || currentLevel === 13) { if (fishingRodActive) { ... } }
For fishing levels, checks if the rod is already cast; if so, waits for cooldown and then generates a random catch
let catchRoll = random(); if (catchRoll < 0.05) { fishingCatch = { type: 'diamond', ... }; }
Rolls a random number to determine what you catch: 5% diamond, 15% sushi, 80% fish (or adjusted odds for Level 13)
panning = true; prevTouchX = touches[0].x; prevTouchY = touches[0].y;
Starts panning mode and records the initial touch position so touchMoved() can calculate the drag distance
let d = dist(tapVirtualX, tapVirtualY, diamondX, diamondY);
Calculates the distance from the tap to the diamond center using the Euclidean distance formula
if (d < diamondSize / 2 + tapTolerance) { diamondFound = true; ... }
If the tap distance is within the diamond's radius plus tap tolerance, the diamond is found and a victory sound plays
setTimeout(() => { resetGame(currentLevel + 1); }, 2000);
After finding the diamond, waits 2 seconds (for animations and celebration) then advances to the next level

touchMoved()

touchMoved() is called continuously while the user is dragging their finger across the screen. It calculates the drag distance from the previous touch position, updates the camera offset to pan the view, constrains the camera to the town bounds, and stores velocity for special effects (drift on Level 9, slippery panning on Level 18). The function returns false to prevent default browser behaviors like scrolling.

function touchMoved() {
  if (howToPlayModal.elt.style.display === 'flex') {
    return false;
  }
  let disableInteractionByHint = ((currentLevel === 7 || currentLevel === 15) && hintCircleActive);
  if (panning && !diamondFound && !gameCompleted && !diggingAnimationActive && currentLevel !== 8 && currentLevel !== 9 && currentLevel !== 13 && currentLevel !== 20 && !disableInteractionByHint) {
    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;
      }
      if (currentLevel === 18) {
        slipperyPanVelocityX = dx * 0.5;
        slipperyPanPanVelocityY = dy * 0.5;
      }
    }
  }
  return false;
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

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

Prevents panning if the How to Play modal is open

conditional Panning Enabled Check if (panning && !diamondFound && !gameCompleted && !diggingAnimationActive && ...) {

Only allows panning when actively touching, diamond not found, game not completed, not digging, and on valid levels

calculation Camera Update let dx = touches[0].x - prevTouchX; camX += dx;

Calculates the drag distance and updates the camera offset to pan the view

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

For Level 9, stores the current drag velocity so it can be applied as drift after panning stops

calculation Slippery Pan Initialization (Level 18) if (currentLevel === 18) { slipperyPanVelocityX = dx * 0.5; slipperyPanPanVelocityY = dy * 0.5; }

For Level 18 (ice), stores the drag as reduced velocity to create a slippery, inertia-based panning effect

if (howToPlayModal.elt.style.display === 'flex') { return false; }
Checks if the How to Play modal is visible; if so, ignores the panning action and returns early
let disableInteractionByHint = ((currentLevel === 7 || currentLevel === 15) && hintCircleActive);
For Levels 7 and 15, disables panning while the hint circle is being displayed
if (panning && !diamondFound && !gameCompleted && !diggingAnimationActive && currentLevel !== 8 && currentLevel !== 9 && currentLevel !== 13 && currentLevel !== 20 && !disableInteractionByHint) {
Complex condition ensuring panning is only allowed on appropriate levels and states: must be in panning mode, diamond not found, game not completed, no digging/hint active, and not on fishing/space/deep sea/void levels
let dx = touches[0].x - prevTouchX; let dy = touches[0].y - prevTouchY;
Calculates how far the touch moved since the last touchMoved call (in pixels)
camX += dx; camY += dy;
Updates the camera offset by the drag distance, causing the view to pan in the direction of the drag
camX = constrain(camX, -(TOWN_WIDTH - width), 0);
Constrains the camera to the horizontal bounds of the town (can't scroll past the left or right edges)
prevTouchX = touches[0].x; prevTouchY = touches[0].y;
Updates the stored touch position so the next touchMoved call can calculate the incremental drag distance
if (currentLevel === 9) { lastPanDriftX = dx; lastPanDriftY = dy; }
For Level 9, stores the current drag velocity so it can be applied as inertial drift after the touch ends
if (currentLevel === 18) { slipperyPanVelocityX = dx * 0.5; slipperyPanPanVelocityY = dy * 0.5; }
For Level 18, stores the drag as 50% velocity, creating a slippery ice effect where the camera continues sliding after the touch ends

drawTownElements()

drawTownElements() is a critical rendering function that uses a three-pass approach: Pass 1 draws road grid lines for all visible blocks, Pass 2 draws all people as colored shapes, and Pass 3 draws all buildings. This multi-pass approach ensures roads appear underneath people and buildings. The function calculates visible blocks based on camera offset, adds a buffer zone for smooth scrolling, and only draws blocks within that range. For Level 16, it also renders a horizontally mirrored version of the town for a mirror dimension effect.

🔬 These four lines draw a grid square around each block. What happens if you change the line color or remove one of the four lines? Try removing the top and bottom lines.

          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);
function drawTownElements() {
  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);
  // Pass 1: Draw all roads in the visible area
  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();
  // Pass 2: Draw all people in the visible area
  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);
          }
        }
      }
    }
  }
  // Pass 3: Draw all buildings in the visible area
  for (let bx = startDrawBlockX; bx < endDrawBlockX; bx++) {
    for (let by = startDrawBlockY; by < endBlockY; 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);
          }
        }
      }
    }
  }
  // Handle Level 16 Mirror Dimension effect
  if (currentLevel === 16 && mirrorActive) {
    push();
    translate(TOWN_WIDTH, 0);
    scale(-1, 1);
    // Redraw town elements for the mirrored view
    // ... (mirror drawing code omitted for brevity)
    pop();
  }
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

calculation Visible Area with Buffer let visibleStartX = -camX; let visibleEndX = visibleStartX + width; let buffer = BLOCK_SIZE * 2;

Calculates the visible area and adds a buffer zone for pre-rendering blocks before they scroll into view

for-loop Roads Drawing Pass (Pass 1) for (let bx = startDrawBlockX; bx < endDrawBlockX; bx++) { for (let by = startDrawBlockY; by < endDrawBlockY; by++) { ... line(x, y, ...) } }

Draws grid lines around all visible blocks to create the road network appearance

for-loop People Drawing Pass (Pass 2) for (let bx = startDrawBlockX; bx < endDrawBlockX; bx++) { ... for (let person of blockData.people) { rect(...); ellipse(...); } }

Draws all people in visible blocks using rectangles for bodies and ellipses for heads

for-loop Buildings Drawing Pass (Pass 3) for (let bx = startDrawBlockX; bx < endDrawBlockX; bx++) { ... for (let building of blockData.buildings) { rect(...); } }

Draws all buildings in visible blocks as colored rectangles

conditional Level 16 Mirror Dimension if (currentLevel === 16 && mirrorActive) { push(); translate(TOWN_WIDTH, 0); scale(-1, 1);

For Level 16, redraws the entire town flipped horizontally using scale(-1, 1) to create a mirror effect

let visibleStartX = -camX; let visibleEndX = visibleStartX + width;
Converts camera offset to the virtual pixel coordinates of the visible area (what the player sees on screen)
let buffer = BLOCK_SIZE * 2; visibleStartX -= buffer;
Adds a 2-block buffer around the visible area so blocks are rendered ahead of time, preventing sudden pop-in
let startDrawBlockX = floor(visibleStartX / BLOCK_SIZE);
Converts pixel coordinates to block grid coordinates using floor division
stroke(100); strokeWeight(2);
Sets up dark gray strokes for drawing road grid lines
line(x, y, x + BLOCK_SIZE, y); line(x + BLOCK_SIZE, y, x + BLOCK_SIZE, y + BLOCK_SIZE);
Draws the four edges of each block's grid square to create the road network illusion
noStroke(); // Set noStroke once for all people and buildings in the next passes
Disables stroke outlines for people and buildings to improve performance
for (let person of blockData.people) { fill(person.color); rect(person.x, person.y, person.bodyW, person.bodyH);
Iterates through each person in the block data and draws them as a colored rectangle (body)
ellipse(person.x + person.bodyW / 2, person.y - person.headSize / 2, person.headSize, person.headSize);
Draws the person's head as a circle centered above their body
for (let building of blockData.buildings) { fill(building.color); rect(building.x, building.y, building.w, building.h); }
Iterates through each building in the block data and draws them as colored rectangles
if (currentLevel === 16 && mirrorActive) { translate(TOWN_WIDTH, 0); scale(-1, 1);
For Level 16, translates to the right edge of the town and flips the x-axis to create a horizontally mirrored town reflection

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

drawUFO() is a simple helper function that renders a UFO shape using three ellipses: a main body, a dome, and a glowing window. It's used for Level 6 (both the chasing red UFO and the friendly green pet UFO) and serves as an example of how to create composite shapes in p5.js by combining simple primitives with scaling based on a size parameter.

function drawUFO(x, y, size, bodyColor, lightColor) {
  fill(bodyColor);
  noStroke();
  // UFO Body
  ellipse(x, y, size * 2, size * 0.75);
  // UFO Dome
  ellipse(x, y - size * 0.3, size * 0.8, size * 0.5);
  // UFO Light/Window
  fill(lightColor);
  ellipse(x, y - size * 0.3, size * 0.4, size * 0.2);
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

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

Draws the main UFO body as a horizontal ellipse

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

Draws the UFO dome on top of the body

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

Draws a bright window/light on the dome

fill(bodyColor); noStroke();
Sets the fill color for the UFO and disables stroke outlines for clean geometry
ellipse(x, y, size * 2, size * 0.75);
Draws the main UFO body as a horizontal ellipse centered at (x, y) with width size*2 and height size*0.75
ellipse(x, y - size * 0.3, size * 0.8, size * 0.5);
Draws the UFO dome above the body, positioned at y - size*0.3 (higher up), smaller than the body
fill(lightColor); ellipse(x, y - size * 0.3, size * 0.4, size * 0.2);
Draws a bright light/window on the dome using the lightColor, creating a glowing effect

class ConfettiParticle

ConfettiParticle is an ES6 class that represents a single falling piece of confetti. Each particle has a position, velocity, and acceleration to simulate gravity-pulled motion. The constructor initializes particles with upward velocity and random colors/sizes. The update() method applies physics (gravity acceleration), display() renders it as a rotating rectangle, and isOffScreen() checks when it should be removed. This is a clean example of object-oriented design in p5.js for managing particle effects.

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 Constructor Initialization this.pos = createVector(x, y); this.vel = createVector(random(-5, 5), random(-15, -5));

Initializes confetti particle at (x, y) with random upward velocity and gravity acceleration

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

Updates particle velocity by adding acceleration (gravity) and updates position by adding velocity

calculation Rendering translate(this.pos.x, this.pos.y); rotate(this.rotation); rect(0, 0, this.size, this.size / 2);

Renders the particle as a small rotating rectangle at its current position

conditional Off-Screen Detection return this.pos.y > -camY + height;

Returns true if the particle has fallen below the bottom of the visible screen, so it can be removed

this.pos = createVector(x, y);
Stores the particle's starting position as a p5.Vector
this.vel = createVector(random(-5, 5), random(-15, -5));
Initializes random velocity: horizontal range -5 to 5 (left/right), vertical range -15 to -5 (upward)
this.acc = createVector(0, 0.5);
Sets up constant downward acceleration (gravity effect) with magnitude 0.5
this.color = color(random(255), random(255), random(255), random(150, 255));
Assigns a random color with full red/green/blue range and partial transparency (alpha 150-255)
this.size = random(8, 16);
Sets a random size between 8 and 16 pixels for visual variety
this.rotation = random(TWO_PI); this.rotationSpeed = random(-0.1, 0.1);
Initializes the particle with a random starting rotation and slow rotation speed for spinning effect
this.vel.add(this.acc); this.pos.add(this.vel);
Standard physics: adds acceleration to velocity, then adds velocity to position (Euler integration)
rotate(this.rotation); rect(0, 0, this.size, this.size / 2);
Draws a rotating rectangle centered at the particle's position
return this.pos.y > -camY + height;
Checks if the particle's y-position is below the visible screen (accounting for camera offset)

📦 Key Variables

currentLevel number

Stores the currently active level (1-20), used throughout the code to branch to level-specific logic

let currentLevel = 1;
diamondFound boolean

Flag indicating whether the diamond has been found (true = game won for this level)

let diamondFound = false;
diamondX, diamondY number

Virtual world coordinates of the diamond's position, set randomly in generateInitialTown()

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

Camera offset values controlling the viewport pan (updated by touchMoved(), used in translate())

let camX = 0, camY = 0;
generatedBlocks object (Map)

Map storing generated blocks keyed by 'blockX,blockY' string, each containing arrays of buildings and people

let generatedBlocks = new Map();
initialDiamondDepth number

How deep (in pixels) the diamond is buried, set per level in resetGame() switch statement

let initialDiamondDepth = 1000;
diamondSize number

Radius/diameter of the diamond in pixels, difficulty-dependent and controls how big the target is

let diamondSize = 12;
tapTolerance number

Acceptable tap distance from diamond center in pixels to successfully collect it

let tapTolerance = 30;
panning boolean

Flag indicating whether the user is currently dragging (set in touchStarted, cleared in touchEnded)

let panning = false;
confetti array

Array of ConfettiParticle objects displayed when a level is completed

let confetti = [];
gameCompleted boolean

Flag indicating whether the current level's win animation is complete

let gameCompleted = false;
fishingRodActive boolean

For Levels 8 and 13, true when the rod is cast and waiting for a bite

let fishingRodActive = false;
fishingCatch object

Stores the current catch data { type: 'fish'|'sushi'|'diamond', x, y, size } while reeling in

let fishingCatch = undefined;
asteroids array

For Level 9, array of asteroid objects to draw and animate in space

let asteroids = [];
chasingUFO object

For Level 6, stores the pursuing red UFO's position, size, and speed

let chasingUFO = undefined;
petUFO object

For Level 6, stores the friendly green pet UFO's position, size, and color

let petUFO = undefined;
diggingAnimationActive boolean

For Level 7, true when the 1-second digging animation is playing

let diggingAnimationActive = false;
audioStarted boolean

Flag ensuring audio context is initialized only once on first user interaction

let audioStarted = false;

🔧 Potential Improvements (6)

Here are some ways this code could be enhanced:

BUG touchStarted() Level 20 tap detection

Level 20 uses screen coordinates (touches[0].x, touches[0].y) for tap detection while other levels use virtual coordinates. This inconsistency could cause tap misses if the diamond is not centered.

💡 Apply camera offset consistently: change the Level 20 tap check to use `dist(-camX + touches[0].x, -camY + touches[0].y, diamondX, diamondY)` to match other levels, or document why it's intentionally different.

PERFORMANCE draw() Level 16 mirror effect

The mirror dimension for Level 16 redraws the entire town (roads, people, buildings) a second time every frame, doubling the rendering load even when most blocks are off-screen.

💡 Cache the mirrored view in a graphics buffer (createGraphics) and only re-render it when blocks change, or use a shader for real-time mirroring with less overhead.

STYLE touchStarted(), touchMoved(), touchEnded()

The panning logic is spread across three touch event handlers with overlapping state checks and complex conditionals, making it hard to follow and maintain.

💡 Refactor into a single PanningController class or helper object to centralize pan state, velocity, and constraints. This would reduce duplication and make the code clearer.

BUG resetGame() Level 10 initialization

Level 10 sets `diamondX = width / 2` and `diamondY = height / 2` inside the switch case, but these should use virtualworld coordinates (relative to camX/camY) for consistency with other town levels. The diamond may not be findable if the camera isn't at (0,0).

💡 Move the Level 10 diamond positioning to generateInitialTown() after other town logic, using virtual coordinates: `diamondX = random(TOWN_WIDTH * 0.2, TOWN_WIDTH * 0.8); diamondY = initialDiamondDepth + random(BLOCK_SIZE * 0.2, BLOCK_SIZE * 0.8);`

FEATURE localStorage persistence

The game saves only the current level, but doesn't track statistics like total playtime, levels completed, or fastest find times, which could enhance replayability.

💡 Extend localStorage to store a stats object with { levelsCompleted: [], totalTime: 0, fastestTimes: [] } and display achievements or a high-score board on startup.

PERFORMANCE generatedBlocks.clear() in resetGame()

Clearing all blocks on every level reset forces regeneration of the entire town every time, even if you revisit a level. This is inefficient.

💡 Store blocks per level in a nested Map structure (levelNumber -> blockMap) so when you revisit a level, the old blocks are already cached and don't need regeneration.

🔄 Code Flow

Code flow showing setup, resetgame, draw, checkandgeneratenewblocks, generateblock, touchtarted, touchmoved, drawtown, drawufo, confettiparticle

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

graph TD start[Start] --> setup[setup] setup --> canvas-creation[Canvas Creation] setup --> level-recovery[Level Recovery from Storage] setup --> modal-listeners[Modal Event Listeners] setup --> resetgame[resetGame] resetgame --> state-reset[Global State Reset] resetgame --> difficulty-switch[Difficulty Parameter Switch] resetgame --> storage-save[Progress Save] resetgame --> generateInitialTown[generateInitialTown] setup --> draw[draw loop] draw --> visible-area-calc[Visible Area Calculation] draw --> buffer-expansion[Buffer Zone Expansion] draw --> checkandgeneratenewblocks[checkAndGenerateNewBlocks] checkandgeneratenewblocks --> block-grid-iteration[Block Grid Iteration] block-grid-iteration --> block-coordinate-calc[Block to Virtual Coordinate Conversion] block-grid-iteration --> level-specific-branches[Level-Specific Generation] level-specific-branches --> asteroid-generation[Asteroid Generation] level-specific-branches --> building-generation[Building Generation] level-specific-branches --> people-generation[People Generation] draw --> drawtown[drawTownElements] drawtown --> roads-pass[Roads Drawing Pass] drawtown --> people-pass[People Drawing Pass] drawtown --> buildings-pass[Buildings Drawing Pass] drawtown --> mirror-effect[Level 16 Mirror Dimension] draw --> diamond-drawing[Diamond Drawing Logic] draw --> confetti-animation[Confetti Update Loop] draw --> grabbing-animation[Grabbing People Animation] draw --> touchtarted[touchStarted] touchtarted --> audio-init[Audio Context Initialization] touchtarted --> fishing-logic[Fishing Mechanics] touchtarted --> panning-and-tapping[Panning and Diamond Tap] panning-and-tapping --> level-specific-tap[Level-Specific Tap Handling] draw --> touchmoved[touchMoved] touchmoved --> panning-check[Panning Enabled Check] touchmoved --> camera-update[Camera Update] touchmoved --> drift-storage[Drift Velocity Storage] touchmoved --> slippery-pan[Slippery Pan Initialization] click setup href "#fn-setup" click resetgame href "#fn-resetgame" click draw href "#fn-draw" click checkandgeneratenewblocks href "#fn-checkandgeneratenewblocks" click generateblock href "#fn-generateblock" click touchtarted href "#fn-touchtarted" click touchmoved href "#fn-touchmoved" click drawtown href "#fn-drawtown" click drawufo href "#fn-drawufo" click confettiparticle href "#fn-confettiparticle" click canvas-creation href "#sub-canvas-creation" click level-recovery href "#sub-level-recovery" click modal-listeners href "#sub-modal-listeners" click state-reset href "#sub-state-reset" click difficulty-switch href "#sub-difficulty-switch" click storage-save href "#sub-storage-save" click hint-button-visibility href "#sub-hint-button-visibility" click background-selection href "#sub-background-selection" click camera-drift href "#sub-camera-drift" click diamond-drawing href "#sub-diamond-drawing" click confetti-animation href "#sub-confetti-animation" click grabbing-animation href "#sub-grabbing-animation" click early-exit href "#sub-early-exit" click visible-area-calc href "#sub-visible-area-calc" click buffer-expansion href "#sub-buffer-expansion" click block-grid-iteration href "#sub-block-grid-iteration" click block-coordinate-calc href "#sub-block-coordinate-calc" click level-specific-branches href "#sub-level-specific-branches" click asteroid-generation href "#sub-asteroid-generation" click building-generation href "#sub-building-generation" click people-generation href "#sub-people-generation" click modal-check href "#sub-modal-check" click audio-init href "#sub-audio-init" click fishing-logic href "#sub-fishing-logic" click panning-and-tapping href "#sub-panning-and-tapping" click level-specific-tap href "#sub-level-specific-tap" click modal-block href "#sub-modal-block" click panning-check href "#sub-panning-check" click camera-update href "#sub-camera-update" click drift-storage href "#sub-drift-storage" click slippery-pan href "#sub-slippery-pan" click visible-bounds href "#sub-visible-bounds" click roads-pass href "#sub-roads-pass" click people-pass href "#sub-people-pass" click buildings-pass href "#sub-buildings-pass" click mirror-effect href "#sub-mirror-effect" click body-draw href "#sub-body-draw" click dome-draw href "#sub-dome-draw" click light-draw href "#sub-light-draw" click constructor href "#sub-constructor" click update-method href "#sub-update-method" click display-method href "#sub-display-method" click offscreen-check href "#sub-offscreen-check"

❓ Frequently Asked Questions

What visual elements can users expect to see in the '20 level is easy' p5.js sketch?

Users will encounter a virtual town map filled with grid blocks representing buildings and roads, along with dynamic elements like asteroids and nebulae in Level 9.

How can players interact with the '20 level is easy' sketch?

Players can pan around the town map using touch gestures, allowing them to explore various levels and discover hidden diamonds.

What creative coding techniques are demonstrated in this p5.js sketch?

The sketch showcases procedural generation for creating town layouts and dynamic visual effects like camera panning and particle systems for confetti.

Preview

20 level is easy - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of 20 level is easy - Code flow showing setup, resetgame, draw, checkandgeneratenewblocks, generateblock, touchtarted, touchmoved, drawtown, drawufo, confettiparticle
Code Flow Diagram