Unfinished project

This sketch creates a pixelated adventure game where you play as a diver exploring a flooded world. You swim through water, walk on land, enter houses, interact with NPCs, and complete quests by flooding buildings to reclaim the land for the sea.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make the player super fast — Double the playerSpeed value so you zip across the world instantly—useful for quickly testing different areas.
  2. Start with unlimited air — Change the airDepletionRate to 0 so you never run out of air on land—removes the survival pressure and lets you explore freely.
  3. Make fish chase the player instead of flee — Change the flee logic so fish run toward you instead of away—creates a chaotic fishing minigame feeling.
  4. Spawn more fish per flooded house — Increase the fish spawn range from 2-5 to 8-12 so flooded houses feel packed with residents.
  5. Unlock all houses instantly — Remove the lock check so you can flood any house without finding a key—speeds up testing the quest progression.
  6. Change the water color to green — The underwater world will look more swampy with a green tint instead of blue.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch builds a complete pixelated game world with smooth camera following, a quest system, and NPC interactions. The player dives through water and land, discovers locked houses, collects keys, and floods buildings as part of a story arc. The visual style is charmingly retro pixel art, achieved by drawing everything to an off-screen graphics buffer (createGraphics) at low resolution and scaling it up, which keeps the code simple and the aesthetic consistent.

The code is organized into a setup() function that populates the world with houses and obstacles, a draw() loop that updates physics and renders the scene, and several game classes—Human for the player, Fish for enemies and NPCs, House for buildable structures, and Obstacle for environmental clutter. By studying this sketch, you'll learn how to manage game state (playing, game_over, game_complete), implement a camera system that follows the player, design a quest tracker, and build simple NPC dialogue. The architecture separates concerns cleanly: the player updates position and air, houses spawn fish when flooded, and the camera locks to the player in the world but fixes itself in interior rooms.

⚙️ How It Works

  1. When setup() runs, it creates a low-resolution off-screen graphics buffer (pg), spawns the player in the water, and populates the world with 10 randomly-positioned houses along a scrollable landscape. Houses start locked except for House #1, and the quest system defines a series of goals: flood specific houses or unlock them with a key.
  2. Every frame, draw() first checks the game state—if the player is dead (air = 0) or has won (all quests complete), it displays an end screen. Otherwise, it updates the player's position based on keyboard input, applies physics like buoyancy and friction, and drains air if the player is above water outside a flooded house.
  3. The camera follows the player smoothly in the world room, but when the player enters a flooded house interior, the camera fixes itself to that house's position. This keeps the player centered and prevents disorientation in tight indoor spaces.
  4. When the player presses 'E' near a house, the interact() function checks if it matches the current quest target. If the house is unflooded and matches the quest, it floods—spawning fish and helmeted residents inside. If the house is already flooded, pressing 'E' enters its interior room where the player can meet NPCs, receive items (like keys), and collect air.
  5. Fish and obstacles are drawn from arrays (roomFish, roomObstacles) that change depending on the current room. Interior rooms add interior-specific obstacles and display only the fish from that house. The quest UI updates in real-time, and dialogue messages appear when NPCs are talked to or houses are entered/exited.
  6. Finally, the scene is rendered from the low-res buffer to the full-screen canvas using image(), creating the pixelated aesthetic, and UI elements (air gauge, quest tracker, dialogue boxes) are drawn on the main canvas on top.

🎓 Concepts You'll Learn

Game state managementCamera system and viewport scrollingPhysics simulation (gravity, buoyancy, friction)Collision detection and interactionQuest and dialogue systemClass-based object designOff-screen graphics buffer for pixelationRoom/level management

📝 Code Breakdown

setup()

setup() runs once when the game starts. It initializes all global variables, creates the low-res graphics buffer, spawns the player and world entities, and defines the quest list. This is where game parameters (world dimensions, physics constants, initial positions) are set.

function setup() {
  pixelDensity(1);
  createCanvas(windowWidth, windowHeight);
  pg = createGraphics(worldWidth, worldHeight);
  calculateScaleFactor();

  player = null;
  worldHouses = [];
  worldObstacles = [];
  roomObstacles = [];
  roomFish = [];
  quests = [];
  currentQuestIndex = 0;
  showQuestCompleteMessage = false;
  questCompleteTimer = 0;
  showDialogueMessage = false;
  dialogueMessage = "";
  dialogueTimer = 0;
  currentInteriorHouse = null;

  // Initialize player in the water in the world
  player = new Human(worldWidth / 4, surfaceY + 20);

  // Spawn houses on land and assign IDs, ensuring no overlap
  let currentHouseX = 50; // Starting X position for the first house, giving more space
  const minHouseGap = 10; // Minimum pixel gap between houses
  for (let i = 0; i < 10; i++) {
    const tempHouse = new House(0, surfaceY, i);
    const houseWidth = tempHouse.width;

    let safeX = currentHouseX + houseWidth / 2 + random(0, minHouseGap);
    safeX = constrain(safeX, houseWidth / 2, worldScrollableWidth - houseWidth / 2);

    const newHouse = new House(safeX, surfaceY, i);
    worldHouses.push(newHouse);
    worldObstacles.push(newHouse); // Houses are also world obstacles

    // Lock all houses except House #1 initially
    if (i !== 1) {
      newHouse.isLocked = true;
    }

    currentHouseX = safeX + houseWidth / 2 + minHouseGap;

    if (currentHouseX > worldScrollableWidth - houseWidth / 2) {
      break;
    }
  }

  // Define quests
  if (worldHouses.length > 1) quests.push({ description: "Flood House #1 and scare the residents!", targetHouseId: 1, isCompleted: false });
  if (worldHouses.length > 2) quests.push({ description: "Use the key to unlock House #2!", targetHouseId: 2, isCompleted: false, requiresKey: true });
  if (worldHouses.length > 2) quests.push({ description: "Flood House #2 and reclaim more land!", targetHouseId: 2, isCompleted: false });
  if (worldHouses.length > 5) quests.push({ description: "Flood House #5 for the glory of the deep!", targetHouseId: 5, isCompleted: false });
  if (worldHouses.length > 8) quests.push({ description: "Flood House #8, one last push for the sea!", targetHouseId: 8, isCompleted: false });

  // Spawn world underwater obstacles
  for (let i = 0; i < 15; i++) {
    worldObstacles.push(new Obstacle(random(worldScrollableWidth), random(surfaceY + 10, worldHeight - 10)));
  }

  gameState = "playing";
  enterRoom("world"); // Start in the world room
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

for-loop House Spawning Loop for (let i = 0; i < 10; i++) {

Creates up to 10 randomly-positioned houses along the world, ensuring they don't overlap by tracking the previous house position

conditional Quest Definition if (worldHouses.length > 1) quests.push(...)

Populates the quest array with story objectives, checking that enough houses exist before defining quests

for-loop Obstacle Spawning for (let i = 0; i < 15; i++) {

Scatters 15 random rocks and objects underwater to fill the world with visual interest and obstacles

pixelDensity(1);
Disables high-DPI scaling so pixels stay crisp and blocky on retina screens—essential for pixel-art games.
createCanvas(windowWidth, windowHeight);
Creates a full-screen canvas that fills the browser window.
pg = createGraphics(worldWidth, worldHeight);
Creates a low-resolution off-screen graphics buffer (320x180) where all game visuals are drawn before scaling up.
calculateScaleFactor();
Calculates how much to scale the 320x180 buffer to fill the screen, maintaining aspect ratio.
player = new Human(worldWidth / 4, surfaceY + 20);
Spawns the player one-quarter across the world width, slightly below the water surface to start submerged.
let safeX = currentHouseX + houseWidth / 2 + random(0, minHouseGap);
Calculates a new house X position, adding randomness so houses are spaced unpredictably but never overlap.
if (i !== 1) { newHouse.isLocked = true; }
Locks all houses except House #1 so the player must unlock them via quests or keys.
enterRoom("world");
Initializes the first room as 'world', populating room-specific obstacles and fish arrays.

draw()

draw() runs 60 times per second and is the heartbeat of the game. It updates all game objects, handles camera logic, renders to the low-res buffer, and displays UI. The separation of game state (game_over, game_complete, playing) keeps the code clean—only the relevant visuals are drawn in each state.

🔬 These two blocks hide messages after a delay. What happens if you change `questCompleteDuration` from 120 to 300 (frames)? The message will stay on screen much longer—try it!

  // Handle quest complete message timer
  if (showQuestCompleteMessage && frameCount > questCompleteTimer + questCompleteDuration) {
    showQuestCompleteMessage = false;
  }

  // Handle dialogue message timer
  if (showDialogueMessage && frameCount > dialogueTimer + dialogueDuration) {
    showDialogueMessage = false;
  }
function draw() {
  if (gameState === "game_over") {
    pg.background(0);
    pg.fill(255);
    pg.textSize(24);
    pg.textAlign(CENTER, CENTER);
    pg.text("GAME OVER!", worldWidth / 2, worldHeight / 2 - 20);
    pg.textSize(12);
    pg.text("You ran out of air!", worldWidth / 2, worldHeight / 2);
    pg.text("Press 'R' to Restart", worldWidth / 2, worldHeight / 2 + 20);
    image(pg, 0, 0, width, height);
    return;
  }

  if (gameState === "game_complete") {
    pg.background(0, 150, 0);
    pg.fill(255);
    pg.textSize(24);
    pg.textAlign(CENTER, CENTER);
    pg.text("WORLD FLOODED!", worldWidth / 2, worldHeight / 2 - 20);
    pg.textSize(12);
    pg.text("You successfully reclaimed the land for the sea!", worldWidth / 2, worldHeight / 2);
    pg.text("Press 'R' to Play Again", worldWidth / 2, worldHeight / 2 + 20);
    image(pg, 0, 0, width, height);
    return;
  }

  // --- Update game state ---
  player.update();

  // Update camera to follow player (room-aware)
  if (currentRoomName === "world") {
    cameraX = player.x - worldWidth / 2;
    cameraX = constrain(cameraX, 0, worldScrollableWidth - worldWidth);
  } else if (currentRoomName === "house1_interior") {
    // Fix camera to the house's position for interior views
    if (currentInteriorHouse) {
      cameraX = currentInteriorHouse.x - worldWidth / 2;
      // Also constrain the interior camera to world bounds just in case, though it should be fixed.
      cameraX = constrain(cameraX, 0, worldScrollableWidth - worldWidth);
    }
  }

  // Update fish in the current room
  for (let f of roomFish) {
    f.update();
  }

  // Handle quest complete message timer
  if (showQuestCompleteMessage && frameCount > questCompleteTimer + questCompleteDuration) {
    showQuestCompleteMessage = false;
  }

  // Handle dialogue message timer
  if (showDialogueMessage && frameCount > dialogueTimer + dialogueDuration) {
    showDialogueMessage = false;
  }

  // --- Draw pixelated world to off-screen buffer (pg) ---
  // Draw background based on room
  if (currentRoomName === "world") {
    pg.background(0, 100, 255); // Water color
    // Draw land (top half of the world)
    pg.fill(100, 200, 100); // Land color
    pg.noStroke();
    pg.rect(0, 0, worldWidth, surfaceY);
    // Draw environmental features
    drawSkyFeatures(cameraX);
    drawLandFeatures(cameraX);
    drawWaterFeatures(cameraX);
  } else if (currentRoomName === "house1_interior") {
    // Draw interior background
    pg.background(0, 50, 100); // Darker blue for flooded interior
    // Interior decorations/walls are handled by roomObstacles
  }

  // Draw current room's obstacles
  for (let o of roomObstacles) {
    o.draw(cameraX);
  }

  // Draw current room's fish
  for (let f of roomFish) {
    f.draw(cameraX);
  }

  // Draw player
  player.draw(cameraX);

  // --- Display the scaled pixelated world on the main canvas ---
  image(pg, 0, 0, width, height);

  // Display some instructions on the main canvas
  fill(0);
  textSize(20);
  textAlign(LEFT, TOP);
  text("Use WASD or Arrow Keys to swim!", 10, 10);
  text("Explore the backward world!", 10, 40);
  text("Don't run out of air on land!", 10, 70);
  text("Press 'E' to interact!", 10, 100);

  // Draw air gauge UI on the main canvas
  drawAirGaugeUI();

  // Draw quest UI on the main canvas
  drawQuestUI();

  // Draw quest complete message if active
  drawQuestCompleteMessage();

  // Draw dialogue message if active
  drawNPCDialogue();
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

conditional Game Over State if (gameState === "game_over") {

Displays the game-over screen and halts normal gameplay when the player runs out of air

conditional Game Complete State if (gameState === "game_complete") {

Displays a victory screen when all quests are complete

conditional Room-Aware Camera if (currentRoomName === "world") {

Adjusts camera behavior—follows player smoothly in the world but locks to the house in interior rooms

for-loop Fish Update Loop for (let f of roomFish) {

Updates physics and AI for all fish currently in the active room

conditional UI Message Timers if (showQuestCompleteMessage && frameCount > questCompleteTimer + questCompleteDuration) {

Automatically hides the quest-complete and dialogue messages after a set duration

for-loop Drawing Obstacles and Fish for (let o of roomObstacles) {

Renders all obstacles and fish in the current room to the low-res buffer

if (gameState === "game_over") {
Checks if the player has died (air depleted); if so, stops normal gameplay and shows the game-over screen.
pg.background(0);
Clears the off-screen buffer with black for the game-over screen.
player.update();
Calls the player's update function, which processes keyboard input, applies physics, and tracks air.
cameraX = player.x - worldWidth / 2;
Centers the camera on the player by placing them in the middle of the 320-pixel-wide view.
cameraX = constrain(cameraX, 0, worldScrollableWidth - worldWidth);
Prevents the camera from scrolling beyond the world edges, so blank areas don't appear.
for (let f of roomFish) { f.update(); }
Updates each fish's position, velocity, and AI each frame.
pg.background(0, 100, 255);
Clears the low-res buffer to light blue (water color) at the start of each frame.
pg.rect(0, 0, worldWidth, surfaceY);
Draws a green rectangle from the top of the screen to the water surface, representing land.
image(pg, 0, 0, width, height);
Renders the completed low-res buffer to the main canvas, scaled to fill the screen.
drawAirGaugeUI();
Draws the air meter and key indicator on the main canvas (not the low-res buffer).

keyPressed()

keyPressed() is called once per key press and lets you handle single-press input (like 'R' to restart). For continuous movement (like 'W' to swim up every frame), the main sketch uses keyIsDown() inside the player's update() function instead.

function keyPressed() {
  if (gameState === "game_over" && key === 'r') {
    setup();
  }
  if (gameState === "game_complete" && key === 'r') {
    setup();
  }

  if (gameState === "playing" && (key === 'e' || key === 'E')) {
    player.interact();
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Restart on 'R' if (gameState === "game_over" && key === 'r') {

Allows the player to press 'R' to restart the game after losing or winning

conditional Interact on 'E' if (gameState === "playing" && (key === 'e' || key === 'E')) {

Triggers the player's interact function when 'E' is pressed during normal gameplay

if (gameState === "game_over" && key === 'r') {
Checks if the player has died AND pressed 'R'.
setup();
Restarts the entire game by re-running setup(), which resets all variables and spawns a fresh world.
if (gameState === "playing" && (key === 'e' || key === 'E')) {
Checks if the game is active AND the player pressed 'E' or 'e' (case-insensitive).
player.interact();
Calls the player's interact function, which checks for nearby houses or NPCs to engage with.

enterRoom()

enterRoom() is the gateway function for transitioning between the world and interior rooms. It clears old room data, populates the new room, and repositions the player. This pattern (clear arrays, rebuild based on room type) keeps the code flexible for adding more room types later.

🔬 These two lines add decorations to the house interior. What happens if you add a third line with `roomObstacles.push(new Obstacle(houseObject.x, surfaceY + 40));` to add a center decoration? The interior will feel more furnished!

      // Add interior decorations/obstacles specific to House 1
      roomObstacles.push(new Obstacle(houseObject.x - 10, surfaceY + 30));
      roomObstacles.push(new Obstacle(houseObject.x + 10, surfaceY + 50));
function enterRoom(roomName, houseObject = null) {
  roomObstacles = [];
  roomFish = [];
  currentRoomName = roomName;
  currentInteriorHouse = houseObject; // Store reference to the house if entering its interior

  if (roomName === "world") {
    // Repopulate with all world houses and obstacles
    for (let o of worldObstacles) {
      roomObstacles.push(o);
    }
    // Repopulate with internal fish from all flooded houses in the world
    for (let house of worldHouses) {
      if (house.isFlooded) {
        for (let f of house.internalFish) {
          roomFish.push(f);
        }
      }
    }
    player.x = worldWidth / 4; // Reset player position in world
    player.y = surfaceY + 20;
  } else if (roomName === "house1_interior") {
    if (houseObject) {
      // Add the house itself as an obstacle for its footprint/walls
      roomObstacles.push(houseObject);
      // Add internal fish of this house
      for (let f of houseObject.internalFish) {
        roomFish.push(f);
      }
      // Add interior decorations/obstacles specific to House 1
      roomObstacles.push(new Obstacle(houseObject.x - 10, surfaceY + 30));
      roomObstacles.push(new Obstacle(houseObject.x + 10, surfaceY + 50));
      // Place player inside the house
      player.x = houseObject.x;
      player.y = surfaceY + 10;
    }
  }
  // Add other room setups here if you expand
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional World Room Initialization if (roomName === "world") {

Populates the room with all world houses and fish from flooded buildings, then resets the player position

conditional Interior Room Initialization } else if (roomName === "house1_interior") {

Sets up a flooded house interior by placing the house itself, its fish, decorative obstacles, and the player inside

roomObstacles = [];
Clears the current room's obstacle list so old obstacles from the previous room don't linger.
roomFish = [];
Clears the current room's fish list.
currentRoomName = roomName;
Stores the room name globally so draw() and interact() know which room is active.
for (let o of worldObstacles) { roomObstacles.push(o); }
Copies all world obstacles (houses and rocks) into the current room's obstacle array.
for (let house of worldHouses) { if (house.isFlooded) { for (let f of house.internalFish) { roomFish.push(f); } } }
Finds every flooded house and adds its internal fish to the room's fish array so they appear in the world.
player.x = worldWidth / 4;
Resets the player to a starting position in the world when returning from an interior.
roomObstacles.push(new Obstacle(houseObject.x - 10, surfaceY + 30));
Adds decorative obstacles inside the house interior to make it feel more furnished.
player.x = houseObject.x;
Places the player inside the house (at the house's center X position) when entering an interior.

Human (Player Class)

The Human class represents the player character. The constructor sets up all initial properties: position, velocity, size, air meter, and inventory (the key). Every class in p5.js needs a constructor to define what properties each instance has.

class Human {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.vx = 0;
    this.vy = 0;
    this.width = 8;
    this.height = 10;
    this.color = color(255, 200, 150); // Skin color
    this.swimCooldown = 0;
    this.swimDelay = 10; // Frames between upward kicks

    this.maxAir = 100; // Max air level
    this.air = this.maxAir; // Current air level
    this.airDepletionRate = 1; // Air lost per frame out of water
    this.airReplenishmentRate = 0.5; // Air gained per frame in water

    this.hasKey = false; // New property: Player starts without a key
  }
Line-by-line explanation (9 lines)
constructor(x, y) {
Initializes a new Human with a starting x and y position on the canvas.
this.vx = 0;
Sets horizontal velocity to 0 at the start—the player won't move until keys are pressed.
this.vy = 0;
Sets vertical velocity to 0 at the start.
this.width = 8;
The player is 8 pixels wide in the low-res world—used for collision detection and drawing.
this.swimCooldown = 0;
Tracks remaining frames until the next swim kick is allowed, preventing infinite upward force.
this.maxAir = 100;
The maximum air the player can hold before needing to return to water.
this.air = this.maxAir;
Starts the game with full air so the player can explore immediately.
this.airDepletionRate = 1;
Loses 1 air per frame when out of water—at 60 FPS, this means 100 frames (1.67 seconds) before drowning.
this.hasKey = false;
Player starts without a key, so locked houses can't be opened until an NPC gives one.

Human.update()

Human.update() is the core of player physics. Every frame, it reads input, applies forces (buoyancy, swimming, gravity), updates position, checks air safety (collision with flooded houses), and enforces world boundaries. The air system is clever—flooded houses act as 'pockets' of breathable air, rewarding the player for completing quests.

🔬 This block controls upward movement with a cooldown between kicks. What happens if you change `this.swimDelay` from 10 to 2? The player will swim much faster because kicks can happen more frequently!

    if (keyIsDown(87) || keyIsDown(UP_ARROW)) {
      if (this.swimCooldown <= 0) {
        this.vy -= swimForce;
        this.swimCooldown = this.swimDelay;
      }
    }

🔬 This constant upward force keeps you from sinking. What if you remove this entire block? The player will sink to the bottom of the world and need to swim much harder to stay afloat—try it and see how it changes the feel of the game.

    // Apply buoyancy if in water
    if (this.y > surfaceY) {
      this.vy -= buoyancy;
    }
  update() {
    // Horizontal movement
    if (keyIsDown(65) || keyIsDown(LEFT_ARROW)) {
      this.vx = -playerSpeed;
    } else if (keyIsDown(68) || keyIsDown(RIGHT_ARROW)) {
      this.vx = playerSpeed;
    } else {
      this.vx *= 0.8;
    }

    // "Swimming" (up/down movement)
    if (keyIsDown(87) || keyIsDown(UP_ARROW)) {
      if (this.swimCooldown <= 0) {
        this.vy -= swimForce;
        this.swimCooldown = this.swimDelay;
      }
    }
    if (keyIsDown(83) || keyIsDown(DOWN_ARROW)) {
      this.vy += swimForce * 2;
    }

    if (this.swimCooldown > 0) {
      this.swimCooldown--;
    }

    // Apply buoyancy if in water
    if (this.y > surfaceY) {
      this.vy -= buoyancy;
    }

    // Apply water friction
    this.vy *= (1 - waterFriction);

    // Update position
    this.x += this.vx;
    this.y += this.vy;

    // --- Air gauge logic ---
    let isInsideAnyFloodedHouse = false;
    // Check if human is inside a flooded house (above surfaceY but safe)
    for (let house of worldHouses) { // Check all world houses for air safety
      if (house.isFlooded) {
        const humanTop = this.y - this.height / 2;
        const humanLeft = this.x - this.width / 2;
        const humanRight = this.x + this.width / 2;

        const houseLeft = house.x - house.width / 2;
        const houseRight = house.x + house.width / 2;

        // If in world room, check against all flooded houses.
        // If in interior room, check only against the current interior house.
        if (currentRoomName === "world" || house === currentInteriorHouse) {
          if (humanTop < surfaceY &&
              humanRight > houseLeft &&
              humanLeft < houseRight) {
            isInsideAnyFloodedHouse = true;
            break;
          }
        }
      }
    }

    if (this.y < surfaceY + this.height / 2 && !isInsideAnyFloodedHouse) { // Out of water or in air outside flooded house
      this.air -= this.airDepletionRate;
    } else { // In water or inside a flooded house
      this.air += this.airReplenishmentRate;
    }
    this.air = constrain(this.air, 0, this.maxAir);

    // --- Death Condition: Air runs out ---
    if (this.air <= 0) {
      gameState = "game_over";
      this.vy = 0;
      this.vx = 0;
      return;
    }

    // Boundary checks (stay within water, and world width)
    if (currentRoomName === "world") {
      this.x = constrain(this.x, this.width / 2, worldScrollableWidth - this.width / 2);
      this.y = constrain(this.y, surfaceY + this.height / 2, worldHeight - this.height / 2);
    } else if (currentRoomName === "house1_interior") {
      // Constrain player within the bounds of the current house
      if (currentInteriorHouse) {
        const houseLeft = currentInteriorHouse.x - currentInteriorHouse.width / 2;
        const houseRight = currentInteriorHouse.x + currentInteriorHouse.width / 2;
        const houseCeiling = surfaceY + this.height / 2; // Treat water surface as ceiling
        const houseFloor = currentInteriorHouse.y - this.height / 2; // Base of the house as floor

        this.x = constrain(this.x, houseLeft + this.width / 2, houseRight - this.width / 2);
        this.y = constrain(this.y, houseCeiling, houseFloor);
      }
    }
  }
Line-by-line explanation (14 lines)

🔧 Subcomponents:

conditional Horizontal Movement Input if (keyIsDown(65) || keyIsDown(LEFT_ARROW)) {

Reads left/right arrow or A/D keys and sets horizontal velocity accordingly

conditional Vertical Movement Input if (keyIsDown(87) || keyIsDown(UP_ARROW)) {

Applies upward force when W/up-arrow is pressed, with a cooldown to prevent infinite thrust

conditional Buoyancy in Water if (this.y > surfaceY) {

Applies constant upward force when the player is below the water surface

for-loop Air Safety Check for (let house of worldHouses) {

Tests if the player is inside a flooded house, which grants air even above the water surface

conditional Air Depletion Logic if (this.y < surfaceY + this.height / 2 && !isInsideAnyFloodedHouse) {

Reduces air when the player is above water and not in a safe (flooded) house

conditional Death Condition if (this.air <= 0) {

Triggers game-over when air runs out

conditional Boundary Constraints if (currentRoomName === "world") {

Prevents the player from leaving the world in world mode, or leaving the house in interior mode

if (keyIsDown(65) || keyIsDown(LEFT_ARROW)) {
Checks if 'A' (key code 65) or the left arrow is held down.
this.vx = -playerSpeed;
Sets horizontal velocity to negative (leftward) when a left key is pressed.
} else { this.vx *= 0.8; }
If no key is pressed, slow the player down by multiplying velocity by 0.8—this creates momentum/friction.
if (this.swimCooldown <= 0) {
Only allows a swim kick if enough frames have passed since the last kick, preventing instant infinite upward thrust.
this.vy -= swimForce;
Applies upward velocity when a swim kick happens.
this.swimCooldown = this.swimDelay;
Resets the cooldown timer so the next kick can't happen for another 10 frames.
if (this.y > surfaceY) { this.vy -= buoyancy; }
When underwater (below surfaceY), applies constant upward buoyancy force to resist sinking.
this.vy *= (1 - waterFriction);
Slows down vertical velocity by multiplying by 0.97 (since waterFriction = 0.03), simulating water drag.
this.x += this.vx;
Moves the player horizontally by adding the current velocity to position each frame.
if (humanTop < surfaceY && humanRight > houseLeft && humanLeft < houseRight) {
Checks if the player's bounds overlap with a flooded house's bounds using AABB (axis-aligned bounding box) collision.
if (this.y < surfaceY + this.height / 2 && !isInsideAnyFloodedHouse) {
Drains air when the player is above water and NOT inside a flooded house (the safe zone).
this.air = constrain(this.air, 0, this.maxAir);
Clamps the air value between 0 and maxAir so it never goes negative or exceeds the limit.
gameState = "game_over";
Ends the game when air reaches 0.
this.x = constrain(this.x, this.width / 2, worldScrollableWidth - this.width / 2);
Prevents the player from moving beyond the left or right edge of the scrollable world.

Human.draw()

Human.draw() renders the player character to the low-res graphics buffer. Every pixel is intentional—the body, head, mask, snorkel, and animated arms come together to form a cute pixel-art diver. The camera offset (offsetX) is crucial: it makes the world scroll around the player while the player stays centered on screen.

🔬 These lines draw the body and head. What happens if you swap the y-offsets so the head is at `y - 15` instead of `y - 10`? The head will move further up, making the player look taller!

    // Body
    pg.fill(this.color);
    pg.rect(x - 4, y - 5, 8, 10);

    // Head
    pg.rect(x - 3, y - 10, 6, 5);
  draw(offsetX) {
    const x = this.x - offsetX;
    const y = this.y;

    pg.noStroke();

    // Body
    pg.fill(this.color);
    pg.rect(x - 4, y - 5, 8, 10);

    // Head
    pg.rect(x - 3, y - 10, 6, 5);
    pg.rect(x - 2, y - 11, 4, 1);

    // Eyes (behind mask)
    pg.fill(0);
    pg.rect(x - 2, y - 8, 1, 1);
    pg.rect(x + 1, y - 8, 1, 1);

    // Diving mask
    pg.fill(100);
    pg.rect(x - 3, y - 9, 6, 3);
    pg.fill(150, 200, 255, 150);
    pg.rect(x - 2, y - 8, 4, 1);

    // Snorkel
    pg.fill(100);
    pg.rect(x + 3, y - 10, 1, 3);
    pg.rect(x + 3, y - 13, 3, 1);

    // Arms (simple swimming animation)
    const armYOffset = (this.swimCooldown > this.swimDelay / 2) ? -1 : 0;
    pg.rect(x - 6, y - 3 + armYOffset, 2, 8);
    pg.rect(x + 4, y - 3 - armYOffset, 2, 8);

    // Legs
    pg.rect(x - 3, y + 5, 2, 5);
    pg.rect(x + 1, y + 5, 2, 5);
  }
Line-by-line explanation (6 lines)

🔧 Subcomponents:

calculation Camera Offset Application const x = this.x - offsetX;

Adjusts the player's draw position by the camera offset so they appear to move across the screen as the camera scrolls

conditional Swimming Arm Animation const armYOffset = (this.swimCooldown > this.swimDelay / 2) ? -1 : 0;

Bounces the arms up and down based on the swim cooldown to create a swimming animation

const x = this.x - offsetX;
Subtracts the camera offset from the player's world position to get the screen-space position. This makes the player appear to stay centered as the world scrolls.
pg.noStroke();
Turns off outline drawing so all shapes are solid blocks with no borders.
pg.fill(this.color);
Sets the fill color to the player's skin tone (peach).
pg.rect(x - 4, y - 5, 8, 10);
Draws the player's body as an 8×10 pixel rectangle, centered at (x, y).
const armYOffset = (this.swimCooldown > this.swimDelay / 2) ? -1 : 0;
If more than half the swim delay has passed since the last kick, offset arms up by -1; otherwise, offset is 0. This creates a bobbing animation synced to the kick cycle.
pg.rect(x - 6, y - 3 + armYOffset, 2, 8);
Draws the left arm, moving it up and down based on the armYOffset.

Human.interact()

Human.interact() is the branching logic hub for the entire game. It handles different room types (interior vs. world), checks distances to NPCs and houses, verifies quest completion, manages inventory (the key), and triggers state changes (flooding houses, entering/exiting rooms). The nested conditionals can look complex, but each layer answers a simple question: Are we in an interior? Are we close to something? Does the player have what they need?

  interact() {
    console.log("Interact called!");
    console.log("Player X:", this.x, "Y:", this.y);

    // Get the current quest if available
    const currentQuest = (currentQuestIndex < quests.length) ? quests[currentQuestIndex] : null;

    // --- Interaction with NPC Fish (if in an interior room) ---
    if (currentRoomName === "house1_interior") {
      for (let f of roomFish) {
        if (f.isNPC) {
          const d = dist(this.x, this.y, f.x, f.y);
          const npcInteractionDistance = 20; // Distance to interact with NPC
          if (d < npcInteractionDistance) {
            f.interactWithNPC(this); // Pass player reference to NPC fish
            return; // Interact with NPC, don't check houses
          }
        }
      }
      // If no NPC found, check for exit point
      const exitDistance = 20; // Distance to exit the house
      if (currentInteriorHouse && dist(this.x, this.y, currentInteriorHouse.x, currentInteriorHouse.y + currentInteriorHouse.height / 2) < exitDistance) {
        displayDialogue("Exiting House #" + currentInteriorHouse.id + "!");
        enterRoom("world");
        return;
      }
      return; // If in interior room and no NPC/exit, do nothing else.
    }

    // --- Interaction with Houses (only in world room) ---
    if (currentRoomName === "world") {
      for (let o of worldHouses) { // Iterate through all world houses
        const d = dist(this.x, this.y, o.x, o.y);
        const interactionDistance = o.width / 2 + 15;

        console.log("--- Checking House ID:", o.id, "---");
        console.log("House X:", o.x, "Y:", o.y, "Width:", o.width, "Height:", o.height);
        console.log("Distance to house center (player to house):", d.toFixed(2));
        console.log("Required interaction distance:", interactionDistance.toFixed(2));
        console.log("Is player close enough?", d < interactionDistance);

        if (d < interactionDistance) {
          // Check if locked
          if (o.isLocked) {
            if (this.hasKey) {
              o.unlock();
              displayDialogue("You unlocked House #" + o.id + "!");
              // If unlocking House 2 is the current quest
              if (currentQuest && currentQuest.targetHouseId === 2 && currentQuest.requiresKey) {
                currentQuest.isCompleted = true;
                showQuestCompleteMessage = true;
                questCompleteTimer = frameCount;
                currentQuestIndex++; // Move to "Flood House #2" quest
              }
            } else {
              displayDialogue("House #" + o.id + " is locked! You need a key.");
            }
            return; // Block further interaction if locked
          }

          // House is not locked, proceed with quest/flooding logic
          if (currentQuest) {
            console.log("Active Quest Target ID:", currentQuest.targetHouseId);
            console.log("Is this the target quest house?", o.id === currentQuest.targetHouseId);

            if (o.id === currentQuest.targetHouseId) {
              if (!o.isFlooded) {
                console.log("Conditions met! Flooding House #", o.id, "!");
                o.flood(); // Call the new flood method
                currentQuest.isCompleted = true;
                showQuestCompleteMessage = true;
                questCompleteTimer = frameCount;

                // Move to the next quest or complete the game
                currentQuestIndex++;
                if (currentQuestIndex >= quests.length) {
                  gameState = "game_complete";
                }
              } else {
                // House is already flooded, you can "enter" it
                console.log("Entering already flooded House #", o.id, "!");
                enterRoom("house1_interior", o); // Enter the interior room
              }
            } else {
              console.log("This is House #", o.id, ", but your current quest is for House #", currentQuest.targetHouseId, ".");
            }
          } else {
            console.log("No active quest to complete for this house.");
          }
          return; // Only interact with one house per 'E' press
        }
      }
    }
  }
Line-by-line explanation (12 lines)

🔧 Subcomponents:

for-loop NPC Interaction Loop for (let f of roomFish) {

Checks if the player is close to any NPC fish in an interior room

conditional House Exit Check if (currentInteriorHouse && dist(this.x, this.y, currentInteriorHouse.x, currentInteriorHouse.y + currentInteriorHouse.height / 2) < exitDistance) {

Allows the player to leave the interior when near the house's bottom (exit point)

for-loop World House Interaction Loop for (let o of worldHouses) {

Iterates through all houses in the world to find one close enough to interact with

conditional Lock State Check if (o.isLocked) {

Handles locked houses: requires a key or displays a dialogue explaining they're locked

conditional Quest Flood Logic if (o.id === currentQuest.targetHouseId) {

Checks if the house matches the current quest target and floods it or enters it

const currentQuest = (currentQuestIndex < quests.length) ? quests[currentQuestIndex] : null;
Gets the current quest object if one exists, otherwise null. Uses a ternary operator for compact conditional assignment.
if (currentRoomName === "house1_interior") {
Checks if the player is currently inside a house interior, and if so, handles NPC and exit interactions instead of house interactions.
const d = dist(this.x, this.y, f.x, f.y);
Calculates the distance from the player to a fish using the dist() function.
if (d < npcInteractionDistance) {
Checks if the player is close enough to the NPC (within 20 pixels).
f.interactWithNPC(this);
Calls the NPC fish's interaction method, passing the player as a reference so the NPC can modify the player's properties (like giving a key).
const interactionDistance = o.width / 2 + 15;
Calculates an interaction radius based on the house's width, so larger houses have larger interaction zones.
if (o.isLocked) {
Checks if the house is locked before attempting to flood or enter it.
if (this.hasKey) {
Checks if the player currently has a key (granted by NPCs).
if (o.id === currentQuest.targetHouseId) {
Checks if the house's ID matches the current quest's target house ID—only the target can be flooded to complete the quest.
if (!o.isFlooded) {
If the house hasn't been flooded yet, flood it and mark the quest as complete.
currentQuestIndex++;
Advances to the next quest in the array.
if (currentQuestIndex >= quests.length) {
Checks if there are no more quests left—if so, the game is complete.

Fish Class

The Fish class represents both enemies (scared residents in flooded houses) and NPCs (key-givers). The constructor sets up all properties: position, velocity, appearance, animation state, house reference, and NPC flags. Fish in flooded houses wear helmets and panic when the player approaches.

class Fish {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.vx = random(-0.5, 0.5);
    this.vy = 0;
    this.width = 12;
    this.height = 6;
    this.color = color(random(100, 255), random(100, 255), random(100, 150)); // Random blue-greenish fish color for underwater
    this.flopCooldown = 0;
    this.flopDelay = random(30, 90);

    this.isInFloodedHouse = false; // true if fish is inside a flooded house
    this.hasHelmet = false; // true if fish is wearing a helmet
    this.targetHouse = null; // Reference to the House it's inside
    this.isNPC = false; // New: true if this is an NPC fish
    this.hasGivenKey = false; // New: for NPC fish, tracks if key was given
  }
Line-by-line explanation (7 lines)
this.vx = random(-0.5, 0.5);
Assigns a random starting horizontal velocity between -0.5 and 0.5 so fish move at different speeds.
this.color = color(random(100, 255), random(100, 255), random(100, 150));
Generates a random color for each fish in the blue-green range, making each fish visually distinct.
this.flopDelay = random(30, 90);
Assigns a random delay between flopping movements so fish don't all flop in sync.
this.isInFloodedHouse = false;
Flag to track whether this fish is trapped inside a flooded house (true) or free in the world (false).
this.targetHouse = null;
Reference to the House object that this fish belongs to, used to constrain its movement within house bounds.
this.isNPC = false;
Flag for NPCs—true if this fish is a named character that can dialogue with the player.
this.hasGivenKey = false;
Tracks whether this NPC fish has already given the player a key (prevents duplicate gifts).

Fish.update()

Fish.update() is simpler than Human.update() because fish only exist in flooded houses. Their behavior depends on the isNPC flag: regular residents panic and flee, while NPC fish stay still to accept dialogue. The flopping animation and gravity give them life, even in a simple pixel-art style.

🔬 This code makes fish run away when you're close. What happens if you change `this.vx = dx > 0 ? -1 : 1;` to `this.vx = dx > 0 ? 1 : -1;`? The fish will run TOWARD you instead of away—try it and see how the game becomes backwards!

        if (playerDistance < scareDistance) {
          const dx = player.x - this.x;
          this.vx = dx > 0 ? -1 : 1;
        } else {
          this.vx = random(-0.2, 0.2);
        }
  update() {
    // Apply gravity (pulls down towards water)
    this.vy += gravity;

    // --- FISH IN FLOODED HOUSE BEHAVIOR (including NPC) ---
    if (this.isInFloodedHouse && this.targetHouse) {
      if (!this.isNPC) { // Only scare non-NPC fish
        const scareDistance = 40;
        const playerDistance = dist(player.x, player.y, this.x, this.y);

        if (playerDistance < scareDistance) {
          const dx = player.x - this.x;
          this.vx = dx > 0 ? -1 : 1;
        } else {
          this.vx = random(-0.2, 0.2);
        }
      } else {
        // NPC fish are stationary horizontally
        this.vx = 0;
      }

      // Small vertical flop/wiggle
      if (this.flopCooldown <= 0) {
        this.vy = random(-0.5, -0.1);
        this.flopCooldown = this.flopDelay;
      }
      this.flopCooldown--;

      // Apply land friction
      this.vx *= (1 - landFriction);

      // Update position
      this.x += this.vx;
      this.y += this.vy;

      // Constrain to house bounds
      const houseLeft = this.targetHouse.x - this.targetHouse.width / 2;
      const houseRight = this.targetHouse.x + this.targetHouse.width / 2;
      const houseBase = this.targetHouse.y;

      this.x = constrain(this.x, houseLeft + this.width / 2, houseRight - this.width / 2);
      this.y = constrain(this.y, surfaceY + this.height / 2, houseBase - this.height / 2);

      // Bounce off the "floor" (house base)
      if (this.y >= houseBase - this.height / 2) {
        this.vy = -abs(this.vy) * 0.5;
      }

    } else {
      // Regular flopping fish behavior - DELETED: No regular flopper fish anymore
      // This block will not be executed unless regular fish are re-added.
      this.vx *= (1 - landFriction);
      this.x += this.vx;
      this.y += this.vy;
      this.x = constrain(this.x, this.width / 2, worldScrollableWidth - this.width / 2);
      this.y = constrain(this.y, this.height / 2, surfaceY - this.height / 2);
      if (this.y >= surfaceY - this.height / 2) {
        this.vy = -abs(this.vy) * 0.5;
      }
    }
  }
Line-by-line explanation (11 lines)

🔧 Subcomponents:

conditional Flooded House AI if (this.isInFloodedHouse && this.targetHouse) {

Handles fish behavior when inside a flooded house, including panic AI for non-NPCs and stationary behavior for NPCs

conditional Scare AI if (playerDistance < scareDistance) {

Makes non-NPC fish run away from the player when they get too close

conditional Flop Animation if (this.flopCooldown <= 0) {

Triggers a vertical bounce movement periodically to animate fish flopping on the ground

calculation House Boundary Constraint this.x = constrain(this.x, houseLeft + this.width / 2, houseRight - this.width / 2);

Prevents fish from leaving the house by clamping their position within house bounds

conditional Floor Bounce if (this.y >= houseBase - this.height / 2) {

Makes fish bounce when they hit the bottom of the house, simulating a flopping motion

this.vy += gravity;
Applies constant downward acceleration so fish sink toward the floor.
if (this.isInFloodedHouse && this.targetHouse) {
Checks if this fish is inside a flooded house before applying house-specific physics.
const playerDistance = dist(player.x, player.y, this.x, this.y);
Calculates distance from the fish to the player.
if (playerDistance < scareDistance) {
If the player is within 40 pixels, the fish panics.
const dx = player.x - this.x;
Calculates the horizontal direction to the player (positive = player is to the right).
this.vx = dx > 0 ? -1 : 1;
Sets fish velocity to move away from the player: if player is to the right, move left (-1); if to the left, move right (1).
this.vx = 0;
NPCs stay still horizontally so they're easier to talk to.
this.vy = random(-0.5, -0.1);
Applies a random upward velocity to make the fish flop/jump.
this.vx *= (1 - landFriction);
Slows horizontal movement by multiplying by 0.95 (since landFriction = 0.05).
if (this.y >= houseBase - this.height / 2) {
Checks if the fish has reached the floor (house base).
this.vy = -abs(this.vy) * 0.5;
Reverses vertical velocity (makes it positive/upward) and reduces it by half, creating a bouncing effect.

Fish.draw()

Fish.draw() renders the fish character with a body, fins, tail, eye, and an optional helmet. The tail wagging is tied to the flop cooldown, so it animates in sync with the flopping motion. The helmet is a key visual indicator that tells players "this fish is trapped in a flooded house and affected by your actions."

  draw(offsetX) {
    const x = this.x - offsetX;
    const y = this.y;

    pg.noStroke();
    pg.fill(this.color);

    // Body (more fish-like)
    pg.rect(x - 5, y - 3, 10, 6);
    pg.rect(x - 7, y - 2, 2, 4);
    pg.rect(x + 5, y - 2, 2, 4);

    // Tail (simple flopping animation)
    const tailXOffset = (this.flopCooldown > this.flopDelay / 2) ? 1 : 0;
    pg.triangle(
      x + 7 + tailXOffset, y - 3,
      x + 10 + tailXOffset, y,
      x + 7 + tailXOffset, y + 3
    );

    // Fins
    pg.triangle(
      x - 3, y - 3,
      x - 1, y - 6,
      x, y - 3
    );
    pg.triangle(
      x - 3, y + 3,
      x - 1, y + 6,
      x, y + 3
    );

    // Eye
    pg.fill(0);
    pg.rect(x - 4, y - 1, 1, 1);

    // Mouth (gasping on land)
    pg.fill(200, 50, 50);
    pg.rect(x - 6, y + 1, 2, 1);

    // --- HELMET DRAWING ---
    if (this.hasHelmet) {
      pg.fill(150);
      pg.rect(x - 4, y - 5, 8, 4);
      pg.fill(100);
      pg.rect(x - 2, y - 6, 4, 1);
      pg.fill(200, 220, 255, 150);
      pg.rect(x - 2, y - 4, 4, 2);
    }
  }
Line-by-line explanation (6 lines)

🔧 Subcomponents:

conditional Tail Flop Animation const tailXOffset = (this.flopCooldown > this.flopDelay / 2) ? 1 : 0;

Moves the tail horizontally based on the flop cooldown to create a wagging animation

conditional Helmet Rendering if (this.hasHelmet) {

Draws a protective helmet on fish that are inside flooded houses

const x = this.x - offsetX;
Applies camera offset so the fish appears to scroll with the world.
pg.rect(x - 5, y - 3, 10, 6);
Draws the fish's main body as a 10×6 rectangle.
const tailXOffset = (this.flopCooldown > this.flopDelay / 2) ? 1 : 0;
Offsets the tail 1 pixel horizontally during the second half of the flop cycle, creating a wagging tail.
pg.triangle(...);
Draws the tail as a triangle, offset horizontally by tailXOffset.
pg.fill(200, 50, 50);
Changes fill color to red for the mouth.
if (this.hasHelmet) {
Conditionally draws a helmet if the fish is wearing one (true for flooded-house residents).

Fish.interactWithNPC()

Fish.interactWithNPC() is the NPC dialogue system. The first time the player talks to an NPC fish, they receive a key and see story dialogue. On repeat interactions, they see a different (humorous) message. This simple pattern could expand to give players different items, trigger side quests, or reveal story exposition.

  interactWithNPC(playerRef) {
    if (this.isNPC) {
      if (!this.hasGivenKey) {
        playerRef.hasKey = true;
        this.hasGivenKey = true;
        displayDialogue("Oh no, you flooded my house! Please, take this key. It unlocks House #2. Just don't flood it too hard, okay?");
      } else {
        displayDialogue("I already gave you the key! Now go, before I catch a cold... or rather, dry up!");
      }
    }
  }
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Key Gift Logic if (!this.hasGivenKey) {

Gives the player a key on first interaction, then displays a different message on subsequent interactions

if (this.isNPC) {
Only runs if this fish is marked as an NPC.
if (!this.hasGivenKey) {
Checks if the key has not yet been given to the player.
playerRef.hasKey = true;
Sets the player's hasKey property to true, granting access to locked houses.
this.hasGivenKey = true;
Marks this NPC as having given away the key, preventing duplicate gifts.
displayDialogue(...)
Displays a dialogue message that acknowledges the flooded house and explains the key's purpose.

Obstacle Class

The Obstacle class creates random underwater rocks with irregular shapes and surface texture. Obstacles are purely visual in this sketch (they don't cause collisions), but they add visual interest to the underwater world. The vertex-based shape and texture pixels make each rock unique.

class Obstacle {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.width = random(10, 30);
    this.height = random(10, 20);
    this.color = color(random(50, 150), random(50, 150), random(50, 150)); // Rock color

    // Define vertices for an irregular shape once in constructor
    this.vertices = [
      { x: -this.width / 2, y: -this.height / 2 },
      { x: this.width / 2, y: -this.height / 2 + random(-2, 2) },
      { x: this.width / 2 - random(0, 5), y: this.height / 2 },
      { x: -this.width / 2 + random(0, 5), y: this.height / 2 - random(0, 3) }
    ];

    // Define some texture pixels once
    this.texturePixels = [];
    pg.fill(this.color.levels[0] * 0.8, this.color.levels[1] * 0.8, this.color.levels[2] * 0.8);
    for (let i = 0; i < 5; i++) {
      this.texturePixels.push({
        x: random(-this.width / 2, this.width / 2),
        y: random(-this.height / 2, this.height / 2)
      });
    }
  }
Line-by-line explanation (3 lines)
this.width = random(10, 30);
Assigns a random width between 10 and 30 pixels so rocks vary in size.
this.vertices = [...]
Defines 4 vertices that form an irregular quadrilateral, creating a rocky, non-rectangular shape.
this.texturePixels.push({...})
Generates 5 random points within the rock's bounds to later draw as texture (like moss or details).

Obstacle.draw()

Obstacle.draw() uses p5.js's beginShape() and endShape() to draw an irregular polygon, creating a more natural rocky look than rectangles. The texture pixels add visual detail without slowing down performance.

  draw(offsetX) {
    const x = this.x - offsetX;
    const y = this.y;

    pg.noStroke();
    pg.fill(this.color);

    pg.beginShape();
    for (let v of this.vertices) {
      pg.vertex(x + v.x, y + v.y);
    }    pg.endShape(CLOSE);

    // Add some "texture" pixels
    pg.fill(this.color.levels[0] * 0.8, this.color.levels[1] * 0.8, this.color.levels[2] * 0.8);
    for (let tp of this.texturePixels) {
      pg.rect(x + tp.x, y + tp.y, 1, 1);
    }
  }
Line-by-line explanation (4 lines)
pg.beginShape();
Starts defining a custom polygon shape.
for (let v of this.vertices) { pg.vertex(x + v.x, y + v.y); }
Iterates through the stored vertices and adds each one to the shape, offsetting by the camera.
pg.endShape(CLOSE);
Closes the shape by drawing a line from the last vertex back to the first, completing the polygon.
pg.fill(this.color.levels[0] * 0.8, ...)
Creates a darker version of the rock's color (multiplied by 0.8) for the texture pixels, giving depth.

House Class

The House class represents buildings on the land. Each house has a unique ID, random dimensions and colors, a lock state, and an array of internal fish. When flooded, the house spawns fish residents that can be talked to for quest rewards.

class House {
  constructor(x, y, id) {
    this.x = x;
    this.y = y;
    this.id = id; // Unique ID for quests
    this.width = random(20, 30);
    this.height = random(15, 25);
    this.color = color(random(150, 200), random(100, 150), random(50, 100)); // Earthy house color
    this.roofColor = color(random(200, 250), random(150, 200), random(100, 150)); // Lighter roof color
    this.doorColor = color(100, 50, 20); // Door color
    this.isFlooded = false; // New property for flooding
    this.internalFish = []; // Array to hold fish inside the house
    this.isLocked = false; // New property: House starts locked (default false, but set in setup)
  }
Line-by-line explanation (4 lines)
this.id = id;
Stores a unique ID (0-9) for each house, used to identify them in quests and the console.
this.width = random(20, 30);
Assigns a random width so houses aren't all the same size.
this.internalFish = [];
Initializes an empty array to hold fish that will spawn inside when the house is flooded.
this.isLocked = false;
Starts unlocked by default (locked state is set in setup() for specific houses).

House.flood()

House.flood() is the quest callback that transforms a house into a quest-completion landmark. When flooded, it spawns helmeted fish residents and (for House #1) a special NPC that gives the player a key. This structure allows each house to have unique dialogue or spawned entities if you extend the code.

🔬 This loop spawns 2-4 random fish per house. What happens if you change the range from `random(2, 5)` to `random(5, 10)` so each house spawns 5-9 fish instead? The houses will feel much more crowded when you flood them!

    // Spawn fish inside the house when it floods
    const numInternalFish = int(random(2, 5)); // 2 to 4 fish
    for (let i = 0; i < numInternalFish; i++) {
  flood() {
    this.isFlooded = true;
    // Spawn fish inside the house when it floods
    const numInternalFish = int(random(2, 5)); // 2 to 4 fish
    for (let i = 0; i < numInternalFish; i++) {
      const fishX = random(this.x - this.width / 2, this.x + this.width / 2);
      // Spawn fish above the water surface (surfaceY), but below the house's base (this.y)
      const fishY = random(surfaceY + 10, this.y - 10);
      const internalFishObj = new Fish(fishX, fishY);
      internalFishObj.isInFloodedHouse = true;
      internalFishObj.hasHelmet = true;
      internalFishObj.targetHouse = this; // Fish needs to know its house for bounds/floor
      this.internalFish.push(internalFishObj);
      // Don't add to global fish array yet, only when room is entered
      // fish.push(internalFishObj);
    }

    // Add a specific NPC fish to House #1 if it's the target
    if (this.id === 1) {
      const npcFishX = this.x; // Center of the house
      const npcFishY = surfaceY + 15; // Slightly above the water surface inside
      const npcFishObj = new Fish(npcFishX, npcFishY);
      npcFishObj.isInFloodedHouse = true;
      npcFishObj.hasHelmet = true;
      npcFishObj.targetHouse = this;
      npcFishObj.isNPC = true;
      this.internalFish.push(npcFishObj);
    }
  }
Line-by-line explanation (6 lines)

🔧 Subcomponents:

for-loop Fish Resident Spawning for (let i = 0; i < numInternalFish; i++) {

Spawns 2-4 random fish residents inside the house and equips them with helmets

conditional NPC Spawn Logic if (this.id === 1) {

Adds a special NPC fish to House #1 that gives the player a key

this.isFlooded = true;
Marks the house as flooded so it appears with water inside and grants air to the player.
const numInternalFish = int(random(2, 5));
Randomly decides 2, 3, or 4 fish will spawn in the house.
const fishX = random(this.x - this.width / 2, this.x + this.width / 2);
Spawns the fish somewhere randomly within the house's width.
const fishY = random(surfaceY + 10, this.y - 10);
Spawns the fish in the flooded section (between the water surface and the house's base).
internalFishObj.targetHouse = this;
Tells the fish which house to stay in, so it can constrain itself to that house's bounds.
if (this.id === 1) {
Special case: House #1 gets a unique NPC fish that will give the player a key when talked to.

House.draw()

House.draw() renders a simple pixel-art house with a body, roof, door, and optional window. The two conditionals (flooded and locked) add critical visual feedback: blue water inside shows the player's progress, and a gold padlock warns them the house can't be entered without a key.

  draw(offsetX) {
    const x = this.x - offsetX;
    const y = this.y;

    pg.noStroke();

    // Body of the house
    pg.fill(this.color);
    pg.rect(x - this.width / 2, y - this.height, this.width, this.height);

    // Roof (simple pyramid/triangle)
    pg.fill(this.roofColor);
    pg.triangle(
      x - this.width / 2 - 5, y - this.height, // Left point below body
      x, y - this.height - 10,                 // Top point
      x + this.width / 2 + 5, y - this.height   // Right point below body
    );

    // Door
    pg.fill(this.doorColor);
    pg.rect(x - 5, y - this.height + 8, 10, 12);

    // Window (optional)
    if (random() > 0.5) {
      pg.fill(150, 200, 255); // Window glass color
      pg.rect(x - 10, y - this.height + 5, 5, 5);
    }

    // --- Draw flooded water if applicable ---
    if (this.isFlooded) {
      pg.fill(0, 150, 255, 150); // Semi-transparent water color
      // Draw water from the bottom of the house up to surfaceY
      // House bottom is 'y', house top is 'y - this.height'
      // Water should fill from 'y' up to 'surfaceY'
      // The height of the water drawn is 'y - surfaceY'
      if (y > surfaceY) { // Only draw if house is actually on land above surfaceY
        pg.rect(x - this.width / 2, surfaceY, this.width, y - surfaceY);
      }
    }

    // --- Draw lock if applicable ---
    if (this.isLocked) {
      pg.fill(200, 150, 0); // Goldish color for lock
      pg.rect(x - 3, y - this.height + 5, 6, 6);
      pg.fill(255, 200, 0); // Lighter gold
      pg.rect(x - 1, y - this.height + 7, 2, 2);
    }
  }
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Flooded Water Rendering if (this.isFlooded) {

Draws a semi-transparent blue rectangle inside the house to visualize the water that filled it

conditional Lock Icon Rendering if (this.isLocked) {

Draws a gold padlock icon on the house if it's locked, warning the player they can't interact with it

pg.rect(x - this.width / 2, y - this.height, this.width, this.height);
Draws the house body as a rectangle from its top (y - this.height) down to its base (y).
pg.triangle(x - ..., x, x + ...);
Draws a roof triangle, with the peak at the center and the base corners extending beyond the house width for a classic pitched-roof look.
if (random() > 0.5) {
Randomly decides whether to draw a window—this adds visual variety without much code.
if (this.isFlooded) {
Draws blue water inside the house to show the player has flooded it.
pg.rect(x - this.width / 2, surfaceY, this.width, y - surfaceY);
The water rectangle starts at the water surface level (surfaceY) and fills down to the house base (y), so the water level is visually accurate.

📦 Key Variables

worldWidth number

The low-resolution world's width in pixels (320). All positions and sizes are relative to this.

const worldWidth = 320;
worldHeight number

The low-resolution world's height in pixels (180). Defines the vertical extent of the game world.

const worldHeight = 180;
worldScrollableWidth number

The total width of the scrollable world (640 pixels, twice the screen width). Allows the camera to scroll side-to-side.

const worldScrollableWidth = worldWidth * 2;
surfaceY number

The Y-coordinate of the water surface (worldHeight * 0.5 = 90). Separates the land (above) from the water (below).

const surfaceY = worldHeight * 0.5;
player object

The Human object representing the player character. Contains position, velocity, air, and inventory.

let player = new Human(worldWidth / 4, surfaceY + 20);
worldHouses array

Array holding all House objects spawned in the world. Used to check quests and allow interaction.

let worldHouses = [];
worldObstacles array

Array holding all Obstacle and House objects in the world. Used to render the environment.

let worldObstacles = [];
roomObstacles array

Array of obstacles (houses or decorations) in the current room. Changes when entering/exiting rooms.

let roomObstacles = [];
roomFish array

Array of fish in the current room. Updated when the player floods a house or enters an interior.

let roomFish = [];
cameraX number

The X offset of the camera. Used to center the world on the screen and apply parallax scrolling.

let cameraX = 0;
gameState string

Tracks the current game state: 'playing', 'game_over', or 'game_complete'. Controls which screen is shown.

let gameState = 'playing';
currentRoomName string

Tracks the current room the player is in: 'world' or 'house1_interior'. Used to manage which room's assets are rendered.

let currentRoomName = 'world';
quests array

Array of quest objects. Each quest has a description, targetHouseId, and isCompleted flag.

let quests = [{description: '...', targetHouseId: 1, isCompleted: false}];
currentQuestIndex number

Index of the active quest in the quests array. Increments when quests are completed.

let currentQuestIndex = 0;
gravity number

Constant acceleration downward applied to the player. Affects how quickly the player sinks without swimming.

const gravity = 0.15;
buoyancy number

Constant upward force applied to the player underwater. Resists sinking and makes floating easier.

const buoyancy = 0.12;
swimForce number

Upward velocity applied when the player presses W to swim. Higher values make swimming more powerful.

const swimForce = 0.2;
waterFriction number

Multiplicative drag applied to vertical velocity in water (0.03 means velocity * 0.97 each frame). Slows the player naturally.

const waterFriction = 0.03;
landFriction number

Multiplicative drag applied to fish horizontal velocity on land (0.05 means velocity * 0.95 each frame).

const landFriction = 0.05;
playerSpeed number

Horizontal movement speed when the player holds left/right keys. Set to the velocity directly, not acceleration.

const playerSpeed = 1;
pg object (p5.Graphics)

An off-screen graphics buffer at low resolution (320x180). All game visuals are drawn here, then scaled up to the main canvas.

let pg = createGraphics(worldWidth, worldHeight);
showQuestCompleteMessage boolean

Flag to show/hide the 'Quest Complete!' popup message.

let showQuestCompleteMessage = false;
questCompleteTimer number

The frame number when the quest-complete message was shown. Used to calculate when to hide it.

let questCompleteTimer = 0;
questCompleteDuration number

Number of frames to show the quest-complete message (120 = 2 seconds at 60 FPS).

const questCompleteDuration = 120;
showDialogueMessage boolean

Flag to show/hide the NPC dialogue popup.

let showDialogueMessage = false;
dialogueMessage string

The text content of the current dialogue message shown to the player.

let dialogueMessage = '';
dialogueTimer number

The frame number when the current dialogue was triggered. Used to calculate when to hide it.

let dialogueTimer = 0;

🔧 Potential Improvements (6)

Here are some ways this code could be enhanced:

BUG Fish.update() flopped house behavior

The else block for non-flooded-house fish is commented out as 'deleted', but the code is still there and would execute if a fish is ever not in a flooded house. This dead code creates confusion.

💡 Either remove the entire else block or rename fish so they only exist in flooded houses. If adding world fish back, ensure their physics match the player's (gravity, buoyancy, friction).

BUG Human.interact() duplicate quest increment

When a quest completes, the code sets `currentQuest.isCompleted = true` and increments `currentQuestIndex++`, but doesn't check if the quest is already marked complete. Rapid 'E' presses could skip quests.

💡 Add a guard at the start of the flood block: `if (currentQuest.isCompleted) return;` to prevent duplicate completion.

PERFORMANCE Obstacle.draw() texture generation

Texture pixels are generated once in the constructor, but they're generated every time an Obstacle is created, even if many are created at once. The pg.fill() call in the constructor is unnecessary.

💡 Move the texture generation loop outside the draw() function and into the constructor. Remove the pg.fill() from the constructor—it doesn't do anything there.

STYLE Global variables

There are many global variables (pg, player, worldHouses, quests, cameraX, etc.) which can be hard to track in a larger game. The code organization mixes state and logic.

💡 Consider wrapping game state into a `gameManager` object: `let gameManager = { player: null, worldHouses: [], quests: [], ... }`. This makes dependencies clearer and prevents variable-name collisions.

FEATURE enterRoom() and draw()

Only House #1 has an interior room implemented. Adding interior rooms for other houses requires duplicating code in enterRoom().

💡 Refactor rooms into an object: `let rooms = { world: {...}, house1_interior: {...}, house2_interior: {...} }` with a `setup()` method for each. This makes adding new rooms trivial.

BUG House.draw() optional window rendering

The window is randomly drawn every frame (with a 50% chance each frame). This causes the window to flicker, appearing and disappearing unpredictably.

💡 Move the random() call to the House constructor and store it as a property (e.g., `this.hasWindow`). That way, each house consistently shows or hides its window.

🔄 Code Flow

Code flow showing setup, draw, keyPressed, enterRoom, setup, humanupdate, humandraw, humaninteract, fish, fishupdate, fishdraw, fishinteractnpc, obstacle, obstacledraw, house, houseflood, housedraw

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

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> game-over-check[Game Over Check] draw --> game-complete-check[Game Complete Check] draw --> camera-follow-logic[Camera Follow Logic] draw --> drawing-loop[Drawing Loop] drawing-loop --> fish-update-loop[Fish Update Loop] drawing-loop --> obstacle-spawning[Obstacle Spawning] drawing-loop --> horizontal-input[Horizontal Input] drawing-loop --> vertical-input[Vertical Input] drawing-loop --> buoyancy-force[Buoyancy Force] drawing-loop --> air-check-loop[Air Check Loop] drawing-loop --> death-check[Death Check] drawing-loop --> boundary-clamp[Boundary Clamp] drawing-loop --> offset-calculation[Offset Calculation] drawing-loop --> arm-animation[Arm Animation] drawing-loop --> message-timer-logic[Message Timer Logic] click setup href "#fn-setup" click draw href "#fn-draw" click game-over-check href "#sub-game-over-check" click game-complete-check href "#sub-game-complete-check" click camera-follow-logic href "#sub-camera-follow-logic" click drawing-loop href "#sub-drawing-loop" click fish-update-loop href "#sub-fish-update-loop" click obstacle-spawning href "#sub-obstacle-spawning" click horizontal-input href "#sub-horizontal-input" click vertical-input href "#sub-vertical-input" click buoyancy-force href "#sub-buoyancy-force" click air-check-loop href "#sub-air-check-loop" click death-check href "#sub-death-check" click boundary-clamp href "#sub-boundary-clamp" click offset-calculation href "#sub-offset-calculation" click arm-animation href "#sub-arm-animation" click message-timer-logic href "#sub-message-timer-logic"

❓ Frequently Asked Questions

What visual experience does the Unfinished Project sketch offer?

The sketch creates a charming pixelated world featuring flooded landscapes, cozy house interiors, and vibrant NPCs, all rendered in a retro style.

How can users interact with the Unfinished Project sketch?

Users can navigate through the environment by swimming in water, walking on land, entering houses, and engaging in dialogue with NPCs while completing simple quests.

What creative coding concepts are demonstrated in the Unfinished Project sketch?

The sketch showcases techniques such as smooth camera movement, game physics for swimming and walking, and a quest system for interactive storytelling.

Preview

Unfinished project - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Unfinished project - Code flow showing setup, draw, keyPressed, enterRoom, setup, humanupdate, humandraw, humaninteract, fish, fishupdate, fishdraw, fishinteractnpc, obstacle, obstacledraw, house, houseflood, housedraw
Code Flow Diagram