pixel parkour

Pixel Parkour is a 30-level platformer game where players jump across obstacles, collect coins, and buy character upgrades from a shop. Each character has unique ability modifiers that change movement speed, jump strength, gravity, and special perks like shields or cash bonuses.

🧪 Try This!

Experiment with the code by making these changes:

  1. Boost the player's base speed — Higher speed makes the character move twice as fast horizontally—notice how much snappier the controls feel
  2. Make gravity stronger — Increased gravity makes the player fall faster and jumps feel heavier—a classic arcade feel
  3. Give all coins double value — Each coin pickup now awards $400 instead of $200—watch your coin total climb much faster
  4. Reduce lava rise speed — Lava rises slower, reducing time pressure on later levels—easier but less intense
  5. Make all platforms wider — Platforms become easier to land on since they're larger targets—easier platforming overall
  6. Start with all characters owned — Remove the paywall—all 10 characters are immediately available to equip without purchasing
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch creates a complete pixel-art platformer game with 30 progressively harder levels. Players control a customizable character who must jump across moving platforms, avoid rising lava, collect coins, and reach a goal platform. The game combines core p5.js techniques: the draw loop, physics simulation (gravity and velocity), collision detection using axis-aligned bounding boxes (AABB), camera following, procedural level generation with seeded randomness, and an interactive shop UI for purchasing character skins and abilities.

The code is organized into three main sections: player physics and input handling (handleInput, updatePlayerPhysics, attemptJump), level generation and world logic (createLevel, updateMovingPlatforms, updateLava), and UI/shop systems (drawShopOverlay, selectCharacterFromShop). By studying this sketch, you will learn how to build a complete game loop that handles multiple game states (start, playing, gameOver, win, complete), implement character progression with stat modifiers, and create a dynamic camera that follows the player across wide levels.

⚙️ How It Works

  1. When the sketch loads, setup() creates the canvas, initializes the character roster with 10 unique skins and abilities, and generates the first level with procedural platforms, hazards, and coins using a seeded random number generator.
  2. Every frame, draw() calls gameLoop() which handles input, updates physics, checks collisions, and renders the world. The camera translates the scene so the player stays centered on screen.
  3. The player accelerates left/right based on key presses, applies gravity each frame, and collides with platforms—horizontal collisions stop sideways movement, vertical collisions set the player on ground and reset jump counts.
  4. Lava rises slowly each frame (faster on harder levels), and the player dies if they touch it without a shield. Coins on platforms are collected for $200 each, and reaching the goal awards level completion coins.
  5. Between levels, players open the shop to buy new character skins (costing $300–$940) which unlock unique abilities like extra jumps, higher speed, reduced gravity, or a hazard shield.
  6. Thirty levels are generated procedurally: each level places a starting platform, paths a series of jumping challenges upward, adds moving platforms and lava, then places a final goal far to the right. Difficulty increases by making lava rise faster, platforms thinner, and gaps wider.

🎓 Concepts You'll Learn

Game state machinePhysics simulation and gravityAxis-aligned bounding box (AABB) collision detectionCamera following and translationProcedural level generation with seeded randomnessAnimation and interpolationUI and interactive shop systemCharacter stat modifiers and progression

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. Here we prepare the canvas, initialize all game variables, and generate the first level. This is where you define the visual style (pixelated), prepare the character system, and launch into the start screen.

function setup() {
  createCanvas(windowWidth, windowHeight);
  pixelDensity(1);
  noSmooth();
  textFont('monospace');
  textAlign(CENTER, CENTER);

  createLevel(currentLevel);
  generateBackgroundTiles();
  initCharacterRoster();
  resetGame();
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

calculation Canvas Creation createCanvas(windowWidth, windowHeight);

Creates the game canvas that fills the entire window

calculation Pixel Art Settings pixelDensity(1); noSmooth();

Makes sure retro pixel art doesn't blur on high-DPI screens

calculation Character Initialization initCharacterRoster();

Creates all 10 unique character skins with randomized abilities and costs

createCanvas(windowWidth, windowHeight);
Creates a canvas sized to fit the entire window, making the game responsive
pixelDensity(1);
Ensures pixel art stays sharp by disabling high-DPI scaling
noSmooth();
Prevents p5 from smoothing (anti-aliasing) pixel edges—keeping the blocky retro style
textFont('monospace');
Sets all text to monospace so the HUD looks pixelated and consistent
textAlign(CENTER, CENTER);
Centers text at the point you draw it—makes positioning easier
createLevel(currentLevel);
Generates all platforms, hazards, coins, and the goal for the current level
generateBackgroundTiles();
Creates a parallax background of random decorative tiles that move at half camera speed
initCharacterRoster();
Creates 10 characters by pairing each skin template with a shuffled ability, setting ownership and prices
resetGame();
Places the player at the starting position with zero velocity and resets jump counts

draw()

draw() runs 60 times per second and is the heartbeat of any p5.js sketch. This game's draw loop routes between five game states—each state has its own screen and logic. The state machine pattern (if/else switching on a 'state' variable) is the foundation of game development.

🔬 These conditionals check gameState and display different screens. What if you changed 'gameState === "start"' to 'true' to always show the start screen? What would break?

  if (gameState === 'start') {
    drawStartScreen();
  } else if (gameState === 'playing') {
    gameLoop();
  } else if (gameState === 'gameOver') {
    drawGameOverScreen();
  } else if (gameState === 'win') {
    drawWinScreen();
  } else if (gameState === 'complete') {
    drawCompleteScreen();
  }
function draw() {
  background(20, 22, 38);
  drawBackground();

  if (gameState === 'start') {
    drawStartScreen();
  } else if (gameState === 'playing') {
    gameLoop();
  } else if (gameState === 'gameOver') {
    drawGameOverScreen();
  } else if (gameState === 'win') {
    drawWinScreen();
  } else if (gameState === 'complete') {
    drawCompleteScreen();
  }

  drawMetaHUD();
  drawShopButton();
  if (shopOpen) {
    drawShopOverlay();
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

switch-case Game State Router if (gameState === 'start') { drawStartScreen(); } else if (gameState === 'playing') { gameLoop(); } else if (gameState === 'gameOver') { drawGameOverScreen(); } else if (gameState === 'win') { drawWinScreen(); } else if (gameState === 'complete') { drawCompleteScreen(); }

Routes the game to different screens and logic based on the current game state

background(20, 22, 38);
Clears the canvas with a dark blue-grey color each frame, erasing previous drawings
drawBackground();
Draws parallax background tiles that move slowly—decorative, not interactive
if (gameState === 'start') {
Checks the current game state and branches to appropriate logic or drawing
gameLoop();
When playing, run physics, input, collision checks, and render the world
drawMetaHUD();
Always draws coins display, character name, and reward flash messages on top of everything
drawShopButton();
Always draws the Character Shop button at the bottom of the screen
if (shopOpen) { drawShopOverlay(); }
If the shop is open, overlay a darkened panel showing all purchasable characters and their abilities

gameLoop()

gameLoop() is the heartbeat of gameplay—it alternates between an update phase (where logic runs) and a render phase (where we draw). The camera system is the key magic: we apply translate(-cameraX, 0) before drawing world objects so they appear in the right screen position relative to the player.

🔬 This constrains cameraX between 0 and the level width. What happens if you remove the constrain() so the camera can slide past the level edges?

  cameraX = constrain(
    player.pos.x + playerSize.w / 2 - width / 2,
    0,
    max(worldWidth - width, 0)
  );
function gameLoop() {
  if (!shopOpen) {
    handleInput();
    updatePlayerPhysics();
    updateMovingPlatforms();
    updateLava();
    checkHazards();
    checkCoinCollection();
    checkWinCondition();
    updatePlayerAnimation();
    updatePlayerTimers();
  }

  cameraX = constrain(
    player.pos.x + playerSize.w / 2 - width / 2,
    0,
    max(worldWidth - width, 0)
  );

  push();
  translate(-cameraX, 0);
  drawPlatforms();
  drawHazards();
  drawCoins();
  drawGoal();
  drawPlayer();
  pop();

  drawHUD();
}
Line-by-line explanation (16 lines)

🔧 Subcomponents:

calculation Update Phase if (!shopOpen) { handleInput(); updatePlayerPhysics(); updateMovingPlatforms(); updateLava(); checkHazards(); checkCoinCollection(); checkWinCondition(); updatePlayerAnimation(); updatePlayerTimers(); }

Updates player position, checks collisions, and triggers game events—skipped when shop is open

calculation Camera Following cameraX = constrain( player.pos.x + playerSize.w / 2 - width / 2, 0, max(worldWidth - width, 0) );

Centers the camera on the player, clamping so it never shows past level boundaries

calculation Render Phase push(); translate(-cameraX, 0); drawPlatforms(); drawHazards(); drawCoins(); drawGoal(); drawPlayer(); pop();

Applies camera translation, draws world objects at correct positions, then restores coordinates

if (!shopOpen) {
Only process game logic if the shop is closed—pauses the game while shopping
handleInput();
Reads arrow keys and WASD, updates player velocity, handles jump key logic
updatePlayerPhysics();
Applies gravity, moves player by velocity, and resolves collisions with platforms
updateMovingPlatforms();
Oscillates moving platforms back and forth along their predefined paths
updateLava();
Slowly raises lava each frame, making hazards creep upward over time
checkHazards();
Tests if player touched lava—kills them unless they have a shield charge
checkCoinCollection();
Tests if player touched a coin—collects it, adds $200, and removes it from the world
checkWinCondition();
Tests if player reached the goal—awards coins and advances to next level or complete state
updatePlayerAnimation();
Advances walk cycle and bobbing animation based on movement and frame time
updatePlayerTimers();
Decrements invulnerability timer after being hit by a shield
cameraX = constrain( player.pos.x + playerSize.w / 2 - width / 2, 0, max(worldWidth - width, 0) );
Centers camera on player's center, but clamps to level bounds so camera never shows empty space
push();
Saves the current coordinate system before applying camera translation
translate(-cameraX, 0);
Shifts everything left by cameraX—negative so objects move right when camera pans right
drawPlatforms(); drawHazards(); drawCoins(); drawGoal(); drawPlayer();
Draws all world objects—they are automatically positioned correctly by the camera translation
pop();
Restores the original coordinate system so UI drawing happens at screen coordinates
drawHUD();
Draws level and distance text in screen coordinates (not affected by camera)

handleInput()

handleInput() reads the keyboard every frame and updates the player's velocity and jumping state. The jumpKeyHeld flag is crucial: it prevents the jump from retriggering on every frame the key is held, giving precise one-jump-per-press gameplay. This pattern is called 'edge detection' and is fundamental to responsive game controls.

🔬 This uses jumpKeyHeld to detect a single jump per keypress. What happens if you remove the '&& !jumpKeyHeld' condition so the jump runs every frame the key is held?

  const jumpPressed = keyIsDown(UP_ARROW) || keyIsDown(87) || keyIsDown(32);
  if (jumpPressed && !jumpKeyHeld) {
    attemptJump();
    jumpKeyHeld = true;
  }
  if (!jumpPressed) {
    jumpKeyHeld = false;
  }
function handleInput() {
  if (shopOpen) return;

  let dir = 0;
  if (keyIsDown(LEFT_ARROW) || keyIsDown(65)) dir -= 1;
  if (keyIsDown(RIGHT_ARROW) || keyIsDown(68)) dir += 1;
  player.vel.x = dir * activeStats.speed;
  if (dir !== 0) player.facing = dir;

  const jumpPressed = keyIsDown(UP_ARROW) || keyIsDown(87) || keyIsDown(32);
  if (jumpPressed && !jumpKeyHeld) {
    attemptJump();
    jumpKeyHeld = true;
  }
  if (!jumpPressed) {
    jumpKeyHeld = false;
  }
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

calculation Horizontal Movement let dir = 0; if (keyIsDown(LEFT_ARROW) || keyIsDown(65)) dir -= 1; if (keyIsDown(RIGHT_ARROW) || keyIsDown(68)) dir += 1; player.vel.x = dir * activeStats.speed;

Reads arrow and WASD keys, accumulates movement direction, and applies speed

conditional Jump Edge Trigger const jumpPressed = keyIsDown(UP_ARROW) || keyIsDown(87) || keyIsDown(32); if (jumpPressed && !jumpKeyHeld) { attemptJump(); jumpKeyHeld = true; } if (!jumpPressed) { jumpKeyHeld = false; }

Detects when the jump key transitions from released to pressed, triggering one jump per keypress

if (shopOpen) return;
Exit early if shop is open—don't process game input while shopping
let dir = 0;
Initialize direction accumulator (will be -1, 0, or +1)
if (keyIsDown(LEFT_ARROW) || keyIsDown(65)) dir -= 1;
If left arrow or A is pressed, subtract 1 from direction (move left)
if (keyIsDown(RIGHT_ARROW) || keyIsDown(68)) dir += 1;
If right arrow or D is pressed, add 1 to direction (move right)
player.vel.x = dir * activeStats.speed;
Set horizontal velocity to direction times the active speed stat (affected by character ability)
if (dir !== 0) player.facing = dir;
Remember which direction the player is facing (for animation flip, if needed)
const jumpPressed = keyIsDown(UP_ARROW) || keyIsDown(87) || keyIsDown(32);
Check if any jump key (up arrow, W, or space) is currently held down
if (jumpPressed && !jumpKeyHeld) {
Trigger jump only if key is pressed NOW and wasn't held LAST frame—prevents holding to jump repeatedly
jumpKeyHeld = true;
Remember that the jump key is now held, so we don't trigger again until it's released
if (!jumpPressed) { jumpKeyHeld = false; }
Reset the flag when the key is released, allowing another jump on the next keypress

attemptJump()

attemptJump() implements double-jump (or triple-jump, depending on extraJumps) by tracking remainingJumps. When the player jumps from the ground, they don't spend a jump charge—only mid-air jumps consume the pool. This is why extra jump abilities feel so powerful in parkour games.

function attemptJump() {
  if (player.onGround || player.remainingJumps > 0) {
    player.vel.y = -activeStats.jumpStrength;
    if (!player.onGround) player.remainingJumps--;
    player.onGround = false;
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Jump Authority Check if (player.onGround || player.remainingJumps > 0) {

Allows jump if on ground (normal jump) or has extra jumps remaining (mid-air jump)

if (player.onGround || player.remainingJumps > 0) {
Only jump if standing on ground OR have extra jump charges—prevents infinite jumping
player.vel.y = -activeStats.jumpStrength;
Set upward velocity to negative jumpStrength (negative Y is up in p5.js)
if (!player.onGround) player.remainingJumps--;
If jumping mid-air, spend one extra jump charge—don't spend if jumping from ground
player.onGround = false;
Mark that the player is now airborne, so they won't trigger another ground jump until landing

updatePlayerPhysics()

This is the core physics engine. Each frame: gravity accelerates the player downward, X and Y positions update separately (allowing sliding along walls), and collisions are resolved per-axis. Separating horizontal and vertical collision detection is a classic arcade physics trick that prevents the player getting stuck in corners.

🔬 This moves horizontally then collides. What happens if you swap the order: collide first, then move? Would the player still slide smoothly on platforms?

  player.pos.x += player.vel.x;
  player.pos.x = constrain(player.pos.x, 0, worldWidth - playerSize.w);
  resolveHorizontalCollisions();
function updatePlayerPhysics() {
  player.vel.y += activeStats.gravity;

  player.pos.x += player.vel.x;
  player.pos.x = constrain(player.pos.x, 0, worldWidth - playerSize.w);
  resolveHorizontalCollisions();

  player.pos.y += player.vel.y;
  player.onGround = false;
  resolveVerticalCollisions();

  if (player.pos.y > height + 200) {
    setGameOver();
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

calculation Gravity Application player.vel.y += activeStats.gravity;

Accelerates the player downward each frame, simulating gravity

calculation Horizontal Position Update player.pos.x += player.vel.x; player.pos.x = constrain(player.pos.x, 0, worldWidth - playerSize.w);

Moves player by horizontal velocity and clamps position to world bounds

calculation Vertical Position Update player.pos.y += player.vel.y; player.onGround = false; resolveVerticalCollisions();

Moves player down by velocity, resets ground state, then detects standing on platforms

conditional Off-World Death if (player.pos.y > height + 200) { setGameOver(); }

Kills player if they fall too far below the visible canvas

player.vel.y += activeStats.gravity;
Increases downward velocity by gravity each frame—simple Euler integration of acceleration
player.pos.x += player.vel.x;
Moves the player left/right by their horizontal velocity
player.pos.x = constrain(player.pos.x, 0, worldWidth - playerSize.w);
Clamps X position so player can't go past left or right edges of the level
resolveHorizontalCollisions();
Checks if player overlaps any platform; if so, pushes them back and zeroes horizontal velocity
player.pos.y += player.vel.y;
Moves the player up/down by their vertical velocity (gravity + jumps)
player.onGround = false;
Assume player is airborne, then resolveVerticalCollisions will set to true if standing on something
resolveVerticalCollisions();
Checks if player is on top of a platform; if so, lands them and restores jump counts
if (player.pos.y > height + 200) { setGameOver(); }
If the player falls way below the canvas, they're lost—end the level

resolveHorizontalCollisions()

Horizontal and vertical collisions are resolved separately in arcade physics. This function handles the X-axis: if the player is overlapping a platform, we push them flush against it and stop their horizontal velocity. The direction check ensures the player is pushed away, not through the platform.

🔬 This stops at the first collision. What if you removed the `break` (if added) or kept checking all platforms? Could the player get pushed by multiple platforms at once?

  for (const platform of platforms) {
    if (aabb(player, platform)) {
      if (player.vel.x > 0) {
        player.pos.x = platform.x - playerSize.w;
      } else if (player.vel.x < 0) {
        player.pos.x = platform.x + platform.w;
      }
      player.vel.x = 0;
    }
  }
function resolveHorizontalCollisions() {
  for (const platform of platforms) {
    if (aabb(player, platform)) {
      if (player.vel.x > 0) {
        player.pos.x = platform.x - playerSize.w;
      } else if (player.vel.x < 0) {
        player.pos.x = platform.x + platform.w;
      }
      player.vel.x = 0;
    }
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

for-loop Platform Iteration for (const platform of platforms) {

Tests every platform in the level for collision with the player

conditional Collision Direction if (player.vel.x > 0) { player.pos.x = platform.x - playerSize.w; } else if (player.vel.x < 0) { player.pos.x = platform.x + platform.w; }

Pushes the player away from the platform based on which direction they were moving

for (const platform of platforms) {
Loop through every platform in the current level
if (aabb(player, platform)) {
Test if player and platform bounding boxes overlap
if (player.vel.x > 0) {
If player is moving right and hit something, push them back to the left
player.pos.x = platform.x - playerSize.w;
Set player's right edge to be flush with platform's left edge
} else if (player.vel.x < 0) {
If player is moving left and hit something, push them back to the right
player.pos.x = platform.x + platform.w;
Set player's left edge to be flush with platform's right edge
player.vel.x = 0;
Stop horizontal movement so the player doesn't slide through the platform

resolveVerticalCollisions()

When the player's downward velocity collides with a platform, they land and regain all extra jump charges. This is why double-jump games feel fair: you can always jump again after landing, no matter how many mid-air jumps you spent. Jumping into platforms from below creates ceiling bumps—a classic arcade physics feature.

function resolveVerticalCollisions() {
  for (const platform of platforms) {
    if (aabb(player, platform)) {
      if (player.vel.y > 0) {
        player.pos.y = platform.y - playerSize.h;
        player.vel.y = 0;
        player.onGround = true;
        player.remainingJumps = activeStats.extraJumps;
      } else if (player.vel.y < 0) {
        player.pos.y = platform.y + platform.h;
        player.vel.y = 0;
      }
    }
  }
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

conditional Landing Condition if (player.vel.y > 0) { player.pos.y = platform.y - playerSize.h; player.vel.y = 0; player.onGround = true; player.remainingJumps = activeStats.extraJumps;

When falling onto a platform, land the player and reset jump charges

conditional Ceiling Bump } else if (player.vel.y < 0) { player.pos.y = platform.y + platform.h; player.vel.y = 0; }

When jumping into a platform from below, bump the player down and stop upward momentum

for (const platform of platforms) {
Loop through every platform in the level
if (aabb(player, platform)) {
Check if player and platform bounding boxes overlap
if (player.vel.y > 0) {
If player is moving downward (falling), they're landing on top of this platform
player.pos.y = platform.y - playerSize.h;
Position player so their bottom edge sits on top of the platform
player.vel.y = 0;
Stop downward velocity so the player sits still on the platform
player.onGround = true;
Mark that the player is standing on something—allows normal jump
player.remainingJumps = activeStats.extraJumps;
Restore all extra jump charges when landing—crucial for double/triple jump mechanics
} else if (player.vel.y < 0) {
If player is moving upward, they've hit the platform from below (jumping into a ceiling)
player.pos.y = platform.y + platform.h;
Position player so their top edge sits on the bottom of the platform
player.vel.y = 0;
Stop upward velocity—the player bonks on the ceiling and falls back down

updateMovingPlatforms()

Moving platforms use an offset variable that swings back and forth. By tracking baseY and adding offset, we can smoothly oscillate without recalculating. The range / 2 check ensures platforms bounce at the extremes, creating a predictable rhythm for the player to jump onto.

function updateMovingPlatforms() {
  for (const platform of platforms) {
    if (platform.moving) {
      platform.offset += platform.speed;
      if (abs(platform.offset) > platform.range / 2) {
        platform.speed *= -1;
      }
      platform.y = platform.baseY + platform.offset;
    }
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

for-loop Moving Platform Iterator for (const platform of platforms) { if (platform.moving) {

Iterates through all platforms and updates the position of any marked as moving

calculation Oscillation Logic platform.offset += platform.speed; if (abs(platform.offset) > platform.range / 2) { platform.speed *= -1; }

Moves the platform and reverses direction when it reaches the end of its range

for (const platform of platforms) {
Loop through all platforms in the level
if (platform.moving) {
Only update platforms that have a moving flag set to true
platform.offset += platform.speed;
Increment the offset by speed each frame—moves the platform smoothly
if (abs(platform.offset) > platform.range / 2) {
Check if platform has moved too far from its starting position
platform.speed *= -1;
Reverse direction by flipping the sign of speed—platform bounces back
platform.y = platform.baseY + platform.offset;
Update the actual Y position by adding offset to the base position

updateLava()

Lava is a rising hazard that adds time pressure to later levels. By slowly decreasing Y (moving upward), we create a tense race against a rising tide. The max() clamp prevents lava from completely disappearing, so players can always see it creeping closer.

🔬 This decrements lava.y each frame. What if you multiplied riseSpeed by a larger number, like 2? The time pressure would increase—how would that change the difficulty?

function updateLava() {
  for (const hazard of hazards) {
    if (hazard.type === 'lava') {
      hazard.y -= hazard.riseSpeed;
      // avoid climbing completely off-screen
      hazard.y = max(-hazard.h, hazard.y);
    }
  }
}
function updateLava() {
  for (const hazard of hazards) {
    if (hazard.type === 'lava') {
      hazard.y -= hazard.riseSpeed;
      // avoid climbing completely off-screen
      hazard.y = max(-hazard.h, hazard.y);
    }
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

for-loop Lava Iterator for (const hazard of hazards) { if (hazard.type === 'lava') {

Finds lava hazards and updates their rising position

calculation Lava Rise hazard.y -= hazard.riseSpeed; hazard.y = max(-hazard.h, hazard.y);

Moves lava upward each frame and clamps it so it doesn't go too far off-screen

for (const hazard of hazards) {
Loop through all hazards in the level (spikes, lava, etc.)
if (hazard.type === 'lava') {
Only update hazards that are lava—spikes don't move
hazard.y -= hazard.riseSpeed;
Decrease Y (move upward) by riseSpeed each frame—negative Y is up in p5.js
hazard.y = max(-hazard.h, hazard.y);
Clamp lava so it never goes more than its height above the screen—stops it from disappearing

checkHazards()

Shield abilities are a game design feature that reduces frustration. When the player hits lava but has a Guardian Armor charge, they get launched upward and a brief grace period where hazards can't hurt them again. This makes the Guardian ability feel rewarding and gives players a second chance on tough jumps.

function checkHazards() {
  if (player.invulnTimer > 0) return;

  for (const hazard of hazards) {
    if (aabb(player, hazard)) {
      if (player.shieldCharges > 0) {
        player.shieldCharges--;
        player.invulnTimer = 30;
        player.pos.y = hazard.y - playerSize.h - 4;
        player.vel.y = -activeStats.jumpStrength * 0.7;
        setShopMessage('Guardian shield saved you!');
        return;
      }
      setGameOver();
      return;
    }
  }
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

conditional Invulnerability Grace Period if (player.invulnTimer > 0) return;

Skip hazard checks if player just used a shield—gives brief invincibility

conditional Shield Activation if (player.shieldCharges > 0) { player.shieldCharges--; player.invulnTimer = 30; player.pos.y = hazard.y - playerSize.h - 4; player.vel.y = -activeStats.jumpStrength * 0.7; setShopMessage('Guardian shield saved you!'); return; }

Consumes a shield, grants invulnerability, launches player away from hazard

if (player.invulnTimer > 0) return;
If player is in a grace period after using a shield, skip all hazard checks
for (const hazard of hazards) {
Loop through all hazards (lava, spikes, etc.)
if (aabb(player, hazard)) {
Check if player's bounding box overlaps with hazard
if (player.shieldCharges > 0) {
If the player has a shield ability equipped and hasn't used it this attempt
player.shieldCharges--;
Consume one shield charge (Guardian Armor ability grants 1 per level)
player.invulnTimer = 30;
Set invulnerability grace period for 30 frames (~0.5 seconds)
player.pos.y = hazard.y - playerSize.h - 4;
Launch player upward, placing them above the hazard
player.vel.y = -activeStats.jumpStrength * 0.7;
Give them an upward boost of 70% jump strength—weaker than a full jump
setShopMessage('Guardian shield saved you!');
Display feedback message so player knows the shield worked
setGameOver();
If no shield, game over immediately—lava kill

checkCoinCollection()

Backward iteration (i--) is essential when removing items from an array during a loop. If you remove item 3 while looping forward, indices shift and you skip item 4. Looping backward avoids this entirely—when you remove index 5, indices 0–4 are unaffected. This is a crucial pattern in game development.

🔬 This loops backward. What happens if you changed the loop to go forward (i = 0; i < coinPickups.length; i++) and still use splice()? Why does backward matter?

  for (let i = coinPickups.length - 1; i >= 0; i--) {
    const coin = coinPickups[i];
    const coinRect = {
      x: coin.x - coin.size / 2,
      y: coin.y - coin.size / 2,
      w: coin.size,
      h: coin.size
    };
    if (aabb(player, coinRect)) {
      coins += COIN_VALUE;
      lastRewardCoins = COIN_VALUE;
      rewardFlashTimer = REWARD_FLASH_DURATION;
      coinPickups.splice(i, 1);
    }
  }
function checkCoinCollection() {
  for (let i = coinPickups.length - 1; i >= 0; i--) {
    const coin = coinPickups[i];
    const coinRect = {
      x: coin.x - coin.size / 2,
      y: coin.y - coin.size / 2,
      w: coin.size,
      h: coin.size
    };
    if (aabb(player, coinRect)) {
      coins += COIN_VALUE;
      lastRewardCoins = COIN_VALUE;
      rewardFlashTimer = REWARD_FLASH_DURATION;
      coinPickups.splice(i, 1);
    }
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

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

Loops backwards through coins so removing items doesn't skip indices

calculation Collision Box Creation const coinRect = { x: coin.x - coin.size / 2, y: coin.y - coin.size / 2, w: coin.size, h: coin.size };

Creates a square bounding box centered on the coin for collision testing

calculation Coin Removal coinPickups.splice(i, 1);

Removes the coin from the array so it won't be drawn or checked again

for (let i = coinPickups.length - 1; i >= 0; i--) {
Loop backwards through coins (from last to first)—safe for removing items mid-loop
const coin = coinPickups[i];
Get the current coin object
const coinRect = { x: coin.x - coin.size / 2, y: coin.y - coin.size / 2, w: coin.size, h: coin.size };
Create a square bounding box centered on the coin for AABB collision testing
if (aabb(player, coinRect)) {
Check if player overlaps the coin
coins += COIN_VALUE;
Add $200 to the player's coin total
lastRewardCoins = COIN_VALUE;
Store amount for the reward flash text display
rewardFlashTimer = REWARD_FLASH_DURATION;
Start the animation timer for a glowing '+200' message
coinPickups.splice(i, 1);
Remove the coin from the array so it disappears and is no longer checked

checkWinCondition()

checkWinCondition() uses a goalRewarded flag to prevent reward spam. If the player stood still on the goal, coins would be added every frame. The flag ensures the reward fires exactly once per level completion, a common pattern in game design.

function checkWinCondition() {
  if (aabb(player, goal)) {
    if (!player.goalRewarded) {
      awardWinCoins();
      player.goalRewarded = true;
    }
    if (currentLevel >= totalLevels - 1) {
      gameState = 'complete';
    } else {
      gameState = 'win';
    }
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Single Reward Prevention if (!player.goalRewarded) { awardWinCoins(); player.goalRewarded = true; }

Ensures coins are only awarded once per level, even if player stays in goal area

conditional Game End Detection if (currentLevel >= totalLevels - 1) { gameState = 'complete'; } else { gameState = 'win'; }

Determines whether to show level-cleared or all-30-levels-cleared screen

if (aabb(player, goal)) {
Check if player's bounding box overlaps the goal area at end of level
if (!player.goalRewarded) {
Only reward coins once—check if they haven't been awarded yet
awardWinCoins();
Add level completion coins (100 + any character bonus) to player's total
player.goalRewarded = true;
Mark this level as already rewarded so coins aren't added again
if (currentLevel >= totalLevels - 1) {
Check if this is the final level (level 29 out of 0–29)
gameState = 'complete';
If on last level, move to the game-complete state
} else { gameState = 'win'; }
Otherwise, go to the level-cleared state (which advances to the next level)

drawPlayer()

drawPlayer() builds a pixel character from rectangles, using sine-wave animation (walkPhase) to create smooth, natural-looking movement. The palette system lets you swap colors per character. By separating head, torso, arms, and legs, the code creates a flexible character sprite that animates convincingly without pre-drawn frames.

🔬 These animation values use swing and its variants. What happens if you make legSwing and armSwing larger, like swing * 10? The walk would look exaggerated—how does animation amplitude affect visual feel?

  const swing = sin(player.walkPhase);
  const legSwing = swing * 4;
  const armSwing = -swing * 3;
  const bodyBob = abs(swing) * 2;
function drawPlayer() {
  noStroke();

  const palette = currentPalette || defaultPalette;
  const hair = palette.hair || defaultPalette.hair;
  const hairAccent = palette.hairAccent || defaultPalette.hairAccent;
  const skin = palette.skin || defaultPalette.skin;
  const gi = palette.gi || defaultPalette.gi;
  const trim = palette.trim || defaultPalette.trim;
  const boots = palette.boots || defaultPalette.boots;

  const swing = sin(player.walkPhase);
  const legSwing = swing * 4;
  const armSwing = -swing * 3;
  const bodyBob = abs(swing) * 2;

  fill(...hair);
  const headWidth = playerSize.w;
  const headHeight = playerSize.h * 0.55;
  const headX = player.pos.x;
  const headY = player.pos.y - headHeight * 0.25 - bodyBob * 0.4;
  rect(headX, headY, headWidth, headHeight);
  fill(...hairAccent);
  rect(headX - 4, headY + 6, headWidth + 8, headHeight * 0.4);
  rect(headX + 6, headY - 6, headWidth * 0.8, headHeight * 0.5);

  fill(...skin);
  rect(
    player.pos.x + 3,
    player.pos.y + 4 - bodyBob * 0.4,
    playerSize.w - 6,
    playerSize.h * 0.35
  );

  fill(20);
  rect(player.pos.x + 5, player.pos.y + 12 - bodyBob * 0.4, 3, 4);
  rect(player.pos.x + playerSize.w - 8, player.pos.y + 12 - bodyBob * 0.4, 3, 4);

  const torsoWidth = playerSize.w * 0.6;
  const torsoX = player.pos.x + (playerSize.w - torsoWidth) / 2;
  const torsoY = player.pos.y + playerSize.h * 0.38 - bodyBob * 0.6;
  const torsoHeight = playerSize.h * 0.42;

  fill(...gi);
  rect(torsoX, torsoY, torsoWidth, torsoHeight);

  fill(...trim);
  rect(torsoX, torsoY, torsoWidth, 4);
  rect(torsoX, torsoY + torsoHeight - 6, torsoWidth, 6);

  const armWidth = torsoWidth * 0.18;
  const armHeight = torsoHeight;
  fill(...gi);
  rect(torsoX - armWidth - 2, torsoY + armSwing, armWidth, armHeight);
  rect(torsoX + torsoWidth + 2, torsoY - armSwing, armWidth, armHeight);

  const legWidth = torsoWidth * 0.45;
  const legGap = torsoWidth - legWidth * 2;
  const legY = torsoY + torsoHeight;
  const legHeight = playerSize.h * 0.18;

  fill(...gi);
  rect(torsoX + legSwing, legY, legWidth, legHeight);
  rect(torsoX + legWidth + legGap - legSwing, legY, legWidth, legHeight);

  fill(...boots);
  rect(torsoX + legSwing, legY + legHeight - 4, legWidth, 6);
  rect(torsoX + legWidth + legGap - legSwing, legY + legHeight - 4, legWidth, 6);
}
Line-by-line explanation (12 lines)

🔧 Subcomponents:

calculation Animation Values const swing = sin(player.walkPhase); const legSwing = swing * 4; const armSwing = -swing * 3; const bodyBob = abs(swing) * 2;

Converts walkPhase (0 to 2π) into smooth arm, leg, and bob animations using sine waves

calculation Head and Hair fill(...hair); const headWidth = playerSize.w; const headHeight = playerSize.h * 0.55; const headX = player.pos.x; const headY = player.pos.y - headHeight * 0.25 - bodyBob * 0.4; rect(headX, headY, headWidth, headHeight); fill(...hairAccent); rect(headX - 4, headY + 6, headWidth + 8, headHeight * 0.4); rect(headX + 6, headY - 6, headWidth * 0.8, headHeight * 0.5);

Draws the character's head with two hair accent pieces for visual interest

calculation Torso and Limbs fill(...gi); rect(torsoX, torsoY, torsoWidth, torsoHeight); fill(...trim); rect(torsoX, torsoY, torsoWidth, 4); rect(torsoX, torsoY + torsoHeight - 6, torsoWidth, 6); const armWidth = torsoWidth * 0.18; const armHeight = torsoHeight; fill(...gi); rect(torsoX - armWidth - 2, torsoY + armSwing, armWidth, armHeight); rect(torsoX + torsoWidth + 2, torsoY - armSwing, armWidth, armHeight);

Draws the central gi (martial arts jacket) with trim, plus swinging arms

noStroke();
Disable outlines on all shapes so the character looks solid
const palette = currentPalette || defaultPalette;
Use the currently selected character's palette, or default to the starting colors
const swing = sin(player.walkPhase);
Convert walkPhase (radians, 0 to 2π) into a sine wave that smoothly oscillates -1 to +1
const legSwing = swing * 4;
Legs swing ±4 pixels as the player walks
const armSwing = -swing * 3;
Arms swing ±3 pixels, opposite to legs (when left leg moves forward, right arm moves forward)
const bodyBob = abs(swing) * 2;
Body bobs up and down ±2 pixels using absolute value (always 0 to +2, never negative)
const headY = player.pos.y - headHeight * 0.25 - bodyBob * 0.4;
Head position is adjusted by bodyBob so it rises and falls with the walk animation
rect(...hair);
Draw the head rectangle
fill(...hairAccent); rect(headX - 4, headY + 6, headWidth + 8, headHeight * 0.4);
Draw a lighter hair stripe across the forehead for visual detail
rect(torsoX - armWidth - 2, torsoY + armSwing, armWidth, armHeight);
Draw left arm, positioned with armSwing offset so it swings back and forth
rect(torsoX + legSwing, legY, legWidth, legHeight);
Draw left leg, swinging with legSwing offset
rect(torsoX + legWidth + legGap - legSwing, legY, legWidth, legHeight);
Draw right leg, swinging opposite to left leg (- legSwing instead of + legSwing)

initCharacterRoster()

initCharacterRoster() builds the entire character shop. By shuffling abilities and pairing them with skins, it creates replay value—each playthrough has different ability combinations on the same skins. The cost scaling ensures later characters are progressively more expensive, encouraging meaningful economic choices.

🔬 The cost formula is CHARACTER_COST_BASE + idx * 80. What happens if you change it to idx * 100 (no base)? Or CHARACTER_COST_BASE * (idx + 1)? How do these affect shop progression?

function initCharacterRoster() {
  const abilityOrder = shuffleArray(abilityTemplates.map((a) => ({ ...a })));
  characters = skinTemplates.map((skin, idx) => ({
    ...skin,
    ability: abilityOrder[idx],
    cost: CHARACTER_COST_BASE + idx * 80,
    owned: idx === 0
  }));
  selectedCharacterId = characters[0].id;
  applyCharacterStats(characters[0]);
}
function initCharacterRoster() {
  const abilityOrder = shuffleArray(abilityTemplates.map((a) => ({ ...a })));
  characters = skinTemplates.map((skin, idx) => ({
    ...skin,
    ability: abilityOrder[idx],
    cost: CHARACTER_COST_BASE + idx * 80,
    owned: idx === 0
  }));
  selectedCharacterId = characters[0].id;
  applyCharacterStats(characters[0]);
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

calculation Ability Randomization const abilityOrder = shuffleArray(abilityTemplates.map((a) => ({ ...a })));

Copies all abilities and shuffles them so each playthrough pairs skins with different abilities

calculation Character Creation characters = skinTemplates.map((skin, idx) => ({ ...skin, ability: abilityOrder[idx], cost: CHARACTER_COST_BASE + idx * 80, owned: idx === 0 }));

Creates 10 character objects by pairing each skin with a shuffled ability and setting prices

const abilityOrder = shuffleArray(abilityTemplates.map((a) => ({ ...a })));
Copy all 10 ability templates, shuffle them randomly, creating a new pairing each game
characters = skinTemplates.map((skin, idx) => ({
For each of the 10 skin templates, create a complete character object
...skin,
Copy the skin's id, name, and palette into the new character
ability: abilityOrder[idx],
Assign the shuffled ability at the same index
cost: CHARACTER_COST_BASE + idx * 80,
Set price: first character costs 300, second costs 380, third costs 460, etc.
owned: idx === 0
Mark the first character (index 0) as already owned; others must be purchased
selectedCharacterId = characters[0].id;
Start the game with the first character selected
applyCharacterStats(characters[0]);
Load the first character's stats and palette so the game begins with their modifiers active

applyCharacterStats(character, resetPlayer = false)

applyCharacterStats() bridges the character abilities and gameplay. It reads modifiers (speedMult, jumpBoost, etc.) and applies them to base stats, creating the final values used in physics. Some modifiers multiply (speedMult, gravityMult) while others add (jumpBoost, extraJumpsBonus)—this distinction shapes how powerful abilities feel.

function applyCharacterStats(character, resetPlayer = false) {
  const mods = character.ability.modifiers || {};
  activeStats = {
    speed: baseStats.speed * (mods.speedMult || 1),
    gravity: baseStats.gravity * (mods.gravityMult || 1),
    jumpStrength: baseStats.jumpStrength + (mods.jumpBoost || 0),
    extraJumps: baseStats.extraJumps + (mods.extraJumpsBonus || 0),
    cashBonus: mods.cashBonus || 0,
    hazardShield: mods.hazardShield || 0,
    shopDiscount: mods.shopDiscount || 0
  };
  currentPalette = character.palette || defaultPalette;
  selectedCharacterId = character.id;

  if (resetPlayer) {
    resetGame();
  }
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

calculation Stat Modification const mods = character.ability.modifiers || {}; activeStats = { speed: baseStats.speed * (mods.speedMult || 1), gravity: baseStats.gravity * (mods.gravityMult || 1), jumpStrength: baseStats.jumpStrength + (mods.jumpBoost || 0), extraJumps: baseStats.extraJumps + (mods.extraJumpsBonus || 0), cashBonus: mods.cashBonus || 0, hazardShield: mods.hazardShield || 0, shopDiscount: mods.shopDiscount || 0 };

Applies the character's ability modifiers to baseStats, creating the active stat values used in gameplay

const mods = character.ability.modifiers || {};
Extract the modifiers object from the ability, or use an empty object if none
speed: baseStats.speed * (mods.speedMult || 1),
Multiply base speed by speedMult (e.g., 5 * 1.35 = 6.75 for Flash Step)
gravity: baseStats.gravity * (mods.gravityMult || 1),
Multiply base gravity by gravityMult (e.g., 0.8 * 0.65 = 0.52 for Featherfall—lighter feel)
jumpStrength: baseStats.jumpStrength + (mods.jumpBoost || 0),
Add jumpBoost to base jump strength (e.g., 15 + 4 = 19 for Skybound Kick)
extraJumps: baseStats.extraJumps + (mods.extraJumpsBonus || 0),
Add extra jumps (e.g., 1 + 1 = 2 total jumps for Nimbus Boost)
cashBonus: mods.cashBonus || 0,
Store cash bonus so it's added when clearing levels (Lucky Aura grants +50 per level)
hazardShield: mods.hazardShield || 0,
Store shield charge count (Guardian Armor grants 1, used once per level)
shopDiscount: mods.shopDiscount || 0,
Store discount percentage (Merchant Sense makes everything 20% cheaper)
currentPalette = character.palette || defaultPalette;
Load the character's color palette so drawPlayer() uses the right colors
selectedCharacterId = character.id;
Mark this character as currently selected
if (resetPlayer) { resetGame(); }
Optionally reset the player position—true when selecting from shop, false at game start

createLevel(levelIndex = 0)

createLevel() is the procedural level generator. It uses a seeded RNG (mulberry32) so the same level is always identical, then generates platforms with difficulty-scaled gaps, widths, and heights. The lava rises faster as difficulty increases, and moving platforms phase in around level 8. This system creates 30 unique challenges while keeping the core mechanics identical.

🔬 This generates 10–27 platforms across 30 levels. What if you changed the formula to 15 + levelIndex (no floor)? Or 10 + levelIndex * 2? How does platform count affect level length and difficulty?

  const platformCount = 10 + floor(levelIndex * 0.9);
  for (let i = 0; i < platformCount; i++) {
function createLevel(levelIndex = 0) {
  platforms = [];
  hazards = [];
  coinPickups = [];

  const rng = mulberry32(levelIndex + 1);
  const difficulty = levelIndex / (totalLevels - 1);
  worldWidth = 2000 + levelIndex * 90;
  const groundY = height - 60;

  for (let x = 0; x < worldWidth; x += 320) {
    platforms.push({ x, y: groundY, w: 320, h: 60, color: color(60, 52, 46) });
  }

  const lavaHeight = 26;
  hazards.push({
    x: 0,
    y: groundY - lavaHeight,
    w: worldWidth,
    h: lavaHeight,
    type: 'lava',
    riseSpeed: 0.06 + difficulty * 0.12
  });

  const pathPlatforms = [];

  let prev = {
    x: 80,
    y: groundY - 110,
    w: 160,
    h: 18,
    color: color(92, 168, 92)
  };
  pathPlatforms.push(prev);

  const maxJumpableGap = 180;

  const platformCount = 10 + floor(levelIndex * 0.9);
  for (let i = 0; i < platformCount; i++) {
    const gapMin = 110;
    const gapMax = min(maxJumpableGap, 150 + difficulty * 30);
    const gap = gapMin + rng() * (gapMax - gapMin);

    const widthMin = 80;
    const widthMax = max(90, 150 - difficulty * 40);
    const platWidth = constrain(widthMin + rng() * (widthMax - widthMin), 70, 150);

    const targetX = prev.x + prev.w + gap;
    if (targetX > worldWidth - 320) break;

    const minHeight = groundY - 320;
    const maxHeight = groundY - 90;
    const desiredY = constrain(
      groundY - (120 + rng() * (220 + difficulty * 120)),
      minHeight,
      maxHeight
    );

    let verticalDiff = desiredY - prev.y;
    const maxStep = 120;
    verticalDiff = constrain(verticalDiff, -maxStep, maxStep);
    const finalY = prev.y + verticalDiff;

    if (abs(verticalDiff) > 80) {
      const midX = prev.x + prev.w + gap * 0.5 - platWidth * 0.4;
      const midY = prev.y + verticalDiff * 0.5;
      const midPlatform = {
        x: midX,
        y: midY,
        w: platWidth * 0.8,
        h: 18,
        color: color(120, 200, 120)
      };
      pathPlatforms.push(midPlatform);
      maybePlaceCoin(midPlatform, rng);
    }

    const platform = {
      x: targetX,
      y: finalY,
      w: platWidth,
      h: 18,
      color: color(92, 168, 92)
    };
    pathPlatforms.push(platform);
    maybePlaceCoin(platform, rng);
    prev = platform;

    if (i % 3 === 2 && difficulty > 0.25) {
      platform.moving = true;
      platform.baseY = platform.y;
      platform.offset = (rng() - 0.5) * 40;
      platform.range = 60 + difficulty * 80;
      platform.speed = (rng() < 0.5 ? 1 : -1) * (1 + difficulty * 1.2);
    }
  }

  for (const plat of pathPlatforms) {
    platforms.push(plat);
  }

  const goalPlatform = {
    x: worldWidth - 260,
    y: groundY - (150 + difficulty * 150),
    w: 180,
    h: 18,
    color: color(240, 190, 110)
  };
  const approachPlatform = {
    x: goalPlatform.x - 210,
    y: goalPlatform.y - 30,
    w: 150,
    h: 18,
    color: color(92, 168, 92)
  };
  platforms.push(approachPlatform);
  platforms.push(goalPlatform);
  maybePlaceCoin(approachPlatform, rng, true);

  const goalHeight = 190;
  goal = {
    x: goalPlatform.x + goalPlatform.w / 2 - 45,
    y: goalPlatform.y - goalHeight,
    w: 90,
    h: goalHeight
  };
}
}
Line-by-line explanation (14 lines)

🔧 Subcomponents:

calculation Seeded RNG const rng = mulberry32(levelIndex + 1);

Creates a seeded random number generator so the same level is identical every playthrough

calculation Difficulty Progression const difficulty = levelIndex / (totalLevels - 1);

Maps level 0–29 to difficulty 0.0–1.0, scaling level features from easy to hard

for-loop Ground Platform Placement for (let x = 0; x < worldWidth; x += 320) { platforms.push({ x, y: groundY, w: 320, h: 60, color: color(60, 52, 46) }); }

Creates a continuous ground layer with 320-pixel-wide tiles

calculation Lava Initialization hazards.push({ x: 0, y: groundY - lavaHeight, w: worldWidth, h: lavaHeight, type: 'lava', riseSpeed: 0.06 + difficulty * 0.12 });

Creates the rising lava hazard—faster on harder levels

for-loop Procedural Path Generation for (let i = 0; i < platformCount; i++) {

Generates 10–27 platforms based on level difficulty, creating the jumping challenge

calculation Goal Platform and Flag const goalPlatform = { ... }; goal = { ... };

Creates the final platform and goal flag the player must reach to complete the level

platforms = []; hazards = []; coinPickups = [];
Clear all old world data so each level starts fresh
const rng = mulberry32(levelIndex + 1);
Create a seeded RNG using the level index—same seed = same random sequence every time
const difficulty = levelIndex / (totalLevels - 1);
Map level 0–29 to 0.0–1.0 difficulty, used to scale gaps, platforms, and lava speed
worldWidth = 2000 + levelIndex * 90;
Later levels are wider, requiring more jumping to reach the goal
for (let x = 0; x < worldWidth; x += 320) {
Create ground platform tiles across the entire level width
hazards.push({ ..., type: 'lava', riseSpeed: 0.06 + difficulty * 0.12 });
Add rising lava—early levels rise slowly (0.06), final level rises at 0.18 pixels/frame
let prev = { x: 80, y: groundY - 110, ... };
Initialize the first platform high above ground, starting the jumping path
const platformCount = 10 + floor(levelIndex * 0.9);
Early levels have ~10 platforms, later levels have 26–27, ramping difficulty
const gap = gapMin + rng() * (gapMax - gapMin);
Generate a random horizontal gap size between platforms using seeded RNG
const platWidth = constrain(widthMin + rng() * (widthMax - widthMin), 70, 150);
Randomly generate platform width; harder levels have narrower platforms
if (abs(verticalDiff) > 80) { ... midPlatform ... }
If height difference is large, add a stepping stone platform halfway up to help the jump
if (i % 3 === 2 && difficulty > 0.25) { platform.moving = true; ... }
Every 3rd platform (and only on levels after 25% difficulty) becomes a moving platform
const goalPlatform = { x: worldWidth - 260, y: groundY - (150 + difficulty * 150), ... };
Create the final platform, positioned higher on harder levels
goal = { x: ..., y: ..., w: 90, h: 190 };
Create the goal flag area at the top of the goal platform

maybePlaceCoin(platform, rng, force = false)

maybePlaceCoin() adds randomized rewards. By using the seeded RNG and a 35% chance, each level has a deterministic coin distribution—but the force flag ensures the approach platform always has a coin, rewarding players for reaching the final stretch. This blends randomness with intentional design.

function maybePlaceCoin(platform, rng, force = false) {
  if (platform.moving) return;

  const chance = force ? 1 : 0.35;
  if (rng() < chance) {
    coinPickups.push({
      x: platform.x + platform.w / 2,
      y: platform.y - 20,
      size: 20,
      phase: rng() * 1000
    });
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Skip Moving Platforms if (platform.moving) return;

Never place coins on moving platforms—they'd be impossible to collect

conditional Probabilistic Placement const chance = force ? 1 : 0.35; if (rng() < chance) {

35% chance to spawn a coin, or 100% if forced (for goal approach platform)

if (platform.moving) return;
Don't place coins on moving platforms—they bounce around and become uncollectable
const chance = force ? 1 : 0.35;
If force=true, always place a coin (used for goal approach platform); otherwise 35% chance
if (rng() < chance) {
Random number 0–1; if below chance threshold, place the coin
x: platform.x + platform.w / 2,
Center the coin horizontally on the platform
y: platform.y - 20,
Place it 20 pixels above the platform so it hovers visibly
size: 20,
All coins are 20 pixels in diameter
phase: rng() * 1000
Random offset for the bobbing animation—coins don't all bob in sync

shuffleArray(arr)

shuffleArray() implements the Fisher-Yates shuffle algorithm, guaranteeing a uniformly random permutation. This is used in initCharacterRoster() to randomize ability assignments, ensuring each playthrough pairs skins with different abilities while remaining fair and unbiased.

function shuffleArray(arr) {
  for (let i = arr.length - 1; i > 0; i--) {
    const j = floor(random(i + 1));
    [arr[i], arr[j]] = [arr[j], arr[i]];
  }
  return arr;
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

for-loop Fisher-Yates Shuffle for (let i = arr.length - 1; i > 0; i--) { const j = floor(random(i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; }

Classic shuffle algorithm—picks a random element and swaps it with the current position

for (let i = arr.length - 1; i > 0; i--) {
Loop backward from the last element to the second element (i > 0, not i >= 0)
const j = floor(random(i + 1));
Pick a random index from 0 to i (inclusive)—narrows as the loop progresses
[arr[i], arr[j]] = [arr[j], arr[i]];
JavaScript destructuring: swap elements at positions i and j
return arr;
Return the shuffled array

mulberry32(seed)

mulberry32() is a lightweight seeded pseudorandom number generator. Calling it with the same seed twice produces identical sequences—crucial for replay consistency. Each level calls mulberry32(levelIndex + 1), guaranteeing the platforms, gaps, and lava speed are always the same for level 5, level 12, etc.

function mulberry32(seed) {
  return function () {
    let t = (seed += 0x6D2B79F5);
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Hash Function let t = (seed += 0x6D2B79F5); t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296;

Performs bit-level mixing to generate a pseudorandom number from the seed

function mulberry32(seed) {
Returns a function that generates pseudorandom numbers seeded by the input parameter
return function () {
This is a closure—the inner function 'remembers' the seed and updates it each call
let t = (seed += 0x6D2B79F5);
Add a magic constant to seed and store it in t—advances the sequence
t = Math.imul(t ^ (t >>> 15), t | 1);
Bitwise operations (XOR, right shift, OR) mix the bits together for better randomness
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
Final mixing and division by 2^32 produces a number between 0 and 1

aabb(a, b)

aabb() (Axis-Aligned Bounding Box) is the fundamental collision detection function used throughout the game. All four conditions must be true for overlap to exist. This method is simple, fast, and works for axis-aligned (non-rotated) rectangles—perfect for arcade platformers.

function aabb(a, b) {
  return (
    a.pos.x < b.x + b.w &&
    a.pos.x + playerSize.w > b.x &&
    a.pos.y < b.y + b.h &&
    a.pos.y + playerSize.h > b.y
  );
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional AABB Test return ( a.pos.x < b.x + b.w && a.pos.x + playerSize.w > b.x && a.pos.y < b.y + b.h && a.pos.y + playerSize.h > b.y );

Checks if two axis-aligned rectangles overlap using four 1D comparisons

a.pos.x < b.x + b.w &&
Is the player's left edge to the left of the platform's right edge?
a.pos.x + playerSize.w > b.x &&
Is the player's right edge to the right of the platform's left edge?
a.pos.y < b.y + b.h &&
Is the player's top edge above the platform's bottom edge?
a.pos.y + playerSize.h > b.y
Is the player's bottom edge below the platform's top edge?

drawShopOverlay()

drawShopOverlay() renders the entire character shop UI. It uses a grid layout (5x2) to display all 10 purchasable characters, color-coding them by state (selected, owned, locked), and showing prices with active discounts. The responsive sizing ensures it works on different screen sizes.

function drawShopOverlay() {
  fill(5, 5, 15, 200);
  rect(0, 0, width, height);

  const panelW = min(width - 80, 900);
  const panelH = min(height - 100, 520);
  const panelX = (width - panelW) / 2;
  const panelY = (height - panelH) / 2;
  shopPanelBounds = { x: panelX, y: panelY, w: panelW, h: panelH };

  fill(25, 26, 56, 245);
  rect(panelX, panelY, panelW, panelH, 20);

  fill(255);
  textAlign(LEFT, CENTER);
  textSize(28);
  text('Character Shop', panelX + 24, panelY + 34);

  const discount = activeStats.shopDiscount || 0;
  textSize(14);
  if (discount > 0) {
    text(
      `Active discount: ${round(discount * 100)}% off`,
      panelX + 24,
      panelY + 64
    );
  } else {
    text('Earn coins by clearing levels and finding map coins.', panelX + 24, panelY + 64);
  }

  const closeSize = 32;
  const closeX = panelX + panelW - closeSize - 18;
  const closeY = panelY + 18;
  shopCloseBounds = { x: closeX, y: closeY, w: closeSize, h: closeSize };
  fill(70, 40, 40);
  rect(closeX, closeY, closeSize, closeSize, 10);
  fill(255);
  textAlign(CENTER, CENTER);
  textSize(20);
  text('X', closeX + closeSize / 2, closeY + closeSize / 2);

  const cols = 5;
  const rows = 2;
  const cardPadding = 12;
  const cardW = (panelW - cardPadding * (cols + 1)) / cols;
  const cardH = 155;
  const gridTop = panelY + 90;

  characterCardBounds = [];

  characters.forEach((char, idx) => {
    const col = idx % cols;
    const row = floor(idx / cols);
    const cardX = panelX + cardPadding + col * (cardW + cardPadding);
    const cardY = gridTop + row * (cardH + cardPadding);
    characterCardBounds.push({ x: cardX, y: cardY, w: cardW, h: cardH });

    const owned = char.owned;
    const selected = char.id === selectedCharacterId;

    if (selected) {
      fill(70, 120, 190, 230);
    } else if (owned) {
      fill(40, 100, 60, 200);
    } else {
      fill(30, 30, 60, 190);
    }
    rect(cardX, cardY, cardW, cardH, 14);
    noStroke();
    fill(255);
    textAlign(LEFT, TOP);
    textSize(16);
    text(char.name, cardX + 12, cardY + 10);

    textSize(12);
    fill(200);
    text(char.ability.title, cardX + 12, cardY + 32);
    fill(220);
    text(char.ability.description, cardX + 12, cardY + 52, cardW - 24, 60);

    push();
    rectMode(CENTER);
    drawMiniAvatar(cardX + cardW - 48, cardY + 52, 0.7, char.palette);
    pop();

    textAlign(LEFT, TOP);
    textSize(13);
    if (owned) {
      fill(selected ? 255 : 180, 255, 200);
      text(selected ? 'Equipped' : 'Owned', cardX + 12, cardY + cardH - 28);
    } else {
      fill(255, 230, 180);
      const price = getCharacterPrice(char);
      if (discount > 0 && price !== char.cost) {
        text(`Cost: $${price} (base $${char.cost})`, cardX + 12, cardY + cardH - 28);
      } else {
        text(`Cost: $${price}`, cardX + 12, cardY + cardH - 28);
      }
    }
  });

  if (shopMessageTimer > 0) {
    shopMessageTimer--;
    textAlign(CENTER, CENTER);
    textSize(16);
    fill(255, 240, 200);
    text(shopMessage, panelX + panelW / 2, panelY + panelH - 24);
  }

  textAlign(LEFT, TOP);
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

calculation Darkening Overlay fill(5, 5, 15, 200); rect(0, 0, width, height);

Darkens the entire screen behind the shop panel

calculation Panel Dimensions const panelW = min(width - 80, 900); const panelH = min(height - 100, 520); const panelX = (width - panelW) / 2; const panelY = (height - panelH) / 2;

Calculates responsive panel size and centers it on screen

for-loop Character Card Grid characters.forEach((char, idx) => { ... });

Loops through all 10 characters and draws them in a 5x2 grid

fill(5, 5, 15, 200);
Dark tint color with 78% opacity—darkens background without completely black
const panelW = min(width - 80, 900);
Panel width is at most 900px or 80px less than window width—ensures margins on small screens
const panelX = (width - panelW) / 2;
Centers the panel horizontally
const cols = 5; const rows = 2;
Grid layout: 5 characters per row, 2 rows for 10 total characters
const col = idx % cols; const row = floor(idx / cols);
Converts character index (0–9) into grid position: col 0–4, row 0–1
if (selected) { fill(70, 120, 190, 230); }
Blue background for the currently equipped character
} else if (owned) { fill(40, 100, 60, 200); }
Green background for owned but unequipped characters
} else { fill(30, 30, 60, 190); }
Dark blue background for unowned (purchasable) characters
const price = getCharacterPrice(char);
Calculate the price with any active discount applied
if (discount > 0 && price !== char.cost) {
Show discounted price in parentheses if a discount is active

📦 Key Variables

player object

Stores the player character's position, velocity, animation state, jump counts, shield charges, and invulnerability timer

player = { pos: createVector(120, 300), vel: createVector(0, 0), onGround: false, remainingJumps: 1, ... }
playerSize object

Defines the player's collision box dimensions (width 24, height 32 pixels)

const playerSize = { w: 24, h: 32 };
baseStats object

Stores the default character stat values before ability modifiers are applied

const baseStats = { speed: 5, gravity: 0.8, jumpStrength: 15, extraJumps: 1 };
activeStats object

The current stats in use during gameplay, calculated from baseStats plus the selected character's ability modifiers

activeStats = { speed: 6.75, gravity: 0.52, jumpStrength: 19, extraJumps: 2, ... }
coins number

The player's total coin count, used to purchase characters in the shop

coins = 1200;
gameState string

The current game state: 'start', 'playing', 'gameOver', 'win', or 'complete'

gameState = 'playing';
currentLevel number

The current level index (0–29)

currentLevel = 5;
platforms array

Array of all solid platforms in the current level—collision bodies the player can stand on

platforms = [{ x: 0, y: 400, w: 320, h: 60, ... }, ...]
hazards array

Array of hazards (lava, spikes) that end the level if touched

hazards = [{ x: 0, y: 374, w: 2000, h: 26, type: 'lava', riseSpeed: 0.12 }]
coinPickups array

Array of coin positions scattered throughout the level; collected for $200 each

coinPickups = [{ x: 160, y: 340, size: 20, phase: 500 }, ...]
goal object

The goal flag position at the end of the level—reaching it advances to the next level

goal = { x: 1740, y: 160, w: 90, h: 190 };
cameraX number

The horizontal scroll offset—used to center the camera on the player

cameraX = 200;
characters array

Array of 10 character objects, each with unique skin, ability, cost, and ownership status

characters = [{ id: 'solar-flare', name: 'Solar Flare', ability: { ... }, cost: 300, owned: true }, ...]
selectedCharacterId string

The ID of the currently equipped character

selectedCharacterId = 'solar-flare';
currentPalette object

RGB color arrays for the currently selected character (hair, skin, gi, boots, etc.)

currentPalette = { hair: [255, 213, 69], skin: [255, 232, 210], ... };
shopOpen boolean

Whether the character shop panel is currently open

shopOpen = true;
worldWidth number

The total width of the current level in pixels

worldWidth = 2270;

🔧 Potential Improvements (6)

Here are some ways this code could be enhanced:

BUG updatePlayerPhysics() / resolveVerticalCollisions()

Player can get stuck on colliding with multiple platforms simultaneously if positioned between them; the collision resolution doesn't break after finding one collision

💡 Add a break statement after resolving vertical collision to prevent redundant checks, or track which platform was collided with to avoid stacking corrections

PERFORMANCE gameLoop()

Every frame, all platforms and hazards are looped through twice (physics checks + drawing)—with 30+ objects, this is acceptable but could be optimized

💡 Cache collision results or use spatial partitioning (grid or quadtree) for large level counts, though current performance is fine for this scale

FEATURE Character abilities

Character abilities are fixed modifiers; there's no way to swap multiple abilities or combine them

💡 Implement ability slots so players can equip multiple modifier combinations, or add progression unlocks that activate higher tiers of existing abilities as they level up

BUG drawShopOverlay() / handleShopClick()

If the player's coins decrease (e.g., through a hypothetical negative coin event), already-purchased characters could theoretically become 'unpurchasable' again if the game doesn't properly track ownership

💡 Ensure char.owned flag persists independently of coin count—separate state for ownership from affordability

STYLE createLevel()

The level generation function is 150+ lines and mixes platform creation, difficulty scaling, and coin placement—hard to follow and modify

💡 Refactor into helper functions like generatePlatformPath(), scaleDifficultyParams(), and placeCoinsAndHazards() for clarity and reusability

FEATURE Shop system

No undo/revert button—if a player accidentally purchases the wrong character, they can't refund

💡 Add a refund button in the shop (refund 75% of cost) or implement a confirmation dialog before purchase

🔄 Code Flow

Code flow showing setup, draw, gameloop, handleinput, attemptjump, updateplayerphysics, resolvehorizontalcollisions, resolveverticalcollisions, updatemoving, updatelava, checkhazards, checkcoin, checkwin, createplayer, initcharacter, applycharacter, createlevel, maybeplacecoin, shufflearray, mulberry32, aabb, drawshop

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

graph TD start[Start] --> setup[setup] setup --> canvas-setup[Canvas Creation] setup --> pixel-style[Pixel Art Settings] setup --> init-roster[Character Initialization] setup --> draw[draw loop] click setup href "#fn-setup" click canvas-setup href "#sub-canvas-setup" click pixel-style href "#sub-pixel-style" click init-roster href "#sub-init-roster" draw --> state-switch[Game State Router] draw --> update-phase[Update Phase] draw --> render-phase[Render Phase] click draw href "#fn-draw" click state-switch href "#sub-state-switch" click update-phase href "#sub-update-phase" click render-phase href "#sub-render-phase" update-phase --> handleinput[handleInput] update-phase --> gameloop[gameLoop] click handleinput href "#fn-handleinput" click gameloop href "#fn-gameloop" gameloop --> updateplayerphysics[updatePlayerPhysics] gameloop --> updatelava[Update Lava] gameloop --> updatemoving[Update Moving Platforms] gameloop --> checkhazards[Check Hazards] gameloop --> checkcoin[Check Coin] gameloop --> checkwin[Check Win Condition] click updateplayerphysics href "#fn-updateplayerphysics" click updatelava href "#fn-updatelava" click updatemoving href "#fn-updatemoving" click checkhazards href "#fn-checkhazards" click checkcoin href "#fn-checkcoin" click checkwin href "#fn-checkwin" handleinput --> move-input[Horizontal Movement] handleinput --> jump-edge[Jump Edge Trigger] click move-input href "#sub-move-input" click jump-edge href "#sub-jump-edge" updateplayerphysics --> gravity-apply[Gravity Application] updateplayerphysics --> horizontal-movement[Horizontal Position Update] updateplayerphysics --> vertical-movement[Vertical Position Update] updateplayerphysics --> fall-check[Off-World Death] updateplayerphysics --> collision-loop[Platform Iteration] click gravity-apply href "#sub-gravity-apply" click horizontal-movement href "#sub-horizontal-movement" click vertical-movement href "#sub-vertical-movement" click fall-check href "#sub-fall-check" click collision-loop href "#sub-collision-loop" collision-loop --> direction-check[Collision Direction] collision-loop --> landing-check[Landing Condition] collision-loop --> ceiling-check[Ceiling Bump] click direction-check href "#sub-direction-check" click landing-check href "#sub-landing-check" click ceiling-check href "#sub-ceiling-check" updatemoving --> moving-loop[Moving Platform Iterator] moving-loop --> oscillation[Oscillation Logic] click moving-loop href "#sub-moving-loop" click oscillation href "#sub-oscillation" updatelava --> lava-loop[Lava Iterator] lava-loop --> rise-logic[Lava Rise] click lava-loop href "#sub-lava-loop" click rise-logic href "#sub-rise-logic" checkhazards --> invuln-check[Invulnerability Grace Period] checkhazards --> shield-logic[Shield Activation] click invuln-check href "#sub-invuln-check" click shield-logic href "#sub-shield-logic" checkcoin --> coin-loop[Reverse Iteration] checkcoin --> coin-rect[Collision Box Creation] checkcoin --> coin-collect[Coin Removal] checkcoin --> reward-logic[Single Reward Prevention] click coin-loop href "#sub-coin-loop" click coin-rect href "#sub-coin-rect" click coin-collect href "#sub-coin-collect" click reward-logic href "#sub-reward-logic" checkwin --> end-check[Game End Detection] click end-check href "#sub-end-check" render-phase --> camera-calc[Camera Following] render-phase --> drawshop[Draw Shop Overlay] click camera-calc href "#sub-camera-calc" click drawshop href "#fn-drawshop" drawshop --> overlay-darken[Darkening Overlay] drawshop --> panel-layout[Panel Dimensions] drawshop --> card-grid[Character Card Grid] click overlay-darken href "#sub-overlay-darken" click panel-layout href "#sub-panel-layout" click card-grid href "#sub-card-grid"

❓ Frequently Asked Questions

What visual experience does the pixel parkour sketch provide?

The pixel parkour sketch creates a vibrant, pixel-art platformer environment with colorful characters and dynamic backgrounds, showcasing a series of progressively challenging levels.

How can players interact with the pixel parkour game?

Players can control their character's movements, jump between platforms, collect coins, and access a character shop to purchase new skins and abilities.

What creative coding concepts are demonstrated in the pixel parkour sketch?

The sketch demonstrates concepts such as level progression, character customization, and physics-based movement, showcasing how to implement game mechanics in a browser-based environment.

Preview

pixel parkour - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of pixel parkour - Code flow showing setup, draw, gameloop, handleinput, attemptjump, updateplayerphysics, resolvehorizontalcollisions, resolveverticalcollisions, updatemoving, updatelava, checkhazards, checkcoin, checkwin, createplayer, initcharacter, applycharacter, createlevel, maybeplacecoin, shufflearray, mulberry32, aabb, drawshop
Code Flow Diagram