loadrunner

This sketch recreates the classic arcade game Lode Runner: a player runs, climbs ladders, collects gold, and digs holes to trap chasing enemies, all rendered with hand-drawn retro pixel-art tiles and synthesized 8-bit sound effects. It also ships a built-in ASCII level editor so players can design and instantly play their own levels.

🧪 Try This!

Experiment with the code by making these changes:

  1. Speed up the player — MOVE_SPEED controls how fast the sliding animation between tiles progresses, so raising it makes the player noticeably snappier.
  2. Make enemies much faster — ENEMY_SPEED controls how quickly enemies slide between tiles, so raising it turns a leisurely chase into a frantic one.
  3. Change the background mood — background() at the top of draw() sets the color behind everything else drawn that frame, instantly changing the whole game's atmosphere.
  4. Make holes stay open much longer — HOLE_DURATION controls how many frames a dug hole remains open before resealing, giving you far more time to lure and trap enemies.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch is a mobile-friendly Lode Runner clone built entirely in p5.js: a runner climbs ladders, grabs glowing gold coins, and digs temporary holes in brick walls to trap red enemies chasing him. Visually it mixes procedurally drawn brick, ladder, and gold tiles with a simple state machine that drives a full mini-game loop of lives, score, a timer, and win/game-over screens. Under the hood it leans on p5.js fundamentals like a 2D grid stored in an array, frame-by-frame position interpolation with lerp() for smooth sliding movement, keyIsDown()/touches for cross-platform input, and the raw Web Audio API for chiptune-style sound instead of p5.sound.

The code is organized into clear sections: constants and level data at the top, entity 'factory' functions that create plain JavaScript objects for the player and enemies, movement and collision helper functions that query the tile grid, a family of small drawX() functions that render each tile type and character, and finally setup()/draw() that tie everything together every frame. Studying it teaches you how to represent a game world as a 2D array, how to animate grid-based movement smoothly instead of teleporting tile-to-tile, how simple 'if standing on X' rules can produce convincing gravity and ladder-climbing, and how to layer keyboard, mouse, and touch input into one unified control scheme.

⚙️ How It Works

  1. When the page loads, setup() creates a full-window canvas, computes how big each grid tile should be to fit the screen, builds the on-screen D-pad and dig buttons, wires up the hidden DOM level editor, and calls startNewGame() to parse the ASCII levelTemplate into a live 2D array of tiles plus player/enemy objects.
  2. Every frame, draw() clears the background, reads keyboard/touch/mouse input into a shared controls object, and - if the game is in the 'playing' state - updates hole timers, moves the player, moves every enemy, and checks for player-enemy collisions.
  3. Player and enemy movement is grid-based but visually smooth: instead of jumping instantly from tile to tile, each character stores a 'from' and 'to' tile plus a progress value that increases every frame, and getInterpolatedPosition() uses lerp() to blend between those two tiles for the actual pixel drawing position.
  4. Gravity and ladders are implemented with simple tile lookups: shouldFallFrom() checks the tile directly below a character and forces a fall if it isn't a brick or ladder, while isOnLadder() and canMoveVerticalPlayer() only allow vertical movement when the current or destination tile is a ladder ('H').
  5. Digging works by turning a solid brick below-and-beside the player into a temporary TILE_HOLE for HOLE_DURATION frames; updateHoles() counts each hole down and, when it expires, either kills the player or respawns any enemy still trapped inside before turning the tile back into solid brick.
  6. The whole level grid and every character are redrawn each frame with dedicated drawX() functions (drawBrickCell, drawLadderCell, drawGoldCell, drawPlayer, drawEnemies), and drawHUD() overlays score, lives, a timer, win/game-over text, and an EDIT button that opens a DOM-based textarea overlay for pasting a brand-new ASCII level.

🎓 Concepts You'll Learn

2D grid/array-based level representationInterpolated (lerp) movement animationState machines (gameState, editorVisible)Unified keyboard/mouse/touch input handlingWeb Audio API oscillators and envelopesObject factories vs. classes for entitiesDOM overlay UI alongside a p5.js canvas

📝 Code Breakdown

initAudioContext()

Browsers require audio to be initialized after a user gesture (click/tap/key press), which is why this is only ever called from ensureAudioContext(), itself only triggered from input handlers.

function initAudioContext() {
  const AudioCtx = window.AudioContext || window.webkitAudioContext;
  if (!AudioCtx) {
    console.warn('Web Audio API not supported in this browser.');
    return;
  }
  if (!audioCtx) {
    audioCtx = new AudioCtx();
    masterGain = audioCtx.createGain();
    masterGain.gain.value = 0.35; // global volume
    masterGain.connect(audioCtx.destination);
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

conditional Browser Support Check if (!AudioCtx) {

Bails out gracefully if the browser has no Web Audio API

conditional Lazy Creation Guard if (!audioCtx) {

Only creates the audio context once, avoiding duplicate contexts

const AudioCtx = window.AudioContext || window.webkitAudioContext;
Grabs whichever version of the AudioContext constructor the browser supports (Safari historically needed the webkit-prefixed version).
if (!AudioCtx) { ... return; }
If neither exists, the browser can't play synthesized sound, so the function exits early instead of crashing.
audioCtx = new AudioCtx();
Creates the single audio context used for all sound generation in the game.
masterGain = audioCtx.createGain();
Creates one shared volume control node that every sound effect will route through.
masterGain.gain.value = 0.35;
Sets the overall game volume to 35% so effects aren't too loud.
masterGain.connect(audioCtx.destination);
Connects the volume node to the speakers so any sound routed into it is actually audible.

ensureAudioContext()

This is the function actually called from keyPressed(), mousePressed(), and touchStarted() so that audio is unlocked as soon as the player interacts with the page.

function ensureAudioContext() {
  if (!audioCtx) {
    initAudioContext();
  }
  if (audioCtx && audioCtx.state === 'suspended') {
    audioCtx.resume();
  }
}
Line-by-line explanation (2 lines)
if (!audioCtx) { initAudioContext(); }
Creates the audio context the first time this is called, if it doesn't exist yet.
if (audioCtx && audioCtx.state === 'suspended') { audioCtx.resume(); }
Browsers auto-suspend audio contexts until user interaction; this resumes it so sounds can actually play.

playTone()

playTone() is the single building block every sound effect (dig, gold, death, win) is made from by calling it multiple times with different frequencies and timings to create tiny melodies.

🔬 This envelope makes every sound fade out. What happens if you make attack much larger, like 0.2, so notes fade IN slowly instead of popping on instantly?

  gain.gain.setValueAtTime(0, startTime);
  gain.gain.linearRampToValueAtTime(volume, startTime + attack);
  gain.gain.exponentialRampToValueAtTime(0.001, endTime);
function playTone(freq, startOffset, duration, type = 'square', volume = 1) {
  if (!audioCtx || audioCtx.state !== 'running') return;

  const osc = audioCtx.createOscillator();
  const gain = audioCtx.createGain();

  osc.type = type;
  osc.frequency.value = freq;

  const now = audioCtx.currentTime;
  const startTime = now + (startOffset || 0);
  const attack = 0.01;
  const endTime = startTime + duration;

  gain.gain.setValueAtTime(0, startTime);
  gain.gain.linearRampToValueAtTime(volume, startTime + attack);
  gain.gain.exponentialRampToValueAtTime(0.001, endTime);

  osc.connect(gain);
  gain.connect(masterGain);

  osc.start(startTime);
  osc.stop(endTime + 0.05);
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

calculation Volume Envelope gain.gain.linearRampToValueAtTime(volume, startTime + attack);

Fades the tone in quickly then decays it exponentially so notes don't click or drone forever

if (!audioCtx || audioCtx.state !== 'running') return;
Skips playing anything if audio isn't ready, preventing errors.
const osc = audioCtx.createOscillator();
Creates a single oscillator node - the actual sound-wave generator.
osc.type = type;
Chooses the waveform shape ('square', 'triangle', 'sawtooth') which gives each sound its distinct retro timbre.
osc.frequency.value = freq;
Sets the pitch of the tone in Hertz.
const startTime = now + (startOffset || 0);
Lets callers schedule a tone to start slightly in the future, useful for chaining notes into a melody.
gain.gain.setValueAtTime(0, startTime); ... exponentialRampToValueAtTime(0.001, endTime);
Builds a volume envelope: silent, then a quick rise (attack), then an exponential fade to near-zero, which is what makes the beep sound like a plucked note instead of a harsh buzz.
osc.connect(gain); gain.connect(masterGain);
Wires the oscillator through its own volume envelope and then into the shared master volume.
osc.start(startTime); osc.stop(endTime + 0.05);
Schedules the oscillator to start and automatically stop itself, so nodes don't pile up and leak memory.

playDigSound()

Combining two quick playTone() calls with different frequencies and start offsets is the recipe used for every sound effect in this game.

function playDigSound() {
  ensureAudioContext();
  if (!audioCtx || audioCtx.state !== 'running') return;
  // Fast descending blip
  playTone(900, 0.00, 0.045, 'square', 0.55);
  playTone(650, 0.04, 0.045, 'square', 0.5);
}
Line-by-line explanation (2 lines)
playTone(900, 0.00, 0.045, 'square', 0.55);
Plays a short high-pitched square-wave blip immediately.
playTone(650, 0.04, 0.045, 'square', 0.5);
Plays a slightly lower blip 0.04 seconds later, creating a fast descending two-note dig sound.

playGoldSound()

Called from handlePlayerLanding() whenever the player steps onto a gold tile.

function playGoldSound() {
  ensureAudioContext();
  if (!audioCtx || audioCtx.state !== 'running') return;
  // Small ascending sparkle
  playTone(650, 0.00, 0.06, 'triangle', 0.55);
  playTone(950, 0.05, 0.06, 'triangle', 0.55);
  playTone(1300,0.10, 0.10, 'triangle', 0.6);
}
Line-by-line explanation (3 lines)
playTone(650, 0.00, 0.06, 'triangle', 0.55);
First, lowest note of the sparkle.
playTone(950, 0.05, 0.06, 'triangle', 0.55);
Second, higher note played slightly after the first.
playTone(1300,0.10, 0.10, 'triangle', 0.6);
Third, highest note, finishing the rising 'ta-da' sparkle when gold is collected.

playDeathSound()

Called from killPlayer() every time an enemy catches the player or a hole closes on top of them.

function playDeathSound() {
  ensureAudioContext();
  if (!audioCtx || audioCtx.state !== 'running') return;
  // Descending "fail" sweep
  playTone(700, 0.00, 0.18, 'sawtooth', 0.5);
  playTone(450, 0.14, 0.20, 'sawtooth', 0.45);
  playTone(260, 0.28, 0.24, 'square',   0.4);
}
Line-by-line explanation (3 lines)
playTone(700, 0.00, 0.18, 'sawtooth', 0.5);
Starts with a harsh, higher sawtooth note - sawtooth waves sound edgier than square or triangle.
playTone(450, 0.14, 0.20, 'sawtooth', 0.45);
Drops to a lower note shortly after.
playTone(260, 0.28, 0.24, 'square', 0.4);
Finishes on the lowest, longest note for a 'failure' feeling, switching to a square wave for a duller thud.

playWinSound()

Called once, exactly when goldCollected reaches goldTotal and gameState switches to 'won'.

function playWinSound() {
  ensureAudioContext();
  if (!audioCtx || audioCtx.state !== 'running') return;
  // Short victory arpeggio (C major)
  playTone(523.25, 0.00, 0.16, 'triangle', 0.45); // C5
  playTone(659.25, 0.08, 0.16, 'triangle', 0.45); // E5
  playTone(783.99, 0.16, 0.20, 'triangle', 0.5);  // G5
}
Line-by-line explanation (3 lines)
playTone(523.25, 0.00, 0.16, 'triangle', 0.45); // C5
Plays the musical note C5 - the exact frequencies here form a real C major chord arpeggio.
playTone(659.25, 0.08, 0.16, 'triangle', 0.45); // E5
Plays E5 shortly after, the second note of the chord.
playTone(783.99, 0.16, 0.20, 'triangle', 0.5); // G5
Plays G5 last and slightly louder/longer, completing an upbeat victory jingle.

UIButton (class)

This is a small reusable UI class - a good example of using an ES6 class instead of a plain object when you need both data (position/state) and behavior (contains, draw) bundled together.

class UIButton {
  constructor(x, y, w, h, label, type) {
    this.x = x;
    this.y = y;
    this.w = w;
    this.h = h;
    this.label = label;
    this.type = type;        // 'left','right','up','down','digLeft','digRight'
    this.isPressed = false;
    this.wasPressed = false;
  }

  contains(px, py) {
    return px >= this.x && px <= this.x + this.w &&
           py >= this.y && py <= this.y + this.h;
  }

  draw() {
    push();
    rectMode(CORNER);
    textAlign(CENTER, CENTER);
    textSize(this.h * 0.5);
    noStroke();

    const bg = this.isPressed ? color(50, 120, 255, 230) : color(20, 60, 160, 210);
    fill(bg);
    rect(this.x, this.y, this.w, this.h, 12);

    fill(255);
    text(this.label, this.x + this.w / 2, this.y + this.h / 2);
    pop();
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Rectangle Hit Test return px >= this.x && px <= this.x + this.w && py >= this.y && py <= this.y + this.h;

Checks whether a point (finger or mouse) falls inside the button's rectangle

conditional Pressed Color Swap const bg = this.isPressed ? color(50, 120, 255, 230) : color(20, 60, 160, 210);

Lights the button up brighter blue while it's actively being touched

constructor(x, y, w, h, label, type) { ... }
Stores the button's position, size, text label, and a type string ('left', 'digLeft', etc.) used later to decide what action it triggers.
this.isPressed = false; this.wasPressed = false;
Tracks current and previous frame's pressed state, which lets the game detect the exact moment a button is first pressed (an 'edge').
contains(px, py) { ... }
A simple point-in-rectangle test used to check if a touch or mouse point overlaps this button.
push(); ... pop();
Isolates all the style changes (fill, stroke, text settings) inside draw() so they don't leak out and affect other drawing later in the frame.
const bg = this.isPressed ? color(50, 120, 255, 230) : color(20, 60, 160, 210);
Picks a brighter highlighted color when pressed versus a duller default color.
rect(this.x, this.y, this.w, this.h, 12);
Draws the rounded-rectangle button background; the last argument is the corner radius.
text(this.label, this.x + this.w / 2, this.y + this.h / 2);
Draws the button's label (like ◀ or 'L') centered inside it.

setupLevelEditorUI()

This function shows how p5.js can mix canvas drawing with regular DOM elements (createDiv, createElement, createButton) for things like text input that are awkward to build by hand on a canvas.

function setupLevelEditorUI() {
  // Overlay
  editorOverlay = createDiv();
  editorOverlay.addClass('editor-overlay');

  // Panel
  const panel = createDiv();
  panel.addClass('editor-panel');
  panel.parent(editorOverlay);

  const title = createElement('h2', 'Level Editor');
  title.parent(panel);

  const info = createP(
    `Paste ${ROWS} lines of ${COLS} characters.\n` +
    'Use: # = brick, H = ladder, . = gold, S = player, E = enemy, _ = empty.'
  );
  info.parent(panel);

  editorTextarea = createElement('textarea');
  editorTextarea.addClass('editor-textarea');
  editorTextarea.parent(panel);

  editorError = createDiv('');
  editorError.addClass('editor-error');
  editorError.parent(panel);

  const btnRow = createDiv();
  btnRow.addClass('editor-buttons');
  btnRow.parent(panel);

  const applyBtn = createButton('Apply & Restart');
  applyBtn.addClass('editor-button');
  applyBtn.addClass('primary');
  applyBtn.parent(btnRow);
  applyBtn.mousePressed(applyEditorLevel);

  const cancelBtn = createButton('Cancel');
  cancelBtn.addClass('editor-button');
  cancelBtn.parent(btnRow);
  cancelBtn.mousePressed(hideEditor);

  // Start hidden (CSS sets display:none)
  editorVisible = false;
}
Line-by-line explanation (7 lines)
editorOverlay = createDiv();
Uses p5.js DOM functions to create an actual HTML <div> element (not canvas drawing) that will cover the whole screen.
editorOverlay.addClass('editor-overlay');
Applies the CSS class from style.css that positions and styles the overlay as a fullscreen dark backdrop.
panel.parent(editorOverlay);
Nests the inner panel div inside the overlay div so it's centered on screen.
editorTextarea = createElement('textarea');
Creates the actual text box where players type or paste their ASCII level design.
applyBtn.mousePressed(applyEditorLevel);
Wires the Apply button's click event directly to the applyEditorLevel() function, which validates and loads the new level.
cancelBtn.mousePressed(hideEditor);
Wires the Cancel button to simply close the overlay without changing anything.
editorVisible = false;
Ensures the game knows the editor starts closed, matching the CSS's default display:none.

showEditor()

Triggered by tapping the EDIT button drawn in drawHUD().

function showEditor() {
  if (!editorOverlay) return;
  editorTextarea.value(levelTemplate.join('\n'));
  editorError.html('');
  editorOverlay.style('display', 'flex');
  editorVisible = true;
}
Line-by-line explanation (3 lines)
editorTextarea.value(levelTemplate.join('\n'));
Fills the textarea with the current level's ASCII rows joined into one multi-line string, so players start editing from the live level.
editorOverlay.style('display', 'flex');
Reveals the overlay by switching its CSS display property from 'none' to 'flex'.
editorVisible = true;
Flags the game state so the main draw loop and input handlers know to pause gameplay while editing.

hideEditor()

Called both by the Cancel button and automatically after a successful Apply.

function hideEditor() {
  if (!editorOverlay) return;
  editorOverlay.style('display', 'none');
  editorVisible = false;
}
Line-by-line explanation (2 lines)
editorOverlay.style('display', 'none');
Hides the overlay div again.
editorVisible = false;
Lets gameplay and input resume as normal.

applyEditorLevel()

This function is a great example of defensive input validation: it checks length, characters, and game-logic requirements (a player start) before ever touching the real game state, and reports exactly what's wrong at each failure point.

🔬 This is the whitelist of legal level symbols. What happens if you add a new letter, like 'G', to this string - could you invent a brand-new tile type?

  if (!'#H.SE_'.includes(ch)) { // allowed: #, H, ., S, E, _
        editorError.html(`Invalid character "${ch}" at row ${r + 1}, col ${c + 1}.`);
        return;
      }
function applyEditorLevel() {
  const raw = editorTextarea.value();
  const lines = raw
    .replace(/\r/g, '')
    .split('\n')
    .filter(l => l.trim().length > 0);

  if (lines.length !== ROWS) {
    editorError.html(`Expected ${ROWS} lines, got ${lines.length}.`);
    return;
  }

  for (let r = 0; r < ROWS; r++) {
    const line = lines[r];
    if (line.length !== COLS) {
      editorError.html(`Line ${r + 1} has ${line.length} characters; expected ${COLS}.`);
      return;
    }
    for (let c = 0; c < COLS; c++) {
      const ch = line[c];
      if (!'#H.SE_'.includes(ch)) { // allowed: #, H, ., S, E, _
        editorError.html(`Invalid character "${ch}" at row ${r + 1}, col ${c + 1}.`);
        return;
      }
    }
  }

  // Require at least one player start
  let playerCount = 0;
  for (const line of lines) {
    for (const ch of line) {
      if (ch === 'S') playerCount++;
    }
  }
  if (playerCount === 0) {
    editorError.html('Level needs at least one S (player start).');
    return;
  }

  // Update levelTemplate in place (it's a const array)
  for (let i = 0; i < ROWS; i++) {
    levelTemplate[i] = lines[i];
  }

  hideEditor();
  startNewGame(); // treat edited level as a fresh game
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional Row Count Validation if (lines.length !== ROWS) {

Rejects the pasted text if it doesn't have exactly ROWS lines

for-loop Character Validation Loop for (let c = 0; c < COLS; c++) {

Checks every character in every row is one of the allowed tile symbols

for-loop Player Start Counter for (const line of lines) {

Counts how many 'S' player-start symbols exist to make sure there's at least one

const raw = editorTextarea.value();
Reads whatever text is currently typed in the textarea.
.replace(/\r/g, '').split('\n').filter(l => l.trim().length > 0);
Cleans up Windows-style line endings, splits the text into an array of lines, and drops any blank lines.
if (lines.length !== ROWS) { ... return; }
Refuses to continue if the pasted level doesn't have exactly 14 rows, showing a helpful error message instead.
if (line.length !== COLS) { ... return; }
Checks each row is exactly 20 characters wide, since the grid is fixed-size.
if (!'#H.SE_'.includes(ch)) { ... return; }
Makes sure every character is one of the six legal symbols; anything else (like a typo) stops validation with a precise row/column error.
if (playerCount === 0) { ... return; }
Guarantees the level is actually playable by requiring at least one player spawn point.
for (let i = 0; i < ROWS; i++) { levelTemplate[i] = lines[i]; }
Overwrites the existing template rows in place, since levelTemplate is declared const (the array reference can't change, but its contents can).
hideEditor(); startNewGame();
Closes the editor and rebuilds the whole game world from the new template, as if starting fresh.

getEditButtonRect()

Centralizing this calculation in one function means the button's clickable area (isOverEditButton) always matches exactly where it's drawn (drawHUD), avoiding a common bug where hitboxes drift out of sync with visuals.

function getEditButtonRect() {
  const margin = 10;
  const w = max(70, width * 0.18);
  const h = 26;
  const x = width - margin - w;
  const y = margin;
  return { x, y, w, h };
}
Line-by-line explanation (3 lines)
const w = max(70, width * 0.18);
Makes the button at least 70px wide, but scales up to 18% of the screen width on bigger screens.
const x = width - margin - w;
Positions the button flush against the right edge of the screen, minus a small margin.
return { x, y, w, h };
Returns a plain object describing the rectangle, so both drawing and click-detection code can reuse the exact same numbers.

isOverEditButton()

Called from mousePressed() and touchStarted() to detect taps on the EDIT button before falling through to normal gameplay input handling.

function isOverEditButton(px, py) {
  const b = getEditButtonRect();
  return px >= b.x && px <= b.x + b.w &&
         py >= b.y && py <= b.y + b.h;
}
Line-by-line explanation (2 lines)
const b = getEditButtonRect();
Reuses the same rectangle calculation used for drawing, guaranteeing consistency.
return px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h;
Standard point-in-rectangle test to see if a click or tap landed on the EDIT button.

setup()

setup() runs exactly once when the sketch starts. It's the natural place to size the canvas and build anything that depends on that size, before the animation loop (draw) begins.

function setup() {
  createCanvas(windowWidth, windowHeight); // https://p5js.org/reference/#/p5/createCanvas
  pixelDensity(1);                         // better performance on high‑DPI phones
  textFont('sans-serif');

  computeLayout();
  setupControls();
  setupLevelEditorUI();
  startNewGame();
}
Line-by-line explanation (7 lines)
createCanvas(windowWidth, windowHeight);
Creates a canvas that fills the entire browser window, key for a mobile-friendly full-screen game.
pixelDensity(1);
Forces p5.js to render at 1 pixel per canvas unit instead of matching the device's high-DPI 'retina' pixel ratio, which keeps the frame rate smooth on phones.
textFont('sans-serif');
Sets the default font used for all HUD and button text.
computeLayout();
Calculates tileSize and offsets so the 20x14 grid fits nicely inside whatever screen size was just created.
setupControls();
Builds the on-screen D-pad and dig buttons, sized and positioned relative to the current screen.
setupLevelEditorUI();
Creates the hidden DOM-based level editor overlay so it's ready to show later.
startNewGame();
Resets lives/score and builds the very first playable level from the template.

windowResized()

Handling windowResized() is essential for any responsive p5.js game so rotating a phone or resizing a browser window doesn't leave the layout broken.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight); // https://p5js.org/reference/#/p5/resizeCanvas
  computeLayout();
  setupControls();
}
Line-by-line explanation (3 lines)
resizeCanvas(windowWidth, windowHeight);
A built-in p5.js callback that fires automatically whenever the browser window changes size; this resizes the canvas to match.
computeLayout();
Recomputes tileSize and offsets so the grid still fits nicely at the new size.
setupControls();
Rebuilds the on-screen buttons at positions appropriate for the new screen size.

computeLayout()

Every drawing function in this game multiplies grid coordinates by tileSize and adds offsetX/offsetY, so this one function controls the scale and centering of the entire visual world.

function computeLayout() {
  tileSize = floor(min(width / COLS, height / ROWS));
  tileSize = max(tileSize, 18); // minimum tile size

  const worldW = tileSize * COLS;
  const worldH = tileSize * ROWS;

  offsetX = (width - worldW) / 2;
  offsetY = (height - worldH) / 2;
}
Line-by-line explanation (3 lines)
tileSize = floor(min(width / COLS, height / ROWS));
Picks the largest square tile size that lets the whole 20x14 grid fit inside the current screen, using whichever dimension (width or height) is more restrictive.
tileSize = max(tileSize, 18);
Enforces a minimum tile size so the game doesn't become unplayably tiny on very small screens.
offsetX = (width - worldW) / 2; offsetY = (height - worldH) / 2;
Centers the grid horizontally and vertically by calculating the leftover space on each side.

setupControls()

This function demonstrates instantiating multiple objects from the same class (UIButton) with different constructor arguments to build a whole UI layout.

function setupControls() {
  buttons = [];

  const sBase = min(width, height) * 0.13;
  const s = constrain(sBase, 44, 110);
  const margin = 16;

  // D‑pad near bottom-left
  const padCX = margin + s * 1.2;
  const padCY = height - margin - s * 1.2;

  btnLeft  = new UIButton(padCX - s, padCY,       s, s, "◀", "left");
  btnRight = new UIButton(padCX + s, padCY,       s, s, "▶", "right");
  btnUp    = new UIButton(padCX,     padCY - s,   s, s, "▲", "up");
  btnDown  = new UIButton(padCX,     padCY + s,   s, s, "▼", "down");

  // Dig buttons at bottom-right
  const digY = height - margin - s;
  const digRightX = width - margin - s;
  const digLeftX  = digRightX - s * 1.35;

  btnDigLeft  = new UIButton(digLeftX,  digY, s, s, "L", "digLeft");
  btnDigRight = new UIButton(digRightX, digY, s, s, "R", "digRight");

  buttons = [btnLeft, btnRight, btnUp, btnDown, btnDigLeft, btnDigRight];
}
Line-by-line explanation (5 lines)
const s = constrain(sBase, 44, 110);
Calculates a button size proportional to the screen, but clamped between 44 and 110 pixels so buttons are never too small to tap or too huge.
const padCX = margin + s * 1.2; const padCY = height - margin - s * 1.2;
Chooses the center point of the D-pad cluster, positioned near the bottom-left corner.
btnLeft = new UIButton(padCX - s, padCY, s, s, "◀", "left");
Creates the left arrow button using the UIButton class, positioned to the left of the D-pad center.
const digLeftX = digRightX - s * 1.35;
Places the left-dig button just to the left of the right-dig button with a small gap between them.
buttons = [btnLeft, btnRight, btnUp, btnDown, btnDigLeft, btnDigRight];
Collects all six buttons into one array so input handling and drawing code can just loop over 'buttons' instead of listing each one individually.

startNewGame()

Keeping startNewGame() (full reset) separate from resetGame() (level rebuild only) lets the code reuse resetGame() logic without accidentally wiping score/lives on every simple restart.

function startNewGame() {
  lives = START_LIVES;
  score = 0;
  resetGame();
}
Line-by-line explanation (3 lines)
lives = START_LIVES;
Resets the player's lives to the starting amount (3).
score = 0;
Resets the score counter to zero, since this is a brand-new run, not just a respawn.
resetGame();
Delegates to resetGame() to rebuild the actual level, player, and enemies.

resetGame()

This function is the bridge between the human-readable ASCII levelTemplate and the actual runtime data structures (a 2D tile array plus player/enemy objects) the rest of the game operates on.

🔬 This is where template characters turn into game objects. What happens if you push a SECOND enemy every time you see an 'E' (spawning two enemies per E marker)?

      if (ch === 'S') {
        player = createPlayer(c, r);
        ch = TILE_EMPTY;
      } else if (ch === 'E') {
        enemies.push(createEnemy(c, r));
        ch = TILE_EMPTY;
      } else if (ch === TILE_GOLD) {
        goldTotal++;
      }
function resetGame() {
  holes = [];
  level = [];
  enemies = [];
  goldTotal = 0;
  goldCollected = 0;
  gameState = 'playing';
  levelStartFrame = frameCount;
  lastLevelTimeSec = 0;

  for (let r = 0; r < ROWS; r++) {
    const rowStr = levelTemplate[r];
    const row = [];
    for (let c = 0; c < COLS; c++) {
      let ch = rowStr.charAt(c);
      if (ch === '_') ch = TILE_EMPTY;

      if (ch === 'S') {
        player = createPlayer(c, r);
        ch = TILE_EMPTY;
      } else if (ch === 'E') {
        enemies.push(createEnemy(c, r));
        ch = TILE_EMPTY;
      } else if (ch === TILE_GOLD) {
        goldTotal++;
      }

      row.push(ch);
    }
    level.push(row);
  }
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

for-loop Grid Building Loop for (let r = 0; r < ROWS; r++) {

Walks every row and column of the ASCII template to build the live 2D 'level' array

conditional Character Dispatch if (ch === 'S') {

Turns special characters (S, E, .) into actual game objects/counters instead of leaving them as plain tiles

holes = []; level = []; enemies = [];
Clears out any leftover state from a previous level before rebuilding everything fresh.
levelStartFrame = frameCount;
Records the current frame number as 'time zero' for this level, used later to calculate the elapsed play time.
for (let r = 0; r < ROWS; r++) { ... }
Loops through each of the 14 template rows to convert the ASCII strings into a real 2D array.
let ch = rowStr.charAt(c);
Reads the character at this exact grid position from the ASCII template string.
if (ch === '_') ch = TILE_EMPTY;
Converts the human-readable underscore placeholder into the internal empty-tile character (a space).
if (ch === 'S') { player = createPlayer(c, r); ch = TILE_EMPTY; }
When an 'S' is found, it doesn't become a tile at all - instead it spawns the player object at that grid position, and the tile underneath becomes empty.
} else if (ch === 'E') { enemies.push(createEnemy(c, r)); ch = TILE_EMPTY; }
Similarly, 'E' spawns an enemy object and leaves an empty tile behind.
} else if (ch === TILE_GOLD) { goldTotal++; }
Counts up the total gold pieces in the level so the HUD can show 'collected / total' and detect the win condition.
row.push(ch); ... level.push(row);
Builds up each row array and then the full 2D level array, one cell and one row at a time.

createPlayer()

This is a plain object 'factory' function rather than a class - a lightweight pattern for creating consistently-shaped game entities without the ceremony of a class.

function createPlayer(c, r) {
  return {
    col: c,
    row: r,
    startCol: c,
    startRow: r,
    fromCol: c,
    fromRow: r,
    toCol: c,
    toRow: r,
    progress: 0,
    isMoving: false
  };
}
Line-by-line explanation (4 lines)
col: c, row: r,
The player's current logical grid position (which tile they occupy).
startCol: c, startRow: r,
Remembers the original spawn point so killPlayer() can respawn the player there after losing a life.
fromCol: c, fromRow: r, toCol: c, toRow: r, progress: 0,
These four fields together drive the smooth sliding animation: 'from' and 'to' are the tiles being interpolated between, and progress (0 to 1) is how far along that slide the player currently is.
isMoving: false
A flag that's true while the player is actively sliding between two tiles, false when resting on a tile waiting for input.

createEnemy()

Notice this shares almost the same shape as createPlayer() but adds two AI-specific fields - a common pattern when two entity types share most behavior but diverge slightly.

function createEnemy(c, r) {
  return {
    col: c,
    row: r,
    startCol: c,
    startRow: r,
    fromCol: c,
    fromRow: r,
    toCol: c,
    toRow: r,
    progress: 0,
    isMoving: false,
    trapped: false,
    moveCooldown: 0
  };
}
Line-by-line explanation (2 lines)
trapped: false
Extra field (beyond the player's) marking whether this enemy has fallen into a dug hole and is stuck until it closes.
moveCooldown: 0
Extra field that makes the enemy pause for ENEMY_PAUSE frames after each move, giving the AI a believable 'thinking' delay rather than moving every single frame.

tileAt()

Almost every other game-logic function (movement, gravity, digging, drawing) calls tileAt() instead of reading the level array directly, which centralizes the out-of-bounds safety check in one place.

function tileAt(c, r) {
  if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return TILE_BLOCK;
  return level[r][c];
}
Line-by-line explanation (2 lines)
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return TILE_BLOCK;
If asked about a tile outside the grid, pretends it's a solid brick wall - this stops the player or enemies from ever walking off the edge of the level.
return level[r][c];
Otherwise looks up and returns the actual character stored in the 2D level array at that row and column.

isOnLadder()

Small single-purpose helpers like this make the rest of the codebase read almost like plain English, e.g. 'if (isOnLadder(...))'.

function isOnLadder(c, r) {
  return tileAt(c, r) === TILE_LADDER;
}
Line-by-line explanation (1 lines)
return tileAt(c, r) === TILE_LADDER;
A tiny, readable helper that answers 'is this exact tile a ladder?' - used throughout movement and AI logic instead of repeating the comparison everywhere.

shouldFallFrom()

This one boolean check is the entire 'physics engine' for gravity in this game - proof that convincing platformer physics doesn't require a full physics library.

function shouldFallFrom(c, r) {
  const below = tileAt(c, r + 1);
  // standing on block or ladder = supported
  return !(below === TILE_BLOCK || below === TILE_LADDER);
}
Line-by-line explanation (2 lines)
const below = tileAt(c, r + 1);
Looks at the tile directly one row below the given position.
return !(below === TILE_BLOCK || below === TILE_LADDER);
Gravity should only pull the character down if there's nothing solid supporting them - bricks and ladders both count as support, anything else (empty space or a hole) means they fall.

keyPressed()

p5.js automatically calls keyPressed() once every time any key goes down, as opposed to keyIsDown() which you must poll every frame - this function is used for one-shot discrete actions like digging and restarting.

function keyPressed() {
  if (editorVisible) return; // ignore game keys while editing

  // Unlock audio on first key interaction
  ensureAudioContext();

  // Dig keys (discrete actions)
  if (key === 'z' || key === 'Z') tryDig('left');
  if (key === 'x' || key === 'X') tryDig('right');

  // Restart (full new game)
  if (key === 'r' || key === 'R') {
    startNewGame();
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Editor Focus Guard if (editorVisible) return; // ignore game keys while editing

Prevents game shortcuts from firing while typing in the level editor textarea

if (editorVisible) return;
Stops the function immediately if the level editor is open, so typing 'r' in the textarea doesn't accidentally restart the game.
ensureAudioContext();
Unlocks/creates the Web Audio context on the very first key press, satisfying the browser's requirement that audio starts from a user gesture.
if (key === 'z' || key === 'Z') tryDig('left');
The Z key digs a hole to the player's left, checking both lowercase and uppercase in case Caps Lock is on.
if (key === 'x' || key === 'X') tryDig('right');
The X key digs to the right.
if (key === 'r' || key === 'R') { startNewGame(); }
The R key fully restarts the game (resets lives and score), usable any time including after winning or losing.

mousePressed()

p5.js calls mousePressed() automatically on desktop click events; this game pairs it with touchStarted() below so the same actions work identically on mobile.

function mousePressed() {
  if (editorVisible) return; // clicks handled by DOM overlay

  // Unlock audio on mouse
  ensureAudioContext();

  if (isOverEditButton(mouseX, mouseY)) {
    showEditor();
    return;
  }

  if (gameState === 'won' || gameState === 'gameover') {
    startNewGame();
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Edit Button Click if (isOverEditButton(mouseX, mouseY)) {

Opens the level editor if the click landed on the EDIT button

conditional Restart on End Screen if (gameState === 'won' || gameState === 'gameover') {

Lets a click anywhere restart the game once it has ended

if (editorVisible) return;
Ignores canvas clicks while the DOM editor overlay is open, since the overlay has its own buttons.
if (isOverEditButton(mouseX, mouseY)) { showEditor(); return; }
Checks if the click was on the EDIT button and, if so, opens the editor instead of doing anything else.
if (gameState === 'won' || gameState === 'gameover') { startNewGame(); }
On the win or game-over screens, any click anywhere on the canvas restarts a fresh game.

touchStarted()

Handling both mousePressed() and touchStarted() with nearly identical logic is the standard p5.js pattern for building a sketch that works the same on desktop and mobile.

function touchStarted() {
  if (editorVisible) {
    // Let the browser handle touches for the textarea/buttons
    return;
  }

  // Unlock audio on touch
  ensureAudioContext();

  const x = touches.length > 0 ? touches[0].x : mouseX;
  const y = touches.length > 0 ? touches[0].y : mouseY;

  if (isOverEditButton(x, y)) {
    showEditor();
    return false;
  }

  if (gameState === 'won' || gameState === 'gameover') {
    startNewGame();
  }

  // Prevent scrolling on mobile while playing
  return false;
}
Line-by-line explanation (4 lines)
if (editorVisible) { return; }
Lets normal browser touch behavior work inside the DOM editor overlay (so you can actually tap into the textarea).
const x = touches.length > 0 ? touches[0].x : mouseX;
p5.js exposes an array of active touch points; this grabs the first touch's x coordinate, falling back to mouseX if somehow no touch is registered.
if (isOverEditButton(x, y)) { showEditor(); return false; }
Same EDIT button check as mousePressed(), but using touch coordinates.
return false;
Returning false from touchStarted() tells the browser to prevent its default touch behavior (like scrolling or zooming), which is essential for a playable full-screen mobile game.

updateInputFromPointers()

This function is the single place where keyboard, mouse, and touch are unified into one simple 'controls' object that the rest of the game logic reads - a clean pattern for building cross-platform input in any p5.js game.

🔬 This edge-detection stops digging from repeating every frame. What happens if you remove the '&& !b.wasPressed' condition so digging fires continuously while held?

    if (b.type === 'digLeft' && b.isPressed && !b.wasPressed) {
      tryDig('left');
    }
    if (b.type === 'digRight' && b.isPressed && !b.wasPressed) {
      tryDig('right');
    }
function updateInputFromPointers() {
  // --- Keyboard state via keyIsDown (fixes "stuck" keys) ---
  const kbLeft  = keyIsDown(LEFT_ARROW)  || keyIsDown(65); // A
  const kbRight = keyIsDown(RIGHT_ARROW) || keyIsDown(68); // D
  const kbUp    = keyIsDown(UP_ARROW)    || keyIsDown(87); // W
  const kbDown  = keyIsDown(DOWN_ARROW)  || keyIsDown(83); // S

  let pointerLeft = false;
  let pointerRight = false;
  let pointerUp = false;
  let pointerDown = false;

  // Reset pressed flags; keep previous in .wasPressed
  for (const b of buttons) {
    b.wasPressed = b.isPressed;
    b.isPressed = false;
  }

  // Collect active pointers: touches or mouse
  const points = [];

  if (touches.length > 0) {
    for (const t of touches) {
      points.push({ x: t.x, y: t.y });
    }
  } else if (mouseIsPressed) {
    points.push({ x: mouseX, y: mouseY });
  }

  for (const p of points) {
    for (const b of buttons) {
      if (b.contains(p.x, p.y)) {
        b.isPressed = true;
      }
    }
  }

  // Map button presses to control booleans and dig actions
  for (const b of buttons) {
    if (b.type === 'left'  && b.isPressed) pointerLeft  = true;
    if (b.type === 'right' && b.isPressed) pointerRight = true;
    if (b.type === 'up'    && b.isPressed) pointerUp    = true;
    if (b.type === 'down'  && b.isPressed) pointerDown  = true;

    // On "edge" press, trigger dig
    if (b.type === 'digLeft' && b.isPressed && !b.wasPressed) {
      tryDig('left');
    }
    if (b.type === 'digRight' && b.isPressed && !b.wasPressed) {
      tryDig('right');
    }
  }

  // Final control state = OR of keyboard + touch/mouse
  controls.left  = kbLeft  || pointerLeft;
  controls.right = kbRight || pointerRight;
  controls.up    = kbUp    || pointerUp;
  controls.down  = kbDown  || pointerDown;
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

calculation Keyboard State Read const kbLeft = keyIsDown(LEFT_ARROW) || keyIsDown(65); // A

Polls current keyboard state every frame, supporting both arrow keys and WASD

conditional Touch vs Mouse Collection if (touches.length > 0) {

Prefers touch points when available, falling back to mouse for desktop

for-loop Button Hit Testing for (const p of points) {

Checks every active pointer against every button to mark which buttons are currently pressed

conditional Edge-Triggered Dig if (b.type === 'digLeft' && b.isPressed && !b.wasPressed) {

Fires the dig action only once per press instead of every frame the button is held down

const kbLeft = keyIsDown(LEFT_ARROW) || keyIsDown(65); // A
Checks every frame whether the left arrow OR the 'A' key is currently held down, using keyIsDown() which (unlike keyPressed()) works well for continuous movement.
for (const b of buttons) { b.wasPressed = b.isPressed; b.isPressed = false; }
Before checking this frame's touches, saves each button's last-known state into wasPressed and resets isPressed, so 'was it just pressed this frame' can be detected later.
if (touches.length > 0) { ... } else if (mouseIsPressed) { ... }
Builds a list of active pointer positions - all active touches if on a touchscreen, otherwise the mouse position if a button is held.
if (b.contains(p.x, p.y)) { b.isPressed = true; }
Marks a button as pressed if any active pointer currently overlaps it, supporting multi-touch (e.g. moving and digging at the same time).
if (b.type === 'digLeft' && b.isPressed && !b.wasPressed) { tryDig('left'); }
Only calls tryDig() on the exact frame the button transitions from not-pressed to pressed, preventing the dig action from repeating every frame while held.
controls.left = kbLeft || pointerLeft;
Combines keyboard and touch/mouse input with OR logic, so either input method (or both at once) moves the player.

canMoveHorizontalPlayer()

Notice holes are NOT blocked here - the player CAN walk into a hole (and fall through it), which is different from enemies who avoid holes entirely, as seen in canEnemyMoveHorizontal().

function canMoveHorizontalPlayer(dx) {
  const c = player.col;
  const r = player.row;
  const nc = c + dx;
  if (nc < 0 || nc >= COLS) return false;
  const dest = tileAt(nc, r);
  if (dest === TILE_BLOCK) return false;
  return true;
}
Line-by-line explanation (4 lines)
const nc = c + dx;
Calculates the column the player would move to (-1 for left, +1 for right).
if (nc < 0 || nc >= COLS) return false;
Blocks movement off the left or right edge of the grid.
if (dest === TILE_BLOCK) return false;
Blocks movement into solid brick walls.
return true;
If neither check failed, the move is allowed.

canMoveVerticalPlayer()

This function has a code-quality quirk worth noticing: the if/else branches are identical, meaning the dy < 0 check is currently unnecessary - see the improvements section for a simplification.

function canMoveVerticalPlayer(dy) {
  const c = player.col;
  const r = player.row;
  const nr = r + dy;
  if (nr < 0 || nr >= ROWS) return false;
  const dest = tileAt(c, nr);
  if (dest === TILE_BLOCK) return false;

  // Need ladder either in current or destination tile
  if (dy < 0) {
    return isOnLadder(c, r) || dest === TILE_LADDER;
  } else {
    return isOnLadder(c, r) || dest === TILE_LADDER;
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Ladder Requirement return isOnLadder(c, r) || dest === TILE_LADDER;

Only allows climbing up/down if either the current tile or the destination tile is a ladder

if (nr < 0 || nr >= ROWS) return false;
Blocks movement off the top or bottom of the grid.
if (dest === TILE_BLOCK) return false;
Blocks climbing into solid brick.
if (dy < 0) { return isOnLadder(c, r) || dest === TILE_LADDER; } else { return isOnLadder(c, r) || dest === TILE_LADDER; }
Both branches do exactly the same check - vertical movement (up or down) is only allowed if either the tile the player is standing on or the tile they're moving into is a ladder.

startPlayerMove()

This function kicks off the sliding animation; updatePlayer() then advances progress each frame until it reaches 1, at which point the move completes.

function startPlayerMove(toC, toR) {
  player.isMoving = true;
  player.fromCol = player.col;
  player.fromRow = player.row;
  player.toCol = toC;
  player.toRow = toR;
  player.progress = 0;
}
Line-by-line explanation (4 lines)
player.isMoving = true;
Flags that the player is now sliding between two tiles.
player.fromCol = player.col; player.fromRow = player.row;
Records the tile the player is leaving as the interpolation start point.
player.toCol = toC; player.toRow = toR;
Records the destination tile as the interpolation end point.
player.progress = 0;
Resets the animation progress to the very beginning of this new slide.

handlePlayerLanding()

This is called every frame the player is resting on a tile (not just once), but because the tile is set to TILE_EMPTY immediately, it will only ever trigger gold once per coin.

function handlePlayerLanding() {
  const c = player.col;
  const r = player.row;
  const t = tileAt(c, r);

  if (t === TILE_GOLD) {
    goldCollected++;
    level[r][c] = TILE_EMPTY;
    score += SCORE_PER_GOLD;
    playGoldSound();
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

conditional Gold Pickup Check if (t === TILE_GOLD) {

Collects gold and awards points the moment the player occupies a gold tile

const t = tileAt(c, r);
Reads the tile the player is currently standing on.
if (t === TILE_GOLD) { ... }
If it's a gold coin, run the pickup logic.
goldCollected++;
Increments the running total of gold collected, used for the HUD and win condition.
level[r][c] = TILE_EMPTY;
Removes the gold from the grid so it can't be collected twice and disappears visually.
score += SCORE_PER_GOLD;
Adds 100 points to the score.
playGoldSound();
Plays the ascending sparkle sound effect.

killPlayer()

Notice that the level itself (bricks, remaining gold, holes) is NOT reset here - only the player's position - so dying doesn't erase your progress on that level, just costs a life.

function killPlayer() {
  playDeathSound();
  lives--;

  if (lives <= 0) {
    // Run ended
    gameState = 'gameover';
    lastLevelTimeSec = floor((frameCount - levelStartFrame) / 60);
    return;
  }

  // Respawn player at start position, keep level state
  player.col = player.startCol;
  player.row = player.startRow;
  player.fromCol = player.col;
  player.fromRow = player.row;
  player.toCol = player.col;
  player.toRow = player.row;
  player.isMoving = false;
  player.progress = 0;
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Game Over Check if (lives <= 0) {

Ends the run entirely once all lives are used up, instead of respawning

playDeathSound();
Plays the descending failure sound immediately.
lives--;
Deducts one life.
if (lives <= 0) { gameState = 'gameover'; ... return; }
If no lives remain, switches the whole game to the 'gameover' state and records the elapsed time, then exits without respawning.
player.col = player.startCol; player.row = player.startRow;
Otherwise, teleports the player back to their original spawn tile.
player.isMoving = false; player.progress = 0;
Cancels any in-progress slide animation so the respawn is instant, not a slide from the death location.

updatePlayer()

This function is the heart of player control, showing a common game-loop pattern: check if an animation is in progress first, then check environmental forces (gravity), then finally process player input - each earlier check can 'consume' the frame and skip the rest.

🔬 This block IS gravity. What happens if you comment out the 'return;' so the player can still move sideways in mid-air while falling?

  if (!isOnLadder(player.col, player.row) &&
      shouldFallFrom(player.col, player.row)) {
    startPlayerMove(player.col, player.row + 1);
    return;
  }
function updatePlayer() {
  if (!player) return;

  if (player.isMoving) {
    player.progress += MOVE_SPEED;
    if (player.progress >= 1) {
      player.col = player.toCol;
      player.row = player.toRow;
      player.isMoving = false;
      player.progress = 0;
      handlePlayerLanding();
    }
    return;
  }

  // On resting tile
  handlePlayerLanding();

  // Gravity (if not on ladder)
  if (!isOnLadder(player.col, player.row) &&
      shouldFallFrom(player.col, player.row)) {
    startPlayerMove(player.col, player.row + 1);
    return;
  }

  // Movement input
  let dx = 0;
  let dy = 0;

  if (controls.left && !controls.right)  dx = -1;
  else if (controls.right && !controls.left) dx = 1;

  if (controls.up && !controls.down)    dy = -1;
  else if (controls.down && !controls.up) dy = 1;

  // Prefer vertical movement if on ladder, otherwise horizontal
  if (dy !== 0 && canMoveVerticalPlayer(dy)) {
    startPlayerMove(player.col, player.row + dy);
  } else if (dx !== 0 && canMoveHorizontalPlayer(dx)) {
    startPlayerMove(player.col + dx, player.row);
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Slide Animation Progress if (player.isMoving) {

Advances the player's slide toward the destination tile and snaps into place when finished

conditional Gravity Check if (!isOnLadder(player.col, player.row) && shouldFallFrom(player.col, player.row)) {

Forces the player to fall if not on a ladder and nothing solid is below

calculation Input to Direction if (controls.left && !controls.right) dx = -1;

Converts the controls booleans into a single -1/0/1 direction, canceling out if opposite keys are both held

if (!player) return;
Safety check in case this runs before the player object exists.
if (player.isMoving) { player.progress += MOVE_SPEED; ... }
While a slide is in progress, just advance the animation timer - no new input is processed until the current move finishes.
if (player.progress >= 1) { player.col = player.toCol; ... handlePlayerLanding(); }
Once the slide reaches full progress, snap the logical position to the destination tile and immediately check for gold/etc. at the new position.
handlePlayerLanding();
Also called every frame the player is standing still, ensuring gold is picked up even without moving onto it this exact frame (e.g. after death respawn).
if (!isOnLadder(...) && shouldFallFrom(...)) { startPlayerMove(player.col, player.row + 1); return; }
Gravity takes priority over input: if the player isn't holding a ladder and there's nothing below them, they immediately start falling and any movement keys are ignored this frame.
if (controls.left && !controls.right) dx = -1; else if (controls.right && !controls.left) dx = 1;
Turns the four boolean directions into a single horizontal direction, canceling out to 0 if both left and right are pressed simultaneously.
if (dy !== 0 && canMoveVerticalPlayer(dy)) { startPlayerMove(...); } else if (dx !== 0 && canMoveHorizontalPlayer(dx)) { startPlayerMove(...); }
Vertical movement is checked and applied first; only if there's no valid vertical move does the code fall back to horizontal movement, which is why climbing takes priority over sideways stepping when both are pressed.

tryDig()

tryDig() is called both from keyPressed() (Z/X keys) and from the on-screen dig buttons in updateInputFromPointers(), demonstrating how one action function can be triggered by multiple different input sources.

🔬 This condition stops you from digging where a hole already exists. What happens if you remove the 'sideTile !== TILE_HOLE' check - can you dig chains of holes through already-open spaces?

  if (targetTile === TILE_BLOCK &&
      sideTile !== TILE_BLOCK &&
      sideTile !== TILE_HOLE) {
    level[targetR][sideC] = TILE_HOLE;
    holes.push({ row: targetR, col: sideC, timer: HOLE_DURATION });
    playDigSound();
  }
function tryDig(direction) {
  if (gameState !== 'playing' || !player) return;
  if (player.isMoving) return;

  const c = player.col;
  const r = player.row;
  const dc = direction === 'left' ? -1 : 1;
  const sideC = c + dc;
  const targetR = r + 1;

  if (sideC < 0 || sideC >= COLS || targetR < 0 || targetR >= ROWS) return;

  const sideTile = tileAt(sideC, r);
  const targetTile = tileAt(sideC, targetR);

  // Only dig if target is a solid block and side is not a block/hole
  if (targetTile === TILE_BLOCK &&
      sideTile !== TILE_BLOCK &&
      sideTile !== TILE_HOLE) {
    level[targetR][sideC] = TILE_HOLE;
    holes.push({ row: targetR, col: sideC, timer: HOLE_DURATION });
    playDigSound();
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional Dig State Guard if (gameState !== 'playing' || !player) return;

Prevents digging while the game isn't actively playing

conditional Dig Eligibility Check if (targetTile === TILE_BLOCK && sideTile !== TILE_BLOCK && sideTile !== TILE_HOLE) {

Only allows digging into solid brick that's diagonally below an open side tile

if (player.isMoving) return;
Disallows digging mid-slide, keeping the action tied to discrete resting positions.
const dc = direction === 'left' ? -1 : 1;
Converts the direction string into a column offset.
const sideC = c + dc; const targetR = r + 1;
Digging always targets the tile diagonally below-and-to-the-side of the player, matching classic Lode Runner rules.
if (sideC < 0 || sideC >= COLS || targetR < 0 || targetR >= ROWS) return;
Bails out if the target position would be off the edge of the grid.
const sideTile = tileAt(sideC, r); const targetTile = tileAt(sideC, targetR);
Reads both the tile beside the player and the tile below that, since both matter for the dig rule.
if (targetTile === TILE_BLOCK && sideTile !== TILE_BLOCK && sideTile !== TILE_HOLE) { ... }
Only allows digging if there's solid brick to dig into, AND the tile beside the player (where you'd need to stand to fall into the hole) isn't already a wall or another hole.
level[targetR][sideC] = TILE_HOLE;
Turns that brick into a temporary hole tile.
holes.push({ row: targetR, col: sideC, timer: HOLE_DURATION });
Adds a tracking object to the holes array with a countdown timer, so updateHoles() knows when to reseal it.

updateHoles()

This function demonstrates the standard safe pattern for removing items from an array while iterating over it: loop backwards and use splice() rather than forward iteration, which would otherwise skip elements.

🔬 What happens if you change this so the player only loses a life if BOTH the row and column values are even numbers (a silly rule, but a good test of conditionals) - try `(h.row % 2 === 0 && h.col % 2 === 0)`?

      if (player && player.row === h.row && player.col === h.col) {
        killPlayer();
      }
function updateHoles() {
  for (let i = holes.length - 1; i >= 0; i--) {
    const h = holes[i];
    h.timer--;
    if (h.timer <= 0) {
      // Hole closes
      // Player trapped?
      if (player && player.row === h.row && player.col === h.col) {
        killPlayer();
      }
      // Enemies trapped?
      for (const e of enemies) {
        if (e.row === h.row && e.col === h.col) {
          respawnEnemy(e);
        }
      }
      level[h.row][h.col] = TILE_BLOCK;
      holes.splice(i, 1);
    }
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

for-loop Reverse Iteration for (let i = holes.length - 1; i >= 0; i--) {

Loops backward through the holes array so removing items with splice() doesn't skip the next element

conditional Hole Closing Check if (h.timer <= 0) {

Detects when a hole's countdown has finished so it can reseal

for-loop Trapped Enemy Scan for (const e of enemies) {

Checks every enemy to see if it's standing in the closing hole and needs to respawn

for (let i = holes.length - 1; i >= 0; i--) {
Iterates backward through the array specifically because the loop body may remove ('splice') elements - looping backward avoids the classic bug of skipping the item after a removed one.
h.timer--;
Counts down this hole's remaining open time by one frame.
if (player && player.row === h.row && player.col === h.col) { killPlayer(); }
If the player is still standing in this exact tile when it closes, they get crushed/trapped and lose a life.
for (const e of enemies) { if (e.row === h.row && e.col === h.col) { respawnEnemy(e); } }
Checks every enemy for the same condition; a trapped enemy is sent back to its spawn point rather than being 'killed' outright.
level[h.row][h.col] = TILE_BLOCK;
Turns the tile back into solid brick, resealing the hole.
holes.splice(i, 1);
Removes this hole's tracking object from the array since it's no longer active.

startEnemyMove()

Identical in structure to startPlayerMove() but takes the enemy object as a parameter since there are multiple enemies, whereas there's only ever one global player.

function startEnemyMove(e, toC, toR) {
  e.isMoving = true;
  e.fromCol = e.col;
  e.fromRow = e.row;
  e.toCol = toC;
  e.toRow = toR;
  e.progress = 0;
}
Line-by-line explanation (3 lines)
e.isMoving = true;
Marks the enemy as currently sliding between tiles.
e.fromCol = e.col; e.fromRow = e.row;
Captures the enemy's current tile as the slide's starting point.
e.toCol = toC; e.toRow = toR; e.progress = 0;
Sets the destination tile and resets the animation counter to zero.

canEnemyMoveHorizontal()

Comparing this to canMoveHorizontalPlayer() highlights an important asymmetry: the player CAN walk into holes (and fall through/get trapped), but enemies actively avoid them, which is the core strategic mechanic of the game.

function canEnemyMoveHorizontal(e, dx) {
  const c = e.col;
  const r = e.row;
  const nc = c + dx;
  if (nc < 0 || nc >= COLS) return false;
  const dest = tileAt(nc, r);
  if (dest === TILE_BLOCK || dest === TILE_HOLE) return false;
  return true;
}
Line-by-line explanation (1 lines)
if (dest === TILE_BLOCK || dest === TILE_HOLE) return false;
Unlike the player's equivalent check, enemies also refuse to walk into holes - this is what makes digging an effective trap.

canEnemyMoveVertical()

This mirrors canMoveVerticalPlayer() almost exactly, just with the added TILE_HOLE restriction - a good example of near-duplicate logic across two entity types that could be refactored into one shared function taking a 'canEnterHoles' flag.

function canEnemyMoveVertical(e, dy) {
  const c = e.col;
  const r = e.row;
  const nr = r + dy;
  if (nr < 0 || nr >= ROWS) return false;
  const dest = tileAt(c, nr);
  if (dest === TILE_BLOCK || dest === TILE_HOLE) return false;

  if (dy < 0) {
    return isOnLadder(c, r) || dest === TILE_LADDER;
  } else {
    return isOnLadder(c, r) || dest === TILE_LADDER;
  }
}
Line-by-line explanation (2 lines)
if (dest === TILE_BLOCK || dest === TILE_HOLE) return false;
Enemies can't climb into brick or into a hole.
return isOnLadder(c, r) || dest === TILE_LADDER;
Same ladder requirement as the player: vertical movement needs a ladder either at the current tile or the destination.

respawnEnemy()

Called from updateHoles() when a hole closes on top of a trapped enemy - this is the 'punishment' for enemies that fall for the player's trap.

function respawnEnemy(e) {
  e.col = e.startCol;
  e.row = e.startRow;
  e.fromCol = e.col;
  e.fromRow = e.row;
  e.toCol = e.col;
  e.toRow = e.row;
  e.isMoving = false;
  e.progress = 0;
  e.trapped = false;
  e.moveCooldown = ENEMY_PAUSE;
}
Line-by-line explanation (4 lines)
e.col = e.startCol; e.row = e.startRow;
Teleports the enemy back to wherever it originally spawned in the level template.
e.isMoving = false; e.progress = 0;
Cancels any animation in progress so the teleport is instant.
e.trapped = false;
Clears the trapped flag so the enemy resumes normal AI behavior.
e.moveCooldown = ENEMY_PAUSE;
Gives the enemy a brief grace period before it starts moving again, so it doesn't instantly chase the player the moment it respawns.

updateEnemy()

This function is a compact example of simple 'greedy chase' AI: prefer moving directly toward the target when possible, and fall back to random wandering otherwise - no pathfinding algorithm needed for a convincing enemy.

🔬 This is the enemy's 'chase the player vertically' logic. What happens if you flip the comparisons (< becomes >) so enemies flee from the player's row instead of chasing it?

  if (isOnLadder(e.col, e.row) && player && player.row !== e.row) {
    if (player.row < e.row && canEnemyMoveVertical(e, -1)) dy = -1;
    else if (player.row > e.row && canEnemyMoveVertical(e, 1)) dy = 1;
  }
function updateEnemy(e) {
  if (e.trapped) {
    // Wait until hole closes & respawnEnemy is called
    return;
  }

  // If standing in a hole tile, get trapped
  if (tileAt(e.col, e.row) === TILE_HOLE) {
    e.trapped = true;
    return;
  }

  if (e.isMoving) {
    e.progress += ENEMY_SPEED;
    if (e.progress >= 1) {
      e.col = e.toCol;
      e.row = e.toRow;
      e.isMoving = false;
      e.progress = 0;
    }
    return;
  }

  // Gravity (if not on ladder)
  if (!isOnLadder(e.col, e.row) &&
      shouldFallFrom(e.col, e.row)) {
    startEnemyMove(e, e.col, e.row + 1);
    return;
  }

  if (e.moveCooldown > 0) {
    e.moveCooldown--;
    return;
  }

  let dx = 0;
  let dy = 0;

  // Simple chasing AI
  if (isOnLadder(e.col, e.row) && player && player.row !== e.row) {
    if (player.row < e.row && canEnemyMoveVertical(e, -1)) dy = -1;
    else if (player.row > e.row && canEnemyMoveVertical(e, 1)) dy = 1;
  }

  if (dy === 0 && player && player.col !== e.col) {
    if (player.col < e.col && canEnemyMoveHorizontal(e, -1)) dx = -1;
    else if (player.col > e.col && canEnemyMoveHorizontal(e, 1)) dx = 1;
  }

  // If no direct chase move, pick a random valid direction
  if (dx === 0 && dy === 0) {
    const options = [];
    if (canEnemyMoveHorizontal(e, -1)) options.push({ dx: -1, dy: 0 });
    if (canEnemyMoveHorizontal(e, 1))  options.push({ dx: 1,  dy: 0 });

    if (isOnLadder(e.col, e.row)) {
      if (canEnemyMoveVertical(e, -1)) options.push({ dx: 0, dy: -1 });
      if (canEnemyMoveVertical(e, 1))  options.push({ dx: 0, dy: 1 });
    }

    if (options.length > 0) {
      const choice = random(options);
      dx = choice.dx;
      dy = choice.dy;
    }
  }

  if (dy !== 0 && canEnemyMoveVertical(e, dy)) {
    startEnemyMove(e, e.col, e.row + dy);
    e.moveCooldown = ENEMY_PAUSE;
  } else if (dx !== 0 && canEnemyMoveHorizontal(e, dx)) {
    startEnemyMove(e, e.col + dx, e.row);
    e.moveCooldown = ENEMY_PAUSE;
  }
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

conditional Trapped State Check if (e.trapped) {

Freezes a trapped enemy completely until the hole closes

conditional Fall-Into-Hole Detection if (tileAt(e.col, e.row) === TILE_HOLE) {

Traps an enemy the instant it ends up standing on a dug hole tile

conditional Vertical Chase Logic if (isOnLadder(e.col, e.row) && player && player.row !== e.row) {

Makes the enemy climb toward the player's row while on a ladder

conditional Horizontal Chase Logic if (dy === 0 && player && player.col !== e.col) {

Makes the enemy walk toward the player's column when not already climbing

conditional Random Movement Fallback if (dx === 0 && dy === 0) {

Picks a random legal direction when the enemy has no direct line to chase the player

if (e.trapped) { return; }
A trapped enemy does absolutely nothing each frame until updateHoles() calls respawnEnemy() on it.
if (tileAt(e.col, e.row) === TILE_HOLE) { e.trapped = true; return; }
The moment an enemy's own tile becomes a hole (usually because the player just dug it out from under them), it becomes trapped.
if (e.isMoving) { e.progress += ENEMY_SPEED; ... return; }
Same sliding-animation pattern as the player: advance progress and snap into place when the slide finishes.
if (!isOnLadder(e.col, e.row) && shouldFallFrom(e.col, e.row)) { startEnemyMove(e, e.col, e.row + 1); return; }
Enemies obey the same gravity rule as the player.
if (e.moveCooldown > 0) { e.moveCooldown--; return; }
Enforces the pause between decisions, making the AI feel deliberate rather than twitchy.
if (isOnLadder(e.col, e.row) && player && player.row !== e.row) { ... }
If the enemy happens to be on a ladder, it prioritizes climbing up or down toward the player's row.
if (dy === 0 && player && player.col !== e.col) { ... }
If no vertical chase move was chosen, it instead tries to step horizontally toward the player's column.
if (dx === 0 && dy === 0) { const options = []; ... }
If neither chase move was possible (blocked by a wall, hole, or already aligned with the player), the enemy builds a list of all currently legal moves and picks one at random with random(options), giving it some unpredictable wandering behavior.
if (dy !== 0 && canEnemyMoveVertical(e, dy)) { startEnemyMove(...); e.moveCooldown = ENEMY_PAUSE; } else if (dx !== 0 && canEnemyMoveHorizontal(e, dx)) { ... }
Finally commits to whichever direction was decided, starting the slide animation and resetting the cooldown timer for the next decision.

updateEnemies()

Separating updateEnemy() (single enemy logic) from updateEnemies() (looping) keeps each function focused on one responsibility, making the code easier to read and test.

function updateEnemies() {
  for (const e of enemies) {
    updateEnemy(e);
  }
}
Line-by-line explanation (1 lines)

🔧 Subcomponents:

for-loop Per-Enemy Update Loop for (const e of enemies) {

Runs the full AI/movement update for every enemy in the level, once per frame

for (const e of enemies) { updateEnemy(e); }
Simply loops through the enemies array and runs the individual update logic for each one - this pattern of looping over an array and calling a per-item function is extremely common in game code.

checkPlayerEnemyCollision()

This is a simple grid-based collision check (comparing col/row integers) rather than pixel-distance collision, which works well because both characters always occupy exactly one tile even mid-slide.

function checkPlayerEnemyCollision() {
  if (!player) return;
  for (const e of enemies) {
    if (!e.trapped && e.col === player.col && e.row === player.row) {
      killPlayer();
      return;
    }
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

conditional Same-Tile Collision if (!e.trapped && e.col === player.col && e.row === player.row) {

Detects when a non-trapped enemy occupies the exact same grid tile as the player

for (const e of enemies) { ... }
Checks every enemy against the player's position.
if (!e.trapped && e.col === player.col && e.row === player.row) { killPlayer(); return; }
A trapped enemy can't hurt the player. Otherwise, if an enemy's logical grid position exactly matches the player's, it's a collision, so killPlayer() runs and the loop exits immediately (only one death needed per frame).

gridToScreen()

This tiny function is the single conversion point between the game's logical grid coordinates and actual screen pixels, used by both player and enemy drawing.

function gridToScreen(c, r) {
  return {
    x: offsetX + (c + 0.5) * tileSize,
    y: offsetY + (r + 0.5) * tileSize
  };
}
Line-by-line explanation (2 lines)
x: offsetX + (c + 0.5) * tileSize,
Converts a grid column into a pixel x-coordinate, adding 0.5 tiles so the result lands in the CENTER of the tile rather than its top-left corner.
y: offsetY + (r + 0.5) * tileSize
Same idea for the row, converting to a centered pixel y-coordinate, and adding the grid's screen offset so it's correctly positioned even when centered on the canvas.

getInterpolatedPosition()

This function is exactly why the game looks smooth instead of jerky: characters logically 'teleport' between tiles internally, but lerp() makes the on-screen position glide continuously between them, which is one of the most useful animation tricks in p5.js.

function getInterpolatedPosition(obj) {
  let c = obj.col;
  let r = obj.row;
  if (obj.isMoving) {
    const t = obj.progress;
    c = lerp(obj.fromCol, obj.toCol, t);
    r = lerp(obj.fromRow, obj.toRow, t);
  }
  return gridToScreen(c, r);
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Mid-Slide Interpolation if (obj.isMoving) {

Blends between the start and end tile using the animation progress instead of using the resting grid position

let c = obj.col; let r = obj.row;
Defaults to the object's resting logical position.
if (obj.isMoving) { ... }
But if it's currently sliding between tiles, overrides that with an interpolated position instead.
c = lerp(obj.fromCol, obj.toCol, t);
lerp() (linear interpolation) blends smoothly between the starting column and destination column based on progress t (0 = start, 1 = fully arrived).
return gridToScreen(c, r);
Converts the (possibly fractional, mid-slide) grid position into actual screen pixels for drawing.

formatTime()

A small formatting utility that turns a raw frame/second count into a readable timer string for the HUD - a pattern useful in almost any timed game.

function formatTime(sec) {
  const s = max(0, sec | 0); // ensure non-negative int
  const m = Math.floor(s / 60);
  const ss = (s % 60).toString().padStart(2, '0');
  return `${m}:${ss}`;
}
Line-by-line explanation (4 lines)
const s = max(0, sec | 0);
The bitwise OR with 0 truncates any decimal part (a quick integer-conversion trick), and max(0, ...) guards against negative values.
const m = Math.floor(s / 60);
Converts total seconds into whole minutes.
const ss = (s % 60).toString().padStart(2, '0');
Gets the remaining seconds after removing whole minutes, and pads it with a leading zero if needed (e.g. '05' instead of '5').
return `${m}:${ss}`;
Combines minutes and seconds into a familiar 'M:SS' display string.

drawLevel()

drawLevel() demonstrates the 'nested loop over a 2D array' pattern that's fundamental to any tile-based game - the exact same structure used in resetGame() to build the array is used here to render it.

🔬 What happens if you swap which function draws which tile - for example calling drawHoleCell() for TILE_BLOCK - so bricks visually look like holes?

      if (t === TILE_BLOCK) {
        drawBrickCell(x, y);
      }
      if (t === TILE_HOLE) {
        drawHoleCell(x, y);
      }
function drawLevel() {
  rectMode(CORNER);
  noStroke();

  // Deep blue background behind grid
  fill(4, 6, 26);
  rect(offsetX, offsetY, tileSize * COLS, tileSize * ROWS);

  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      const t = tileAt(c, r);
      const x = offsetX + c * tileSize;
      const y = offsetY + r * tileSize;

      if (t === TILE_BLOCK) {
        drawBrickCell(x, y);
      }
      if (t === TILE_HOLE) {
        drawHoleCell(x, y);
      }
      if (t === TILE_LADDER) {
        drawLadderCell(x, y);
      }
      if (t === TILE_GOLD) {
        drawGoldCell(x, y);
      }
    }
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

for-loop Nested Grid Drawing Loop for (let r = 0; r < ROWS; r++) { for (let c = 0; c < COLS; c++) {

Visits every cell in the 20x14 grid exactly once per frame to draw whatever tile occupies it

conditional Tile Type Dispatch if (t === TILE_BLOCK) { drawBrickCell(x, y); }

Routes each tile type to its own dedicated drawing function

fill(4, 6, 26); rect(offsetX, offsetY, tileSize * COLS, tileSize * ROWS);
Paints one large dark-blue rectangle behind the entire grid before drawing individual tiles, giving empty space a moody background color instead of plain black.
for (let r = 0; r < ROWS; r++) { for (let c = 0; c < COLS; c++) { ... } }
A classic nested loop pattern for visiting every (row, column) cell in a 2D grid exactly once.
const x = offsetX + c * tileSize; const y = offsetY + r * tileSize;
Converts this cell's grid coordinates into the pixel coordinates of its top-left corner for drawing.
if (t === TILE_BLOCK) { drawBrickCell(x, y); }
Each tile type check calls out to a dedicated function - this keeps drawLevel() itself simple and delegates the actual visual detail elsewhere.

drawBrickCell()

This function shows how a handful of rect() and line() calls with carefully chosen margins can build a convincing textured tile, rather than relying on an image asset - a useful technique when you want a game to be pure code with no external files.

function drawBrickCell(x, y) {
  const s = tileSize;
  noStroke();
  fill(18, 10, 12);
  rect(x, y, s, s);

  const margin = s * 0.08;
  const brickH = (s - margin * 3) / 2;

  for (let row = 0; row < 2; row++) {
    const by = y + margin + row * (brickH + margin);
    fill(190, 150, 80);
    stroke(90, 60, 30);
    strokeWeight(max(1, s * 0.03));
    rect(x + margin, by, s - margin * 2, brickH, 4);

    stroke(130, 90, 50);
    const splitX = x + s * 0.45;
    line(splitX, by, splitX, by + brickH);
  }

  noFill();
  stroke(255, 220, 150, 130);
  strokeWeight(max(1, s * 0.02));
  line(x + margin * 1.5, y + margin * 1.2, x + s - margin * 1.5, y + margin * 1.2);
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

for-loop Two-Row Brick Pattern for (let row = 0; row < 2; row++) {

Draws two stacked brick rectangles with mortar gaps inside one tile, mimicking a real brick pattern

fill(18, 10, 12); rect(x, y, s, s);
Fills the whole tile with a very dark mortar color as the base layer.
const brickH = (s - margin * 3) / 2;
Calculates the height of each of the two brick rows so they fit within the tile with margins between and around them.
for (let row = 0; row < 2; row++) { ... }
Draws two horizontal brick rectangles stacked on top of each other, each offset by its row index.
rect(x + margin, by, s - margin * 2, brickH, 4);
Draws one brick rectangle with slightly rounded corners (the last argument), using a tan/brown color and darker outline.
const splitX = x + s * 0.45; line(splitX, by, splitX, by + brickH);
Draws a vertical mortar line splitting each brick row into two bricks, offset slightly off-center for visual variety instead of a perfectly symmetric grid.
stroke(255, 220, 150, 130); line(x + margin * 1.5, y + margin * 1.2, x + s - margin * 1.5, y + margin * 1.2);
Draws a subtle semi-transparent highlight line near the top of the tile to fake a light source hitting the brick from above.

drawHoleCell()

A minimal but effective example of using just two shapes and transparency (the alpha value 150) to suggest depth.

function drawHoleCell(x, y) {
  const s = tileSize;
  noStroke();
  fill(5, 2, 4);
  rect(x, y, s, s);
  fill(0, 0, 0, 150);
  ellipse(x + s / 2, y + s * 0.65, s * 0.8, s * 0.4);
}
Line-by-line explanation (2 lines)
fill(5, 2, 4); rect(x, y, s, s);
Fills the tile with a near-black color to represent an open pit.
fill(0, 0, 0, 150); ellipse(x + s / 2, y + s * 0.65, s * 0.8, s * 0.4);
Draws a semi-transparent dark ellipse near the bottom of the tile to fake depth/shadow inside the hole.

drawLadderCell()

A good example of using a loop variable to evenly space repeated elements - the same divide-by-count technique is useful for anything from ladder rungs to grid lines to particle rings.

🔬 What happens visually if you change 'rungs' to 2, or to 10? How does the loop automatically adjust the spacing without any other code changes?

  const rungs = 4;
  for (let i = 1; i < rungs; i++) {
    const ry = y + (s * i) / rungs;
    line(x + pad, ry, x + s - pad, ry);
  }
function drawLadderCell(x, y) {
  const s = tileSize;
  noFill();
  stroke(240, 210, 120);
  strokeWeight(max(1, s * 0.08));
  const pad = s * 0.25;
  line(x + pad, y, x + pad, y + s);
  line(x + s - pad, y, x + s - pad, y + s);

  const rungs = 4;
  for (let i = 1; i < rungs; i++) {
    const ry = y + (s * i) / rungs;
    line(x + pad, ry, x + s - pad, ry);
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

for-loop Ladder Rung Loop for (let i = 1; i < rungs; i++) {

Draws evenly-spaced horizontal rungs between the ladder's two vertical rails

const pad = s * 0.25;
Leaves a 25% margin on each side of the tile so the two vertical rails aren't drawn flush against the tile edges.
line(x + pad, y, x + pad, y + s); line(x + s - pad, y, x + s - pad, y + s);
Draws the two vertical side rails spanning the full height of the tile.
const rungs = 4; for (let i = 1; i < rungs; i++) { ... }
Draws 3 evenly spaced horizontal rungs (from i=1 to i=3) between the rails, dividing the tile height into 4 equal sections.
const ry = y + (s * i) / rungs;
Calculates each rung's vertical position as a fraction of the tile's height.

drawGoldCell()

This is a classic p5.js animation trick: using sin(frameCount * speed) to create smooth, endless oscillation without needing to store or update any extra timing variables yourself.

🔬 What happens if you increase the 0.1 multiplier to something like 0.4, making the pulse amplitude much larger?

  const pulse = 0.3 + 0.1 * sin(frameCount * 0.2);
  const r = s * (0.3 + pulse * 0.25);
function drawGoldCell(x, y) {
  const s = tileSize;
  const cx = x + s / 2;
  const cy = y + s / 2;
  const pulse = 0.3 + 0.1 * sin(frameCount * 0.2);
  const r = s * (0.3 + pulse * 0.25);

  noStroke();
  fill(255, 215, 100, 70);
  ellipse(cx, cy, r * 2.2, r * 2.2);
  fill(255, 230, 120);
  ellipse(cx, cy, r * 2, r * 2);
  fill(255, 255, 255, 160);
  ellipse(cx - r * 0.2, cy - r * 0.2, r * 0.7, r * 0.7);
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Pulsing Size Calculation const pulse = 0.3 + 0.1 * sin(frameCount * 0.2);

Uses a sine wave driven by frameCount to make the gold coin gently grow and shrink over time

const pulse = 0.3 + 0.1 * sin(frameCount * 0.2);
sin() oscillates smoothly between -1 and 1 over time; multiplying by 0.1 and adding 0.3 keeps the pulse value oscillating gently between 0.2 and 0.4, animating without any stored animation state.
const r = s * (0.3 + pulse * 0.25);
Uses the pulse value to compute a radius that grows and shrinks slightly, based on the tile size.
fill(255, 215, 100, 70); ellipse(cx, cy, r * 2.2, r * 2.2);
Draws a larger, very transparent outer glow ellipse behind the coin.
fill(255, 230, 120); ellipse(cx, cy, r * 2, r * 2);
Draws the solid golden coin body.
fill(255, 255, 255, 160); ellipse(cx - r * 0.2, cy - r * 0.2, r * 0.7, r * 0.7);
Draws a small semi-transparent white highlight offset toward the upper-left, faking a shiny reflection on the coin's surface.

drawPlayer()

This function shows how push()/translate()/pop() lets you build a small 'local coordinate space' for a character, so all the individual body-part shapes can be positioned relative to (0,0) instead of juggling the player's world position in every single shape call.

🔬 headR controls the size of the player's head relative to the tile. What happens if you make it much bigger, like s * 0.5, giving the character a giant head?

  fill(245, 230, 210);
  stroke(120, 90, 70);
  const headR = s * 0.28;
  ellipse(0, -s * 0.35, headR * 2, headR * 2);
function drawPlayer() {
  if (!player) return;
  const pos = getInterpolatedPosition(player);

  push();
  translate(pos.x, pos.y);
  const s = tileSize;

  noStroke();
  fill(0, 0, 0, 100);
  ellipse(0, s * 0.45, s * 0.55, s * 0.25);

  rectMode(CENTER);
  fill(70, 180, 255);
  stroke(20, 60, 100);
  strokeWeight(max(1, s * 0.04));
  const bodyW = s * 0.45;
  const bodyH = s * 0.6;
  rect(0, s * 0.05, bodyW, bodyH, 6);

  fill(245, 230, 210);
  stroke(120, 90, 70);
  const headR = s * 0.28;
  ellipse(0, -s * 0.35, headR * 2, headR * 2);

  noStroke();
  fill(250, 240, 170);
  arc(0, -s * 0.38, headR * 2.1, headR * 1.7, PI, TWO_PI);

  // Arms
  stroke(245, 230, 210);
  strokeWeight(max(1, s * 0.06));
  line(-bodyW * 0.7, -s * 0.05, -bodyW * 0.1, -s * 0.15);
  line(bodyW * 0.7,  -s * 0.02,  bodyW * 0.1, -s * 0.12);

  // Legs
  stroke(60, 150, 230);
  strokeWeight(max(1, s * 0.07));
  line(-bodyW * 0.25, s * 0.3, -bodyW * 0.25, s * 0.5);
  line( bodyW * 0.25, s * 0.3,  bodyW * 0.25, s * 0.5);

  pop();
}
Line-by-line explanation (8 lines)
const pos = getInterpolatedPosition(player);
Gets the player's smoothly-animated pixel position, accounting for any in-progress slide between tiles.
push(); translate(pos.x, pos.y); ... pop();
Moves the drawing origin to the player's position so every shape below can be drawn using simple local coordinates (like 0,0 for center) instead of recalculating absolute positions each time; push/pop keeps this transform from affecting anything drawn afterward.
fill(0, 0, 0, 100); ellipse(0, s * 0.45, s * 0.55, s * 0.25);
Draws a soft transparent shadow ellipse beneath the character's feet.
rect(0, s * 0.05, bodyW, bodyH, 6);
Draws the character's torso as a rounded rectangle, using rectMode(CENTER) so the coordinates given are the shape's center rather than its corner.
ellipse(0, -s * 0.35, headR * 2, headR * 2);
Draws the head above the body.
arc(0, -s * 0.38, headR * 2.1, headR * 1.7, PI, TWO_PI);
Draws only the bottom half of an ellipse (from PI to TWO_PI radians) as a simple stylized hair/hat shape on top of the head.
line(-bodyW * 0.7, -s * 0.05, -bodyW * 0.1, -s * 0.15);
Draws one diagonal line representing an arm, angled outward from the body.
line(-bodyW * 0.25, s * 0.3, -bodyW * 0.25, s * 0.5);
Draws a vertical line for a leg below the body.

drawEnemies()

This function is structurally almost identical to drawPlayer() (translate then draw local shapes) but simpler, since enemies don't have arms/legs - a good comparison for seeing how much visual complexity comes from just a handful of extra shapes.

🔬 eyeOffset controls how far apart the enemy's eyes are. What happens if you make it much bigger, like s * 0.3?

    fill(255);
    const eyeOffset = s * 0.12;
    ellipse(-eyeOffset, -s * 0.12, s * 0.12, s * 0.12);
    ellipse(eyeOffset,  -s * 0.12, s * 0.12, s * 0.12);
function drawEnemies() {
  for (const e of enemies) {
    const pos = getInterpolatedPosition(e);
    push();
    translate(pos.x, pos.y);
    const s = tileSize;

    noStroke();
    fill(0, 0, 0, 110);
    ellipse(0, s * 0.4, s * 0.5, s * 0.24);

    fill(220, 70, 70);
    stroke(120, 20, 20);
    strokeWeight(max(1, s * 0.04));
    const r = s * 0.33;
    ellipse(0, -s * 0.05, r * 2, r * 2.1);

    noStroke();
    fill(255);
    const eyeOffset = s * 0.12;
    ellipse(-eyeOffset, -s * 0.12, s * 0.12, s * 0.12);
    ellipse(eyeOffset,  -s * 0.12, s * 0.12, s * 0.12);
    fill(0);
    ellipse(-eyeOffset, -s * 0.12, s * 0.06, s * 0.06);
    ellipse(eyeOffset,  -s * 0.12, s * 0.06, s * 0.06);

    fill(0);
    rectMode(CENTER);
    rect(0, s * 0.02, s * 0.22, s * 0.09, 4);

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

🔧 Subcomponents:

for-loop Per-Enemy Draw Loop for (const e of enemies) {

Draws every enemy in the array at its own interpolated screen position

for (const e of enemies) { ... }
Loops through every enemy so each one is drawn independently with its own position.
const pos = getInterpolatedPosition(e);
Gets this specific enemy's smooth animated pixel position.
fill(220, 70, 70); ... ellipse(0, -s * 0.05, r * 2, r * 2.1);
Draws the enemy's red body as a slightly egg-shaped ellipse (different width vs height multipliers).
fill(255); ellipse(-eyeOffset, -s * 0.12, s * 0.12, s * 0.12);
Draws the white part of each eye at a small offset from center.
fill(0); ellipse(-eyeOffset, -s * 0.12, s * 0.06, s * 0.06);
Draws a smaller black pupil directly on top of the white eye ellipse, giving the enemy a simple cartoon face.
rect(0, s * 0.02, s * 0.22, s * 0.09, 4);
Draws a small black rounded rectangle as the enemy's mouth.

drawHUD()

drawHUD() shows a common pattern of manually tracking a 'cursor' y position and incrementing it after each text() call to stack multiple lines of UI text without needing a layout library.

function drawHUD() {
  push();
  textAlign(LEFT, TOP);
  textSize(16);
  fill(255);
  const margin = 10;
  let y = margin;
  const x = margin;

  text(`Gold:  ${goldCollected} / ${goldTotal}`, x, y); y += 18;
  text(`Lives: ${lives}`, x, y); y += 18;
  text(`Score: ${score}`, x, y); y += 18;

  const elapsedSec = (gameState === 'playing')
    ? floor((frameCount - levelStartFrame) / 60)
    : lastLevelTimeSec;
  text(`Time:  ${formatTime(elapsedSec)}`, x, y);

  // Messages
  if (gameState === 'won') {
    textAlign(CENTER, CENTER);
    textSize(28);
    fill(255, 255, 170);
    text("YOU WIN!\nTap anywhere or press R to restart", width / 2, height * 0.2);
  } else if (gameState === 'gameover') {
    textAlign(CENTER, CENTER);
    textSize(28);
    fill(255, 180, 180);
    text("GAME OVER\nTap anywhere or press R to restart", width / 2, height * 0.2);
  }

  // EDIT button (top-right)
  const b = getEditButtonRect();
  rectMode(CORNER);
  stroke(90, 130, 230);
  strokeWeight(1.5);
  fill(20, 30, 80, 220);
  rect(b.x, b.y, b.w, b.h, 8);

  noStroke();
  fill(235);
  textAlign(CENTER, CENTER);
  textSize(13);
  text('EDIT', b.x + b.w / 2, b.y + b.h / 2);

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

🔧 Subcomponents:

calculation Elapsed Time Calculation const elapsedSec = (gameState === 'playing') ? floor((frameCount - levelStartFrame) / 60) : lastLevelTimeSec;

Shows a live-updating timer while playing but freezes it once the level ends

conditional Win Message if (gameState === 'won') {

Displays the victory text overlay when all gold has been collected

conditional Game Over Message } else if (gameState === 'gameover') {

Displays the game-over text overlay when all lives are lost

text(`Gold: ${goldCollected} / ${goldTotal}`, x, y); y += 18;
Draws the gold counter text, then increments y so the next line of text appears below it - a simple manual layout technique for stacking HUD text.
const elapsedSec = (gameState === 'playing') ? floor((frameCount - levelStartFrame) / 60) : lastLevelTimeSec;
While actively playing, calculates elapsed seconds live from frameCount (since p5.js runs at roughly 60 frames per second); once the game has ended, uses the frozen lastLevelTimeSec value instead so the timer stops counting.
if (gameState === 'won') { ... } else if (gameState === 'gameover') { ... }
Displays a large centered message overlay depending on which end-state the game is in, each with its own color and text.
const b = getEditButtonRect(); ... rect(b.x, b.y, b.w, b.h, 8);
Reuses the same rectangle calculation as the click-detection code to draw the EDIT button in exactly the right place every frame.

drawControls()

Looping over an array of objects and calling the same method on each (b.draw()) is a core object-oriented pattern that scales effortlessly - adding a 7th button would require zero changes to this function.

function drawControls() {
  // Subtle backdrop bar for controls
  push();
  noStroke();
  fill(0, 0, 0, 90);
  const barH = min(height * 0.24, 200);
  rect(0, height - barH, width, barH);
  pop();

  for (const b of buttons) {
    b.draw();
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

for-loop Button Drawing Loop for (const b of buttons) {

Draws every on-screen touch control button by calling each one's own draw() method

fill(0, 0, 0, 90); const barH = min(height * 0.24, 200); rect(0, height - barH, width, barH);
Draws a semi-transparent dark bar across the bottom of the screen behind the buttons, capped at 200px tall, to visually separate the controls from the game world.
for (const b of buttons) { b.draw(); }
Loops through the shared buttons array and calls each UIButton instance's own draw() method - this is polymorphism in action, since each button knows how to draw itself.

draw()

draw() is p5.js's automatic animation loop, called roughly 60 times per second. This sketch's draw() is a great example of separating 'update' logic (physics, AI, state changes) from 'render' logic (the drawX() calls at the bottom) - a structure that scales well as games grow more complex.

🔬 The '120' here is the number of seconds you have to beat for a time bonus. What happens if you lower it to 20, making it much harder to earn bonus points?

      if (goldTotal > 0 && goldCollected >= goldTotal) {
        const elapsedFrames = frameCount - levelStartFrame;
        lastLevelTimeSec = floor(elapsedFrames / 60);
        const timeBonus = max(0, (120 - lastLevelTimeSec)) * TIME_BONUS_PER_SECOND;
        score += LEVEL_CLEAR_BONUS + timeBonus;
        gameState = 'won';
        playWinSound();
      }
function draw() {
  background(2, 3, 10);

  if (!editorVisible) {
    updateInputFromPointers();

    if (gameState === 'playing') {
      updateHoles();
      updatePlayer();
      updateEnemies();
      checkPlayerEnemyCollision();

      // Win condition (play sound once when we switch state)
      if (goldTotal > 0 && goldCollected >= goldTotal) {
        const elapsedFrames = frameCount - levelStartFrame;
        lastLevelTimeSec = floor(elapsedFrames / 60);
        const timeBonus = max(0, (120 - lastLevelTimeSec)) * TIME_BONUS_PER_SECOND;
        score += LEVEL_CLEAR_BONUS + timeBonus;
        gameState = 'won';
        playWinSound();
      }
    }
  }

  drawLevel();
  drawPlayer();
  drawEnemies();
  drawHUD();
  drawControls();
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Editor Pause Guard if (!editorVisible) {

Freezes all game logic updates while the level editor overlay is open

conditional Playing-State Updates if (gameState === 'playing') {

Only runs movement, AI, and collision logic while the game is actively being played

conditional Win Condition Check if (goldTotal > 0 && goldCollected >= goldTotal) {

Detects when all gold has been collected and transitions the game into the 'won' state with a score bonus

background(2, 3, 10);
Clears the entire canvas to a near-black color at the start of every frame, which is essential in p5.js since draw() runs continuously and shapes would otherwise pile up on top of each other.
if (!editorVisible) { updateInputFromPointers(); if (gameState === 'playing') { ... } }
All gameplay logic is skipped entirely while the level editor is open, effectively pausing the game.
updateHoles(); updatePlayer(); updateEnemies(); checkPlayerEnemyCollision();
The core per-frame update sequence: first resolve any closing holes, then move the player, then move all enemies, then check if the player got caught - order matters here since collisions should be checked after everyone has moved.
if (goldTotal > 0 && goldCollected >= goldTotal) { ... }
Checks the win condition every frame while playing; goldTotal > 0 guards against a degenerate level with zero gold instantly 'winning'.
const timeBonus = max(0, (120 - lastLevelTimeSec)) * TIME_BONUS_PER_SECOND;
Rewards faster play: if you finish in under 120 seconds, you get bonus points proportional to how much time you saved; finishing slower than 120 seconds gives zero bonus (never negative, thanks to max(0, ...)).
gameState = 'won'; playWinSound();
Switches the game state, which will now cause drawHUD() to show the win message, and plays the victory jingle exactly once at the moment of transition.
drawLevel(); drawPlayer(); drawEnemies(); drawHUD(); drawControls();
The complete rendering pass, always run regardless of game state or editor visibility, drawn in careful back-to-front order: background tiles first, then characters on top, then HUD text, then on-screen touch controls on top of everything.

📦 Key Variables

COLS number

The fixed width of the level grid in tiles (20 columns)

const COLS = 20;
ROWS number

The fixed height of the level grid in tiles (14 rows)

const ROWS = 14;
TILE_EMPTY / TILE_BLOCK / TILE_LADDER / TILE_GOLD / TILE_HOLE string

Single-character constants representing each possible tile type stored in the level grid, used everywhere instead of hardcoded string literals

const TILE_BLOCK = '#';
MOVE_SPEED number

How much progress the player's slide animation advances per frame, controlling player speed

const MOVE_SPEED = 0.15;
ENEMY_SPEED number

How much progress an enemy's slide animation advances per frame, controlling enemy speed

const ENEMY_SPEED = 0.08;
ENEMY_PAUSE number

Number of frames an enemy waits between movement decisions

const ENEMY_PAUSE = 8;
HOLE_DURATION number

Number of frames a dug hole stays open before resealing into brick

const HOLE_DURATION = 240;
START_LIVES number

How many lives a new game begins with

const START_LIVES = 3;
SCORE_PER_GOLD number

Points awarded for each gold coin collected

const SCORE_PER_GOLD = 100;
LEVEL_CLEAR_BONUS number

Flat bonus points awarded for finishing a level

const LEVEL_CLEAR_BONUS = 500;
TIME_BONUS_PER_SECOND number

Multiplier used to convert unused time into bonus score on level completion

const TIME_BONUS_PER_SECOND = 5;
levelTemplate array

The human-readable ASCII strings describing the level layout; mutated in place when the level editor applies a new design

const levelTemplate = ["####...#", ...];
level array

The live 2D array of tile characters built from levelTemplate, actually used by all game logic and rendering

let level = [];
player object

The single player entity object holding position, animation, and movement state

let player = null;
enemies array

Array of enemy entity objects, each with its own position and AI state

let enemies = [];
holes array

Tracks currently-dug holes with their countdown timers so they can reseal automatically

let holes = [];
goldTotal / goldCollected number

Track how much gold exists in the level versus how much has been picked up, used for the HUD and win condition

let goldTotal = 0;
tileSize number

The pixel size of one grid tile, recalculated whenever the screen resizes

let tileSize;
offsetX / offsetY number

The pixel offset used to center the grid within the canvas

let offsetX;
gameState string

The current phase of the game: 'playing', 'won', or 'gameover', used to gate logic and control HUD messages

let gameState = 'playing';
lives number

How many lives the player currently has left

let lives = START_LIVES;
score number

The player's running score across the current run

let score = 0;
levelStartFrame number

The frameCount value recorded when the current level began, used to calculate elapsed time

let levelStartFrame = 0;
lastLevelTimeSec number

The frozen elapsed time (in seconds) recorded when a level ends, shown on the HUD after winning or losing

let lastLevelTimeSec = 0;
controls object

Unified boolean state for left/right/up/down movement, combined from keyboard and touch/mouse input each frame

let controls = { left: false, right: false, up: false, down: false };
buttons array

Holds all six UIButton instances so input and drawing code can loop over them generically

let buttons = [];
btnLeft, btnRight, btnUp, btnDown, btnDigLeft, btnDigRight object

Individual named references to each on-screen touch button, created in setupControls()

let btnLeft, btnRight, btnUp, btnDown, btnDigLeft, btnDigRight;
audioCtx object

The single Web Audio API AudioContext used to generate all sound effects

let audioCtx = null;
masterGain object

A shared gain (volume) node all sounds are routed through, controlling overall game volume

let masterGain = null;
editorVisible boolean

Whether the DOM level editor overlay is currently open, used to pause the game and gameplay input

let editorVisible = false;
editorOverlay, editorTextarea, editorError object

References to the DOM elements making up the level editor UI (the fullscreen overlay, the text input, and the error message div)

let editorOverlay = null;

🔧 Potential Improvements (5)

Here are some ways this code could be enhanced:

STYLE canMoveVerticalPlayer() and canEnemyMoveVertical()

Both functions have an if/else where both branches execute the exact same return statement (return isOnLadder(c, r) || dest === TILE_LADDER;), making the dy < 0 check pointless.

💡 Simplify to a single return statement: return isOnLadder(c, r) || dest === TILE_LADDER; and remove the if/else entirely, since the logic doesn't actually depend on movement direction.

BUG checkPlayerEnemyCollision()

Collision is only checked by comparing exact col/row integers, so if the player and an enemy pass through each other mid-slide (arriving from opposite directions in the same frame without ever sharing the same resting tile), a collision can be missed.

💡 Consider also checking interpolated pixel distance via getInterpolatedPosition(), or checking swapped-position 'passing through' cases, for a more forgiving/accurate collision feel.

PERFORMANCE drawLevel()

Every tile in the entire 20x14 grid is redrawn from scratch every single frame, even though most tiles (bricks, empty space) never change between frames.

💡 Consider rendering the static parts of the level (bricks, ladders) once to an offscreen createGraphics() buffer and only redrawing the buffer plus the few tiles that actually change (holes, gold), improving performance on lower-end phones.

FEATURE levelTemplate / applyEditorLevel()

The game only supports a single hardcoded level, and there's no way to save an edited level for next time - refreshing the page reverts to the original template.

💡 Use localStorage to persist the current levelTemplate across page reloads, and consider supporting multiple levels with a simple 'next level' progression instead of only win/restart.

BUG resetGame()

If a level template has zero 'E' enemy markers, the game is essentially unloseable except by falling in your own dug holes, since checkPlayerEnemyCollision() has nothing to collide with.

💡 Consider adding validation (similar to the player-count check already in applyEditorLevel()) to warn if a level has no enemies, or add a fallback difficulty mechanic for enemy-free levels.

🔄 Code Flow

Code flow showing initaudiocontext, ensureaudiocontext, playtone, playdigsound, playgoldsound, playdeathsound, playwinsound, uibutton, setuplevele­ditorui, showeditor, hideeditor, applyeditorlevel, geteditbuttonrect, isovereditbutton, setup, windowresized, computelayout, setupcontrols, startnewgame, resetgame, createplayer, createenemy, tileat, isonladder, shouldfallfrom, keypressed, mousepressed, touchstarted, updateinputfrompointers, canmovehorizontalplayer, canmoveverticalplayer, startplayermove, handleplayerlanding, killplayer, updateplayer, trydig, updateholes, startenemymove, canenemymovehorizontal, canenemymovevertical, respawnenemy, updateenemy, updateenemies, checkplayerenemycollision, gridtoscreen, getinterpolatedposition, formattime, drawlevel, drawbrickcell, drawholecell, drawladdercell, drawgoldcell, drawplayer, drawenemies, drawhud, drawcontrols, draw

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

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> audio-support-check[Browser Support Check] draw --> lazy-create[Lazy Creation Guard] draw --> playing-state-updates[Playing-State Updates] playing-state-updates --> updateplayer[updatePlayer] updateplayer --> gravity-check[Gravity Check] updateplayer --> slide-progress[Slide Animation Progress] updateplayer --> input-to-direction[Input to Direction] updateplayer --> checkplayerenemycollision[checkPlayerEnemyCollision] updateplayer --> handleplayerlanding[handlePlayerLanding] updateplayer --> killplayer[killPlayer] updateplayer --> trydig[tryDig] updateplayer --> updateholes[updateHoles] updateplayer --> updateenemies[updateEnemies] updateenemies --> enemy-loop[Per-Enemy Update Loop] enemy-loop --> updateenemy[updateEnemy] updateenemy --> same-tile-check[Same-Tile Collision] updateenemy --> vertical-chase[Vertical Chase Logic] updateenemy --> horizontal-chase[Horizontal Chase Logic] updateenemy --> random-fallback[Random Movement Fallback] draw --> grid-draw-loop[Nested Grid Drawing Loop] grid-draw-loop --> tile-type-dispatch[Tile Type Dispatch] tile-type-dispatch --> drawlevel[drawLevel] drawlevel --> row-col-loop[Row-Column Loop] drawlevel --> two-row-bricks[Two-Row Brick Pattern] drawlevel --> drawbrickcell[drawBrickCell] drawlevel --> drawholecell[drawHoleCell] drawlevel --> drawladdercell[drawLadderCell] drawlevel --> drawgoldcell[drawGoldCell] drawlevel --> drawplayer[drawPlayer] drawlevel --> drawenemies[drawEnemies] draw --> drawhud[drawHUD] drawhud --> button-draw-loop[Button Drawing Loop] drawhud --> elapsed-calc[Elapsed Time Calculation] drawhud --> win-message[Win Message] drawhud --> gameover-message[Game Over Message] click setup href "#fn-setup" click draw href "#fn-draw" click audio-support-check href "#sub-audio-support-check" click lazy-create href "#sub-lazy-create" click playing-state-updates href "#sub-playing-state-updates" click updateplayer href "#fn-updateplayer" click gravity-check href "#sub-gravity-check" click slide-progress href "#sub-slide-progress" click input-to-direction href "#sub-input-to-direction" click checkplayerenemycollision href "#fn-checkplayerenemycollision" click handleplayerlanding href "#fn-handleplayerlanding" click killplayer href "#fn-killplayer" click trydig href "#fn-trydig" click updateholes href "#fn-updateholes" click updateenemies href "#fn-updateenemies" click enemy-loop href "#sub-enemy-loop" click updateenemy href "#fn-updateenemy" click same-tile-check href "#sub-same-tile-check" click vertical-chase href "#sub-vertical-chase" click horizontal-chase href "#sub-horizontal-chase" click random-fallback href "#sub-random-fallback" click grid-draw-loop href "#sub-grid-draw-loop" click tile-type-dispatch href "#sub-tile-type-dispatch" click drawlevel href "#fn-drawlevel" click row-col-loop href "#sub-row-col-loop" click two-row-bricks href "#sub-two-row-bricks" click drawbrickcell href "#fn-drawbrickcell" click drawholecell href "#fn-drawholecell" click drawladdercell href "#fn-drawladdercell" click drawgoldcell href "#fn-drawgoldcell" click drawplayer href "#fn-drawplayer" click drawenemies href "#fn-drawenemies" click drawhud href "#fn-drawhud" click button-draw-loop href "#sub-button-draw-loop" click elapsed-calc href "#sub-elapsed-calc" click win-message href "#sub-win-message" click gameover-message href "#sub-gameover-message"

Preview

loadrunner - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of loadrunner - Code flow showing initaudiocontext, ensureaudiocontext, playtone, playdigsound, playgoldsound, playdeathsound, playwinsound, uibutton, setuplevele­ditorui, showeditor, hideeditor, applyeditorlevel, geteditbuttonrect, isovereditbutton, setup, windowresized, computelayout, setupcontrols, startnewgame, resetgame, createplayer, createenemy, tileat, isonladder, shouldfallfrom, keypressed, mousepressed, touchstarted, updateinputfrompointers, canmovehorizontalplayer, canmoveverticalplayer, startplayermove, handleplayerlanding, killplayer, updateplayer, trydig, updateholes, startenemymove, canenemymovehorizontal, canenemymovevertical, respawnenemy, updateenemy, updateenemies, checkplayerenemycollision, gridtoscreen, getinterpolatedposition, formattime, drawlevel, drawbrickcell, drawholecell, drawladdercell, drawgoldcell, drawplayer, drawenemies, drawhud, drawcontrols, draw
Code Flow Diagram