fun game

This sketch creates a time-loop puzzle game where the player navigates through a maze while previous actions replay as helpful ghosts. You must use ghostly replays to hold down pressure plates, open doors, dodge lethal lasers, and escape from a chasing evil ghost before each 10-second loop resets.

🧪 Try This!

Experiment with the code by making these changes:

  1. Speed up the countdown — Lower the loop duration to make each loop shorter and the puzzle harder.
  2. Make the player move faster — Higher player speed makes navigation easier and gives you more control.
  3. Slow down the evil ghost — Lower evil ghost speed makes it less of a threat, making the puzzle about doors instead of dodging.
  4. Add more ghosts visually — Increase ghost transparency to see stacked ghosts more clearly where they overlap.
  5. Make the evil ghost brighter pink — Change the evil ghost's color to make it even more visually distinct and threatening.
  6. Disable the R key reset
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch creates a challenging puzzle game built on a core mechanic: every action you take in one loop becomes a ghostly replay in the next loop. Your job is to navigate a maze with locked doors, pressure plates, deadly lasers, and a sinister chasing ghost—and you have only 10 seconds per loop before resetting. The sketch combines arrays of game objects, collision detection, path recording and playback, real-time state management, and interactive movement to create a fully playable game.

The code is organized into setup and draw functions, plus helper functions for movement, collision checking, game logic (door-opening mechanics), and rendering. By studying it, you will learn how to build a multi-system game: recording player actions into arrays, replaying them as independent game entities, synchronizing multiple moving objects, detecting various collision types, and managing game state transitions (playing, winning, losing). It is an excellent example of how simple rules create emergent puzzle-solving gameplay.

⚙️ How It Works

  1. When the sketch loads, setup() creates a full-size canvas, configures a 10-second loop timer, and calls setupLevelLayout() to position all game objects: three pressure plates in offset positions, three locked doors blocking the main path, two lethal lasers in the central corridor, an evil ghost that starts at the top-right, and a glowing yellow exit at the far right.
  2. Every frame in the draw loop, the player's real-time position is recorded into currentPath array, and that same frame index is used to replay all previous-loop ghosts at their recorded positions.
  3. The player moves via arrow keys or WASD. Movement is constrained within canvas bounds and blocked by closed doors; a door opens only if its linked pressure plate is pressed—either by the player directly or by a friendly ghost overlapping it.
  4. The evil ghost continuously calculates the direction toward the player and moves toward them at a fixed speed. If it gets close enough, or if the player touches a pink laser beam, the game state becomes 'LOSE'.
  5. When the 10-second timer reaches zero, startNewLoop() saves the current path as a new ghost and resets the player to the starting position, allowing a fresh attempt with all previous loops replaying as helpful ghosts.
  6. If the player reaches the yellow exit zone, the game state becomes 'WIN'. Pressing R at any time calls resetGame() to wipe all ghosts and restart from loop 1.

🎓 Concepts You'll Learn

Game state managementArray-based path recording and playbackCollision detection (axis-aligned rectangles and circles)Real-time timer and loop mechanicsInteractive keyboard inputMulti-object animation and synchronization

📝 Code Breakdown

setup()

setup() runs once when the sketch loads. It creates the canvas, configures drawing modes, and calls helper functions to prepare the full game state.

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

  setupLevelLayout();
  resetGame();
}
Line-by-line explanation (5 lines)
createCanvas(windowWidth, windowHeight);
Creates a canvas that fills the entire browser window, making the game responsive to screen size
frameRate(60);
Sets the sketch to draw 60 times per second, creating smooth animation and consistent physics
rectMode(CENTER);
Makes all future rectangles draw from their center point instead of top-left, simplifying collision math
setupLevelLayout();
Calls a helper function that positions all game objects (plates, doors, lasers, exit) based on canvas size
resetGame();
Initializes all game variables (player position, ghosts array, timer) and prepares for the first loop

setupLevelLayout()

This function runs during setup and whenever the window resizes. It uses proportional positioning (multiplication by width and height) so all level elements scale responsively with screen size. All game objects are stored as simple objects with x, y, w (width), h (height) properties for easy collision checking.

🔬 This creates the first plate. What happens if you change width * 0.28 to width * 0.50? Where does the first plate move?

  plates.push({
    x: width * 0.28,
    y: midY + verticalOffset,
    w: plateW,
    h: plateH
  });
function setupLevelLayout() {
  const margin = width * 0.08;
  const midY = height * 0.5;

  playerStartX = margin;
  playerStartY = midY;

  plates = [];
  doors = [];
  lasers = [];

  const verticalOffset = height * 0.18;
  const plateW = 70;
  const plateH = 25;

  plates.push({
    x: width * 0.28,
    y: midY + verticalOffset,
    w: plateW,
    h: plateH
  });

  plates.push({
    x: width * 0.50,
    y: midY - verticalOffset,
    w: plateW,
    h: plateH
  });

  plates.push({
    x: width * 0.72,
    y: midY + verticalOffset,
    w: plateW,
    h: plateH
  });

  const doorHeight = height - 80;
  const doorW = 30;

  doors.push({
    x: width * 0.36,
    y: midY,
    w: doorW,
    h: doorHeight,
    plateIndex: 0
  });

  doors.push({
    x: width * 0.60,
    y: midY,
    w: doorW,
    h: doorHeight,
    plateIndex: 1
  });

  doors.push({
    x: width * 0.84,
    y: midY,
    w: doorW,
    h: doorHeight,
    plateIndex: 2
  });

  exitZone = {
    x: width * 0.93,
    y: midY,
    w: 80,
    h: 80
  };

  const laserThickness = 10;
  const laserHeight = height * 0.22;

  lasers.push({
    x: width * 0.44,
    y: midY,
    w: laserThickness,
    h: laserHeight
  });

  lasers.push({
    x: width * 0.68,
    y: midY,
    w: laserThickness,
    h: laserHeight
  });
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

loop Plate initialization plates.push({ x: width * 0.28, y: midY + verticalOffset, w: plateW, h: plateH });

Creates three blue pressure plates at different positions that ghosts and the player can stand on to open doors

loop Door initialization doors.push({ x: width * 0.36, y: midY, w: doorW, h: doorHeight, plateIndex: 0 });

Creates three red locked doors blocking the main path, each linked to one plate by plateIndex

loop Laser initialization lasers.push({ x: width * 0.44, y: midY, w: laserThickness, h: laserHeight });

Creates two pink lethal laser beams that kill the player instantly on contact

const margin = width * 0.08;
Calculates an 8% margin from the left edge, used for the player starting position
const midY = height * 0.5;
Finds the vertical center of the canvas, used to position most game objects along the horizontal main path
playerStartX = margin;
Sets where the player begins each loop—on the left side near the margin
plates = [];
Clears the plates array, ensuring old plates are removed when the level is reset
const verticalOffset = height * 0.18;
Places plates 18% above and below the main path so they are offset to the sides, making the puzzle non-trivial
doors.push({ x: width * 0.36, y: midY, w: doorW, h: doorHeight, plateIndex: 0 });
Creates a door object with a plateIndex property linking it to a specific plate—only this plate can open this door
exitZone = { x: width * 0.93, y: midY, w: 80, h: 80 };
Creates the glowing exit zone at the far right of the canvas—reaching it wins the game

resetGame()

resetGame() is called when the sketch first starts and whenever the player presses R. It initializes all game variables to a known clean state, ensuring that a new attempt starts fresh. It is the definitive reset for the entire game.

function resetGame() {
  ghosts = [];
  currentPath = [];
  currentLoopNumber = 1;
  loopFrame = 0;
  loopStartTime = millis();
  timeLeft = loopDuration;
  gameState = "PLAYING";

  player = {
    x: playerStartX,
    y: playerStartY,
    w: 30,
    h: 30,
    speed: 3.2
  };

  evilGhost = {
    x: width * 0.8,
    y: height * 0.25,
    size: 28,
    speed: 1.6
  };
}
Line-by-line explanation (7 lines)
ghosts = [];
Clears all previous ghosts, ensuring a fresh start with no replays from old attempts
currentPath = [];
Resets the current loop's path array so new player positions will be recorded fresh
currentLoopNumber = 1;
Sets the loop counter back to 1, resetting the displayed loop number in the HUD
loopStartTime = millis();
Records the current millisecond time as the loop start, so the countdown timer resets
gameState = "PLAYING";
Sets the game into the active playing state so the game loop will update and draw normally
player = { x: playerStartX, y: playerStartY, w: 30, h: 30, speed: 3.2 };
Creates the player object with starting position, size (30×30), and movement speed
evilGhost = { x: width * 0.8, y: height * 0.25, size: 28, speed: 1.6 };
Creates the evil chasing ghost at a fixed starting position (upper right) with its own size and pursuit speed

startNewLoop()

startNewLoop() is called automatically from updateGame() when the 10-second timer expires. It converts the current loop's recorded path into a persistent ghost object that will replay every frame forever, increments the loop counter, resets the player, and restarts the timer. This is the core mechanic enabling the puzzle's time-loop gameplay.

function startNewLoop() {
  if (currentPath.length > 5) {
    ghosts.push({
      path: currentPath.slice(),
      color: {
        r: random(80, 255),
        g: random(80, 255),
        b: random(80, 255)
      }
    });
  }

  currentLoopNumber++;
  currentPath = [];
  loopFrame = 0;
  loopStartTime = millis();
  timeLeft = loopDuration;

  player.x = playerStartX;
  player.y = playerStartY;

  evilGhost.x = width * 0.8;
  evilGhost.y = height * 0.25;
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional Ghost spawn check if (currentPath.length > 5) {

Only creates a ghost if the player moved for at least 5 frames, filtering out idle or nearly-idle loops

if (currentPath.length > 5) {
Checks if the player actually moved during this loop—only save a ghost if there are at least 5 recorded positions
path: currentPath.slice(),
Makes a copy of the current path array so changes to currentPath don't affect the saved ghost's path
r: random(80, 255),
Assigns a random red channel between 80 and 255, making each ghost a unique pastel-ish color
currentLoopNumber++;
Increments the loop counter, displayed in the HUD so the player knows which loop they are on
currentPath = [];
Clears the path array to start recording a fresh path for this new loop
loopStartTime = millis();
Records the current time as the start of the new loop so the countdown timer resets to the full duration
player.x = playerStartX;
Resets the player's x position back to the starting location on the left side
evilGhost.x = width * 0.8;
Resets the evil ghost back to the upper right, removing any advantage the player might have had chasing it to a corner

draw()

draw() is the main loop that runs 60 times per second. Each frame it clears the canvas, conditionally updates the game state, renders all game objects from background to HUD, and finally draws overlay screens. The order of drawing matters: background first, then game objects, then HUD on top.

🔬 This conditional prevents updates when the game is won or lost. What happens if you remove this if-statement? Will the evil ghost keep chasing you after you die?

  if (gameState === "PLAYING") {
    updateGame();
  }
function draw() {
  background(15, 18, 30);

  if (gameState === "PLAYING") {
    updateGame();
  }

  drawEnvironment();
  drawGhosts();
  drawEvilGhost();
  drawPlayer();
  drawHUD();

  if (gameState === "WIN") {
    drawWinOverlay();
  } else if (gameState === "LOSE") {
    drawLoseOverlay();
  }
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

conditional Game logic update if (gameState === "PLAYING") { updateGame(); }

Runs all physics, collision, and state logic only when the game is active—pauses updates on win or loss

conditional Overlay rendering if (gameState === "WIN") { drawWinOverlay(); }

Conditionally draws the win or lose screen overlay after all game visuals

background(15, 18, 30);
Clears the canvas with a dark blue-gray color each frame, creating a fresh slate for new drawings
if (gameState === "PLAYING") {
Checks whether the game is currently in an active playing state—skips updates if the player has won or lost
updateGame();
Calls the main update function that handles player movement, collisions, path recording, and state transitions
drawEnvironment();
Draws all static and dynamic level elements (plates, doors, lasers, exit zone, room border)
drawGhosts();
Draws all friendly ghosts at their current replay positions from the currentPath array
drawEvilGhost();
Draws the sinister chasing enemy ghost in bright pink
drawPlayer();
Draws the player character as a tan rectangle
drawHUD();
Draws the heads-up display showing loop number, time remaining, controls, and instructions
if (gameState === "WIN") {
Conditionally overlays the win message if the player reached the exit

updateGame()

updateGame() is the core loop that runs every frame when the game state is PLAYING. It handles real-time calculation of the countdown timer, delegates movement to handleInputAndMovement(), checks multiple win/loss conditions (evil ghost, lasers, exit), records the player's path, and increments the frame counter. It is the most important update function in the sketch.

🔬 These two lines calculate the countdown timer. What happens if you change 1000.0 to 2000.0? Will time count down faster or slower?

  const elapsed = (millis() - loopStartTime) / 1000.0;
  timeLeft = max(0, loopDuration - elapsed);
function updateGame() {
  const elapsed = (millis() - loopStartTime) / 1000.0;
  timeLeft = max(0, loopDuration - elapsed);

  handleInputAndMovement();

  updateEvilGhost();
  if (gameState === "LOSE") {
    return;
  }

  if (playerHitsLaser()) {
    gameState = "LOSE";
    return;
  }

  currentPath[loopFrame] = {
    x: player.x,
    y: player.y
  };

  const playerRect = {
    x: player.x,
    y: player.y,
    w: player.w,
    h: player.h
  };
  if (rectsIntersect(playerRect, exitZone)) {
    gameState = "WIN";
    return;
  }

  if (timeLeft <= 0) {
    startNewLoop();
    return;
  }

  loopFrame++;
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

calculation Loop timer calculation timeLeft = max(0, loopDuration - elapsed);

Calculates real-time countdown based on elapsed milliseconds since loop start

calculation Evil ghost pursuit updateEvilGhost();

Moves the evil ghost toward the player and checks for collision

conditional Laser collision detection if (playerHitsLaser()) { gameState = "LOSE"; }

Instantly ends the game if the player touches any pink laser

calculation Path recording currentPath[loopFrame] = { x: player.x, y: player.y };

Saves the player's current position into the path array for ghost replay

conditional Exit zone collision if (rectsIntersect(playerRect, exitZone)) { gameState = "WIN"; }

Detects when the player reaches the goal and transitions to win state

conditional Loop expiration if (timeLeft <= 0) { startNewLoop(); }

Automatically transitions to the next loop when 10 seconds expire

const elapsed = (millis() - loopStartTime) / 1000.0;
Calculates how many seconds have passed since the loop started by comparing current milliseconds to the start time
timeLeft = max(0, loopDuration - elapsed);
Subtracts elapsed time from the total loop duration; max(0, ...) ensures it never goes negative
handleInputAndMovement();
Processes keyboard input and updates the player's position based on keys pressed
updateEvilGhost();
Moves the evil ghost closer to the player and checks whether it has caught the player
if (gameState === "LOSE") { return; }
Early return if the player died this frame—stops further updates to avoid double-processing loss conditions
if (playerHitsLaser()) { gameState = "LOSE"; return; }
Checks if the player overlaps any laser; if so, sets game state to lose and stops updating
currentPath[loopFrame] = { x: player.x, y: player.y };
Records the player's current position at this frame index so ghosts can replay it in future loops
if (rectsIntersect(playerRect, exitZone)) { gameState = "WIN"; return; }
Detects collision with the exit zone; if touching, sets game state to win and stops updating
if (timeLeft <= 0) { startNewLoop(); return; }
If the countdown reached zero, converts the current path to a ghost and starts a new loop
loopFrame++;
Increments the frame counter within the current loop, used to index into recorded paths for ghosts

handleInputAndMovement()

This function is called every frame from updateGame(). It reads all active keyboard input using keyIsDown(), accumulates velocity, applies that velocity to the player, clamps the player within canvas bounds, and finally checks for collisions with closed doors, reverting movement if a closed door was hit. It combines input handling, physics, and collision response into one unified update.

function handleInputAndMovement() {
  let vx = 0;
  let vy = 0;

  if (keyIsDown(LEFT_ARROW) || keyIsDown(65)) vx -= player.speed;
  if (keyIsDown(RIGHT_ARROW) || keyIsDown(68)) vx += player.speed;
  if (keyIsDown(UP_ARROW) || keyIsDown(87)) vy -= player.speed;
  if (keyIsDown(DOWN_ARROW) || keyIsDown(83)) vy += player.speed;

  const prevX = player.x;
  const prevY = player.y;

  player.x += vx;
  player.y += vy;

  const halfW = player.w * 0.5;
  const halfH = player.h * 0.5;
  player.x = constrain(player.x, halfW, width - halfW);
  player.y = constrain(player.y, halfH, height - halfH);

  const playerRect = {
    x: player.x,
    y: player.y,
    w: player.w,
    h: player.h
  };

  for (let i = 0; i < doors.length; i++) {
    const door = doors[i];
    if (!isDoorOpen(door) && rectsIntersect(playerRect, door)) {
      player.x = prevX;
      player.y = prevY;
      break;
    }
  }
}
Line-by-line explanation (13 lines)

🔧 Subcomponents:

conditional Keyboard input handling if (keyIsDown(LEFT_ARROW) || keyIsDown(65)) vx -= player.speed;

Checks for left/right and up/down arrow keys or WASD and accumulates velocity in vx and vy

calculation Canvas boundary constraint player.x = constrain(player.x, halfW, width - halfW);

Clamps the player's position to keep them inside the canvas boundaries

for-loop Door collision loop for (let i = 0; i < doors.length; i++) {

Checks every door to see if the player collided with a closed one; if so, reverts movement

let vx = 0;
Initializes horizontal velocity to zero; will be modified by arrow key input
let vy = 0;
Initializes vertical velocity to zero; will be modified by arrow key input
if (keyIsDown(LEFT_ARROW) || keyIsDown(65)) vx -= player.speed;
Checks for left arrow or 'A' key and moves left by subtracting the player's speed from vx
if (keyIsDown(RIGHT_ARROW) || keyIsDown(68)) vx += player.speed;
Checks for right arrow or 'D' key and moves right by adding the player's speed to vx
if (keyIsDown(UP_ARROW) || keyIsDown(87)) vy -= player.speed;
Checks for up arrow or 'W' key and moves up by subtracting speed from vy (negative is up in p5.js)
if (keyIsDown(DOWN_ARROW) || keyIsDown(83)) vy += player.speed;
Checks for down arrow or 'S' key and moves down by adding speed to vy
const prevX = player.x;
Saves the player's old x position before updating, so we can revert if movement is blocked
player.x += vx;
Updates the player's x position by the velocity amount calculated from input
player.y += vy;
Updates the player's y position by the velocity amount calculated from input
player.x = constrain(player.x, halfW, width - halfW);
Clamps x to stay between the left and right edges of the canvas
player.y = constrain(player.y, halfH, height - halfH);
Clamps y to stay between the top and bottom edges of the canvas
if (!isDoorOpen(door) && rectsIntersect(playerRect, door)) {
Checks if the player collided with a closed door; if so, reverts the move
player.x = prevX;
Resets player x to the previous position, blocking the movement into the closed door

updateEvilGhost()

This function is called every frame from updateGame() when the game is active. It calculates a pursuit vector from the evil ghost toward the player, moves the ghost a fixed distance per frame in that direction, constrains the ghost within the canvas, and finally checks for collision using circular collision detection. The normalized direction vector ensures the ghost always moves at the same speed regardless of distance.

🔬 This code moves the ghost toward the player. What happens if you remove the 'else' block so the ghost always moves in fixed steps even when very close? Will it catch the player more easily?

  if (d > step) {
      evilGhost.x += (dx / d) * step;
      evilGhost.y += (dy / d) * step;
    } else {
      evilGhost.x = player.x;
      evilGhost.y = player.y;
    }
function updateEvilGhost() {
  if (!evilGhost || !player) return;

  const dx = player.x - evilGhost.x;
  const dy = player.y - evilGhost.y;
  const distSq = dx * dx + dy * dy;

  if (distSq > 0.0001) {
    const d = sqrt(distSq);
    const step = evilGhost.speed;

    if (d > step) {
      evilGhost.x += (dx / d) * step;
      evilGhost.y += (dy / d) * step;
    } else {
      evilGhost.x = player.x;
      evilGhost.y = player.y;
    }
  }

  const halfSize = evilGhost.size * 0.5;
  evilGhost.x = constrain(evilGhost.x, halfSize + 20, width - halfSize - 20);
  evilGhost.y = constrain(evilGhost.y, halfSize + 20, height - halfSize - 20);

  const dToPlayer = dist(evilGhost.x, evilGhost.y, player.x, player.y);
  const playerRadius = player.w * 0.5;
  const evilRadius = evilGhost.size * 0.5;

  if (dToPlayer < playerRadius + evilRadius) {
    gameState = "LOSE";
  }
}
Line-by-line explanation (12 lines)

🔧 Subcomponents:

calculation Chase vector calculation const dx = player.x - evilGhost.x;

Calculates the direction vector from evil ghost to player using displacement

conditional Distance-based movement if (d > step) {

Moves the ghost a fixed step amount toward the player unless already very close

calculation Evil ghost boundary constraint evilGhost.x = constrain(evilGhost.x, halfSize + 20, width - halfSize - 20);

Clamps the evil ghost inside the canvas boundaries with a small margin

conditional Evil ghost collision with player if (dToPlayer < playerRadius + evilRadius) {

Detects when the two circular objects touch and ends the game

if (!evilGhost || !player) return;
Safety check: if either object doesn't exist, exit the function immediately
const dx = player.x - evilGhost.x;
Calculates the x component of the displacement vector from ghost to player
const dy = player.y - evilGhost.y;
Calculates the y component of the displacement vector from ghost to player
const distSq = dx * dx + dy * dy;
Computes squared distance to avoid expensive square root; used for quick distance comparisons
if (distSq > 0.0001) {
Only calculates movement if the ghost is not already nearly on top of the player
const d = sqrt(distSq);
Takes the square root to get actual distance, used for normalizing the direction vector
const step = evilGhost.speed;
Retrieves the ghost's movement speed—how many pixels it moves per frame toward the player
if (d > step) {
If the distance is greater than one step, move a fixed step amount closer; otherwise snap to player
evilGhost.x += (dx / d) * step;
Moves the ghost by dividing displacement by distance (normalizing) and multiplying by step speed
const dToPlayer = dist(evilGhost.x, evilGhost.y, player.x, player.y);
Calculates actual Euclidean distance from ghost center to player center for collision detection
const playerRadius = player.w * 0.5;
Treats the player as a circle with radius equal to half its width
if (dToPlayer < playerRadius + evilRadius) {
Collision detected: if distance is less than the sum of radii, the two circles overlap

playerHitsLaser()

This helper function is called from updateGame() every frame. It returns true only if the player rectangle overlaps any laser rectangle. It uses the same rectsIntersect() collision function as door checking, ensuring consistent collision detection across the game.

function playerHitsLaser() {
  const playerRect = {
    x: player.x,
    y: player.y,
    w: player.w,
    h: player.h
  };

  for (let i = 0; i < lasers.length; i++) {
    const laser = lasers[i];
    if (rectsIntersect(playerRect, laser)) {
      return true;
    }
  }
  return false;
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

for-loop Laser collision loop for (let i = 0; i < lasers.length; i++) {

Checks every laser in the lasers array to see if the player overlaps with any

const playerRect = {
Creates a temporary rectangle object representing the player's current collision bounds
for (let i = 0; i < lasers.length; i++) {
Loops through every laser in the lasers array
if (rectsIntersect(playerRect, laser)) {
Calls the collision detection helper to check if the player rectangle overlaps the current laser
return true;
If any collision is found, immediately returns true, ending the function
return false;
If the loop completes without finding any collision, returns false

isDoorOpen()

This is a simple query function that returns whether a given door is currently open. A door is open if and only if its linked plate (by index) is currently pressed by either the player or a friendly ghost. It is called from handleInputAndMovement() to block movement through closed doors.

function isDoorOpen(door) {
  if (!door) return false;
  const plate = plates[door.plateIndex];
  if (!plate) return false;
  return isPlatePressed(plate);
}
Line-by-line explanation (4 lines)
if (!door) return false;
Safety check: if the door object doesn't exist, return false so no open door is assumed
const plate = plates[door.plateIndex];
Looks up the plate associated with this door by indexing into the plates array
if (!plate) return false;
Safety check: if the plate doesn't exist, return false
return isPlatePressed(plate);
Delegates to isPlatePressed() to check if the plate is currently being stood on by player or ghost

isPlatePressed()

This function checks whether a given plate is currently being pressed. A plate is pressed if either the player is standing on it or any friendly ghost has its recorded position overlapping it at the current frame. It is called from isDoorOpen(), which is called from both door rendering and movement blocking. This function is the linchpin of the puzzle mechanic: ghosts pressing plates = doors opening.

function isPlatePressed(plateRect) {
  const playerRect = {
    x: player.x,
    y: player.y,
    w: player.w,
    h: player.h
  };
  if (rectsIntersect(playerRect, plateRect)) {
    return true;
  }

  for (let i = 0; i < ghosts.length; i++) {
    const ghost = ghosts[i];
    const pos = ghost.path[loopFrame];
    if (!pos) continue;

    const ghostRect = {
      x: pos.x,
      y: pos.y,
      w: player.w,
      h: player.h
    };
    if (rectsIntersect(ghostRect, plateRect)) {
      return true;
    }
  }
  return false;
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional Player on plate check if (rectsIntersect(playerRect, plateRect)) {

Checks if the player's rectangle overlaps the plate

for-loop Ghost on plate check loop for (let i = 0; i < ghosts.length; i++) {

Loops through all friendly ghosts and checks if any ghost overlaps the plate at the current frame

const playerRect = {
Creates a temporary rectangle representing the player's current collision bounds
if (rectsIntersect(playerRect, plateRect)) {
Checks if the player is touching the plate; if so, the plate is pressed
return true;
Early return if player is pressing the plate—no need to check ghosts
for (let i = 0; i < ghosts.length; i++) {
Loops through every friendly ghost to see if any are standing on the plate
const pos = ghost.path[loopFrame];
Retrieves the ghost's position at the current frame index from its recorded path
if (!pos) continue;
Skips this ghost if it doesn't have a recorded position for this frame (path too short)
const ghostRect = { x: pos.x, y: pos.y, w: player.w, h: player.h };
Creates a rectangle for the ghost using its recorded position and the same size as the player
if (rectsIntersect(ghostRect, plateRect)) {
Checks if the ghost rectangle overlaps the plate; if so, the plate is pressed by a ghost

rectsIntersect()

This is a fundamental axis-aligned bounding box (AABB) collision detection function used throughout the game. It assumes both objects have center-based coordinates (due to rectMode(CENTER)) and checks whether they overlap in both x and y axes. Multiply by 2 because we're comparing full widths/heights to the distance, which is from center to center.

function rectsIntersect(a, b) {
  return (
    Math.abs(a.x - b.x) * 2 < (a.w + b.w) &&
    Math.abs(a.y - b.y) * 2 < (a.h + b.h)
  );
}
Line-by-line explanation (2 lines)
Math.abs(a.x - b.x) * 2 < (a.w + b.w) &&
Checks horizontal overlap: distance between centers × 2 must be less than the sum of widths
Math.abs(a.y - b.y) * 2 < (a.h + b.h)
Checks vertical overlap: distance between centers × 2 must be less than the sum of heights

drawEnvironment()

drawEnvironment() is called every frame from draw() and renders all level geometry: the room border, all three plates (with dynamic color based on pressed state), all three doors (with dynamic color based on open state), both lasers, and the exit zone. It uses push/pop to locally change drawing styles for each element.

function drawEnvironment() {
  push();
  noFill();
  stroke(80);
  strokeWeight(3);
  rect(width * 0.5, height * 0.5, width - 40, height - 40);
  pop();

  for (let i = 0; i < plates.length; i++) {
    const plate = plates[i];
    push();
    noStroke();
    if (isPlatePressed(plate)) {
      fill(80, 180, 255);
    } else {
      fill(40, 90, 160);
    }
    rect(plate.x, plate.y, plate.w, plate.h, 8);
    pop();
  }

  for (let i = 0; i < doors.length; i++) {
    const door = doors[i];
    push();
    noStroke();
    if (isDoorOpen(door)) {
      fill(60, 200, 120, 170);
    } else {
      fill(180, 50, 50);
    }
    rect(door.x, door.y, door.w, door.h, 4);
    pop();
  }

  for (let i = 0; i < lasers.length; i++) {
    const laser = lasers[i];
    push();
    noStroke();
    fill(255, 80, 180, 220);
    rect(laser.x, laser.y, laser.w, laser.h, 4);
    pop();
  }

  push();
  noStroke();
  fill(255, 220, 80);
  rect(exitZone.x, exitZone.y, exitZone.w, exitZone.h, 12);

  fill(40);
  textAlign(CENTER, CENTER);
  textSize(16);
  text("EXIT", exitZone.x, exitZone.y);
  pop();
}
Line-by-line explanation (13 lines)

🔧 Subcomponents:

calculation Room border rendering rect(width * 0.5, height * 0.5, width - 40, height - 40);

Draws a dark gray rectangle outlining the boundaries of the playable area

for-loop Plate rendering loop for (let i = 0; i < plates.length; i++) {

Draws each plate, changing color based on whether it is currently pressed

for-loop Door rendering loop for (let i = 0; i < doors.length; i++) {

Draws each door, changing color and transparency based on whether it is open

for-loop Laser rendering loop for (let i = 0; i < lasers.length; i++) {

Draws each pink laser beam

push();
Saves the current drawing style so changes don't affect other drawing functions
noFill();
Disables fill so the border is drawn as an outline only
stroke(80);
Sets stroke color to a dark gray
strokeWeight(3);
Sets the border thickness to 3 pixels
if (isPlatePressed(plate)) {
Checks if the current plate is being pressed by checking player and ghost overlap
fill(80, 180, 255);
Bright blue when plate is pressed, providing visual feedback that it is active
fill(40, 90, 160);
Darker blue when plate is not pressed, showing it is inactive
if (isDoorOpen(door)) {
Checks whether the door's linked plate is being pressed
fill(60, 200, 120, 170);
Green with transparency when open, showing the door is passable
fill(180, 50, 50);
Red when closed, warning the player that the door is impassable
fill(255, 80, 180, 220);
Bright pink for lasers, instantly recognizable as a hazard
fill(255, 220, 80);
Golden yellow for the exit zone, visually distinctive and inviting
text("EXIT", exitZone.x, exitZone.y);
Draws the word 'EXIT' centered in the exit zone for clarity

drawGhosts()

drawGhosts() is called every frame from draw(). It loops through all saved ghosts and draws each one at its position in the recorded path, using the current frame index to look up where that ghost should be. Semi-transparent rendering prevents ghosts from completely obscuring the level or player.

function drawGhosts() {
  for (let i = 0; i < ghosts.length; i++) {
    const ghost = ghosts[i];
    const pos = ghost.path[loopFrame];
    if (!pos) continue;

    push();
    noStroke();
    fill(ghost.color.r, ghost.color.g, ghost.color.b, 120);
    const size = player.w * 0.9;
    ellipse(pos.x, pos.y, size, size);
    pop();
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

for-loop Ghost rendering loop for (let i = 0; i < ghosts.length; i++) {

Draws each friendly ghost at its current frame position using a unique random color

for (let i = 0; i < ghosts.length; i++) {
Loops through every ghost in the ghosts array
const pos = ghost.path[loopFrame];
Retrieves the ghost's position from its recorded path at the current frame index
if (!pos) continue;
Skips drawing this ghost if it has no position recorded for this frame (path too short)
fill(ghost.color.r, ghost.color.g, ghost.color.b, 120);
Sets fill to the ghost's unique random color with 120 alpha for semi-transparency
const size = player.w * 0.9;
Makes the ghost slightly smaller than the player (90% width) for visual distinction
ellipse(pos.x, pos.y, size, size);
Draws a circle at the ghost's current position with the calculated size

drawEvilGhost()

drawEvilGhost() is called every frame from draw(). It simply renders the evil ghost as a bright pink circle at its current position. The distinct color (bright pinkish-red vs. pastel random colors for friendly ghosts) makes it clear to the player which ghost is dangerous.

function drawEvilGhost() {
  if (!evilGhost) return;

  push();
  noStroke();
  fill(255, 70, 110, 230);
  ellipse(evilGhost.x, evilGhost.y, evilGhost.size, evilGhost.size);
  pop();
}
Line-by-line explanation (3 lines)
if (!evilGhost) return;
Safety check: if the evil ghost doesn't exist, exit immediately
fill(255, 70, 110, 230);
Sets color to bright pinkish-red with high alpha, making it visually distinct from friendly ghosts
ellipse(evilGhost.x, evilGhost.y, evilGhost.size, evilGhost.size);
Draws the evil ghost as a circle at its current position using its size property

drawPlayer()

drawPlayer() is called every frame from draw(). It renders the player as a rounded tan rectangle at their current position. The warm color and distinct shape make the player instantly recognizable among all other game elements.

function drawPlayer() {
  if (!player) return;

  push();
  noStroke();
  fill(255, 240, 180);
  rect(player.x, player.y, player.w, player.h, 8);
  pop();
}
Line-by-line explanation (3 lines)
if (!player) return;
Safety check: if the player doesn't exist, exit immediately
fill(255, 240, 180);
Sets fill to a warm tan/beige color, visually distinct from all other objects
rect(player.x, player.y, player.w, player.h, 8);
Draws the player as a rounded rectangle (8 is the corner rounding radius) at the current position

drawHUD()

drawHUD() is called every frame from draw() and renders the heads-up display in the top corners. The left side shows real-time stats (loop, time, ghost count) while the right side shows controls and objectives. It uses textAlign() to position text from different anchor points.

function drawHUD() {
  push();
  fill(255);
  noStroke();
  textSize(16);

  textAlign(LEFT, TOP);
  text("Loop: " + currentLoopNumber, 20, 20);
  text("Time left: " + nf(timeLeft, 1, 1) + "s", 20, 40);
  text("Ghosts: " + ghosts.length, 20, 60);

  textAlign(RIGHT, TOP);
  textSize(14);
  text(
    "Move: arrows / WASD\n" +
      "Every 10s, a ghost repeats your last path.\n" +
      "Use ghosts to hold BLUE plates and open RED doors.\n" +
      "Avoid the EVIL ghost and the PINK LASERS.\n" +
      "Reach the yellow EXIT.\n" +
      "Press R to restart.",
    width - 20,
    20
  );
  pop();
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

calculation Game statistics display text("Loop: " + currentLoopNumber, 20, 20);

Shows the current loop number and remaining time in the HUD

calculation Instructions text text("Move: arrows / WASD\n...", width - 20, 20);

Displays game controls and objectives to the player

fill(255);
Sets text color to white for high contrast against the dark background
textSize(16);
Sets the font size for the main stats display
textAlign(LEFT, TOP);
Aligns text to the top-left, making it easy to position in the upper-left corner
text("Loop: " + currentLoopNumber, 20, 20);
Displays the current loop number at coordinates (20, 20)
text("Time left: " + nf(timeLeft, 1, 1) + "s", 20, 40);
Displays the countdown timer formatted to 1 decimal place at (20, 40)
text("Ghosts: " + ghosts.length, 20, 60);
Shows how many saved ghosts are currently replaying at (20, 60)
textAlign(RIGHT, TOP);
Switches alignment to top-right for the instructions block
textSize(14);
Sets a slightly smaller font size for the instructions

drawWinOverlay()

drawWinOverlay() is called from draw() only when gameState is "WIN". It draws a darkening overlay and displays a centered victory message. The push/pop ensures the rectMode change doesn't affect other drawing functions.

function drawWinOverlay() {
  push();
  rectMode(CORNER);
  noStroke();
  fill(0, 180);
  rect(0, 0, width, height);

  fill(255);
  textAlign(CENTER, CENTER);
  textSize(28);
  text(
    "You escaped the time loop!\nPress R to play again.",
    width * 0.5,
    height * 0.5
  );
  pop();
}
Line-by-line explanation (5 lines)
rectMode(CORNER);
Switches to corner-based rect mode (different from CENTER used in the rest of the game)
fill(0, 180);
Sets a semi-transparent dark overlay (RGB 0,0,0 with alpha 180) that darkens the entire screen
rect(0, 0, width, height);
Draws the overlay rectangle covering the entire canvas from corner to corner
fill(255);
Sets text color to white for contrast
text("You escaped the time loop!\nPress R to play again.", width * 0.5, height * 0.5);
Displays the victory message centered on the screen

drawLoseOverlay()

drawLoseOverlay() is called from draw() only when gameState is "LOSE". It is nearly identical to drawWinOverlay() but uses a reddish text color and a loss message to clearly communicate failure. Like the win overlay, it darkens the entire screen with a modal background.

function drawLoseOverlay() {
  push();
  rectMode(CORNER);
  noStroke();
  fill(0, 200);
  rect(0, 0, width, height);

  fill(255, 120, 120);
  textAlign(CENTER, CENTER);
  textSize(28);
  text(
    "You died!\n(The evil ghost or a laser got you.)\nPress R to try again.",
    width * 0.5,
    height * 0.5
  );
  pop();
}
Line-by-line explanation (3 lines)
fill(0, 200);
Sets a semi-transparent dark overlay (slightly more opaque than the win screen)
fill(255, 120, 120);
Sets text color to reddish (high red, moderate green and blue) for a loss/warning feel
text("You died!\n(The evil ghost or a laser got you.)\nPress R to try again.", width * 0.5, height * 0.5);
Displays the loss message with explanation centered on the screen

keyPressed()

keyPressed() is a p5.js built-in function called once every time a key is pressed (not held). It checks whether the R key was pressed and resets the game if so. This allows the player to restart at any time without reloading the page.

function keyPressed() {
  if (key === "r" || key === "R") {
    resetGame();
  }
}
Line-by-line explanation (2 lines)
if (key === "r" || key === "R") {
Checks whether the pressed key is lowercase r or uppercase R
resetGame();
Calls resetGame() to wipe all state and restart from the first loop

windowResized()

windowResized() is a p5.js built-in function called whenever the browser window is resized. It ensures the canvas and all level geometry scale responsively. This makes the game work smoothly on any screen size.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  setupLevelLayout();
  resetGame();
}
Line-by-line explanation (3 lines)
resizeCanvas(windowWidth, windowHeight);
Resizes the p5.js canvas to match the new window dimensions
setupLevelLayout();
Recalculates positions and sizes of all level objects based on the new canvas size
resetGame();
Resets all game state so the player starts fresh in the new window size

📦 Key Variables

player object

Stores the player character's position (x, y), size (w, h), and movement speed. Updated every frame based on input.

let player = { x: 100, y: 200, w: 30, h: 30, speed: 3.2 };
playerStartX, playerStartY number

Global variables storing the player's starting position. Used to reset the player at the beginning of each loop.

let playerStartX = margin; let playerStartY = midY;
ghosts array

Array of ghost objects. Each ghost has a recorded path (array of positions) and a unique color. Ghosts replay every frame.

let ghosts = []; // Later: ghosts.push({ path: [...], color: {r, g, b} });
currentPath array

Array of positions recorded during the current loop. When time runs out, this becomes a new ghost.

let currentPath = []; // Later: currentPath[0] = {x, y};
loopDuration number

The number of seconds per loop (10). Controls how long the player has before resetting.

let loopDuration = 10;
loopStartTime number

Millisecond timestamp when the current loop began. Used to calculate the countdown timer.

let loopStartTime = millis();
loopFrame number

Frame index within the current loop (0 to ~600 at 60fps). Used to index into ghost paths.

let loopFrame = 0;
currentLoopNumber number

Counter tracking which loop the player is on. Displayed in the HUD.

let currentLoopNumber = 1;
timeLeft number

Remaining seconds in the current loop. Calculated real-time from loopStartTime.

let timeLeft = loopDuration;
gameState string

Current game state: 'PLAYING', 'WIN', or 'LOSE'. Controls which update and overlay functions run.

let gameState = 'PLAYING';
plates array

Array of pressure plate objects. Each plate has position (x, y) and size (w, h). Player and ghosts pressing plates open doors.

let plates = []; // Later: plates.push({ x: ..., y: ..., w: 70, h: 25 });
doors array

Array of door objects. Each door has position, size, and a plateIndex linking it to a plate. Closed doors block movement.

let doors = []; // Later: doors.push({ x: ..., y: ..., w: 30, h: doorHeight, plateIndex: 0 });
exitZone object

The goal area at the far right. Reaching it transitions gameState to WIN.

let exitZone = { x: ..., y: ..., w: 80, h: 80 };
lasers array

Array of lethal laser objects. Touching any laser transitions gameState to LOSE.

let lasers = []; // Later: lasers.push({ x: ..., y: ..., w: 10, h: ... });
evilGhost object

The sinister enemy ghost that chases the player at a fixed speed. Collision ends the game.

let evilGhost = { x: width * 0.8, y: height * 0.25, size: 28, speed: 1.6 };

🔧 Potential Improvements (6)

Here are some ways this code could be enhanced:

BUG startNewLoop()

Ghost paths may not sync with the current frame count if a loop is very long. If currentPath has 1000 frames but loopFrame is 500 when trying to access ghost.path[loopFrame], it will be undefined.

💡 Add a bounds check in drawGhosts() or ensure all recorded paths are padded to the same length when saved. Or better: record the elapsed time instead of frame count, making ghosts frame-rate independent.

PERFORMANCE isPlatePressed()

This function is called multiple times per frame (once per plate, once per door check) and loops through all ghosts every time, leading to O(ghosts × plates × doors) calculations.

💡 Cache the result of isPlatePressed() at the start of updateGame(), storing which plates are pressed that frame. Reuse the cache throughout the frame.

FEATURE Game mechanics

Currently, the evil ghost is purely a threat with no puzzle element. All challenge is from the ghost's speed.

💡 Add a mechanic where the ghost must also press plates or obstacles, forcing the player to think strategically about the evil ghost's path, not just avoid it.

STYLE setupLevelLayout()

Hard-coded positions like width * 0.28, width * 0.50, width * 0.72 are magic numbers scattered throughout. If you want to add more plates or change the layout, it's tedious.

💡 Create a level data structure (array of plate positions, laser positions, etc.) and iterate through it in setupLevelLayout(). This makes the level easier to design and modify.

BUG updateEvilGhost()

The evil ghost can move between pixels in fractional steps, but collision detection uses integer-based rectangles. This can cause jittering at frame boundaries.

💡 Round evilGhost.x and evilGhost.y to integers each frame, or use pixel-perfect collision detection that matches the ghost's actual rendering.

FEATURE Draw loop

The game has no difficulty progression. The player always faces the same loop duration, ghost speed, and obstacles in every attempt.

💡 Add progressive difficulty: increase loop speed after N attempts, add new lasers or shrink the time limit, or introduce bonus mechanics for using fewer loops to solve the puzzle.

🔄 Code Flow

Code flow showing setup, setuplevellayout, resetgame, startnewloop, draw, updategame, handleinputandmovement, updateevilghost, playerhistlaser, isdooropen, isplatepressed, rectsintersect, drawenvironment, drawghosts, drawevilghost, drawplayer, drawhud, drawwinoverlay, drawloseoverlay, keypressed, windowresized

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

graph TD start[Start] --> setup[setup] setup --> setuplevellayout[setuplevellayout] setup --> resetgame[resetgame] setup --> draw[draw loop] draw --> game-update[game-update] game-update --> timer-calc[timer-calc] game-update --> keyboard-input[keyboard-input] game-update --> canvas-bounds[canvas-bounds] game-update --> door-collision[door-collision] game-update --> evil-ghost-update[evil-ghost-update] game-update --> laser-check[laser-check] game-update --> path-record[path-record] game-update --> exit-check[exit-check] game-update --> loop-timer[loop-timer] draw --> overlay-draw[overlay-draw] overlay-draw --> drawwinoverlay[drawwinoverlay] overlay-draw --> drawloseoverlay[drawloseoverlay] draw --> drawenvironment[drawenvironment] draw --> drawghosts[drawghosts] draw --> drawevilghost[drawevilghost] draw --> drawplayer[drawplayer] draw --> drawhud[drawhud] click setup href "#fn-setup" click setuplevellayout href "#fn-setuplevellayout" click resetgame href "#fn-resetgame" click draw href "#fn-draw" click game-update href "#sub-game-update" click timer-calc href "#sub-timer-calc" click keyboard-input href "#sub-keyboard-input" click canvas-bounds href "#sub-canvas-bounds" click door-collision href "#sub-door-collision" click evil-ghost-update href "#sub-evil-ghost-update" click laser-check href "#sub-laser-check" click path-record href "#sub-path-record" click exit-check href "#sub-exit-check" click loop-timer href "#sub-loop-timer" click overlay-draw href "#sub-overlay-draw" click drawwinoverlay href "#fn-drawwinoverlay" click drawloseoverlay href "#fn-drawloseoverlay" click drawenvironment href "#fn-drawenvironment" click drawghosts href "#fn-drawghosts" click drawevilghost href "#fn-drawevilghost" click drawplayer href "#fn-drawplayer" click drawhud href "#fn-drawhud"

❓ Frequently Asked Questions

What visual experience does the fun game sketch offer?

The sketch creates a minimalist maze environment filled with glowing ghosts, lethal lasers, and a vibrant exit zone, all designed to enhance the player's experience as they navigate through the time-loop challenge.

How can players interact with the fun game in this p5.js sketch?

Players control their character to dash through the maze, avoiding deadly lasers and an evil ghost, while utilizing past runs represented as glowing ghosts to hold down pressure plates and open doors.

What creative coding concepts are showcased in the fun game sketch?

This sketch demonstrates the use of time-loop mechanics, where previous player actions are replayed as ghosts, and incorporates game elements like pressure plates, chasers, and collision detection.

Preview

fun game - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of fun game - Code flow showing setup, setuplevellayout, resetgame, startnewloop, draw, updategame, handleinputandmovement, updateevilghost, playerhistlaser, isdooropen, isplatepressed, rectsintersect, drawenvironment, drawghosts, drawevilghost, drawplayer, drawhud, drawwinoverlay, drawloseoverlay, keypressed, windowresized
Code Flow Diagram