Simple MineCraft Cline (Remix) (Remix)

This sketch creates a 2-player Minecraft-inspired sandbox where two players navigate a procedurally generated pixel world, place and break blocks, and survive zombie attacks during night cycles. The game combines tile-based world generation, physics simulation, day/night cycling, and enemy AI into a cooperative survival experience.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make zombies spawn during the day — Remove the day/night phase check so zombies spawn constantly—makes the game much harder and more action-packed
  2. Make day/night cycle much faster — Speeds up the time progression so you see day/night changes every few seconds instead of minutes—watch the sky flicker
  3. Double the player's jump height — JUMP_SPEED controls how high players jump—doubling it lets them reach much taller platforms
  4. Make zombies much faster and stronger — Increases zombie movement speed and damage, making night survival much harder—perfect for a challenge run
  5. Change Player 1's body color to purple — The colorBody property sets a player's appearance—change the RGB to make Player 1 stand out more
  6. Prevent players from breaking stone — Make stone unbreakable by returning early if the target is stone—forces players to use wooden or dirt builds
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch creates a complete 2-player Minecraft-inspired sandbox game in p5.js. Two players explore a procedurally generated tile-based world, break and place blocks to build or dig, and survive zombie attacks that intensify during night hours. The sketch combines several intermediate p5.js techniques: Perlin noise for terrain generation, 2D collision detection for player and zombie physics, a day/night cycle that controls game difficulty, and entity management for tracking multiple players and enemies simultaneously.

The code is organized into distinct systems: world generation and state (the 2D tile array), player physics and input handling (separate keyboard controls for each player), zombie AI and spawning (enemies that hunt players at night), and UI rendering (hotbars, health bars, time display). By studying it, you will learn how to build tile-based games, implement multi-entity physics, manage game state across multiple interactive characters, and coordinate camera movement to follow multiple players at once.

⚙️ How It Works

  1. When the sketch loads, setup() calls initWorld(), which generates a 250×80 tile landscape using Perlin noise. Grass, dirt, and stone form layers, trees are randomly placed, and ore veins (coal, iron) are scattered underground. Two players spawn near the center, separated slightly, with full inventories and health bars.
  2. Every frame, the draw loop updates time (currentTime increases to simulate hours), determines whether it's day or night, and sets the sky color accordingly. At night, zombies have a chance to spawn at safe distances from players.
  3. Player movement is handled per-frame: handleInput() reads keyboard and touch controls for each player separately, updatePlayer() applies gravity and collision detection, and the camera centers on the midpoint between the two players so both stay visible.
  4. Zombies pathfind toward the nearest player using simple AI: they move horizontally to chase and jump over obstacles. When a zombie contacts a player, it deals damage; when a player clicks/taps a block with the break button or key, that block is destroyed and added to the world buffer; when placing a block, collision checks prevent placing inside entities.
  5. The world is rendered to an off-screen graphics buffer (worldBuffer) for performance, which is redrawn only when blocks change. UI elements—hotbars for both players, health bars, instructions, and touch control buttons—are drawn on top each frame.

🎓 Concepts You'll Learn

Tile-based world generationPerlin noise procedural terrain2D collision detectionPhysics simulation (gravity, velocity)Multi-entity state managementAI pathfinding and behaviorDay/night cycle game mechanicsCamera following multiple entitiesGraphics buffering for performanceInput handling (keyboard and touch)Health and damage systems

📝 Code Breakdown

setup()

setup() runs exactly once when the sketch starts. It prepares the canvas and initializes all game state.

function setup() {
  createCanvas(windowWidth, windowHeight); // https://p5js.org/reference/#/p5/createCanvas
  noiseSeed(floor(random(100000)));
  initWorld();

  // Detect touch device
  isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

function-call Canvas Setup createCanvas(windowWidth, windowHeight)

Creates a full-screen canvas that will contain the entire game world and UI

function-call Random Seed for Terrain noiseSeed(floor(random(100000)))

Randomizes the Perlin noise seed so terrain generation produces different maps each time

function-call World Initialization initWorld()

Generates the entire tile-based world, spawns both players, and creates the graphics buffer

createCanvas(windowWidth, windowHeight)
Creates a full-screen p5 canvas sized to fit the browser window
noiseSeed(floor(random(100000)))
Seeds the random number generator so Perlin noise produces a unique but consistent terrain each run
initWorld()
Calls the world generation function, which creates all tiles, spawns players, generates ore veins, and initializes the graphics buffer
isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0
Detects whether the device supports touch input and stores the result so touch controls are only drawn on mobile

draw()

draw() runs ~60 times per second and is the heartbeat of the game. It updates time, checks game conditions (day vs. night), updates all entities, and renders everything. The order matters: input is handled first, then physics updates, then camera adjusts, then rendering draws everything fresh each frame.

🔬 This code advances time and wraps it around at 24 hours. What happens if you increase CYCLE_SPEED to 0.1? Try 0.2? How fast does the day/night cycle become?

  // Update time
  currentTime += CYCLE_SPEED;
  if (currentTime >= 24) currentTime -= 24; // Cycle hours from 0 to 23.99...

🔬 These two loops draw all moving entities. What happens if you comment out the zombie loop? What if you swap the order so zombies draw on top of players?

  // Draw all players
  for (let p of players) {
    drawPlayer(p);
  }
  // Draw all zombies
  for (let z of zombies) {
    drawZombie(z);
  }
function draw() {
  // Update time
  currentTime += CYCLE_SPEED;
  if (currentTime >= 24) currentTime -= 24; // Cycle hours from 0 to 23.99...

  // Determine sky color based on time of day
  let skyColor;
  let currentHour = floor(currentTime);
  let phase = getPhase(currentTime);

  if (phase === 'day') {
    skyColor = color(135, 206, 235); // Day blue
  } else { // Night
    skyColor = color(25, 25, 112); // Night dark blue
  }
  background(skyColor);

  // Draw stars at night
  if (phase === 'night') {
    drawStars();
  }

  handleInput();
  updatePlayer();
  updateZombies(); // Update all zombies
  updateCamera();

  drawWorld(); // Draw the world buffer
  // Draw all players
  for (let p of players) {
    drawPlayer(p);
  }
  // Draw all zombies
  for (let z of zombies) {
    drawZombie(z);
  }

  drawCursorHighlight(); // Still for Player 1 (mouse)

  if (isTouchDevice) {
    drawTouchControls(); // Still for Player 1
  }

  drawUI();

  // Spawn zombies at night
  if (phase === 'night' && zombies.length < MAX_ZOMBIES && random() < ZOMBIE_SPAWN_CHANCE) {
    spawnZombie();
  }
}
Line-by-line explanation (14 lines)

🔧 Subcomponents:

calculation Time Advance currentTime += CYCLE_SPEED;

Advances the game time variable, which controls the day/night cycle

conditional Phase Determination let phase = getPhase(currentTime);

Determines whether it is currently day or night based on the time of day

conditional Sky Color Logic if (phase === 'day') { skyColor = color(135, 206, 235); } else { skyColor = color(25, 25, 112); }

Sets the background sky color based on day/night phase

conditional Zombie Spawning if (phase === 'night' && zombies.length < MAX_ZOMBIES && random() < ZOMBIE_SPAWN_CHANCE) { spawnZombie(); }

Randomly spawns zombies at night when the zombie count is below the maximum

for-loop Entity Rendering for (let p of players) { drawPlayer(p); } for (let z of zombies) { drawZombie(z); }

Draws all players and zombies on every frame

currentTime += CYCLE_SPEED;
Increments the game time by CYCLE_SPEED each frame, advancing the day/night cycle
if (currentTime >= 24) currentTime -= 24;
Wraps time around from 24 hours back to 0, creating a continuous cycle
let phase = getPhase(currentTime);
Calls getPhase() to determine whether it's currently day or night
if (phase === 'day') { skyColor = color(135, 206, 235); } else { skyColor = color(25, 25, 112); }
Sets the sky to light blue during day and dark blue during night
background(skyColor);
Fills the entire canvas with the sky color, clearing the previous frame
if (phase === 'night') { drawStars(); }
Only draws stars when it's nighttime
handleInput();
Processes keyboard and touch input for both players
updatePlayer();
Updates physics (gravity, collisions) for both players
updateZombies();
Updates all zombie physics, AI behavior, and damage calculations
updateCamera();
Repositions the camera to center on both players
drawWorld();
Renders the pre-drawn world buffer to the canvas
for (let p of players) { drawPlayer(p); }
Loops through all players and draws them at their current positions
for (let z of zombies) { drawZombie(z); }
Loops through all zombies and draws them at their current positions
if (phase === 'night' && zombies.length < MAX_ZOMBIES && random() < ZOMBIE_SPAWN_CHANCE) { spawnZombie(); }
During night, randomly spawns a new zombie if the zombie count hasn't reached the maximum

initWorld()

initWorld() runs once at the very start and is the most complex function in the sketch. It creates the entire game world using procedural generation—Perlin noise for terrain, random placement for trees and ore veins, and collision-free spawn point detection. The key insight is that world generation happens in a single pass (left to right), building up layers of blocks and features as it goes. Understanding this function teaches you how tile-based games construct infinite-feeling worlds efficiently.

🔬 This generates the terrain height using Perlin noise. What happens if you increase baseHeight from 30 to 50? How does the landscape change?

    let h = floor(noise(x * 0.12) * 10) + baseHeight;
    h = constrain(h, 12, WORLD_HEIGHT - 6);
function initWorld() {
  world = new Array(WORLD_WIDTH);
  for (let x = 0; x < WORLD_WIDTH; x++) {
    world[x] = new Array(WORLD_HEIGHT).fill(AIR);
  }

  // Initialize world buffer FIRST, before any calls to generateVein or updateWorldBufferTile
  worldBuffer = createGraphics(WORLD_WIDTH * TILE_SIZE, WORLD_HEIGHT * TILE_SIZE);

  const baseHeight = 30;
  for (let x = 0; x < WORLD_WIDTH; x++) {
    // Generate terrain with Perlin noise
    // https://p5js.org/reference/#/p5/noise
    let h = floor(noise(x * 0.12) * 10) + baseHeight;
    h = constrain(h, 12, WORLD_HEIGHT - 6);

    // Stone underground
    for (let y = h; y < WORLD_HEIGHT; y++) {
      world[x][y] = STONE;
    }

    // Dirt and grass surface
    if (h - 1 >= 0) world[x][h - 1] = GRASS;
    if (h - 2 >= 0) world[x][h - 2] = DIRT;
    if (h - 3 >= 0) world[x][h - 3] = DIRT;

    // Random trees
    if (random() < 0.08 && h - 4 > 0) {
      const trunkHeight = floor(random(3, 6)); // Variable trunk height
      for (let t = 0; t < trunkHeight; t++) {
        const ty = h - 2 - t;
        if (ty >= 0) world[x][ty] = WOOD;
      }

      // Leaves ball
      const leafY = h - 2 - trunkHeight;
      const leafRadius = floor(random(2, 4));
      for (let lx = -leafRadius; lx <= leafRadius; lx++) {
        for (let ly = -leafRadius; ly <= 1; ly++) {
          const tx = x + lx;
          const ty = leafY + ly;
          if (tx >= 0 && tx < WORLD_WIDTH && ty >= 0 && ty < WORLD_HEIGHT) {
            if (dist(lx, ly, 0, 0) < leafRadius + 0.6 && world[tx][ty] === AIR) {
              world[tx][ty] = LEAVES;
            }
          }
        }
      }
    }

    // Generate coal and iron veins (now worldBuffer is initialized)
    if (x > 10 && x < WORLD_WIDTH - 10) { // Avoid edges
      if (random() < 0.04) { // Coal vein chance
        generateVein(x, floor(random(h + 5, WORLD_HEIGHT - 10)), COAL, 3, 6);
      }
      if (random() < 0.02) { // Iron vein chance
        generateVein(x, floor(random(h + 10, WORLD_HEIGHT - 15)), IRON, 2, 4);
      }
    }

    // Add small water pools
    if (x > 5 && x < WORLD_WIDTH - 5) {
      if (random() < 0.015) { // Water pool chance
        const poolHeight = floor(random(2, 4));
        const poolWidth = floor(random(4, 8));
        const poolY = h - poolHeight;
        for (let pw = 0; pw < poolWidth; pw++) {
          for (let ph = 0; ph < poolHeight; ph++) {
            const tx = x + pw;
            const ty = poolY + ph;
            if (isInWorld(tx, ty) && world[tx][ty] === AIR) {
              world[tx][ty] = WATER;
            }
          }
        }
      }
    }
  }

  // Spawn player 1 near center, on top of terrain
  const spawnX1 = floor(WORLD_WIDTH / 2);
  let spawnY1 = 0;
  for (let y = 0; y < WORLD_HEIGHT; y++) {
    if (world[spawnX1][y] !== AIR && world[spawnX1][y] !== WATER) { // Spawn on solid ground
      spawnY1 = y - 1;
      break;
    }
  }

  // Spawn player 2 slightly to the right of player 1
  const spawnX2 = spawnX1 + 2; // 2 tiles to the right
  let spawnY2 = 0;
  for (let y = 0; y < WORLD_HEIGHT; y++) {
    if (world[spawnX2][y] !== AIR && world[spawnX2][y] !== WATER) { // Spawn on solid ground
      spawnY2 = y - 1;
      break;
    }
  }

  players = [
    { // Player 1
      id: "player_1",
      x: spawnX1 * TILE_SIZE,
      y: spawnY1 * TILE_SIZE - PLAYER_HEIGHT,
      w: PLAYER_WIDTH,
      h: PLAYER_HEIGHT,
      vx: 0,
      vy: 0,
      onGround: false,
      inWater: false,
      inventory: player1Inventory,
      selectedIndex: 0,
      controls: PLAYER_CONTROLS[0],
      colorBody: PLAYER_CONTROLS[0].colorBody,
      colorPants: PLAYER_CONTROLS[0].colorPants,
      health: 100,
      maxHealth: 100,
      hitTimer: 0 // Timer to show hit effect
    },
    { // Player 2
      id: "player_2",
      x: spawnX2 * TILE_SIZE,
      y: spawnY2 * TILE_SIZE - PLAYER_HEIGHT,
      w: PLAYER_WIDTH,
      h: PLAYER_HEIGHT,
      vx: 0,
      vy: 0,
      onGround: false,
      inWater: false,
      inventory: player2Inventory,
      selectedIndex: 0,
      controls: PLAYER_CONTROLS[1],
      colorBody: PLAYER_CONTROLS[1].colorBody,
      colorPants: PLAYER_CONTROLS[1].colorPants,
      health: 100,
      maxHealth: 100,
      hitTimer: 0 // Timer to show hit effect
    }
  ];

  // Now that all world generation is done, draw the entire static world to the buffer
  drawStaticWorldBuffer();
}
Line-by-line explanation (13 lines)

🔧 Subcomponents:

for-loop Empty World Grid Creation for (let x = 0; x < WORLD_WIDTH; x++) { world[x] = new Array(WORLD_HEIGHT).fill(AIR); }

Creates a 2D array of all air tiles—the foundation of the world that will be filled with blocks

for-loop Perlin Noise Terrain Generation let h = floor(noise(x * 0.12) * 10) + baseHeight;

Uses Perlin noise to generate a height map that creates natural-looking rolling terrain

conditional Random Tree Placement if (random() < 0.08 && h - 4 > 0) { ... }

Randomly places trees (wood trunk + leaf canopy) across the landscape at ~8% of locations

conditional Ore Vein Generation if (random() < 0.04) { generateVein(x, ..., COAL, ...); }

Randomly generates coal and iron ore veins scattered underground for players to mine

conditional Water Pool Generation if (random() < 0.015) { ... }

Randomly creates small water pools scattered across the terrain

calculation Player Spawn Point Calculation for (let y = 0; y < WORLD_HEIGHT; y++) { if (world[spawnX1][y] !== AIR && world[spawnX1][y] !== WATER) { spawnY1 = y - 1; break; } }

Finds the first solid tile below the spawn location and places the player on top of it

world = new Array(WORLD_WIDTH);
Creates an array with length 250 (WORLD_WIDTH), one element per column of tiles
world[x] = new Array(WORLD_HEIGHT).fill(AIR);
For each column, creates an array of 80 tiles (WORLD_HEIGHT) filled with AIR block IDs
worldBuffer = createGraphics(WORLD_WIDTH * TILE_SIZE, WORLD_HEIGHT * TILE_SIZE);
Creates an off-screen graphics buffer the size of the entire world where tiles will be pre-drawn for performance
let h = floor(noise(x * 0.12) * 10) + baseHeight;
Uses p5's noise() function (Perlin noise) to generate a height value for this column; 0.12 controls how 'smooth' terrain changes across columns
h = constrain(h, 12, WORLD_HEIGHT - 6);
Ensures terrain height stays between 12 and 74 tiles, preventing terrain from hitting the top/bottom edges
for (let y = h; y < WORLD_HEIGHT; y++) { world[x][y] = STONE; }
Fills all tiles below terrain height with STONE block ID, creating solid underground
if (h - 1 >= 0) world[x][h - 1] = GRASS;
Places a GRASS block at the terrain surface (one tile above the stone)
if (random() < 0.08 && h - 4 > 0) { ... }
8% chance to spawn a tree; h - 4 > 0 ensures space above terrain for the trunk
const trunkHeight = floor(random(3, 6));
Randomizes tree trunk height between 3 and 6 tiles for visual variety
if (dist(lx, ly, 0, 0) < leafRadius + 0.6 && world[tx][ty] === AIR) { world[tx][ty] = LEAVES; }
Uses dist() to create a circular leaf canopy; only places leaves where air blocks exist
generateVein(x, floor(random(h + 5, WORLD_HEIGHT - 10)), COAL, 3, 6);
Calls generateVein to scatter coal ore veins 5+ tiles below the surface
const spawnX1 = floor(WORLD_WIDTH / 2);
Sets Player 1's spawn X to the center of the world (tile 125)
for (let y = 0; y < WORLD_HEIGHT; y++) { if (world[spawnX1][y] !== AIR && world[spawnX1][y] !== WATER) { spawnY1 = y - 1; break; } }
Searches downward from the top to find the first solid block, then places player one tile above it

handleInput()

handleInput() is called every frame in draw() and converts keyboard/touch states into player velocity. The key insight is that velocity (vx, vy) is separate from position (x, y)—we set velocity here, and updatePlayerPhysics() uses it to actually move the player next frame. This separation is the foundation of physics-based movement.

🔬 This code reads keyboard presses OR touch buttons. What happens if you try pressing left AND right at the same time? Why does only one direction win?

    if (keyIsDown(p.controls.left) || (i === 0 && touchControls.left)) {
      p.vx = -MOVE_SPEED;
    }
    if (keyIsDown(p.controls.right) || (i === 0 && touchControls.right)) {
      p.vx = MOVE_SPEED;
    }
function handleInput() {
  for (let i = 0; i < players.length; i++) {
    let p = players[i];
    p.vx = 0;

    // Handle player-specific movement keys
    if (keyIsDown(p.controls.left) || (i === 0 && touchControls.left)) {
      p.vx = -MOVE_SPEED;
    }
    if (keyIsDown(p.controls.right) || (i === 0 && touchControls.right)) {
      p.vx = MOVE_SPEED;
    }

    // Apply water resistance if player is submerged
    if (p.inWater) {
      p.vx *= 0.6; // Slower movement in water
    }

    // Handle player-specific jump keys
    if (
      (keyIsDown(p.controls.jump) || (i === 0 && touchControls.jump)) &&
      p.onGround
    ) {
      p.vy = -JUMP_SPEED;
      p.onGround = false;
      playJumpSound();
      if (i === 0) touchControls.jump = false; // Reset touch jump for Player 1
    }
    // Allow jumping in water, but weaker
    if (
      (keyIsDown(p.controls.jump) || (i === 0 && touchControls.jump)) &&
      p.inWater && !p.onGround
    ) {
      p.vy = -JUMP_SPEED * 0.6; // Weaker jump in water
      playJumpSound();
      if (i === 0) touchControls.jump = false;
    }
  }
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

for-loop Per-Player Input Processing for (let i = 0; i < players.length; i++) { let p = players[i]; ... }

Iterates through both players and checks their individual key bindings

conditional Horizontal Movement if (keyIsDown(p.controls.left) || (i === 0 && touchControls.left)) { p.vx = -MOVE_SPEED; }

Sets horizontal velocity based on left/right key presses or touch button presses (Player 1 only for touch)

conditional Water Physics if (p.inWater) { p.vx *= 0.6; }

Slows player movement in water by multiplying velocity by 0.6

conditional Jump on Ground if ((keyIsDown(p.controls.jump) || (i === 0 && touchControls.jump)) && p.onGround) { ... }

Allows jumping only when player is on the ground by setting upward velocity

conditional Jump in Water if ((keyIsDown(p.controls.jump) || (i === 0 && touchControls.jump)) && p.inWater && !p.onGround) { ... }

Allows weaker jumping while in water to help players swim upward

for (let i = 0; i < players.length; i++) {
Loops through both players (i=0 for Player 1, i=1 for Player 2)
let p = players[i];
Shorthand reference to the current player object
p.vx = 0;
Resets horizontal velocity to 0 each frame—a key-press updates it if held down
if (keyIsDown(p.controls.left) || (i === 0 && touchControls.left)) {
Checks if the player's left key is pressed OR if it's Player 1 and the left touch button is active
p.vx = -MOVE_SPEED;
Sets velocity to negative (leftward) at speed 3 pixels per frame
if (p.inWater) { p.vx *= 0.6; }
If player is submerged in water, reduces horizontal velocity to 60% of normal—water slows you down
if ((keyIsDown(p.controls.jump) || (i === 0 && touchControls.jump)) && p.onGround) {
Only allows jumping if jump key is pressed AND player is touching ground (onGround is true)
p.vy = -JUMP_SPEED;
Sets upward velocity to -12, creating the jump arc (negative because up is negative Y in p5.js)
p.onGround = false;
Marks player as airborne so they can't jump again until landing
if (i === 0) touchControls.jump = false;
For Player 1 on touch, resets the jump button after using it (prevents automatic re-jump)

updatePlayerPhysics()

updatePlayerPhysics() is the physics engine in miniature. It runs every frame and handles gravity (constant downward acceleration), collision detection (checking tiles in the direction of movement), and state management (onGround, inWater). The collision system works by checking which tiles the player occupies and testing if those tiles are solid—this AABB (axis-aligned bounding box) collision is simple but effective for tile-based games.

🔬 This detects when a player is in water by checking the tile under their feet. What happens if you change the 1 to 5? To 0.1? How does it affect swimming?

  // Check if player is in water
  const playerBottomTileX = floor((p.x + p.w / 2) / TILE_SIZE);
  const playerBottomTileY = floor((p.y + p.h) / TILE_SIZE);
  if (isInWorld(playerBottomTileX, playerBottomTileY) && world[playerBottomTileX][playerBottomTileY] === WATER) {
    p.inWater = true;
    p.vy = min(p.vy, 1); // Slow sinking in water
  }
function updatePlayerPhysics(p) {
  // Gravity
  p.vy += GRAVITY;
  p.vy = min(p.vy, 18);
  p.onGround = false;
  p.inWater = false; // Reset water status

  // Check if player is in water
  const playerBottomTileX = floor((p.x + p.w / 2) / TILE_SIZE);
  const playerBottomTileY = floor((p.y + p.h) / TILE_SIZE);
  if (isInWorld(playerBottomTileX, playerBottomTileY) && world[playerBottomTileX][playerBottomTileY] === WATER) {
    p.inWater = true;
    p.vy = min(p.vy, 1); // Slow sinking in water
  }

  // Horizontal movement + collisions
  let newX = p.x + p.vx;
  newX = constrain(newX, 0, WORLD_WIDTH * TILE_SIZE - p.w);

  if (p.vx > 0) {
    const right = newX + p.w;
    const top = p.y;
    const bottom = p.y + p.h - 1;
    const tileRight = floor(right / TILE_SIZE);
    for (let ty = floor(top / TILE_SIZE); ty <= floor(bottom / TILE_SIZE); ty++) {
      if (isSolidTile(tileRight, ty)) {
        newX = tileRight * TILE_SIZE - p.w;
        p.vx = 0;
        break;
      }
    }
  } else if (p.vx < 0) {
    const left = newX;
    const top = p.y;
    const bottom = p.y + p.h - 1;
    const tileLeft = floor(left / TILE_SIZE);
    for (let ty = floor(top / TILE_SIZE); ty <= floor(bottom / TILE_SIZE); ty++) {
      if (isSolidTile(tileLeft, ty)) {
        newX = (tileLeft + 1) * TILE_SIZE;
        p.vx = 0;
        break;
      }
    }
  }
  p.x = newX;

  // Vertical movement + collisions
  let newY = p.y + p.vy;

  if (p.vy > 0) {
    // Falling
    let bottom = newY + p.h;
    if (bottom >= WORLD_HEIGHT * TILE_SIZE) {
      // Invisible floor at bottom of world (or respawn if fallen too far)
      newY = WORLD_HEIGHT * TILE_SIZE - p.h;
      p.vy = 0;
      p.onGround = true;
    } else {
      const left = p.x;
      const right = p.x + p.w - 1;
      const tileBottom = floor(bottom / TILE_SIZE);
      for (let tx = floor(left / TILE_SIZE); tx <= floor(right / TILE_SIZE); tx++) {
        if (isSolidTile(tx, tileBottom)) {
          newY = tileBottom * TILE_SIZE - p.h;
          p.vy = 0;
          p.onGround = true;
          break;
        }
      }
    }
  } else if (p.vy < 0) {
    // Moving up (jumping)
    let top = newY;
    if (top <= 0) {
      newY = 0;
      p.vy = 0;
    } else {
      const left = p.x;
      const right = p.x + p.w - 1;
      const tileTop = floor(top / TILE_SIZE);
      for (let tx = floor(left / TILE_SIZE); tx <= floor(right / TILE_SIZE); tx++) {
        if (isSolidTile(tx, tileTop)) {
          newY = (tileTop + 1) * TILE_SIZE;
          p.vy = 0;
          break;
        }
      }
    }
  }

  p.y = newY;

  // Update hit timer
  if (p.hitTimer > 0) p.hitTimer--;
}
Line-by-line explanation (15 lines)

🔧 Subcomponents:

calculation Gravity Acceleration p.vy += GRAVITY;

Increases downward velocity by 0.8 each frame, simulating gravity pulling the player down

calculation Terminal Velocity p.vy = min(p.vy, 18);

Caps maximum downward velocity at 18 pixels/frame to prevent falling too fast

conditional Water Submersion Detection if (isInWorld(playerBottomTileX, playerBottomTileY) && world[playerBottomTileX][playerBottomTileY] === WATER) { ... }

Checks if the player's bottom center is in a water tile and applies water physics

conditional Horizontal Collision Detection if (p.vx > 0) { ... } else if (p.vx < 0) { ... }

Tests for solid tiles ahead of the player's left/right edge and stops movement if collision found

conditional Vertical Collision Detection if (p.vy > 0) { ... } else if (p.vy < 0) { ... }

Tests for solid tiles above/below the player and stops movement, setting onGround flag when landing

p.vy += GRAVITY;
Adds gravity (0.8) to the player's vertical velocity every frame, making them accelerate downward
p.vy = min(p.vy, 18);
Limits max fall speed to 18 pixels/frame so players don't fall infinitely fast
p.onGround = false;
Resets onGround flag each frame—it will be set to true if collision detection finds solid ground below
const playerBottomTileX = floor((p.x + p.w / 2) / TILE_SIZE);
Calculates which tile column the player's center X is in
const playerBottomTileY = floor((p.y + p.h) / TILE_SIZE);
Calculates which tile row the player's bottom edge is in
if (isInWorld(playerBottomTileX, playerBottomTileY) && world[playerBottomTileX][playerBottomTileY] === WATER) {
Checks if the tile at player's feet is water; if so, applies water mechanics
p.inWater = true;
Marks the player as submerged so handleInput can apply water resistance
p.vy = min(p.vy, 1);
Limits downward velocity in water to 1 pixel/frame—slows sinking dramatically
let newX = p.x + p.vx;
Calculates the new X position if no collisions occur
newX = constrain(newX, 0, WORLD_WIDTH * TILE_SIZE - p.w);
Prevents the player from moving beyond the world's left and right boundaries
const tileRight = floor(right / TILE_SIZE);
Finds which tile column the player's right edge would occupy
for (let ty = floor(top / TILE_SIZE); ty <= floor(bottom / TILE_SIZE); ty++) { if (isSolidTile(tileRight, ty)) { ... } }
Loops through all tile rows the player occupies vertically and checks if any tile to the right is solid
if (isSolidTile(tileRight, ty)) { newX = tileRight * TILE_SIZE - p.w; p.vx = 0; break; }
If a solid tile is found, snaps the player's right edge to the tile's left edge and stops movement
const tileBottom = floor(bottom / TILE_SIZE);
Finds which tile row is directly below the player's bottom edge
if (isSolidTile(tx, tileBottom)) { newY = tileBottom * TILE_SIZE - p.h; p.vy = 0; p.onGround = true; }
When solid ground is found, snaps player on top of it, zeroes fall velocity, and marks them as on ground

spawnZombie()

spawnZombie() demonstrates randomized entity placement with multiple validation checks. It uses dist() to measure distance and multiple isSolidTile() checks to ensure the spawn location is viable. The retry loop is important: if a random location is unsuitable, it doesn't just fail—it retries up to 100 times, increasing the likelihood of finding a good spot. This is common in game development for procedural placement.

🔬 This checks that zombies don't spawn too close to players. What happens if you change SAFE_SPAWN_DISTANCE from 8 tiles to 20 tiles? How much bigger is the 'safe zone' around players?

    // Check if spawn location is safe (not too close to players)
    let safe = true;
    for (let p of players) {
      if (dist(spawnX + ZOMBIE_WIDTH / 2, spawnY + ZOMBIE_HEIGHT / 2, p.x + p.w / 2, p.y + p.h / 2) < SAFE_SPAWN_DISTANCE) {
        safe = false;
        break;
      }
    }
function spawnZombie() {
  if (zombies.length >= MAX_ZOMBIES) return;

  let spawnX, spawnY;
  let attempts = 0;
  const maxAttempts = 100;

  while (attempts < maxAttempts) {
    spawnX = floor(random(WORLD_WIDTH)) * TILE_SIZE;
    spawnY = 0; // Start at top and let it fall

    // Find a solid ground y coordinate (similar to player spawn)
    for (let y = 0; y < WORLD_HEIGHT; y++) {
      if (isSolidTile(floor(spawnX / TILE_SIZE), y)) {
        spawnY = y * TILE_SIZE - ZOMBIE_HEIGHT;
        break;
      }
    }

    // Check if spawn location is safe (not too close to players)
    let safe = true;
    for (let p of players) {
      if (dist(spawnX + ZOMBIE_WIDTH / 2, spawnY + ZOMBIE_HEIGHT / 2, p.x + p.w / 2, p.y + p.h / 2) < SAFE_SPAWN_DISTANCE) {
        safe = false;
        break;
      }
    }
    // Check if spawn location is not inside a solid block
    if (isSolidTile(floor(spawnX / TILE_SIZE), floor(spawnY / TILE_SIZE)) ||
        isSolidTile(floor((spawnX + ZOMBIE_WIDTH - 1) / TILE_SIZE), floor(spawnY / TILE_SIZE)) ||
        isSolidTile(floor(spawnX / TILE_SIZE), floor((spawnY + ZOMBIE_HEIGHT - 1) / TILE_SIZE)) ||
        isSolidTile(floor((spawnX + ZOMBIE_WIDTH - 1) / TILE_SIZE), floor((spawnY + ZOMBIE_HEIGHT - 1) / TILE_SIZE))) {
      safe = false;
    }

    if (safe) {
      break;
    }
    attempts++;
  }

  if (attempts < maxAttempts) {
    zombies.push({
      id: "zombie_" + frameCount + "_" + zombies.length, // Unique ID for hitting
      x: spawnX,
      y: spawnY,
      w: ZOMBIE_WIDTH,
      h: ZOMBIE_HEIGHT,
      vx: 0,
      vy: 0,
      onGround: false,
      color: color(34, 139, 34), // Green zombie
      health: ZOMBIE_HEALTH,
      maxHealth: ZOMBIE_HEALTH,
      hitTimer: 0 // Timer to show hit effect
    });
  }
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

conditional Spawn Cap if (zombies.length >= MAX_ZOMBIES) return;

Prevents spawning more than MAX_ZOMBIES zombies at once

calculation Random Location Selection spawnX = floor(random(WORLD_WIDTH)) * TILE_SIZE;

Picks a random X coordinate across the world width

for-loop Ground Detection for (let y = 0; y < WORLD_HEIGHT; y++) { if (isSolidTile(floor(spawnX / TILE_SIZE), y)) { ... } }

Searches downward to find the first solid tile at the spawn X coordinate

for-loop Distance to Players Check for (let p of players) { if (dist(...) < SAFE_SPAWN_DISTANCE) { safe = false; } }

Ensures zombie doesn't spawn too close to any player using distance() function

conditional Inside Block Detection if (isSolidTile(...) || isSolidTile(...) || ...) { safe = false; }

Checks all four corners of the zombie's bounding box to prevent spawning inside solid blocks

while-loop Spawn Retry Logic while (attempts < maxAttempts) { ... }

Tries up to 100 times to find a safe spawn location; if no safe spot after 100 tries, gives up

if (zombies.length >= MAX_ZOMBIES) return;
Early exit if the zombie cap has been reached
spawnX = floor(random(WORLD_WIDTH)) * TILE_SIZE;
Picks a random tile column (0-249) and converts to pixel coordinates by multiplying by TILE_SIZE
spawnY = 0; // Start at top and let it fall
Sets spawn Y to the top; the ground-finding loop will move it down to a solid tile
for (let y = 0; y < WORLD_HEIGHT; y++) { if (isSolidTile(floor(spawnX / TILE_SIZE), y)) { spawnY = y * TILE_SIZE - ZOMBIE_HEIGHT; break; } }
Searches downward from y=0 until finding a solid block, then places the zombie one height above it
for (let p of players) { if (dist(spawnX + ZOMBIE_WIDTH / 2, spawnY + ZOMBIE_HEIGHT / 2, p.x + p.w / 2, p.y + p.h / 2) < SAFE_SPAWN_DISTANCE) { safe = false; break; } }
Loops through all players and calculates distance from zombie center to each player's center; if any distance is less than SAFE_SPAWN_DISTANCE (6 tiles), marks as unsafe
if (isSolidTile(...) || isSolidTile(...) || isSolidTile(...) || isSolidTile(...)) { safe = false; }
Tests all four corners of the zombie's bounding box; if any corner is inside a solid tile, marks as unsafe
if (safe) { break; }
If all safety checks pass, exits the while loop and proceeds to spawn the zombie
attempts++;
Increments the attempt counter; if it reaches 100, the loop exits without spawning
zombies.push({ ... });
Adds a new zombie object to the array with initial properties (position, velocity, health, color)

updateZombies()

updateZombies() iterates through all zombies in reverse order, a critical detail when removing elements from an array. The backward loop (i-- instead of i++) ensures that splicing out an element at index i doesn't cause the loop to skip the next element. This function also demonstrates entity-to-entity collision: calculating distances and taking action when they're close enough.

🔬 This loop checks every zombie against every player. How many distance calculations happen if there are 3 zombies and 2 players? What if there are 15 zombies (MAX_ZOMBIES)?

    // Zombie attack players
    for (let p of players) {
      if (dist(z.x + z.w/2, z.y + z.h/2, p.x + p.w/2, p.y + p.h/2) < ZOMBIE_ATTACK_RANGE_PX) {
        p.health -= ZOMBIE_DAMAGE_PER_FRAME;
        p.hitTimer = 10; // Show hit effect for 10 frames
        if (p.health <= 0) {
          respawnPlayer(p);
        }
      }
    }
function updateZombies() {
  for (let i = zombies.length - 1; i >= 0; i--) {
    let z = zombies[i];
    updateZombiePhysics(z);

    // Zombie attack players
    for (let p of players) {
      if (dist(z.x + z.w/2, z.y + z.h/2, p.x + p.w/2, p.y + p.h/2) < ZOMBIE_ATTACK_RANGE_PX) {
        p.health -= ZOMBIE_DAMAGE_PER_FRAME;
        p.hitTimer = 10; // Show hit effect for 10 frames
        if (p.health <= 0) {
          respawnPlayer(p);
        }
      }
    }

    // Update hit timer
    if (z.hitTimer > 0) z.hitTimer--;

    // Remove zombies that fall out of the world or die
    if (z.y > WORLD_HEIGHT * TILE_SIZE + 100 || z.health <= 0) {
      zombies.splice(i, 1);
      playZombieHitSound(); // Sound for zombie death
    }
  }
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

for-loop Reverse Iteration for Safe Removal for (let i = zombies.length - 1; i >= 0; i--) { ... }

Loops backward through the zombie array so removing zombies doesn't skip elements

function-call Zombie Physics Update updateZombiePhysics(z);

Updates the individual zombie's position, velocity, and AI pathfinding

for-loop Damage Calculation for (let p of players) { if (dist(...) < ZOMBIE_ATTACK_RANGE_PX) { ... } }

Checks if any zombie is within attack range of any player and applies damage

conditional Dead/Out-of-Bounds Removal if (z.y > WORLD_HEIGHT * TILE_SIZE + 100 || z.health <= 0) { zombies.splice(i, 1); }

Removes zombies that have died or fallen far below the world

for (let i = zombies.length - 1; i >= 0; i--) {
Loops backward from the last zombie to the first; crucial because splice() changes array length
let z = zombies[i];
Shorthand reference to the current zombie
updateZombiePhysics(z);
Calls the physics update function to move the zombie and apply gravity/collisions
for (let p of players) { if (dist(z.x + z.w/2, z.y + z.h/2, p.x + p.w/2, p.y + p.h/2) < ZOMBIE_ATTACK_RANGE_PX) { ... } }
Loops through all players; if any is within ZOMBIE_ATTACK_RANGE_PX (24 pixels) of the zombie's center, deals damage
p.health -= ZOMBIE_DAMAGE_PER_FRAME;
Subtracts 0.2 health from the player each frame they're in contact with a zombie
p.hitTimer = 10;
Sets the player's hit timer to 10, causing them to flash red for the next 10 frames
if (p.health <= 0) { respawnPlayer(p); }
When a player's health reaches 0 or below, respawns them at a spawn point
if (z.hitTimer > 0) z.hitTimer--;
Decrements the zombie's hit timer each frame; used to show a flash effect when hit by a sword
if (z.y > WORLD_HEIGHT * TILE_SIZE + 100 || z.health <= 0) { zombies.splice(i, 1); }
Removes the zombie if it falls 100 pixels below the world bottom OR its health <= 0

handleBlockInteraction()

handleBlockInteraction() is the core game mechanic function—it translates player input into world state changes. It demonstrates coordinate conversion (screen to tile), action switching (break vs. place), and collision validation (rectsOverlap). The sword mechanic shows how a single item type can have multiple behaviors: placed as a block in the hotbar but used as a weapon to damage entities.

🔬 This converts screen coordinates to world tile coordinates using the camera. Why is cameraX added before dividing? What would happen if you removed it?

  const tileX = floor((px + cameraX) / TILE_SIZE);
  const tileY = floor((py + cameraY) / TILE_SIZE);
  if (!isInWorld(tileX, tileY)) return;
function handleBlockInteraction(px, py, playerObj, action) {
  const tileX = floor((px + cameraX) / TILE_SIZE);
  const tileY = floor((py + cameraY) / TILE_SIZE);
  if (!isInWorld(tileX, tileY)) return;

  const targetBlockId = world[tileX][tileY];
  const selectedItemId = playerObj.inventory[playerObj.selectedIndex];

  if (action === 'break') {
    if (targetBlockId !== AIR && BLOCK_PROPS[targetBlockId].solid) {
      // Check if hitting a zombie instead
      let hitZombie = false;
      if (selectedItemId === SWORD) { // Only sword can hit zombies
        for (let z of zombies) {
          if (rectsOverlap(px, py, 1, 1, z.x, z.y, z.w, z.h)) { // Point-in-rect check
            z.health -= 20; // Sword deals significant damage
            z.hitTimer = 10;
            playZombieHitSound();
            hitZombie = true;
            break;
          }
        }
      }

      if (!hitZombie) {
        world[tileX][tileY] = AIR;
        updateWorldBufferTile(tileX, tileY); // Update the buffer
        playBlockBreakSound();
      }
    }
  } else if (action === 'place') {
    if (targetBlockId === AIR && BLOCK_PROPS[selectedItemId] && !BLOCK_PROPS[selectedItemId].item) {
      const bx = tileX * TILE_SIZE;
      const by = tileY * TILE_SIZE;
      // Check if placing block would overlap with ANY player OR ANY zombie
      let overlapsAnyEntity = false;
      for (let p of players) {
        if (rectsOverlap(bx, by, TILE_SIZE, TILE_SIZE, p.x, p.y, p.w, p.h)) {
          overlapsAnyEntity = true;
          break;
        }
      }
      if (!overlapsAnyEntity) {
        for (let z of zombies) {
          if (rectsOverlap(bx, by, TILE_SIZE, TILE_SIZE, z.x, z.y, z.w, z.h)) {
            overlapsAnyEntity = true;
            break;
          }
        }
      }

      if (!overlapsAnyEntity) {
        world[tileX][tileY] = selectedItemId;
        updateWorldBufferTile(tileX, tileY); // Update the buffer
        playBlockPlaceSound();
      }
    }
  }
}
Line-by-line explanation (15 lines)

🔧 Subcomponents:

calculation Screen to Tile Conversion const tileX = floor((px + cameraX) / TILE_SIZE);

Converts screen pixel coordinates to world tile coordinates by adding camera offset and dividing by tile size

conditional World Bounds Validation if (!isInWorld(tileX, tileY)) return;

Early exit if the target tile is outside the world

conditional Block Break Logic if (action === 'break') { ... }

Handles breaking blocks or hitting zombies with a sword

conditional Zombie Damage with Sword if (selectedItemId === SWORD) { for (let z of zombies) { if (rectsOverlap(...)) { z.health -= 20; ... } } }

If the selected item is a SWORD, loops through zombies and damages any in the targeted location

conditional Block Place Logic else if (action === 'place') { ... }

Handles placing a block in an empty tile, with collision checks for players and zombies

for-loop Collision Check Against All Entities for (let p of players) { if (rectsOverlap(...)) { overlapsAnyEntity = true; } }

Prevents placing blocks inside any player or zombie

const tileX = floor((px + cameraX) / TILE_SIZE);
Converts screen X (mouseX or touchX) plus camera offset to a world tile column; floor() rounds down to nearest integer
const tileY = floor((py + cameraY) / TILE_SIZE);
Converts screen Y (mouseY or touchY) plus camera offset to a world tile row
if (!isInWorld(tileX, tileY)) return;
Early exit if clicking/tapping outside the world boundaries
const targetBlockId = world[tileX][tileY];
Retrieves the block ID at the target tile (e.g., AIR, GRASS, STONE)
const selectedItemId = playerObj.inventory[playerObj.selectedIndex];
Gets the block/item ID that the player has selected in their hotbar
if (action === 'break') { ... }
If the action is 'break', proceed with break logic
if (targetBlockId !== AIR && BLOCK_PROPS[targetBlockId].solid) {
Only break solid blocks that aren't already air
if (selectedItemId === SWORD) { for (let z of zombies) { if (rectsOverlap(px, py, 1, 1, z.x, z.y, z.w, z.h)) { z.health -= 20; ... } } }
If holding a sword, check if clicking on a zombie; if so, deal 20 damage to that zombie
world[tileX][tileY] = AIR;
Sets the target tile to AIR, removing the block
updateWorldBufferTile(tileX, tileY);
Redraws only this single tile in the graphics buffer for efficiency
else if (action === 'place') { ... }
If the action is 'place', proceed with place logic
if (targetBlockId === AIR && BLOCK_PROPS[selectedItemId] && !BLOCK_PROPS[selectedItemId].item) {
Only place if target is air AND the selected item is a real block (not an item like SWORD)
const bx = tileX * TILE_SIZE;
Converts the tile column back to pixel coordinates
if (rectsOverlap(bx, by, TILE_SIZE, TILE_SIZE, p.x, p.y, p.w, p.h)) { overlapsAnyEntity = true; }
Checks if the new block's bounding box overlaps any player; if so, blocks placement
world[tileX][tileY] = selectedItemId;
Places the selected block in the world

drawPlayer()

drawPlayer() is a simple rendering function that converts a player's world position to screen position using the camera, then draws two colored rectangles to represent the body and pants. The hit timer effect demonstrates animation: the alpha channel fades from opaque to transparent over 10 frames using map(), creating a visual feedback when the player takes damage.

🔬 The player is drawn as two rectangles. What happens if you change sy + p.h / 2 to sy + p.h / 3? Where does the color split move?

  // Body
  fill(p.colorBody);
  rect(sx, sy, p.w, p.h);
  // Pants
  fill(p.colorPants);
  rect(sx, sy + p.h / 2, p.w, p.h / 2);
function drawPlayer(p) {
  push();
  const sx = p.x - cameraX;
  const sy = p.y - cameraY;

  noStroke();
  // Body
  fill(p.colorBody);
  rect(sx, sy, p.w, p.h);
  // Pants
  fill(p.colorPants);
  rect(sx, sy + p.h / 2, p.w, p.h / 2);

  // Hit effect: Flash red
  if (p.hitTimer > 0) {
    fill(255, 0, 0, map(p.hitTimer, 0, 10, 0, 150));
    rect(sx, sy, p.w, p.h);
  }

  pop();
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

calculation Screen Position Calculation const sx = p.x - cameraX; const sy = p.y - cameraY;

Converts world coordinates to screen coordinates by subtracting camera position

function-call Body Drawing fill(p.colorBody); rect(sx, sy, p.w, p.h);

Draws the player's torso in their team color

function-call Pants Drawing fill(p.colorPants); rect(sx, sy + p.h / 2, p.w, p.h / 2);

Draws the bottom half of the player in a different color for visual distinction

conditional Hit Effect Animation if (p.hitTimer > 0) { fill(255, 0, 0, map(p.hitTimer, 0, 10, 0, 150)); rect(sx, sy, p.w, p.h); }

Draws a semi-transparent red rectangle over the player when hit, fading over 10 frames

const sx = p.x - cameraX;
Converts player's world X position to screen X by subtracting the camera's X offset
const sy = p.y - cameraY;
Converts player's world Y position to screen Y by subtracting the camera's Y offset
noStroke();
Disables outlines on rectangles for a solid look
fill(p.colorBody);
Sets the fill color to the player's body color (e.g., skin tone for Player 1)
rect(sx, sy, p.w, p.h);
Draws the full-height rectangle representing the player's body
fill(p.colorPants);
Sets the fill color to the player's pants color (e.g., blue for Player 1, red for Player 2)
rect(sx, sy + p.h / 2, p.w, p.h / 2);
Draws a rectangle at y = sy + half-height for half the height, creating a two-tone look
if (p.hitTimer > 0) {
Only draws the hit effect if hitTimer > 0 (meaning the player was recently hit)
fill(255, 0, 0, map(p.hitTimer, 0, 10, 0, 150));
Sets fill to red with alpha that maps from 150 (opaque) at hitTimer=10 to 0 (transparent) at hitTimer=0—creates a fade-out effect
rect(sx, sy, p.w, p.h);
Draws the red overlay rectangle on top of the player

updateCamera()

updateCamera() is a perfect example of a 'follow camera' system. Instead of following a single character, it calculates the midpoint between both players and keeps that point centered on screen, creating the effect of both players being equally visible. The constrain() calls ensure the camera never reveals the world's edges—essential for maintaining the illusion of a continuous world.

🔬 This finds the midpoint between the two players. What happens if you change it to only follow Player 1 (players[0].x)? How does the camera behavior change?

  // Calculate midpoint between players
  let midX = (players[0].x + players[1].x) / 2 + players[0].w / 2;
  let midY = (players[0].y + players[1].y) / 2 + players[0].h / 2;
function updateCamera() {
  // Calculate midpoint between players
  let midX = (players[0].x + players[1].x) / 2 + players[0].w / 2;
  let midY = (players[0].y + players[1].y) / 2 + players[0].h / 2;

  // Center camera on midpoint horizontally
  let targetX = midX - width / 2;
  const maxCamX = max(0, WORLD_WIDTH * TILE_SIZE - width);
  cameraX = constrain(targetX, 0, maxCamX);

  // Center camera on midpoint vertically
  let targetY = midY - height / 2;
  const maxCamY = max(0, WORLD_HEIGHT * TILE_SIZE - height);
  cameraY = constrain(targetY, 0, maxCamY);
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

calculation Midpoint Between Players let midX = (players[0].x + players[1].x) / 2 + players[0].w / 2;

Calculates the point halfway between both players' centers

calculation Horizontal Camera Centering let targetX = midX - width / 2;

Positions the camera so the midpoint is centered on screen horizontally

function-call Horizontal Boundary Clamping cameraX = constrain(targetX, 0, maxCamX);

Prevents camera from scrolling beyond the world's left and right edges

calculation Vertical Camera Centering let targetY = midY - height / 2;

Positions the camera so the midpoint is centered on screen vertically

function-call Vertical Boundary Clamping cameraY = constrain(targetY, 0, maxCamY);

Prevents camera from scrolling beyond the world's top and bottom edges

let midX = (players[0].x + players[1].x) / 2 + players[0].w / 2;
Averages the X positions of both players, then adds half a player width to account for centering within the player's bounding box
let midY = (players[0].y + players[1].y) / 2 + players[0].h / 2;
Averages the Y positions of both players, then adds half a player height for centering
let targetX = midX - width / 2;
Calculates camera X so that the midpoint appears at the center of the screen (screen center = width/2)
const maxCamX = max(0, WORLD_WIDTH * TILE_SIZE - width);
Calculates the furthest right the camera can scroll (world width minus screen width); if world is smaller than screen, maxCamX is 0
cameraX = constrain(targetX, 0, maxCamX);
Clamps camera X between 0 (left edge) and maxCamX (right edge) to prevent scrolling outside the world
let targetY = midY - height / 2;
Calculates camera Y so that the midpoint appears at the center of the screen
const maxCamY = max(0, WORLD_HEIGHT * TILE_SIZE - height);
Calculates the furthest down the camera can scroll; prevents scrolling below the world
cameraY = constrain(targetY, 0, maxCamY);
Clamps camera Y between 0 (top edge) and maxCamY (bottom edge)

mousePressed()

mousePressed() is a p5.js lifecycle function that fires once per mouse click. It serves as the event handler for Player 1's mouse-based input: block breaking and placing. The function carefully separates concerns (hotbar vs. world interaction) and prevents double-handling on touch devices—a common gotcha when supporting both input types.

function mousePressed() {
  initAudioContext(); // unlock audio on first mouse click

  // On touch devices, prevent double-handling (touch + mouse)
  if (touches && touches.length > 0) return;

  // If clicking on the hotbar, just select slot
  if (mouseY > height - UI_BAR_H) {
    handleHotbarClick(mouseX, mouseY, 0); // Player 1 hotbar
    return;
  }
  if (mouseY < UI_BAR_H * 0.7) { // Player 2 hotbar area (top)
    handleHotbarClick(mouseX, mouseY, 1); // Player 2 hotbar
    return;
  }

  // World edit with mouse (Player 1 action)
  if (mouseButton === LEFT) {
    handleBlockInteraction(mouseX, mouseY, players[0], 'break');
  } else if (mouseButton === RIGHT) {
    handleBlockInteraction(mouseX, mouseY, players[0], 'place');
  }
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

function-call Audio Context Initialization initAudioContext();

Initializes the Web Audio API on first user interaction (required for sound)

conditional Touch Device Detection if (touches && touches.length > 0) return;

Prevents handling both touch and mouse events simultaneously on touch devices

conditional Player 1 Hotbar Click if (mouseY > height - UI_BAR_H) { handleHotbarClick(mouseX, mouseY, 0); return; }

Detects clicks on Player 1's hotbar at the bottom and changes the selected block

conditional Player 2 Hotbar Click if (mouseY < UI_BAR_H * 0.7) { handleHotbarClick(mouseX, mouseY, 1); return; }

Detects clicks on Player 2's hotbar at the top and changes the selected block

conditional Left Click Break if (mouseButton === LEFT) { handleBlockInteraction(mouseX, mouseY, players[0], 'break'); }

Left-clicking breaks the block at the mouse position (Player 1 only)

conditional Right Click Place else if (mouseButton === RIGHT) { handleBlockInteraction(mouseX, mouseY, players[0], 'place'); }

Right-clicking places the selected block at the mouse position (Player 1 only)

initAudioContext();
Calls userStartAudio() to unlock the Web Audio API—required once before any p5.sound calls work
if (touches && touches.length > 0) return;
On touch devices, the browser fires both touch and mouse events; this line exits early to prevent processing mouse when touch is active
if (mouseY > height - UI_BAR_H) {
Checks if the click is in the bottom 60 pixels (Player 1's hotbar)
handleHotbarClick(mouseX, mouseY, 0);
Calls handleHotbarClick with player index 0 (Player 1) to potentially select a new inventory slot
if (mouseY < UI_BAR_H * 0.7) {
Checks if the click is in the top 42 pixels (Player 2's hotbar)
handleHotbarClick(mouseX, mouseY, 1);
Calls handleHotbarClick with player index 1 (Player 2) to potentially select a new inventory slot
if (mouseButton === LEFT) {
Checks if the click was with the left mouse button
handleBlockInteraction(mouseX, mouseY, players[0], 'break');
Calls handleBlockInteraction with action 'break' to destroy the block at the clicked tile (Player 1 only)
else if (mouseButton === RIGHT) {
Checks if the click was with the right mouse button (context menu—usually middle or right-click)
handleBlockInteraction(mouseX, mouseY, players[0], 'place');
Calls handleBlockInteraction with action 'place' to place the selected block at the clicked tile (Player 1 only)

drawUI()

drawUI() renders all the heads-up display (HUD) elements: hotbars, instructions, time display, and health bars. It demonstrates several drawing techniques: layered rectangles for panels, text formatting (padStart), alpha-based transparency for visual depth, and proportional bars (health bar width based on health percentage). The instructions panel could be made interactive (hidden/shown with a key press) in future iterations.

function drawUI() {
  push();

  const barH = UI_BAR_H;
  noStroke();
  fill(0, 0, 0, 130);
  rect(0, height - barH, width, barH); // Bottom hotbar background
  rect(0, 0, width, barH * 0.7); // Top hotbar background (smaller)

  // Player 1 Hotbar (bottom)
  drawHotbar(players[0], width / 2, height - barH / 2, barH, 1.0);

  // Player 2 Hotbar (top)
  drawHotbar(players[1], width / 2, barH * 0.7 / 2, barH * 0.7, 0.7);

  // Instructions panel (top-left)
  const panelW = 340;
  const panelH = 210; // Increased height for more instructions
  fill(0, 0, 0, 140);
  rect(10, 10, panelW, panelH, 8);
  fill(255);
  textAlign(LEFT, TOP);
  textSize(12);
  text(
    "Player 1 (Mouse/Touch/A/D/W):\n" +
    "Move: A/D or ←/→\n" +
    "Jump: W / Space / ↑\n" +
    "1–9 or tap hotbar: choose item\n" +
    "Mouse Left: break, Mouse Right: place\n" +
    "Touch: tap blocks to place, 'Break' button to break\n" +
    "Touch buttons: ◀ / ▲ / ▶ / 🔨\n" +
    "\n" +
    "Player 2 (J/L/I/K/P/U/O):\n" +
    "Move: J/L\n" +
    "Jump: I\n" +
    "U/O: cycle inventory\n" +
    "K: break block, P: place block\n" +
    "\n" +
    "Time: " + floor(currentTime).toString().padStart(2, '0') + ":00 (" + getPhase(currentTime) + ")",
    18,
    18
  );

  // Health bars for players (top-right)
  const healthBarWidth = 100;
  const healthBarHeight = 10;
  const healthMargin = 10;

  for (let i = 0; i < players.length; i++) {
    const p = players[i];
    const barX = width - healthBarWidth - healthMargin;
    const barY = healthMargin + i * (healthBarHeight + healthMargin);

    fill(0, 0, 0, 150);
    rect(barX, barY, healthBarWidth, healthBarHeight);

    // Hit effect: Flash red health bar
    if (p.hitTimer > 0) {
      fill(255, 0, 0, map(p.hitTimer, 0, 10, 0, 200));
      rect(barX, barY, healthBarWidth, healthBarHeight);
    } else {
      fill(0, 255, 0);
      rect(barX, barY, healthBarWidth * (p.health / p.maxHealth), healthBarHeight);
    }
    fill(255);
    textAlign(RIGHT, CENTER);
    text("P" + (i + 1) + " Health", barX - healthMargin, barY + healthBarHeight / 2);
  }

  pop();
}
Line-by-line explanation (15 lines)

🔧 Subcomponents:

function-call Hotbar Background Panels rect(0, height - barH, width, barH); // Bottom hotbar background rect(0, 0, width, barH * 0.7); // Top hotbar background (smaller)

Draws semi-transparent dark rectangles behind the hotbars for visibility

function-call Instructions Text Panel fill(0, 0, 0, 140); rect(10, 10, panelW, panelH, 8); fill(255); text(...)

Draws a dark rounded rectangle with white instructions for both players

calculation In-Game Time Display "Time: " + floor(currentTime).toString().padStart(2, '0') + ":00 (" + getPhase(currentTime) + ")"

Formats and displays the current game time (0-23 hours) and day/night phase

function-call Health Bar Background fill(0, 0, 0, 150); rect(barX, barY, healthBarWidth, healthBarHeight);

Draws dark backgrounds for the health bar containers

conditional Health Bar Rendering if (p.hitTimer > 0) { fill(255, 0, 0, ...); } else { fill(0, 255, 0); }

Draws the health bar red if recently hit, otherwise green; width represents current health percentage

for-loop Per-Player Health Bar for (let i = 0; i < players.length; i++) { ... }

Iterates through both players and draws their health bars stacked vertically

const barH = UI_BAR_H;
Shorthand for the UI bar height constant (60)
noStroke();
Disables outlines on rectangles
fill(0, 0, 0, 130);
Sets fill to semi-transparent black (alpha 130 out of 255)
rect(0, height - barH, width, barH);
Draws a rectangle at the bottom of the screen, height-60 to height, full width—Player 1's hotbar background
rect(0, 0, width, barH * 0.7);
Draws a rectangle at the top of the screen, 0 to 42 pixels—Player 2's hotbar background (70% of Player 1's height)
drawHotbar(players[0], width / 2, height - barH / 2, barH, 1.0);
Calls drawHotbar to render Player 1's inventory at the bottom center, centered at (width/2, height-30), at full scale
drawHotbar(players[1], width / 2, barH * 0.7 / 2, barH * 0.7, 0.7);
Calls drawHotbar to render Player 2's inventory at the top center, centered at (width/2, 21), at 70% scale
fill(0, 0, 0, 140); rect(10, 10, panelW, panelH, 8);
Draws a dark rounded rectangle (corner radius 8) at position 10,10 to hold the instructions text
fill(255); textAlign(LEFT, TOP);
Sets text color to white and aligns to top-left for readable instructions
"Time: " + floor(currentTime).toString().padStart(2, '0') + ":00 (" + getPhase(currentTime) + ")"
Formats the current time (0-23) as a zero-padded string (e.g., '19:00 (night)') and includes the phase
for (let i = 0; i < players.length; i++) { ... }
Loops through both players to draw their health bars in the top-right corner
const barX = width - healthBarWidth - healthMargin;
Positions the health bar 10 pixels from the right edge
const barY = healthMargin + i * (healthBarHeight + healthMargin);
Stacks health bars vertically: Player 1 at y=10, Player 2 at y=10+(10+10)=30
if (p.hitTimer > 0) { fill(255, 0, 0, ...); rect(...); } else { fill(0, 255, 0); rect(...); }
If hit timer is active, draws the health bar red with fading alpha; otherwise draws it green to represent current health
rect(barX, barY, healthBarWidth * (p.health / p.maxHealth), healthBarHeight);
Draws the health bar width proportional to health/maxHealth (e.g., if health is 50/100, width is 50)

📦 Key Variables

world array

A 2D array [x][y] storing block IDs at each tile position in the world; core game state

world[125][50] = GRASS; // Set a single tile to grass
worldBuffer p5.Graphics

Off-screen graphics buffer holding the pre-rendered world tiles for performance; redrawn only when blocks change

worldBuffer = createGraphics(WORLD_WIDTH * TILE_SIZE, WORLD_HEIGHT * TILE_SIZE);
players array

Array of player objects, one per player; each holds position, velocity, inventory, health, controls, etc.

let players = [{ x: 100, y: 200, health: 100 }, { x: 200, y: 200, health: 100 }];
zombies array

Array of zombie objects; each holds position, velocity, health, target, and state

zombies.push({ x: 500, y: 300, health: 60, color: color(34, 139, 34) });
cameraX number

Horizontal camera offset in pixels; used to convert world coordinates to screen coordinates

const screenX = worldX - cameraX;
cameraY number

Vertical camera offset in pixels; used to convert world coordinates to screen coordinates

const screenY = worldY - cameraY;
currentTime number

Game time in hours (0-24); controls the day/night cycle and zombie spawning

if (currentTime >= 19) { /* spawn zombies */ }
touchControls object

State object tracking which touch buttons are pressed (left, right, jump, break); used for Player 1 on mobile

touchControls.left = true; // Left button pressed
isTouchDevice boolean

Flag indicating whether the device supports touch input; controls whether touch UI is drawn

if (isTouchDevice) { drawTouchControls(); }

🔧 Potential Improvements (7)

Here are some ways this code could be enhanced:

PERFORMANCE drawBlockPattern() in drawStaticWorldBuffer()

Uses random() inside the pattern generation loops, which means each tile gets re-randomized every frame when the buffer is redrawn or updated

💡 Pre-calculate patterns using a seeded random based on tile coordinates (e.g., random(x*73 + y*37)) so patterns are consistent and don't flicker

BUG respawnPlayer() spawn location selection

The spawn location search uses a fixed search radius and may fail to find a valid spot on some maps, especially if surrounded by blocks

💡 Increase searchRadius or implement a fallback that searches a larger area, or ensure the original spawn point is always clear

STYLE handleBlockInteraction() break action with SWORD

The sword deals 20 damage via a separate code path, while block breaking reuses the same action parameter—mixing item mechanics with block mechanics is confusing

💡 Create a separate 'attack' action type for the sword, keeping 'break' and 'place' strictly for blocks

FEATURE Zombie spawning and difficulty

Zombies spawn randomly anywhere on the map; players can hide in caves or high platforms indefinitely without combat

💡 Add difficulty scaling: increase MAX_ZOMBIES and ZOMBIE_SPAWN_CHANCE as time progresses, or spawn zombies more frequently near players

PERFORMANCE updateZombies() and updateZombiePhysics()

Each zombie's pathfinding recalculates the distance to every player every frame—O(n*m) complexity where n=zombies, m=players

💡 Cache the nearest player once per frame and only update every few frames, or implement spatial partitioning to avoid checking distant players

BUG Collision detection in handleBlockInteraction() place action

Only checks if the new block overlaps players/zombies, not if the player is standing on that tile—a player can be trapped by placing blocks beneath them

💡 Add additional checks to prevent placing blocks directly on entities, or allow breaking out by placing blocks elsewhere and climbing

STYLE Player respawning respawnPlayer()

Hard-coded spawn locations for each player (originalSpawnX calculation checks player controls)—fragile if the players array order changes

💡 Store each player's original spawn position as a property during initialization, then respawn them there directly

🔄 Code Flow

Code flow showing setup, draw, initworld, handleinput, updateplayerphysics, spawnzombie, updatezombies, handleblockinteraction, drawplayer, updatecamera, mousepressed, drawui

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

graph TD start[Start] --> setup[setup] setup --> canvas-creation[Canvas Setup] setup --> noise-seed[Random Seed for Terrain] setup --> world-init[World Initialization] world-init --> tile-array-init[Empty World Grid Creation] tile-array-init --> perlin-terrain[Perlin Noise Terrain Generation] perlin-terrain --> tree-generation[Random Tree Placement] perlin-terrain --> ore-vein-gen[Ore Vein Generation] perlin-terrain --> water-pool-gen[Water Pool Generation] setup --> player-spawn[Player Spawn Point Calculation] setup --> audio-unlock[Audio Context Initialization] setup --> draw[draw loop] click setup href "#fn-setup" click canvas-creation href "#sub-canvas-creation" click noise-seed href "#sub-noise-seed" click world-init href "#sub-world-init" click tile-array-init href "#sub-tile-array-init" click perlin-terrain href "#sub-perlin-terrain" click tree-generation href "#sub-tree-generation" click ore-vein-gen href "#sub-ore-vein-gen" click water-pool-gen href "#sub-water-pool-gen" click player-spawn href "#sub-player-spawn" click audio-unlock href "#sub-audio-unlock" draw --> time-update[Time Advance] time-update --> day-night-check[Phase Determination] day-night-check --> sky-color-set[Sky Color Logic] draw --> zombie-spawn-check[Zombie Spawning] draw --> entity-update-loop[Entity Rendering] entity-update-loop --> updatezombies[updateZombies] entity-update-loop --> drawplayer[drawPlayer] draw --> handleinput[handleInput] handleinput --> player-loop[Per-Player Input Processing] player-loop --> movement-keys[Horizontal Movement] player-loop --> water-resistance[Water Physics] player-loop --> jump-logic[Jump on Ground] player-loop --> water-jump[Jump in Water] draw --> updateplayerphysics[updatePlayerPhysics] updateplayerphysics --> gravity-application[Gravity Acceleration] gravity-application --> velocity-cap[Terminal Velocity] updateplayerphysics --> water-check[Water Submersion Detection] updateplayerphysics --> horizontal-collision[Horizontal Collision Detection] updateplayerphysics --> vertical-collision[Vertical Collision Detection] draw --> updatecamera[updateCamera] updatecamera --> midpoint-calc[Midpoint Between Players] midpoint-calc --> camera-center-h[Horizontal Camera Centering] camera-center-h --> camera-clamp-h[Horizontal Boundary Clamping] midpoint-calc --> camera-center-v[Vertical Camera Centering] camera-center-v --> camera-clamp-v[Vertical Boundary Clamping] draw --> mousepressed[mousePressed] mousepressed --> touch-check[Touch Device Detection] mousepressed --> hotbar-click-p1[Player 1 Hotbar Click] mousepressed --> hotbar-click-p2[Player 2 Hotbar Click] mousepressed --> break-action[Left Click Break] mousepressed --> place-action[Right Click Place] draw --> drawui[drawUI] drawui --> hotbar-bg[Hotbar Background Panels] drawui --> instructions-panel[Instructions Text Panel] drawui --> time-display[In-Game Time Display] drawui --> health-bar-bg[Health Bar Background] health-bar-bg --> health-loop[Per-Player Health Bar] health-loop --> health-bar-fill[Health Bar Rendering] click draw href "#fn-draw" click time-update href "#sub-time-update" click day-night-check href "#sub-day-night-check" click sky-color-set href "#sub-sky-color-set" click zombie-spawn-check href "#sub-zombie-spawn-check" click entity-update-loop href "#sub-entity-update-loop" click updatezombies href "#fn-updatezombies" click drawplayer href "#fn-drawplayer" click handleinput href "#fn-handleinput" click player-loop href "#sub-player-loop" click movement-keys href "#sub-movement-keys" click water-resistance href "#sub-water-resistance" click jump-logic href "#sub-jump-logic" click water-jump href "#sub-water-jump" click updateplayerphysics href "#fn-updateplayerphysics" click gravity-application href "#sub-gravity-application" click velocity-cap href "#sub-velocity-cap" click water-check href "#sub-water-check" click horizontal-collision href "#sub-horizontal-collision" click vertical-collision href "#sub-vertical-collision" click updatecamera href "#fn-updatecamera" click camera-center-h href "#sub-camera-center-h" click camera-clamp-h href "#sub-camera-clamp-h" click camera-center-v href "#sub-camera-center-v" click camera-clamp-v href "#sub-camera-clamp-v" click mousepressed href "#fn-mousepressed" click touch-check href "#sub-touch-check" click hotbar-click-p1 href "#sub-hotbar-click-p1" click hotbar-click-p2 href "#sub-hotbar-click-p2" click break-action href "#sub-break-action" click place-action href "#sub-place-action" click drawui href "#fn-drawui" click hotbar-bg href "#sub-hotbar-bg" click instructions-panel href "#sub-instructions-panel" click time-display href "#sub-time-display" click health-bar-bg href "#sub-health-bar-bg" click health-loop href "#sub-health-loop" click health-bar-fill href "#sub-health-bar-fill"

❓ Frequently Asked Questions

What visual elements does the Simple MineCraft Cline (Remix) sketch showcase?

This sketch creates a 2D Minecraft-like sandbox environment featuring a grid of colorful tiles representing different blocks such as grass, dirt, stone, and water.

How can users interact with the Simple MineCraft Cline (Remix) sketch?

Users can interact with the sketch by placing and breaking blocks using touch controls, allowing them to manipulate the environment in real-time.

What creative coding concepts are illustrated in the Simple MineCraft Cline (Remix) sketch?

The sketch demonstrates tile-based game design and basic physics, showcasing how to manage block properties and user input in a sandbox-style game.

Preview

Simple MineCraft Cline (Remix) (Remix) - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Simple MineCraft Cline (Remix) (Remix) - Code flow showing setup, draw, initworld, handleinput, updateplayerphysics, spawnzombie, updatezombies, handleblockinteraction, drawplayer, updatecamera, mousepressed, drawui
Code Flow Diagram