67

This sketch creates an immersive 3D first-person treasure hunt where you explore a procedurally generated forest of grass tiles and trees, searching for a hidden leprechaun's treasure. Using keyboard controls, you navigate the world, follow distance and direction hints on screen, and dig when you reach the correct location.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make the forest denser — Trees will spawn more frequently (roughly every 4 tiles instead of every 7), making the world feel more crowded
  2. Change the sky color to sunset — The background color shifts to warm orange and red tones, transforming the atmosphere
  3. Dig from farther away — You can press SPACE to dig when standing up to 200 units away instead of 90, making it easier to find the treasure
  4. Speed up the digging animation — The progress bar fills in half the time (25 frames instead of 100), making the game feel snappier
  5. Zoom the camera closer — The third-person camera moves much nearer to the leprechaun, giving a tighter, over-the-shoulder view
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch builds a fully 3D first-person treasure hunt using p5.js's WEBGL renderer. You play as a leprechaun exploring an infinite grassy world dotted with trees, guided by a HUD that tells you the distance and direction to hidden treasure. The visual experience is immersive: a third-person camera hovers behind your character, the world generates tiles around you as you walk, and when you finally dig at the right spot, the treasure rises from the ground with a spinning animation.

The code is organized into four major sections: camera and input handling (movement and turning), procedural world generation using deterministic noise for tree placement, treasure state management with digging and reveal animations, and a comprehensive HUD system that updates hints based on your position. By studying it, you will learn WEBGL 3D rendering, camera positioning and tracking, procedural generation patterns, and how to layer interactive feedback into a game loop.

⚙️ How It Works

  1. When the sketch loads, setup() creates a WEBGL canvas, positions the player at the origin with a heading pointing forward, randomly picks a treasure location far from the start, and begins loading a treasure image asynchronously.
  2. Every frame, draw() clears the background to sky blue, sets up global lighting for the 3D scene, processes keyboard input to move and rotate the player, and updates the digging state and treasure reveal animation.
  3. handleInput() reads W/S/arrow keys to move the player forward or backward along their heading direction using sin() and cos() to convert angle into x and z displacement, and reads A/D/arrow keys to rotate the player left or right.
  4. updateCamera() positions a third-person camera behind and above the player, tracking their position and heading so the view follows as you move.
  5. drawGroundAndTrees() uses a view radius to render only tiles near the player (for performance), draws checkerboard-shaded grass planes, and uses a deterministic pseudo-random hash function to decide which tiles spawn trees—no random() in draw(), ensuring consistency.
  6. drawTreasure() marks the treasure's ground patch with a different shade, and once dug up, raises the treasure image (or fallback golden box) from the ground with a lerp() animation while spinning it with rotateY().
  7. drawHUD() displays control instructions at the top and a bottom panel showing the distance to treasure, a compass direction hint (ahead, to your right, etc. based on relative angle math), and contextual messages ('You're getting warm...', 'Press SPACE to dig', or a completion message).

🎓 Concepts You'll Learn

WEBGL 3D renderingCamera positioning and trackingProcedural world generationDeterministic pseudo-random number generation3D transformations and rotationsFirst-person and third-person camera controlKeyboard input handlingSpatial math (distance, angle, normalization)Animation with lerp and sinHUD and UI text rendering in 3D

📝 Code Breakdown

preload()

preload() runs before setup() and is the safe place to load assets that must exist before the sketch starts. WEBGL text requires explicit font files (WOFF format works well).

function preload() {
  hudFont = loadFont(
    'https://cdn.jsdelivr.net/fontsource/fonts/roboto@latest/latin-400-normal.woff'
  );
}
Line-by-line explanation (1 lines)
hudFont = loadFont('https://cdn.jsdelivr.net/fontsource/fonts/roboto@latest/latin-400-normal.woff')
Loads a web font (Roboto) from a CDN for use in WEBGL text rendering; WEBGL requires explicit font loading rather than system fonts

setup()

setup() runs once when the sketch starts. In WEBGL, you must create the canvas with WEBGL as the third argument to unlock 3D features like perspective, lighting, and transforms.

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

  perspective(PI / 3, width / height, 10, 5000);

  chooseTreasureLocation();

  loadImage(
    'https://i.ebayimg.com/images/g/dGkAAeSwwpVpcq97/s-l1200.jpg',
    img => {
      treasureImg = img;
      treasureImgLoaded = true;
    },
    err => {
      console.error('Treasure image failed to load:', err);
      treasureImgFailed = true;
    }
  );
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

function-call Canvas and perspective setup createCanvas(windowWidth, windowHeight, WEBGL); rectMode(CENTER); perspective(PI / 3, width / height, 10, 5000);

Creates a fullscreen WEBGL canvas and sets up a 3D perspective projection with a 60-degree field of view

function-call Asynchronous image loading with error handling loadImage( 'https://i.ebayimg.com/images/g/dGkAAeSwwpVpcq97/s-l1200.jpg', img => { treasureImg = img; treasureImgLoaded = true; }, err => { console.error('Treasure image failed to load:', err); treasureImgFailed = true; } );

Loads the treasure image from a URL in the background (not in preload to avoid hangs); sets flags on success or failure so the sketch handles missing images gracefully

createCanvas(windowWidth, windowHeight, WEBGL)
Creates a fullscreen canvas with the WEBGL renderer, which enables 3D drawing with perspective, lighting, and rotations
rectMode(CENTER)
Tells all rectangle and plane shapes to be centered at their translate position, rather than drawn from a corner
perspective(PI / 3, width / height, 10, 5000)
Sets the 3D camera's perspective: field of view = 60°, aspect ratio matches canvas, near clipping plane = 10 units, far = 5000 units
chooseTreasureLocation()
Calls the helper function to randomly pick a tile far from the origin where the treasure will be hidden
loadImage(..., img => { treasureImg = img; treasureImgLoaded = true; }, ...)
Requests the image from the URL and stores it in treasureImg when it arrives, setting treasureImgLoaded to true so draw() knows it's safe to texture with it

draw()

draw() runs 60 times per second. Each frame updates state, repositions the camera, and renders the 3D world. The order matters: update logic first, then render.

function draw() {
  background(120, 190, 255); // sky color

  // Global lights for the scene
  ambientLight(100);
  directionalLight(255, 255, 255, 0.3, -1, -0.3);

  handleInput();
  updateDiggingAndTreasure();

  updateTreasureMetrics();
  updateCamera();

  drawGroundAndTrees();
  drawTreasure();
  drawPlayer();
  drawHUD();
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

function-call 3D scene lighting ambientLight(100); directionalLight(255, 255, 255, 0.3, -1, -0.3);

ambientLight() illuminates the whole scene uniformly; directionalLight() adds a sun-like light from a direction, creating shadows and depth

function-call State and logic updates handleInput(); updateDiggingAndTreasure(); updateTreasureMetrics(); updateCamera();

Processes keyboard input, advances digging and treasure animations, calculates distance/angle to treasure, and positions the camera

function-call Rendering in order drawGroundAndTrees(); drawTreasure(); drawPlayer(); drawHUD();

Draws the world geometry first, then the player character, then HUD text on top

background(120, 190, 255)
Fills the canvas with sky blue, clearing the previous frame
ambientLight(100)
Adds uniform light to the entire scene with brightness 100; helps you see all surfaces
directionalLight(255, 255, 255, 0.3, -1, -0.3)
Adds a white light from direction (0.3, -1, -0.3), simulating the sun; creates shadows and depth cues
handleInput()
Reads keyboard keys to update player position and heading
updateDiggingAndTreasure()
Advances the digging progress bar and the treasure rise animation
updateTreasureMetrics()
Calculates distance and relative angle from player to treasure for the HUD
updateCamera()
Positions the camera behind and above the player so the view follows movement

handleInput()

This function reads keyboard input each frame and updates the player's position and rotation. The sin/cos pattern is essential for converting angles into x/z directions in a 3D world.

🔬 These two lines convert an angle (heading) into x and z direction components. What happens if you swap sin and cos? How does the player's turning feel different?

    let dx = sin(playerHeading);
    let dz = cos(playerHeading);
function handleInput() {
  let moveForward = 0;

  // Forward/back
  if (keyIsDown(87) || keyIsDown(UP_ARROW)) { // W or ↑
    moveForward += 1;
  }
  if (keyIsDown(83) || keyIsDown(DOWN_ARROW)) { // S or ↓
    moveForward -= 1;
  }

  // Turn left/right
  if (keyIsDown(65) || keyIsDown(LEFT_ARROW)) { // A or ←
    playerHeading -= turnSpeed;
  }
  if (keyIsDown(68) || keyIsDown(RIGHT_ARROW)) { // D or →
    playerHeading += turnSpeed;
  }

  if (moveForward !== 0) {
    let step = moveSpeed * moveForward;
    let dx = sin(playerHeading);
    let dz = cos(playerHeading);
    playerX += dx * step;
    playerZ += dz * step;
  }
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

conditional Forward and backward movement if (keyIsDown(87) || keyIsDown(UP_ARROW)) { moveForward += 1; } if (keyIsDown(83) || keyIsDown(DOWN_ARROW)) { moveForward -= 1; }

Reads W/up-arrow (move forward = +1) and S/down-arrow (move backward = -1); moveForward can end up -1, 0, or +1

conditional Turning left and right if (keyIsDown(65) || keyIsDown(LEFT_ARROW)) { playerHeading -= turnSpeed; } if (keyIsDown(68) || keyIsDown(RIGHT_ARROW)) { playerHeading += turnSpeed; }

Adjusts playerHeading (angle in radians) by ±turnSpeed; negative angle = turn left, positive = turn right

calculation Position update using heading angle let step = moveSpeed * moveForward; let dx = sin(playerHeading); let dz = cos(playerHeading); playerX += dx * step; playerZ += dz * step;

Converts the player's heading angle into x and z direction vectors using sin/cos, then moves the player in that direction by step amount

let moveForward = 0;
Accumulator for forward/backward movement; starts at 0 (no movement)
if (keyIsDown(87) || keyIsDown(UP_ARROW)) { moveForward += 1; }
If W or up-arrow is pressed, add 1 to moveForward (will move forward)
if (keyIsDown(83) || keyIsDown(DOWN_ARROW)) { moveForward -= 1; }
If S or down-arrow is pressed, subtract 1 from moveForward (will move backward)
if (keyIsDown(65) || keyIsDown(LEFT_ARROW)) { playerHeading -= turnSpeed; }
If A or left-arrow is pressed, decrease heading angle to turn left
if (keyIsDown(68) || keyIsDown(RIGHT_ARROW)) { playerHeading += turnSpeed; }
If D or right-arrow is pressed, increase heading angle to turn right
let step = moveSpeed * moveForward;
Multiply moveForward (-1, 0, or 1) by moveSpeed to get the distance to move this frame
let dx = sin(playerHeading);
sin(angle) gives the x-component of a unit direction vector pointing in the heading direction
let dz = cos(playerHeading);
cos(angle) gives the z-component; together with sin, they form a direction vector (dx, 0, dz)
playerX += dx * step;
Move the player along the x-axis by dx scaled to the step distance
playerZ += dz * step;
Move the player along the z-axis by dz scaled to the step distance; now the player has moved in the direction they're facing

updateCamera()

The camera() function sets the viewpoint for the entire 3D scene. By positioning it behind the player and making it follow their heading, you create an immersive third-person view.

🔬 These lines place the camera behind the player using sin/cos to convert the heading angle. What if you remove the minus signs? Where does the camera move?

  let eyeX = playerX - sin(playerHeading) * camDist;
  let eyeZ = playerZ - cos(playerHeading) * camDist;
function updateCamera() {
  let camDist = 260;
  let camHeight = 120;

  // Third-person camera behind the leprechaun
  let eyeX = playerX - sin(playerHeading) * camDist;
  let eyeZ = playerZ - cos(playerHeading) * camDist;
  let eyeY = playerY - camHeight; // more negative = higher in the "sky"

  camera(eyeX, eyeY, eyeZ, playerX, playerY, playerZ, 0, 1, 0);
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

calculation Third-person camera offset let eyeX = playerX - sin(playerHeading) * camDist; let eyeZ = playerZ - cos(playerHeading) * camDist; let eyeY = playerY - camHeight;

Calculates a camera position 260 units behind the player (opposite to heading) and 120 units above, creating a third-person over-shoulder view

let camDist = 260;
Distance from the player to the camera; larger values zoom out, smaller values zoom in
let camHeight = 120;
How far above the player's eye the camera sits; larger values give a more elevated view
let eyeX = playerX - sin(playerHeading) * camDist;
Place camera camDist units behind the player in the x direction; subtract because we want the opposite of the facing direction
let eyeZ = playerZ - cos(playerHeading) * camDist;
Place camera camDist units behind the player in the z direction
let eyeY = playerY - camHeight;
In WEBGL, negative Y is up; subtracting camHeight places the camera above the player's position
camera(eyeX, eyeY, eyeZ, playerX, playerY, playerZ, 0, 1, 0);
Sets up the camera: eye at (eyeX, eyeY, eyeZ), looking at player at (playerX, playerY, playerZ), with up direction (0, 1, 0)

drawGroundAndTrees()

This function uses a classic trick: only render tiles near the player, not the entire infinite world. The nested loop creates a square grid, and the hasTreeForCell() function uses deterministic noise so the trees are always in the same places.

🔬 This creates a checkerboard by alternating shade between 80 and 98 (80 + 18). What if you change the multiplier 18 to 40 or 0? How does the pattern look?

      let shade = 80 + ((ti + tj) & 1) * 18;
      fill(30, shade, 40);
function drawGroundAndTrees() {
  let viewRadius = 7; // how many tiles around the player to render
  let centerTi = round(playerX / tileSize);
  let centerTj = round(playerZ / tileSize);

  for (let i = -viewRadius; i <= viewRadius; i++) {
    for (let j = -viewRadius; j <= viewRadius; j++) {
      let ti = centerTi + i;
      let tj = centerTj + j;
      let worldX = ti * tileSize;
      let worldZ = tj * tileSize;

      // Ground tile
      push();
      translate(worldX, 0, worldZ);
      rotateX(-HALF_PI);
      noStroke();
      // Slight checkerboard variation
      let shade = 80 + ((ti + tj) & 1) * 18;
      fill(30, shade, 40);
      plane(tileSize, tileSize);
      pop();

      // Tree, unless it's the treasure tile
      if (hasTreeForCell(ti, tj) && !(ti === treasureCellX && tj === treasureCellZ)) {
        drawTreeAt(worldX, worldZ);
      }
    }
  }
}
Line-by-line explanation (15 lines)

🔧 Subcomponents:

for-loop Nested loops to render visible tiles for (let i = -viewRadius; i <= viewRadius; i++) { for (let j = -viewRadius; j <= viewRadius; j++) {

Iterates over a grid of tiles within viewRadius of the player; only renders tiles nearby for performance

calculation Ground tile rendering let shade = 80 + ((ti + tj) & 1) * 18; fill(30, shade, 40); plane(tileSize, tileSize);

Creates a checkerboard pattern using bitwise AND on tile coords, then draws the ground plane with a green color

conditional Tree placement condition if (hasTreeForCell(ti, tj) && !(ti === treasureCellX && tj === treasureCellZ)) { drawTreeAt(worldX, worldZ); }

Draws a tree only if the cell should have one (deterministic hash) AND it's not the treasure tile

let viewRadius = 7;
Number of tiles in each direction around the player to render; 7×7 grids = 15×15 tiles = 225 tiles visible
let centerTi = round(playerX / tileSize);
Find which tile row the player is in by dividing their x position by tile size and rounding
let centerTj = round(playerZ / tileSize);
Find which tile column the player is in
for (let i = -viewRadius; i <= viewRadius; i++) {
Loop over tiles from viewRadius behind to viewRadius ahead in the x direction
for (let j = -viewRadius; j <= viewRadius; j++) {
Nested loop over tiles in the z direction; together they create a square grid around the player
let ti = centerTi + i;
Calculate the actual tile index in x by adding the offset to the center tile
let tj = centerTj + j;
Calculate the actual tile index in z
let worldX = ti * tileSize;
Convert tile index back to world coordinates
let worldZ = tj * tileSize;
Convert tile index back to world coordinates
translate(worldX, 0, worldZ);
Move to the center of this tile
rotateX(-HALF_PI);
Rotate the plane 90° so it lies flat on the ground (default plane faces +Y, we need it to face up)
let shade = 80 + ((ti + tj) & 1) * 18;
Use bitwise AND to alternate between two shades every tile, creating a checkerboard effect
fill(30, shade, 40);
Set color to green with varying brightness; shade alternates between 80 and 98 for checkerboard
plane(tileSize, tileSize);
Draw a square plane with dimensions tileSize × tileSize
if (hasTreeForCell(ti, tj) && !(ti === treasureCellX && tj === treasureCellZ)) {
Only draw a tree if this cell should have one (deterministic hash) AND it's not where the treasure is buried

hasTreeForCell()

This is a deterministic pseudo-random function—the same tile always gets the same result, but the output looks random. It's perfect for procedural generation: no random() calls in draw(), and the world is consistent as you explore.

function hasTreeForCell(ti, tj) {
  let n = ti * 73856093 ^ tj * 19349663;
  n = (n << 13) ^ n;
  let nn = (n * (n * n * 15731 + 789221) + 1376312589) & 0x7fffffff;
  return nn % 7 === 0; // ~1 in 7 tiles has a tree
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

calculation Deterministic pseudo-random hash let n = ti * 73856093 ^ tj * 19349663; n = (n << 13) ^ n; let nn = (n * (n * n * 15731 + 789221) + 1376312589) & 0x7fffffff;

Combines tile coordinates using multiplication and bitwise XOR to create a deterministic but seemingly random hash value

let n = ti * 73856093 ^ tj * 19349663;
Multiply each tile coordinate by large prime numbers, then XOR them together to mix the bits; each tile gets a unique input
n = (n << 13) ^ n;
Shift n left by 13 bits and XOR with itself; further scrambles the bits to break up patterns
let nn = (n * (n * n * 15731 + 789221) + 1376312589) & 0x7fffffff;
Apply polynomial mixing with more primes, then mask to positive 32-bit integer; this is a standard pseudo-random hash function
return nn % 7 === 0;
Return true if the hash is divisible by 7 (1 in 7 chance); ensures roughly 1 tree per 7 tiles

drawTreeAt()

This helper function demonstrates how to combine multiple shapes (cylinder + cone) with push/pop to create a compound object. The y-coordinates are carefully chosen so the trunk sits on the ground (y=0) and the foliage sits above it.

🔬 The cylinder arguments are (radius, height, sides, ?) for the trunk. What happens if you change the radius from 10 to 20? Do trees look thicker or thinner?

  // Trunk
  ambientMaterial(120, 80, 40);
  cylinder(10, 80, 10, 1);
function drawTreeAt(x, z) {
  push();
  // Trunk center at y=-40 → extends from -80 up to 0 (ground)
  translate(x, -40, z);

  // Trunk
  ambientMaterial(120, 80, 40);
  cylinder(10, 80, 10, 1);

  // Foliage
  translate(0, -60, 0);
  ambientMaterial(20, 120, 40);
  cone(45, 120, 14, 1);
  pop();
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

function-call Tree trunk ambientMaterial(120, 80, 40); cylinder(10, 80, 10, 1);

Draws a brown cylindrical trunk with radius 10 and height 80

function-call Tree foliage translate(0, -60, 0); ambientMaterial(20, 120, 40); cone(45, 120, 14, 1);

Moves up and draws a green cone for tree foliage

push();
Save the current transformation state so this tree's transforms don't affect other objects
translate(x, -40, z);
Move to the tree's world position; y=-40 is the center of the trunk (extending from y=-80 to y=0)
ambientMaterial(120, 80, 40);
Set color to brown for the trunk; ambientMaterial() works well with WEBGL lighting
cylinder(10, 80, 10, 1);
Draw a cylinder with radius 10, height 80, and detail levels 10 and 1
translate(0, -60, 0);
Move up (more negative y) to position the foliage above the trunk
ambientMaterial(20, 120, 40);
Change color to green for the foliage
cone(45, 120, 14, 1);
Draw a cone with radius 45 and height 120, giving it a classic tree shape
pop();
Restore the transformation state so the next object isn't affected by this tree's transforms

drawPlayer()

This function builds a leprechaun from basic shapes by stacking translate calls and using rotateY() to make the character face the direction they're moving. The y-coordinates are spaced to align the head above the body and hat above the head.

function drawPlayer() {
  push();
  translate(playerX, playerY, playerZ);
  rotateY(playerHeading);

  // Body
  ambientMaterial(20, 120, 40);
  box(30, 45, 20);

  // Head
  translate(0, -40, 0);
  ambientMaterial(255, 220, 180);
  sphere(14, 20, 16);

  // Hat brim
  translate(0, -18, 0);
  ambientMaterial(10, 90, 10);
  cylinder(16, 8, 12, 1);

  // Hat top (cone)
  translate(0, -8, 0);
  ambientMaterial(20, 140, 20);
  cone(18, 30, 16, 1);

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

🔧 Subcomponents:

function-call Leprechaun body ambientMaterial(20, 120, 40); box(30, 45, 20);

Draws a green rectangular body

function-call Leprechaun head translate(0, -40, 0); ambientMaterial(255, 220, 180); sphere(14, 20, 16);

Moves to neck position and draws a peach-colored spherical head

function-call Leprechaun hat (brim + top) translate(0, -18, 0); ambientMaterial(10, 90, 10); cylinder(16, 8, 12, 1); translate(0, -8, 0); ambientMaterial(20, 140, 20); cone(18, 30, 16, 1);

Draws a green hat with a wide brim (cylinder) and pointed top (cone)

push();
Save transforms so the player doesn't affect other objects
translate(playerX, playerY, playerZ);
Move to the player's world position
rotateY(playerHeading);
Rotate the player around the Y axis by their heading so they face the direction they're walking
box(30, 45, 20);
Draw the body as a green box: 30 units wide, 45 tall, 20 deep
translate(0, -40, 0);
Move upward to the head position (more negative y = higher in WEBGL)
sphere(14, 20, 16);
Draw a peach sphere with radius 14 for the head
translate(0, -18, 0);
Move further up to the hat position
cylinder(16, 8, 12, 1);
Draw the hat brim: radius 16, height 8
translate(0, -8, 0);
Move up again for the pointy hat top
cone(18, 30, 16, 1);
Draw the hat top: radius 18, height 30 (a tall cone)

chooseTreasureLocation()

This function uses a do-while loop to ensure the treasure never spawns too close to the player's starting position, making the game challenging enough to be fun.

function chooseTreasureLocation() {
  // Choose a tile not too close to origin, in a big range
  do {
    treasureCellX = floor(random(-20, 21));
    treasureCellZ = floor(random(-20, 21));
  } while (abs(treasureCellX) <= 2 && abs(treasureCellZ) <= 2);

  treasureX = treasureCellX * tileSize;
  treasureZ = treasureCellZ * tileSize;
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

while-loop Treasure placement do-while loop do { treasureCellX = floor(random(-20, 21)); treasureCellZ = floor(random(-20, 21)); } while (abs(treasureCellX) <= 2 && abs(treasureCellZ) <= 2);

Picks a random tile and rejects it if it's too close to the origin; ensures treasure is always far enough from the start

do {
Begin a do-while loop, which always runs at least once before checking the condition
treasureCellX = floor(random(-20, 21));
Pick a random integer tile index from -20 to 20 for x
treasureCellZ = floor(random(-20, 21));
Pick a random integer tile index from -20 to 20 for z
} while (abs(treasureCellX) <= 2 && abs(treasureCellZ) <= 2);
Loop again if both coordinates are near origin (within ±2); this ensures treasure is always at least a few tiles away
treasureX = treasureCellX * tileSize;
Convert tile index to world coordinates by multiplying by tileSize
treasureZ = treasureCellZ * tileSize;
Convert z tile index to world coordinates

updateDiggingAndTreasure()

This function manages two animations: digging (player holds SPACE) and rising (treasure emerges after success). Both use simple incremental updates to drive the visuals.

function updateDiggingAndTreasure() {
  if (isDigging && !treasureFound) {
    digProgress += 0.01;
    if (digProgress >= 1) {
      digProgress = 1;
      isDigging = false;
      treasureFound = true;
      treasureRise = 0;
    }
  }

  if (treasureFound && treasureRise < 1) {
    treasureRise += 0.02;
  }
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

conditional Digging progress update if (isDigging && !treasureFound) { digProgress += 0.01; if (digProgress >= 1) { digProgress = 1; isDigging = false; treasureFound = true; treasureRise = 0; } }

Increments digProgress until it reaches 1, then marks the treasure as found and starts the rise animation

conditional Treasure rise animation if (treasureFound && treasureRise < 1) { treasureRise += 0.02; }

Gradually increases treasureRise from 0 to 1 after the treasure is found, driving the upward animation

if (isDigging && !treasureFound) {
Only progress digging if we're actively digging and haven't already found the treasure
digProgress += 0.01;
Increment digging progress by 0.01 each frame; takes 100 frames (~1.67 seconds at 60 FPS) to reach 1
if (digProgress >= 1) {
When digging finishes (progress reaches 1)
digProgress = 1;
Clamp progress to 1 so it doesn't overshoot
isDigging = false;
Stop the digging animation
treasureFound = true;
Mark the treasure as found so the HUD and visuals update
treasureRise = 0;
Reset the rise animation to 0 so the treasure starts rising from the ground
if (treasureFound && treasureRise < 1) {
Only animate the rise if treasure is found and it hasn't fully risen yet
treasureRise += 0.02;
Increment rise by 0.02 each frame; takes 50 frames (~0.83 seconds) to fully rise

keyPressed()

keyPressed() is called once per frame when a key is pressed. This function uses three conditions (AND logic) to prevent spam-digging and only allow digging when you're on the right spot.

function keyPressed() {
  if (keyCode === 32) { // SPACE
    let d = dist(playerX, 0, playerZ, treasureX, 0, treasureZ);
    if (!treasureFound && !isDigging && d < 90) {
      isDigging = true;
      digProgress = 0;
    }
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional SPACE key detection if (keyCode === 32) {

Checks if the key pressed was SPACE (ASCII 32)

conditional Dig eligibility check if (!treasureFound && !isDigging && d < 90) { isDigging = true; digProgress = 0; }

Only starts digging if treasure isn't already found, digging isn't in progress, and player is close enough (within 90 units)

if (keyCode === 32) { // SPACE
Check if the SPACE bar was pressed (keyCode 32)
let d = dist(playerX, 0, playerZ, treasureX, 0, treasureZ);
Calculate the distance from the player to the treasure using the dist() function (ignoring y)
if (!treasureFound && !isDigging && d < 90) {
Only proceed if: treasure hasn't been found, digging isn't already happening, and player is within 90 units
isDigging = true;
Set the flag to start digging
digProgress = 0;
Reset the progress bar to 0 so the animation starts from the beginning

drawTreasure()

This function manages the visual feedback for treasure: a pulsing ground patch while digging, and a rising, spinning treasure object after success. It demonstrates lerp() for smooth animation and conditional texture loading with a fallback.

🔬 This makes the ground patch pulse while digging by multiplying its size by a sin wave. What happens if you change the multiplier 0.05 to 0.2 or 0? How does the pulse look?

  if (isDigging && !treasureFound) {
    fill(110, 70, 45);
    patchSize *= 1.05 + 0.05 * sin(frameCount * 0.2);
  }
function drawTreasure() {
  // Ground patch marking the treasure tile
  push();
  translate(treasureX, 0.5, treasureZ);
  rotateX(-HALF_PI);
  noStroke();

  let patchSize = tileSize * 0.4;

  if (isDigging && !treasureFound) {
    fill(110, 70, 45);
    patchSize *= 1.05 + 0.05 * sin(frameCount * 0.2);
  } else if (treasureFound) {
    fill(130, 80, 50);
  } else {
    fill(90, 60, 40);
  }

  plane(patchSize, patchSize);
  pop();

  // Once found, raise the treasure (image / gold block) from the ground
  if (treasureFound) {
    push();
    let rise = lerp(0, 140, treasureRise);
    translate(treasureX, -rise, treasureZ);
    rotateY(frameCount * 0.01);

    if (treasureImgLoaded) {
      noStroke();
      texture(treasureImg);
      let w = 200;
      let h = w * (treasureImg.height / treasureImg.width);
      plane(w, h);
    } else {
      // Fallback: golden block if image failed to load
      ambientMaterial(255, 215, 0);
      box(80, 40, 50);
    }

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

🔧 Subcomponents:

calculation Ground patch marking treasure location if (isDigging && !treasureFound) { fill(110, 70, 45); patchSize *= 1.05 + 0.05 * sin(frameCount * 0.2); } else if (treasureFound) { fill(130, 80, 50); } else { fill(90, 60, 40); }

Changes the ground patch color and size based on state: pulsing while digging, darker when found, subtle otherwise

conditional Treasure rise and image display if (treasureFound) { push(); let rise = lerp(0, 140, treasureRise); translate(treasureX, -rise, treasureZ); rotateY(frameCount * 0.01);

Once the treasure is found, animates it rising from the ground and spinning continuously

push();
Save transformation state for the ground patch
translate(treasureX, 0.5, treasureZ);
Move to the treasure tile center, slightly above ground (y=0.5)
rotateX(-HALF_PI);
Rotate the plane to lie flat on the ground
let patchSize = tileSize * 0.4;
Start with the patch at 40% of tile size
if (isDigging && !treasureFound) {
While actively digging (but before finding)
patchSize *= 1.05 + 0.05 * sin(frameCount * 0.2);
Multiply patch size by a value that oscillates between 1.0 and 1.1, making it pulse visually
plane(patchSize, patchSize);
Draw the ground patch as a square plane
if (treasureFound) {
Once the treasure is found, render the actual treasure object
let rise = lerp(0, 140, treasureRise);
Interpolate between 0 and 140 using treasureRise (0 to 1); drives the upward motion
translate(treasureX, -rise, treasureZ);
Move to treasure position, with y increasing as treasureRise goes from 0 to 1
rotateY(frameCount * 0.01);
Spin the treasure continuously by rotating it based on frameCount
if (treasureImgLoaded) {
If the treasure image loaded successfully
texture(treasureImg);
Apply the image as a texture to the next shape
let w = 200; let h = w * (treasureImg.height / treasureImg.width);
Calculate height to match the image's aspect ratio, keeping width at 200
plane(w, h);
Draw a textured plane (the treasure image)
ambientMaterial(255, 215, 0); box(80, 40, 50);
If the image failed, draw a golden box as a fallback

updateTreasureMetrics()

This function calculates the distance and direction from the player to the treasure, which the HUD uses to display hints. These metrics are updated every frame so hints stay current as the player moves.

function updateTreasureMetrics() {
  let dx = treasureX - playerX;
  let dz = treasureZ - playerZ;
  lastDistToTreasure = sqrt(dx * dx + dz * dz);

  let angleToTreasure = atan2(dx, dz); // angle from +Z axis
  lastRelAngle = normalizeAngle(angleToTreasure - playerHeading);
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Distance calculation let dx = treasureX - playerX; let dz = treasureZ - playerZ; lastDistToTreasure = sqrt(dx * dx + dz * dz);

Calculates straight-line distance from player to treasure using the Pythagorean theorem

calculation Relative angle calculation let angleToTreasure = atan2(dx, dz); lastRelAngle = normalizeAngle(angleToTreasure - playerHeading);

Calculates the absolute angle to treasure and subtracts player heading to get the relative angle (for compass directions)

let dx = treasureX - playerX;
Calculate the horizontal distance in x
let dz = treasureZ - playerZ;
Calculate the horizontal distance in z
lastDistToTreasure = sqrt(dx * dx + dz * dz);
Use the Pythagorean theorem to find the straight-line distance (we ignore y because both player and treasure are at ground level)
let angleToTreasure = atan2(dx, dz);
atan2 converts dx and dz into an angle in radians; the result points from player toward treasure
lastRelAngle = normalizeAngle(angleToTreasure - playerHeading);
Subtract the player's heading to get the angle relative to where they're facing; normalizeAngle wraps it to [-π, π]

normalizeAngle()

This utility function normalizes any angle to the range [-π, π] so that compass directions make sense (straight ahead is near 0, left is negative, right is positive).

function normalizeAngle(a) {
  // Normalize to [-PI, PI]
  a = a % TWO_PI;
  if (a > PI) a -= TWO_PI;
  if (a < -PI) a += TWO_PI;
  return a;
}
Line-by-line explanation (3 lines)
a = a % TWO_PI;
Use modulo to wrap the angle to the range [-2π, 2π]
if (a > PI) a -= TWO_PI;
If the angle is in the upper half [π, 2π], subtract 2π to move it to [-π, 0]
if (a < -PI) a += TWO_PI;
If the angle is in the lower half [-2π, -π], add 2π to move it to [0, π]

drawHUD()

The HUD demonstrates screen-space rendering in WEBGL: resetMatrix() switches from 3D world coordinates to 2D screen coordinates. The logic shows how to compose user-friendly messages from game state and metrics.

🔬 This converts an angle (in radians) into compass directions. What happens if you change PI / 6 to PI / 4? Does the 'ahead' zone get wider or narrower?

  if (absA < PI / 6) {
    dirLabel = 'ahead';
  } else if (absA > (5 * PI) / 6) {
    dirLabel = 'behind you';
  } else if (a > 0) {
    dirLabel = 'to your right';
  } else {
    dirLabel = 'to your left';
  }
function drawHUD() {
  push();
  resetMatrix(); // go to screen-space coords, origin at center
  noLights();

  // Top info bar
  fill(0, 0, 0, 150);
  noStroke();
  rect(0, -height / 2 + 30, width, 60);

  fill(255);
  textFont(hudFont);
  textSize(16);
  textAlign(LEFT, CENTER);
  let controls =
    'W/S or ↑/↓: walk    A/D or ←/→: turn    SPACE: dig when you are on the spot';
  text(controls, -width / 2 + 20, -height / 2 + 30);

  // Bottom status bar
  rect(0, height / 2 - 40, width, 80);

  // Direction label based on relative angle
  let a = lastRelAngle;
  let absA = abs(a);
  let dirLabel;
  if (absA < PI / 6) {
    dirLabel = 'ahead';
  } else if (absA > (5 * PI) / 6) {
    dirLabel = 'behind you';
  } else if (a > 0) {
    dirLabel = 'to your right';
  } else {
    dirLabel = 'to your left';
  }

  textAlign(CENTER, CENTER);
  let distText =
    'The treasure is ' +
    nf(lastDistToTreasure, 1, 1) +
    ' units away, ' +
    dirLabel +
    '.';
  text(distText, 0, height / 2 - 52);

  // Digging / warmth hint
  let hint = '';
  if (treasureFound) {
    hint = 'You dug up the treasure!';
    if (treasureImgFailed) {
      hint += ' (Image could not be loaded, showing a golden block instead.)';
    }
  } else if (lastDistToTreasure < 100) {
    if (isDigging) {
      let pct = floor(digProgress * 100);
      hint = 'Digging... ' + pct + '%';
    } else {
      hint = "You're on the spot! Press SPACE to dig.";
    }
  } else if (lastDistToTreasure < 300) {
    hint = "You're getting warm...";
  } else {
    hint = 'Follow the direction hint and wander the infinite green until you find it.';
  }

  text(hint, 0, height / 2 - 24);

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

🔧 Subcomponents:

function-call Top info bar with controls fill(0, 0, 0, 150); noStroke(); rect(0, -height / 2 + 30, width, 60); fill(255); textFont(hudFont); textSize(16); textAlign(LEFT, CENTER); let controls = ...; text(controls, ...);

Draws a translucent black bar at the top with white text listing the control scheme

conditional Compass direction determination let a = lastRelAngle; let absA = abs(a); let dirLabel; if (absA < PI / 6) { dirLabel = 'ahead'; } else if (absA > (5 * PI) / 6) { dirLabel = 'behind you'; } else if (a > 0) { dirLabel = 'to your right'; } else { dirLabel = 'to your left'; }

Converts the relative angle into a human-readable direction: ahead, behind, left, or right

conditional Contextual hint messages let hint = ''; if (treasureFound) { ... } else if (lastDistToTreasure < 100) { ... } else if (lastDistToTreasure < 300) { ... } else { ... }

Shows different hints based on game state and distance: success message, dig prompt when close, warmth message when nearby, or directional guidance when far

push();
Save transformation state
resetMatrix();
Reset all transformations so we're in screen-space coordinates with (0, 0) at canvas center
noLights();
Disable 3D lighting for HUD text (WEBGL text renders better without active lights)
fill(0, 0, 0, 150);
Set color to black with 150 alpha (translucent)
rect(0, -height / 2 + 30, width, 60);
Draw a rectangle at the top of the screen, 60 pixels tall, centered horizontally
textFont(hudFont);
Use the font loaded in preload()
textSize(16);
Set font size to 16 pixels
textAlign(LEFT, CENTER);
Align text to the left edge and vertically centered
if (absA < PI / 6) { dirLabel = 'ahead'; }
If the absolute relative angle is less than 30° (π/6), the treasure is ahead
else if (absA > (5 * PI) / 6) { dirLabel = 'behind you'; }
If the absolute relative angle is greater than 150° (5π/6), the treasure is behind
else if (a > 0) { dirLabel = 'to your right'; }
Positive relative angle means the treasure is to the right
else { dirLabel = 'to your left'; }
Negative relative angle means the treasure is to the left
let distText = 'The treasure is ' + nf(lastDistToTreasure, 1, 1) + ' units away, ' + dirLabel + '.';
Compose the distance message; nf() formats the number with 1 digit before and 1 after the decimal point
if (treasureFound) { hint = 'You dug up the treasure!'; }
If game is won, show victory message
else if (lastDistToTreasure < 100) {
If treasure is very close (less than 100 units)
if (isDigging) { hint = 'Digging... ' + pct + '%'; }
If currently digging, show progress percentage
else { hint = "You're on the spot! Press SPACE to dig."; }
Otherwise, invite the player to press SPACE
else if (lastDistToTreasure < 300) { hint = "You're getting warm..."; }
If treasure is moderately far (100-300 units), show a warmth hint
else { hint = 'Follow the direction hint and wander the infinite green until you find it.'; }
If treasure is far away, give general guidance

windowResized()

windowResized() is called by p5.js whenever the browser window is resized. This keeps the canvas fullscreen and maintains the correct perspective for 3D rendering.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  perspective(PI / 3, width / height, 10, 5000);
}
Line-by-line explanation (2 lines)
resizeCanvas(windowWidth, windowHeight);
Resize the canvas to match the new window size when the browser is resized
perspective(PI / 3, width / height, 10, 5000);
Update the perspective projection so the aspect ratio stays correct after resize

📦 Key Variables

tileSize number

Size of ground tiles in world units; controls the scale of the landscape

let tileSize = 200;
playerX, playerY, playerZ number

The player's 3D position in world space

let playerX = 0; let playerY = -35; let playerZ = 0;
playerHeading number

The angle (in radians) the player is facing; 0 = facing +Z direction

let playerHeading = 0;
moveSpeed number

How many world units the player moves per frame when walking

let moveSpeed = 4;
turnSpeed number

How many radians the player rotates per frame when turning

let turnSpeed = 0.04;
treasureCellX, treasureCellZ number

The tile coordinates (grid index) where the treasure is buried

let treasureCellX, treasureCellZ;
treasureX, treasureZ number

The world coordinates of the treasure location (cellX/Z multiplied by tileSize)

let treasureX, treasureZ;
isDigging boolean

True if the player is currently holding SPACE and digging

let isDigging = false;
digProgress number

A value from 0 to 1 indicating how far the digging animation has progressed

let digProgress = 0;
treasureFound boolean

True once the player has successfully dug up the treasure

let treasureFound = false;
treasureRise number

A value from 0 to 1 controlling how high the treasure has risen after being found

let treasureRise = 0;
treasureImg p5.Image

The loaded treasure image (or undefined if loading failed)

let treasureImg;
treasureImgLoaded boolean

True once the treasure image has finished loading

let treasureImgLoaded = false;
treasureImgFailed boolean

True if the treasure image failed to load from the URL

let treasureImgFailed = false;
hudFont p5.Font

The font object loaded for rendering HUD text in WEBGL

let hudFont;
lastDistToTreasure number

Cached distance from player to treasure, updated each frame for HUD display

let lastDistToTreasure = 0;
lastRelAngle number

The angle from the player's heading to the treasure (in radians), used for compass hints

let lastRelAngle = 0;

🔧 Potential Improvements (5)

Here are some ways this code could be enhanced:

BUG drawTreeAt()

Trees use cylinder() and cone() without specifying sufficient detail levels; on some systems this may render as low-poly or angular shapes

💡 Increase the detail parameters: change cylinder(10, 80, 10, 1) to cylinder(10, 80, 16, 8) and cone(45, 120, 14, 1) to cone(45, 120, 24, 8) for smoother geometry

PERFORMANCE drawGroundAndTrees()

Every tile calculates the checkerboard shade using bitwise operations each frame, even though it never changes

💡 Precompute tile shading or cache it, since the checkerboard pattern is static. Alternatively, use a texture instead of recalculating every frame

STYLE handleInput()

Multiple identical keyIsDown checks with numeric key codes are error-prone; no clear mapping to physical keys

💡 Use named key constants or a keyMap object: e.g., const FORWARD = () => keyIsDown(87) || keyIsDown(UP_ARROW) to improve readability

FEATURE drawTreasure()

The treasure image is loaded asynchronously but there's no loading UI; players see a blank screen while the image downloads

💡 Add a loading spinner or progress bar while treasureImgLoaded is false, so players know the sketch is working

BUG updateTreasureMetrics()

If player and treasure happen to be at exactly the same position, atan2(0, 0) returns 0, which may confuse the compass logic

💡 Add a guard: if (lastDistToTreasure < 1) { lastRelAngle = 0; } to handle the edge case gracefully

🔄 Code Flow

Code flow showing preload, setup, draw, handleinput, updatecamera, drawgroundandtrees, hastreefortile, drawtreeat, drawplayer, choosetreasurelocation, updatediggingantreasure, keypressed, drawtreasure, updatetreasuremetrics, normalizeangle, drawhud, windowresized

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

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] click setup href "#fn-setup" click draw href "#fn-draw" draw --> stateupdates[state-updates] stateupdates --> handleinput[handleinput] handleinput --> forwardbackcheck[forward-back-check] forwardbackcheck --> turnleftright[turn-left-right] turnleftright --> positionupdate[position-update] positionupdate --> updatecamera[updatecamera] updatecamera --> updatetreasuremetrics[updatetreasuremetrics] updatetreasuremetrics --> diggingcondition[digging-condition] diggingcondition --> diggingprogress[digging-progress] diggingprogress --> treasure-rise[treasure-rise] diggingprogress --> spacecheck[space-check] spacecheck --> drawtreasure[drawtreasure] drawtreasure --> groundpatch[ground-patch] groundpatch --> treasure-rise-render[treasure-rise-render] treasure-rise-render --> distancecalc[distance-calc] distancecalc --> relativeangle[relative-angle] relativeangle --> drawhud[drawhud] draw --> renderorder[render-order] renderorder --> drawgroundandtrees[drawgroundandtrees] drawgroundandtrees --> tilegridloop[tile-grid-loop] tilegridloop --> groundtiledraw[ground-tile-draw] groundtiledraw --> treeconditional[tree-conditional] treeconditional --> hashfunction[hash-function] hashfunction --> trunkdraw[trunk-draw] trunkdraw --> foliagedraw[foliage-draw] foliagedraw --> drawplayer[drawplayer] drawplayer --> bodydraw[body-draw] bodydraw --> headdraw[head-draw] headdraw --> hatdraw[hat-draw] drawhud --> topbar[top-bar] topbar --> directionlabel[direction-label] directionlabel --> statushints[status-hints] click stateupdates href "#sub-state-updates" click handleinput href "#fn-handleinput" click forwardbackcheck href "#sub-forward-back-check" click turnleftright href "#sub-turn-left-right" click positionupdate href "#sub-position-update" click updatecamera href "#fn-updatecamera" click updatetreasuremetrics href "#fn-updatetreasuremetrics" click diggingcondition href "#sub-digging-condition" click diggingprogress href "#sub-digging-progress" click treasure-rise href "#sub-treasure-rise" click spacecheck href "#sub-space-check" click drawtreasure href "#fn-drawtreasure" click groundpatch href "#sub-ground-patch" click treasure-rise-render href "#sub-treasure-rise-render" click distancecalc href "#sub-distance-calc" click relativeangle href "#sub-relative-angle" click drawhud href "#fn-drawhud" click renderorder href "#sub-render-order" click drawgroundandtrees href "#fn-drawgroundandtrees" click tilegridloop href "#sub-tile-grid-loop" click groundtiledraw href "#sub-ground-tile-draw" click treeconditional href "#sub-tree-conditional" click hashfunction href "#sub-hash-function" click trunkdraw href "#sub-trunk-draw" click foliagedraw href "#sub-foliage-draw" click drawplayer href "#fn-drawplayer" click bodydraw href "#sub-body-draw" click headdraw href "#sub-head-draw" click hatdraw href "#sub-hat-draw" click topbar href "#sub-top-bar" click directionlabel href "#sub-direction-label" click statushints href "#sub-status-hints"

❓ Frequently Asked Questions

What kind of visual experience does the 3D Leprechaun Treasure Hunt sketch provide?

The sketch creates a vibrant 3D forest environment that users can explore from a first-person perspective, complete with grassy tiles and hidden treasures.

How can users interact with the 3D forest in the treasure hunt sketch?

Users can move around using the keyboard to walk and turn, and press the spacebar to dig when they believe they've located the treasure.

What creative coding techniques are showcased in this p5.js sketch?

The sketch demonstrates 3D rendering using WEBGL, real-time user input handling, and dynamic treasure placement combined with visual feedback through animations.

Preview

67 - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of 67 - Code flow showing preload, setup, draw, handleinput, updatecamera, drawgroundandtrees, hastreefortile, drawtreeat, drawplayer, choosetreasurelocation, updatediggingantreasure, keypressed, drawtreasure, updatetreasuremetrics, normalizeangle, drawhud, windowresized
Code Flow Diagram