Sign part is done

This is a mobile-friendly candy-catching game where players use a green collector bar to catch falling candy while managing interactions between NPCs and different food types. The game features real candy for points, fake candy that poisons people, storms that end the game, bombs, and airdrops that spawn new characters.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make real candy worth more points
  2. Change the collector color — The green collector will instantly become red, making it stand out differently on the screen
  3. Make people faster walkers — People will walk 2x faster across the screen and toward collectibles, making the game more chaotic
  4. Let bombs appear earlier — Bombs and airdrops will start spawning at score 5 instead of 10, increasing difficulty sooner
  5. Make fake candy less deadly — People will have 120 frames to eat fake candy before dying instead of 60, giving them twice as long
  6. Start with more people — The game will begin with 20 people instead of 10, making it easier to find someone to feed fake candy to
  7. Slower object spawning — Objects will fall every 1 second instead of every 0.5 seconds, giving you breathing room
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch creates a fully playable candy-catching game complete with animated non-player characters (people), multiple falling object types, collision detection, and a scoring system. The visual appeal comes from the continuous motion of falling objects, walking characters, eating animations, and a game-over screen. Under the hood, it uses ES6 classes (Collector, Person, FallingObject), the draw loop for real-time updates, arrays to manage multiple moving entities, collision detection to handle player input consequences, and conditional logic to manage different game states (alive, eating, dying, dead).

The code is organized into three main class definitions (Collector, Person, FallingObject), helper functions to find available characters, and the core p5.js functions (setup, draw, resetGame, touchStarted, windowResized). By studying it, you will learn how to structure a game with multiple interacting systems—how to spawn objects at intervals, update dozens of entities each frame, detect collisions, animate state transitions, and manage a complete game lifecycle from start through game-over and restart.

⚙️ How It Works

  1. When the sketch loads, setup() creates a full-window canvas, initializes the green collector bar in the center-bottom, spawns 10 people who walk across the screen, and resets the game state (score = 0).
  2. On every frame, draw() clears the background and executes the core game loop: it moves the collector to follow touch or mouse input, updates and draws all people (checking their state machines for walking/eating/dying), spawns new falling objects at timed intervals, and updates all falling objects as they fall.
  3. When a falling object collides with the collector, different things happen depending on its type: real candy increases the score and disappears; fake candy gets marked as collected and a nearby person walks over to eat it; bombs trigger game-over; and airdrops ground themselves and wait for a person to eat them.
  4. Each person has a state machine (ALIVE, WALKING_TO_FAKE_CANDY, EATING_FAKE_CANDY, WALKING_TO_AIRDROP_FOOD, EATING_AIRDROP_FOOD, DYING, DEAD) that controls their behavior: alive people walk across the screen, when they reach a collectible they eat it for a fixed duration, and eating fake candy or bad airdrop food triggers a death animation that fades over 120 frames.
  5. Airdrops are special: they ground themselves when collected or when they naturally fall to the ground level, then wait for a person to eat them; good airdrops reward 2 points and spawn 5 new people, while bad airdrops poison the person who eats them.
  6. The game ends when a storm hits the collector or a bomb is collected; the game-over screen displays the final score and waits for a tap to restart, which calls resetGame() to clear all objects and reinitialize the people array.

🎓 Concepts You'll Learn

ES6 class definitions and constructorsState machines for NPC behaviorCollision detection (AABB rectangles)Array iteration and object removalGame loops and frame-based animationTouch and mouse input handlingParametric object spawningTimed events and frame counters

📝 Code Breakdown

class Collector

The Collector class is the simplest entity in this sketch. It demonstrates how to use a class to bundle position, size, and behavior together. The move() method shows the two-part input pattern: check for touch (mobile), fall back to mouse (desktop). The constrain() call is crucial—without it, the collector would scroll off-screen.

class Collector {
  constructor() {
    this.width = collectorWidth;
    this.height = collectorHeight;
    this.x = width / 2;
    this.y = height - this.height / 2 - 10;
    this.speed = 10;
  }

  display() {
    fill(50, 200, 50);
    noStroke();
    rect(this.x, this.y, this.width, this.height, 5);
  }

  move() {
    if (touches.length > 0) {
      this.x = touches[0].x;
    } else {
      this.x = mouseX;
    }
    this.x = constrain(this.x, this.width / 2, width - this.width / 2);
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

constructor Collector Constructor constructor() { ... }

Initializes the collector's position (center-bottom of canvas), size, and speed; runs once when the collector is created

method Display Method display() { ... }

Draws the collector as a green rounded rectangle at its current position

method Move Method move() { ... }

Updates the collector's x position to follow touch or mouse input, then constrains it within canvas boundaries

this.width = collectorWidth;
Stores the collector's width (100 pixels) in the instance variable so it can be used in display() and collision checks
this.x = width / 2;
Centers the collector horizontally on the canvas by setting x to half the canvas width
this.y = height - this.height / 2 - 10;
Positions the collector near the bottom of the canvas, with 10 pixels of margin from the true bottom edge
if (touches.length > 0) {
Checks if any fingers are touching the screen; true on mobile devices, false on desktop
this.x = touches[0].x;
On mobile, move the collector to follow the first touch point's x coordinate
this.x = mouseX;
On desktop (fallback), move the collector to follow the mouse's x coordinate
this.x = constrain(this.x, this.width / 2, width - this.width / 2);
Clamps the collector's x position so it never moves outside the canvas—this prevents the collector from half-disappearing at the edges

class Person

The Person class is the most complex in this sketch because it manages a full state machine: ALIVE, WALKING, EATING, DYING, DEAD. Each state drives different behavior in update() and display(). The class also demonstrates frame-based timers (eatingTimer, deathTimer), animation interpolation (the bobbing head, the falling-over rotation), and the critical pattern of storing a reference to a collectible (collectibleRef) so the person and the object can update each other. Notice how die() and reset() transition between states—this is the foundation of game entity lifecycle management.

class Person {
  constructor() {
    this.width = 30;
    this.height = 50;
    this.direction = random() < 0.5 ? -1 : 1;
    this.x = this.direction === 1 ? -this.width / 2 : width + this.width / 2;
    this.y = height - 100;
    this.state = ALIVE;
    this.deathTimer = 0;
    this.eatingTimer = 0;
    this.targetX = this.x;
    this.walkSpeed = random(1, 3);
    this.collectibleRef = null;
    this.foodRatingText = null;
    this.foodRatingTimer = 0;
  }

  display() {
    if (this.state === DEAD) {
      return;
    }
    push();
    translate(this.x, this.y);
    rectMode(CENTER);

    if (this.state === ALIVE || this.state === WALKING_TO_FAKE_CANDY || this.state === EATING_FAKE_CANDY ||
        this.state === WALKING_TO_AIRDROP_FOOD || this.state === EATING_AIRDROP_FOOD) {
      fill(255, 150, 0);
      rect(0, 0, this.width, this.height, 5);
      fill(0);
      ellipse(0, -this.height / 2, this.width * 0.8, this.width * 0.8);

      if (this.state === EATING_FAKE_CANDY || this.state === EATING_AIRDROP_FOOD) {
        let bob = sin(frameCount * 0.2) * 2;
        ellipse(0, -this.height / 2 + bob, this.width * 0.8, this.width * 0.8);
      }
    } else if (this.state === DYING) {
      fill(255, 0, 0, map(frameCount - this.deathTimer, 0, DEATH_ANIMATION_DURATION, 255, 100));
      let angle = map(frameCount - this.deathTimer, 0, DEATH_ANIMATION_DURATION, 0, HALF_PI);
      rotate(angle);
      rect(0, 0, this.height, this.width, 5);
      fill(0);
      ellipse(0, -this.height / 2, this.width * 0.8, this.width * 0.8);
    }

    if (this.state !== DEAD && this.foodRatingText) {
      fill(0);
      textAlign(CENTER);
      textSize(16);
      text(this.foodRatingText, 0, -this.height / 2 - this.width * 0.8 - 10);
      textSize(24);
    }
    pop();
  }

  startWalkingToCollectible(collectible, collectibleType) {
    if (this.state === ALIVE && this.collectibleRef === null) {
      this.collectibleRef = collectible;
      collectible.eater = this;

      if (collectibleType === FAKE_CANDY_TYPE) {
        this.state = WALKING_TO_FAKE_CANDY;
      } else if (collectibleType === AIRDROP_TYPE) {
        this.state = WALKING_TO_AIRDROP_FOOD;
      }
      this.targetX = collectible.x;
      this.eatingTimer = 0;
      this.foodRatingText = null;
      this.foodRatingTimer = 0;
    }
  }

  die() {
    if (this.state === EATING_FAKE_CANDY || this.state === EATING_AIRDROP_FOOD) {
      this.state = DYING;
      this.deathTimer = frameCount;

      if (this.collectibleRef) {
        let index = fallingObjects.indexOf(this.collectibleRef);
        if (index > -1) {
          fallingObjects.splice(index, 1);
        }
        this.collectibleRef = null;
      }
    }
  }

  reset() {
    this.state = ALIVE;
    this.deathTimer = 0;
    this.eatingTimer = 0;
    this.collectibleRef = null;
    this.foodRatingText = null;
    this.foodRatingTimer = 0;
    this.direction *= -1;
    this.x = this.direction === 1 ? -this.width / 2 : width + this.width / 2;
    this.targetX = this.x;
    this.walkSpeed = random(1, 3);
  }

  update() {
    if (this.state === WALKING_TO_FAKE_CANDY || this.state === WALKING_TO_AIRDROP_FOOD) {
      if (abs(this.x - this.targetX) > this.walkSpeed) {
        this.x += (this.targetX > this.x ? 1 : -1) * this.walkSpeed;
      } else {
        this.x = this.targetX;
        if (this.state === WALKING_TO_FAKE_CANDY) {
          this.state = EATING_FAKE_CANDY;
        } else if (this.state === WALKING_TO_AIRDROP_FOOD) {
          this.state = EATING_AIRDROP_FOOD;
        }
        this.eatingTimer = frameCount;
      }
    } else if (this.state === ALIVE) {
      this.x += this.direction * this.walkSpeed;
      if ((this.direction === 1 && this.x > width + this.width / 2) ||
          (this.direction === -1 && this.x < -this.width / 2)) {
        this.reset();
      }
    } else if (this.state === EATING_FAKE_CANDY) {
      if (frameCount - this.eatingTimer > EATING_DURATION) {
        this.die();
      }
    } else if (this.state === EATING_AIRDROP_FOOD) {
      if (frameCount - this.eatingTimer === EATING_DURATION) {
        if (this.collectibleRef && this.collectibleRef.foodQuality === BAD_FOOD_TYPE) {
          this.foodRatingText = "1/10";
          this.die();
        } else {
          this.foodRatingText = "10/10";
          score += 2;
          if (this.collectibleRef) {
            let index = fallingObjects.indexOf(this.collectibleRef);
            if (index > -1) fallingObjects.splice(index, 1);
            this.collectibleRef = null;
          }
          this.reset();

          for (let i = 0; i < PEOPLE_PER_GOOD_AIRDROP; i++) {
            if (people.length < MAX_PEOPLE) {
              people.push(new Person());
            }
          }
        }
        this.foodRatingTimer = frameCount;
      }
      if (frameCount - this.foodRatingTimer > FOOD_RATING_DISPLAY_DURATION) {
        this.foodRatingText = null;
      }
    } else if (this.state === DYING && frameCount - this.deathTimer > DEATH_ANIMATION_DURATION) {
      this.state = DEAD;
    }
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional State Machine Logic if (this.state === ALIVE || ...) { ... } else if (this.state === DYING) { ... }

The core of the Person class: checks the person's current state and draws them differently (alive, eating, or dying) based on what state they're in

calculation Head Bobbing Animation let bob = sin(frameCount * 0.2) * 2;

Uses a sine wave tied to frameCount to create a smooth up-down bobbing motion when eating

calculation Death Animation let angle = map(frameCount - this.deathTimer, 0, DEATH_ANIMATION_DURATION, 0, HALF_PI);

Smoothly rotates the person from upright to 90 degrees over 120 frames, creating a falling-over effect

conditional Walk Toward Target if (abs(this.x - this.targetX) > this.walkSpeed) { this.x += (this.targetX > this.x ? 1 : -1) * this.walkSpeed; }

Moves the person one step closer to their target (a collectible) each frame, stopping when they arrive

this.direction = random() < 0.5 ? -1 : 1;
Randomly chooses the person's starting direction: -1 means walking left, 1 means walking right
this.x = this.direction === 1 ? -this.width / 2 : width + this.width / 2;
Spawns the person off-screen: if direction is 1 (right), start just left of the left edge; if -1 (left), start just right of the right edge
this.state = ALIVE;
Initializes the person's state machine to ALIVE, the starting state where they walk back and forth
if (this.state === DEAD) { return; }
Early exit from display(): if the person is DEAD, don't draw them at all (they disappear from the screen)
let bob = sin(frameCount * 0.2) * 2;
Creates a smooth up-down bobbing effect: sin() oscillates between -1 and 1 as frameCount increases, multiplied by 2 for a 4-pixel range
if (frameCount - this.eatingTimer > EATING_DURATION) { this.die(); }
After eating fake candy for 60 frames, the person dies; frameCount - this.eatingTimer measures how many frames have passed since they started eating
this.x += (this.targetX > this.x ? 1 : -1) * this.walkSpeed;
Ternary operator that moves the person toward the target: if targetX is to the right, move right (+); if to the left, move left (-)
for (let i = 0; i < PEOPLE_PER_GOOD_AIRDROP; i++) { if (people.length < MAX_PEOPLE) { people.push(new Person()); } }
When a person eats good airdrop food, spawn 5 new people (if under the MAX_PEOPLE cap of 50 total)

class FallingObject

FallingObject is the most versatile class because it handles five different visual types with one code path. The key patterns are: type-based initialization (setting color and properties depending on type), multi-state display (drawing differently when collected vs. falling vs. grounded), and the special case of procedural generation (the generateLightning() method creates randomized shapes at runtime). The collision detection is a classic AABB check—study it to understand how hitboxes work in games.

class FallingObject {
  constructor(type) {
    this.type = type;
    this.size = random(20, 40);
    this.x = random(this.size / 2, width - this.size / 2);
    this.y = -this.size / 2;
    this.speed = random(2, 5);

    if (this.type === REAL_CANDY_TYPE || this.type === FAKE_CANDY_TYPE) {
      this.color = color(random(200, 255), random(100, 200), random(100, 200));
    } else if (this.type === STORM_TYPE) {
      this.color = color(50, 70, 100);
    } else if (this.type === BOMB_TYPE) {
      this.color = color(0);
    } else if (this.type === AIRDROP_TYPE) {
      this.color = color(255);
    }

    if (this.type === STORM_TYPE) {
      this.hasLightning = random() < 0.3;
      this.lightningFlashFrame = 0;
      this.lightningPoints = [];
    }
    this.collected = false;
    this.eater = null;
    this.foodQuality = null;
    this.isGrounded = false;
    this.groundedFrame = 0;
    this.isCollectedByPlayer = false;
  }

  display() {
    if (this.collected && this.type === FAKE_CANDY_TYPE && this.eater) {
      fill(this.color);
      noStroke();
      ellipse(this.eater.x, this.eater.y - this.eater.height / 2 - this.size / 2 - 5, this.size);
      return;
    }
    if (this.isGrounded && this.type === AIRDROP_TYPE && !this.eater) {
      fill(this.color);
      noStroke();
      rectMode(CENTER);
      rect(this.x, this.y, this.size, this.size);
      fill(0);
      textAlign(CENTER, TOP);
      textSize(10);
      text("Airdrop", this.x, this.y + this.size/2 + 2);
      textSize(24);
      return;
    }

    fill(this.color);
    noStroke();
    rectMode(CENTER);

    if (this.type === REAL_CANDY_TYPE || this.type === FAKE_CANDY_TYPE) {
      ellipse(this.x, this.y, this.size);
    } else if (this.type === STORM_TYPE) {
      beginShape();
      vertex(this.x - this.size / 2, this.y);
      bezierVertex(this.x - this.size / 2, this.y - this.size / 2, this.x + this.size / 2, this.y - this.size / 2, this.x + this.size / 2, this.y);
      bezierVertex(this.x + this.size / 4, this.y + this.size / 4, this.x - this.size / 4, this.y + this.size / 4, this.x - this.size / 2, this.y);
      endShape(CLOSE);

      ellipse(this.x - this.size * 0.3, this.y + this.size * 0.1, this.size * 0.6);
      ellipse(this.x + this.size * 0.3, this.y + this.size * 0.1, this.size * 0.7);

      if (this.hasLightning) {
        if (random() < 0.05 && frameCount - this.lightningFlashFrame > 60) {
          this.lightningFlashFrame = frameCount;
          this.generateLightning();
        }

        if (frameCount - this.lightningFlashFrame < 10 && this.lightningPoints.length > 1) {
          stroke(255, 255, 0);
          strokeWeight(2);
          for (let i = 0; i < this.lightningPoints.length - 1; i++) {
            line(this.lightningPoints[i].x, this.lightningPoints[i].y, this.lightningPoints[i+1].x, this.lightningPoints[i+1].y);
          }
          noStroke();
        }
      }
    } else if (this.type === BOMB_TYPE) {
      fill(0);
      ellipse(this.x, this.y, this.size);
      fill(255, 0, 0);
      ellipse(this.x, this.y - this.size / 3, this.size / 5);
    } else if (this.type === AIRDROP_TYPE) {
      fill(255);
      rect(this.x, this.y, this.size, this.size);
      stroke(0);
      strokeWeight(1);
      line(this.x - this.size / 4, this.y - this.size / 2, this.x - this.size / 4, this.y - this.size / 4);
      line(this.x + this.size / 4, this.y - this.size / 2, this.x + this.size / 4, this.y - this.size / 4);
      noFill();
      bezier(
        this.x - this.size / 2, this.y - this.size / 2,
        this.x - this.size / 2, this.y - this.size,
        this.x + this.size / 2, this.y - this.size,
        this.x + this.size / 2, this.y - this.size / 2
      );
      noStroke();
    }
  }

  generateLightning() {
    this.lightningPoints = [];
    const startX = this.x;
    const startY = this.y + this.size / 2;
    const endY = startY + random(20, 50);
    const endX = startX + random(-this.size / 4, this.size / 4);

    this.lightningPoints.push(createVector(startX, startY));

    let currentX = startX;
    let currentY = startY;

    while (currentY < endY) {
      currentY += random(5, 15);
      currentX += random(-10, 10);
      currentX = constrain(currentX, this.x - this.size / 2, this.x + this.size / 2);
      this.lightningPoints.push(createVector(currentX, currentY));
    }

    if (this.lightningPoints.length > 1) {
      this.lightningPoints[this.lightningPoints.length - 1].x = endX;
      this.lightningPoints[this.lightningPoints.length - 1].y = endY;
    }
  }

  fall() {
    if (!this.eater && !this.isGrounded && !this.isCollectedByPlayer) {
      this.y += this.speed;
    }

    if (this.type === AIRDROP_TYPE && (this.y > height - 100 - this.size / 2 || this.isCollectedByPlayer)) {
      this.y = height - 100 - this.size / 2;
      this.isGrounded = true;
      if (this.groundedFrame === 0) {
        this.groundedFrame = frameCount;
        this.foodQuality = random() < 0.01 ? BAD_FOOD_TYPE : GOOD_FOOD_TYPE;
      }
    }
  }

  isOffScreen() {
    return this.y > height + this.size / 2 && this.type !== AIRDROP_TYPE;
  }

  collidesWith(other) {
    return abs(this.x - other.x) * 2 < (this.size + other.width) &&
           abs(this.y - other.y) * 2 < (this.size + other.height);
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

constructor FallingObject Constructor constructor(type) { ... }

Initializes a falling object with a type (real candy, fake candy, storm, bomb, or airdrop), random size and speed, and type-specific properties like color and lightning

method Display Method display() { ... }

Draws the object based on its type and state (falling, collected by person, or grounded); handles special cases like lightning in storms and parachutes on airdrops

method Generate Lightning generateLightning() { ... }

Procedurally creates a jagged lightning bolt using random vertices, stored in an array for repeated drawing during the flash

method Fall Method fall() { ... }

Updates the object's y position to simulate gravity; handles airdrops grounding themselves and determining their food quality

method Collision Detection collidesWith(other) { ... }

Uses AABB (axis-aligned bounding box) collision to detect if this object overlaps with another (the collector or a person)

this.x = random(this.size / 2, width - this.size / 2);
Spawns the object at a random horizontal position, offset by half its size so it doesn't spawn partially off-screen
this.y = -this.size / 2;
Spawns the object just above the top of the canvas so it falls into view
this.color = color(random(200, 255), random(100, 200), random(100, 200));
For candy, generates a random pink/purple color each time; this makes each candy look unique
this.hasLightning = random() < 0.3;
30% chance this storm will have lightning; the other 70% of storms will be silent clouds
if (this.collected && this.type === FAKE_CANDY_TYPE && this.eater) {
Check if this candy is being eaten by a person; if yes, draw it floating above their head instead of falling
if (random() < 0.05 && frameCount - this.lightningFlashFrame > 60) {
5% chance each frame to trigger lightning, but only if at least 60 frames have passed since the last flash (prevents constant flickering)
this.foodQuality = random() < 0.01 ? BAD_FOOD_TYPE : GOOD_FOOD_TYPE;
When an airdrop grounds, roll the dice: 1% chance it's bad food, 99% chance it's good food
return abs(this.x - other.x) * 2 < (this.size + other.width) &&
AABB collision check: multiply the x distance by 2 to convert from center-based to width-based comparison, then check if the combined widths span the gap

findAvailablePerson(xPosition)

This is a common algorithmic pattern: iterate through a collection, track the best match (closest person), and return it. It's the foundation for proximity checks, AI target selection, and game logic like 'what should this AI do next?'. Notice the defensive programming: return null if no one is found, so the caller can check if (closestPerson) before using it.

function findAvailablePerson(xPosition) {
  let closestPerson = null;
  let minDistance = Infinity;
  for (let p of people) {
    if (p.state === ALIVE && p.collectibleRef === null) {
      let d = abs(p.x - xPosition);
      if (d < minDistance) {
        minDistance = d;
        closestPerson = p;
      }
    }
  }
  return closestPerson;
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Distance Check let d = abs(p.x - xPosition);

Calculates the horizontal distance between a person and the falling object

conditional Minimum Distance Tracking if (d < minDistance) { minDistance = d; closestPerson = p; }

Updates the closest person if this one is closer than any found so far

for (let p of people) {
Loop through every person in the people array using the for...of syntax
if (p.state === ALIVE && p.collectibleRef === null) {
Only consider people who are ALIVE (not dying, dead, or already eating) and not currently walking to or eating another collectible
let d = abs(p.x - xPosition);
Calculate the absolute horizontal distance between this person's x position and the falling object's x position
if (d < minDistance) {
If this person is closer than any previous person found, update the record
return closestPerson;
Return the closest available person, or null if no one is available

setup()

setup() is called once when the sketch starts. It's where you initialize the canvas, global variables, and any objects you'll use throughout the game. Notice the call to resetGame()—this pattern of separating initialization (setup) from state reset (resetGame) makes it easy to restart a game without reloading the page.

function setup() {
  createCanvas(windowWidth, windowHeight);
  rectMode(CENTER);

  collector = new Collector();

  for (let i = 0; i < INITIAL_PEOPLE; i++) {
    people.push(new Person());
  }

  resetGame();

  userStartAudio();
}
Line-by-line explanation (6 lines)
createCanvas(windowWidth, windowHeight);
Creates a canvas that fills the entire window; will respond to window resizes thanks to windowResized() later
rectMode(CENTER);
Sets all rectangles to be drawn from their center point, not their top-left—this makes positioning easier
collector = new Collector();
Creates the player's green collector bar and stores it in the global collector variable
for (let i = 0; i < INITIAL_PEOPLE; i++) { people.push(new Person()); }
Spawns the initial 10 people and adds each to the people array
resetGame();
Calls resetGame() to initialize score, gameIsOver flag, and clear the fallingObjects and storms arrays
userStartAudio();
Prepares the audio system to work on mobile devices; required by p5.sound to enable sound after user interaction

draw()

draw() is the heartbeat of the sketch—it runs 60 times per second. Notice the structure: it clears the screen, updates all entities (collector, people, falling objects, storms), checks collisions, draws everything, and finally displays either the game state or the game-over screen. This is the standard game loop pattern used in virtually every game. The key insight is that each frame is independent: nothing is tracked from one frame to the next except the variables you explicitly store (score, people array, etc.). Everything else is recalculated.

function draw() {
  background(220);

  if (!gameIsOver) {
    collector.move();
    collector.display();

    for (let p of people) {
      p.update();
      p.display();
    }

    const anyPersonBusy = people.some(p => p.state === WALKING_TO_FAKE_CANDY || p.state === EATING_FAKE_CANDY ||
                                          p.state === WALKING_TO_AIRDROP_FOOD || p.state === EATING_AIRDROP_FOOD);
    if (millis() - lastSpawnTime > spawnInterval && !anyPersonBusy) {
      let type;
      let r = random();

      if (score < 10) {
        if (r < 0.6) {
          type = REAL_CANDY_TYPE;
        } else if (r < 0.8) {
          type = FAKE_CANDY_TYPE;
        } else {
          type = STORM_TYPE;
        }
      } else {
        if (r < 0.3) {
          type = REAL_CANDY_TYPE;
        } else if (r < 0.3 + 0.15) {
          type = FAKE_CANDY_TYPE;
        } else if (r < 0.3 + 0.15 + 0.2) {
          type = STORM_TYPE;
        } else if (r < 0.3 + 0.15 + 0.2 + 0.2) {
          type = BOMB_TYPE;
        } else {
          type = AIRDROP_TYPE;
        }
      }

      let newObject = new FallingObject(type);
      if (type === STORM_TYPE) {
        storms.push(newObject);
      } else {
        fallingObjects.push(newObject);
      }
      lastSpawnTime = millis();
    }

    for (let i = fallingObjects.length - 1; i >= 0; i--) {
      let obj = fallingObjects[i];

      if ((obj.collected && obj.type === FAKE_CANDY_TYPE && obj.eater) ||
          (obj.isGrounded && obj.type === AIRDROP_TYPE && !obj.eater)) {
        obj.display();
      } else {
        obj.fall();
        obj.display();

        if (obj.collidesWith(collector)) {
          if (obj.type === REAL_CANDY_TYPE) {
            score++;
            fallingObjects.splice(i, 1);
          } else if (obj.type === FAKE_CANDY_TYPE) {
            let closestPerson = findAvailablePerson(obj.x);
            if (closestPerson) {
              obj.collected = true;
              closestPerson.startWalkingToCollectible(obj, FAKE_CANDY_TYPE);
            } else {
              fallingObjects.splice(i, 1);
            }
          } else if (obj.type === BOMB_TYPE) {
            gameIsOver = true;
            break;
          } else if (obj.type === AIRDROP_TYPE) {
            obj.isCollectedByPlayer = true;
          }
        } else if (obj.type === AIRDROP_TYPE && obj.isGrounded && !obj.eater) {
          let closestPerson = findAvailablePerson(obj.x);
          if (closestPerson) {
            closestPerson.startWalkingToCollectible(obj, AIRDROP_TYPE);
          }
        } else if (obj.isOffScreen()) {
          fallingObjects.splice(i, 1);
        } else if (obj.type === AIRDROP_TYPE && obj.isGrounded && !obj.eater && frameCount - obj.groundedFrame > 300) {
          fallingObjects.splice(i, 1);
        }
      }
    }

    for (let i = storms.length - 1; i >= 0; i--) {
      storms[i].fall();
      storms[i].display();

      if (storms[i].collidesWith(collector)) {
        gameIsOver = true;
        break;
      } else if (storms[i].isOffScreen()) {
        storms.splice(i, 1);
      }
    }

    fill(0);
    textAlign(LEFT, TOP);
    textSize(24);
    text(`Score: ${score}`, 20, 20);

  } else {
    fill(0);
    textAlign(CENTER, CENTER);
    textSize(48);
    text("GAME OVER!", width / 2, height / 2 - 50);
    textSize(32);
    text(`Final Score: ${score}`, width / 2, height / 2);
    textSize(20);
    text("Tap anywhere to restart", width / 2, height / 2 + 50);

    fallingObjects = [];
    storms = [];
    for (let p of people) {
      p.reset();
    }
  }
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

calculation Collector Movement and Display collector.move(); collector.display();

Updates the collector's position based on touch/mouse input and draws it on screen

for-loop Update All People for (let p of people) { p.update(); p.display(); }

Cycles through every person, updating their state/position and drawing them

conditional Spawn Falling Objects const anyPersonBusy = people.some(...); if (millis() - lastSpawnTime > spawnInterval && !anyPersonBusy) { ... }

Checks if enough time has passed and no one is eating, then spawns a new falling object at the appropriate probability

for-loop Update Falling Objects for (let i = fallingObjects.length - 1; i >= 0; i--) { ... }

Processes each falling object: falls it, draws it, checks collisions with collector and people, removes it if off-screen

for-loop Update Storms for (let i = storms.length - 1; i >= 0; i--) { ... }

Falls and draws each storm, checks collision with collector (game over), removes if off-screen

calculation Score Display fill(0); textAlign(LEFT, TOP); textSize(24); text(`Score: ${score}`, 20, 20);

Draws the current score in the top-left corner of the screen

conditional Game Over Screen } else { ... }

When gameIsOver is true, displays the final score and waits for the player to tap to restart

background(220);
Clears the entire canvas with light gray, erasing the previous frame and preventing motion trails
const anyPersonBusy = people.some(p => p.state === WALKING_TO_FAKE_CANDY || ...);
Uses the array method .some() to check if any person is currently busy (walking to or eating a collectible); returns true/false
if (millis() - lastSpawnTime > spawnInterval && !anyPersonBusy) {
Only spawn a new object if 500+ milliseconds have passed AND no person is currently eating (prevents spawning during busy moments)
let r = random();
Generate a random number between 0 and 1; use this to determine what type of object to spawn based on probability ranges
if (score < 10) { ... } else { ... }
Difficulty scaling: at low scores, mostly real candy; at score 10+, introduce bombs and airdrops
for (let i = fallingObjects.length - 1; i >= 0; i--) {
Loop backward through the array (from last to first) so we can safely remove items with splice() without skipping any
if (obj.collidesWith(collector)) {
Check if this falling object overlaps with the collector using the AABB collision function
score++;
Real candy collected—add 1 point to the score
fallingObjects.splice(i, 1);
Remove the object from the fallingObjects array by removing 1 item at index i
gameIsOver = true;
Either a bomb was collected or a storm hit the collector—set gameIsOver so the next frame draws the game-over screen
text(`Score: ${score}`, 20, 20);
Display the current score using template literal syntax (backticks and ${variable}) in the top-left corner

resetGame()

resetGame() separates the concept of 'starting fresh' from the one-time initialization in setup(). This design makes it easy to restart without reloading the page. Notice it empties arrays by reassigning them to [] rather than clearing them in-place—this is a simple, reliable pattern in JavaScript.

function resetGame() {
  score = 0;
  gameIsOver = false;
  fallingObjects = [];
  storms = [];
  lastSpawnTime = millis();
  collector.x = width / 2;
  people = [];
  for (let i = 0; i < INITIAL_PEOPLE; i++) {
    people.push(new Person());
  }
}
Line-by-line explanation (8 lines)
score = 0;
Reset the score to 0 for a fresh game
gameIsOver = false;
Set gameIsOver back to false so the game loop resumes
fallingObjects = [];
Empty the array of falling objects (candy, bombs, airdrops) so the game starts clean
storms = [];
Empty the array of storms so no leftover storms appear
lastSpawnTime = millis();
Reset the spawn timer to the current time so objects start spawning immediately after restart
collector.x = width / 2;
Re-center the collector horizontally on the canvas
people = [];
Clear the entire people array
for (let i = 0; i < INITIAL_PEOPLE; i++) { people.push(new Person()); }
Spawn the initial 10 people again, fresh and all in ALIVE state

touchStarted()

p5.js calls touchStarted() when the user touches the screen. By returning false, we tell the browser not to do its normal touch handling (scrolling, zooming, etc.), keeping the focus on the game. This is a mobile-first design pattern.

function touchStarted() {
  if (gameIsOver) {
    resetGame();
  }
  return false;
}
Line-by-line explanation (3 lines)
if (gameIsOver) {
Check if the game is currently over
resetGame();
If the game is over, reset it to start a fresh game
return false;
Return false to prevent default browser behavior (like scrolling or zooming) when touching the screen

touchMoved()

touchMoved() is called every time a touch moves. By returning false, we prevent the browser from scrolling the page—important for a full-screen game that needs to capture all touch input.

function touchMoved() {
  return false;
}
Line-by-line explanation (1 lines)
return false;
Always prevent default touch behavior (scrolling) while the sketch is running

windowResized()

p5.js calls windowResized() automatically whenever the window (or device) size changes. This function ensures the game stays playable across screen rotations and resizing. It's a responsive design pattern essential for mobile games.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  collector.x = width / 2;
  collector.y = height - collector.height / 2 - 10;
  people = [];
  for (let i = 0; i < INITIAL_PEOPLE; i++) {
    people.push(new Person());
  }
}
Line-by-line explanation (4 lines)
resizeCanvas(windowWidth, windowHeight);
Resize the canvas to match the new window dimensions (e.g., when the device is rotated)
collector.x = width / 2;
Re-center the collector on the new canvas width
collector.y = height - collector.height / 2 - 10;
Re-position the collector vertically based on the new canvas height
people = [];
Clear and reinitialize the people array to adjust to the new screen size

📦 Key Variables

collector object (Collector instance)

Stores the green collector bar that the player controls; tracks its x and y position and handles collision detection

let collector = new Collector();
fallingObjects array

Stores all falling objects except storms: real candy, fake candy, bombs, and airdrops; updated every frame to add new objects and remove collected/off-screen ones

let fallingObjects = [];
storms array

Stores all falling storms separately from other objects; storms have special lightning properties

let storms = [];
people array

Stores all people (NPCs) currently on screen; each person has a state machine, position, and walking behavior

let people = [];
score number

Tracks the player's current score; incremented when real candy is collected or good airdrops are eaten

let score = 0;
gameIsOver boolean

Flag that indicates whether the game is still playing (false) or has ended (true); controls whether draw() runs game logic or game-over screen

let gameIsOver = false;
lastSpawnTime number

Stores the millisecond timestamp of the last spawned falling object; used to enforce the spawnInterval delay

let lastSpawnTime = 0;
spawnInterval number (constant)

The minimum time in milliseconds between spawning new falling objects; currently 500ms (one object every half-second)

const spawnInterval = 500;
ALIVE number (constant)

State constant (value 0) for a person who is walking normally and not eating anything

const ALIVE = 0;
WALKING_TO_FAKE_CANDY number (constant)

State constant (value 1) for a person walking toward fake candy after the player collects it

const WALKING_TO_FAKE_CANDY = 1;
EATING_FAKE_CANDY number (constant)

State constant (value 2) for a person currently eating fake candy; leads to death after EATING_DURATION frames

const EATING_FAKE_CANDY = 2;
WALKING_TO_AIRDROP_FOOD number (constant)

State constant (value 3) for a person walking toward a grounded airdrop

const WALKING_TO_AIRDROP_FOOD = 3;
EATING_AIRDROP_FOOD number (constant)

State constant (value 4) for a person eating an airdrop; can be good (10/10, +2 score) or bad (1/10, dies)

const EATING_AIRDROP_FOOD = 4;
DYING number (constant)

State constant (value 5) for a person in the death animation (falling over, fading red)

const DYING = 5;
DEAD number (constant)

State constant (value 6) for a person who is completely dead and no longer drawn

const DEAD = 6;
DEATH_ANIMATION_DURATION number (constant)

The length of the death animation in frames; currently 120 frames (~2 seconds at 60fps)

const DEATH_ANIMATION_DURATION = 120;
EATING_DURATION number (constant)

How long a person eats before dying from fake candy; currently 60 frames (~1 second)

const EATING_DURATION = 60;
FOOD_RATING_DISPLAY_DURATION number (constant)

How long the '10/10' or '1/10' text stays visible above a person's head after eating; currently 90 frames

const FOOD_RATING_DISPLAY_DURATION = 90;
REAL_CANDY_TYPE number (constant)

Type constant (value 0) for real candy that increases score when collected

const REAL_CANDY_TYPE = 0;
FAKE_CANDY_TYPE number (constant)

Type constant (value 2) for fake candy that must be given to a person, poisoning them

const FAKE_CANDY_TYPE = 2;
BOMB_TYPE number (constant)

Type constant (value 3) for bombs that end the game if collected

const BOMB_TYPE = 3;
AIRDROP_TYPE number (constant)

Type constant (value 4) for airdrops that ground themselves and are eaten by people; can be good or bad

const AIRDROP_TYPE = 4;
GOOD_FOOD_TYPE number (constant)

Food quality constant (value 0) for airdrops that give +2 score and spawn new people when eaten

const GOOD_FOOD_TYPE = 0;
BAD_FOOD_TYPE number (constant)

Food quality constant (value 1) for airdrops that poison the person who eats them

const BAD_FOOD_TYPE = 1;
INITIAL_PEOPLE number (constant)

How many people spawn at the start of the game; currently 10

const INITIAL_PEOPLE = 10;
PEOPLE_PER_GOOD_AIRDROP number (constant)

How many new people spawn when a good airdrop is eaten; currently 5

const PEOPLE_PER_GOOD_AIRDROP = 5;
MAX_PEOPLE number (constant)

Maximum population cap to prevent performance issues; currently 50 people max on screen

const MAX_PEOPLE = 50;

🔧 Potential Improvements (6)

Here are some ways this code could be enhanced:

BUG Person.die() and FallingObject.eater reference

If a person is removed from the array while still eating an airdrop, the airdrop keeps the reference but the person may be garbage-collected or reused, causing stale references

💡 Add a check in FallingObject.display() to validate that this.eater still exists in the people array before drawing it

PERFORMANCE findAvailablePerson() and spawn logic

findAvailablePerson() is called every time a fake candy or grounded airdrop needs an eater; looping through all people repeatedly (once per spawn, once per grounded airdrop) can be inefficient with many entities

💡 Cache available people at the start of draw() and reuse that list, or use a Map of people by state to instantly look up available ones

BUG draw() fallingObjects loop

If a person is eating an airdrop and dies, the airdrop is marked as eaten but never removed from fallingObjects; it can accumulate over time

💡 When a person dies while eating an airdrop, also splice the airdrop from fallingObjects in the die() method, similar to fake candy removal

STYLE Constant naming

Constants like STORM_TYPE and REAL_CANDY_TYPE mix semantic meaning (STORM) with technical names (TYPE); the code would be clearer with grouping or different naming

💡 Consider renaming to OBJECT_TYPE_STORM, OBJECT_TYPE_REAL_CANDY, etc., or creating enums; this makes it obvious what the constant represents

FEATURE Game over logic

The game only ends on bomb collection or storm collision; there's no population extinction condition even if all people die

💡 Add logic to end the game if people.length === 0 (all people are dead) for an additional failure state

PERFORMANCE display() for grounded airdrops

Every grounded airdrop recalculates and redraws the 'Airdrop' text label every frame, even if no one is nearby

💡 Draw the label only once when the airdrop grounds, or use a flag to skip redrawing the text label every frame

🔄 Code Flow

Code flow showing collector, person, fallingobject, findavailableperson, setup, draw, resetgame, touchstarted, touchmoved, windowresized

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

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> collector-update[Collector Movement and Display] draw --> people-loop[Update All People] draw --> fallingobjects-loop[Update Falling Objects] draw --> storms-loop[Update Storms] draw --> score-display[Score Display] draw --> gameover-screen[Game Over Screen] collector-update --> collector-move[collector-move] collector-update --> collector-display[collector-display] click setup href "#fn-setup" click draw href "#fn-draw" click collector-update href "#sub-collector-update" click collector-move href "#sub-collector-move" click collector-display href "#sub-collector-display" people-loop --> person-state-machine[person-state-machine] people-loop --> person-walk-target[person-walk-target] people-loop --> person-eating-animation[person-eating-animation] people-loop --> person-death-animation[person-death-animation] click people-loop href "#sub-people-loop" click person-state-machine href "#sub-person-state-machine" click person-walk-target href "#sub-person-walk-target" click person-eating-animation href "#sub-person-eating-animation" click person-death-animation href "#sub-person-death-animation" fallingobjects-loop --> fallingobject-fall[fallingobject-fall] fallingobjects-loop --> fallingobject-display[fallingobject-display] fallingobjects-loop --> fallingobject-collision[fallingobject-collision] fallingobjects-loop --> distance-calculation[distance-calculation] fallingobjects-loop --> closest-comparison[closest-comparison] click fallingobjects-loop href "#sub-fallingobjects-loop" click fallingobject-fall href "#sub-fallingobject-fall" click fallingobject-display href "#sub-fallingobject-display" click fallingobject-collision href "#sub-fallingobject-collision" click distance-calculation href "#sub-distance-calculation" click closest-comparison href "#sub-closest-comparison" storms-loop --> fallingobject-fall click storms-loop href "#sub-storms-loop" draw --> spawn-logic[spawn-logic] click spawn-logic href "#sub-spawn-logic" gameover-screen --> resetgame[resetgame] click gameover-screen href "#sub-gameover-screen"

Preview

Sign part is done - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Sign part is done - Code flow showing collector, person, fallingobject, findavailableperson, setup, draw, resetgame, touchstarted, touchmoved, windowresized
Code Flow Diagram