beanstalk grower

This sketch is a browser-based idle/clicker game: you tap a glowing sun to earn coins and energy, the energy powers a bezier-leafed beanstalk that grows taller over time, and you spend coins on upgrades and a scrollable worker gallery that generates passive income. Random timed events multiply your earnings for a few minutes at a time, keeping the pacing unpredictable.

🧪 Try This!

Experiment with the code by making these changes:

  1. Supercharge sun clicks — Raising baseSunReward makes every tap on the sun worth far more coins, letting you afford upgrades much faster.
  2. Turbo-charge stalk growth — Increasing growthMultiplier makes the beanstalk shoot upward much faster for the same amount of energy.
  3. Recolor the sky gradient — Swapping the two hex colors in backgroundGradient() changes the entire mood of the scene from a night sky to something warmer.
Prefer the full editor? Open it there →

📖 About This Sketch

Beanstalk Grower is a full incremental/idle game built entirely in p5.js: clicking a pulsing Sun object earns coins and energy, energy drives a beanstalk that grows using bezierVertex-drawn leaves, and coins buy upgrades from two always-visible buttons plus a scrollable worker gallery overlay. Random timed events temporarily multiply every payout, and passive income from hired workers ticks in every three seconds even while you're not clicking.

The code is organized around a small handful of state groups at the top - stalk/energy, coin economy, worker economy, shop UI, and random events - followed by setup()/draw() that tie everything together each frame, a cluster of helper functions grouped by comment headers, and a Sun class that encapsulates the clickable sun's position, pulse animation, and hit-testing. Studying it teaches you how to structure a stateful game with plain objects and classes, how to build custom UI buttons with rect-based hit detection instead of HTML elements, how millis()-based timers create real-time economies, and how push()/pop()/translate() keep drawing code readable when many things are positioned relative to each other.

⚙️ How It Works

  1. When the sketch loads, setup() creates a full-window canvas, builds the Sun object, populates the workers array with 20 hireable characters, schedules the first random event, and positions all the buttons based on the current window size.
  2. Every frame, draw() computes elapsed time (dt), paints a vertical gradient sky and a two-tone ground strip, updates and displays the sun's pulse animation, advances the random event timer, adds passive income from owned workers, grows the beanstalk based on current energy, and finally draws all UI (buttons, coin count, energy bar, event banner, and the shop overlay if it's open).
  3. Clicking the sun in mousePressed() adds coins scaled by your coin-upgrade multiplier and any active event multiplier, refills energy up to a cap, and triggers the sun's pulse animation.
  4. Energy drains a little every frame in updateStalk(), and the remaining energy is mapped to a growth amount added to stalkHeight, so more energy (from more sun clicks) means faster, taller growth; leaves are drawn along the stalk at fixed vertical intervals using bezier curves that alternate left/right.
  5. Coins can be spent on two permanent multiplier upgrades (coin reward per sun-click, and passive-income multiplier) whose costs grow exponentially each purchase, and on individual workers in the shop overlay, each of which adds a flat passive coins-per-second rate once hired.
  6. In the background, updateEvents() counts down either 'time until next event' or 'time left in current event'; when an event starts, a random entry from the events array temporarily multiplies all coin income, shown via a banner and a countdown timer in the corner.

🎓 Concepts You'll Learn

Animation loop (draw)ES6 classes (Sun)Custom UI hit-testing with rectanglesmillis()-based real-time timersArray of objects for game data (workers, events)map() and lerp() for value scaling and easingpush()/pop()/translate() for local coordinate systemsbeginShape()/bezierVertex() for organic shapesCanvas clipping with drawingContext.clip()

📝 Code Breakdown

setup()

setup() runs once and prepares every piece of mutable state - the sun, the shop data, the event timers, and button positions - before draw() starts running 60 times per second.

function setup() {
  createCanvas(windowWidth, windowHeight);
  sun = new Sun();
  initWorkers();
  scheduleNextEvent();
  positionButtons();
  positionShopButton();
  lastPassiveUpdate = millis();
  eventClock = millis();
}
Line-by-line explanation (8 lines)
createCanvas(windowWidth, windowHeight);
Makes the canvas fill the entire browser window.
sun = new Sun();
Creates the clickable Sun object, defaulting to its constructor's position.
initWorkers();
Fills the global workers array with 20 hireable character objects.
scheduleNextEvent();
Resets event state and starts the 5-minute countdown to the first random event.
positionButtons();
Places the coin and worker upgrade buttons in the bottom corners based on current window size.
positionShopButton();
Vertically centers the SHOP toggle button on the left edge.
lastPassiveUpdate = millis();
Records the current time so updatePassiveIncome() can measure elapsed time correctly from frame one.
eventClock = millis();
Records the starting timestamp used to compute delta time (dt) for the event system in draw().

draw()

draw() is the heartbeat of the whole game - it runs ~60 times per second and calls out to every subsystem (visuals, economy, events) in a fixed order, which matters because later calls draw on top of earlier ones.

🔬 This draws the background and sun first. What happens visually if you move sun.display() to AFTER drawUI() in the function - does the sun end up on top of or hidden behind the UI?

  backgroundGradient();
  drawGround();
  sun.update();
  sun.display();
function draw() {
  const now = millis();
  const dt = (now - eventClock) / 1000;
  eventClock = now;

  backgroundGradient();
  drawGround();
  sun.update();
  sun.display();
  updateEvents(dt);
  updatePassiveIncome();
  updateStalk();
  drawUI();
  if (shopOpen) drawShopOverlay();
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

conditional Shop Overlay Toggle if (shopOpen) drawShopOverlay();

Only draws the worker shop panel when the player has it open, saving drawing work otherwise.

const dt = (now - eventClock) / 1000;
Computes elapsed seconds since the last frame, used to advance event timers smoothly regardless of frame rate.
backgroundGradient();
Paints the sky gradient behind everything else, every single frame.
drawGround();
Draws the two-tone dirt strip at the bottom of the screen.
sun.update();
Eases the sun's pulse animation back toward zero each frame.
sun.display();
Draws the sun's halo, body, and emoji glyph at its current pulse size.
updateEvents(dt);
Advances the random event countdown or active-event timer using real elapsed seconds.
updatePassiveIncome();
Adds coins from owned workers every 3 real seconds.
updateStalk();
Grows the beanstalk based on current energy and draws it with leaves.
drawUI();
Draws all buttons, the coin count, the energy bar, and the event timer/banner.

scheduleNextEvent()

This function is the 'reset to normal' state used both at startup and whenever an active event expires.

function scheduleNextEvent() {
  eventActive = false;
  eventMultiplier = 1;
  eventName = "";
  nextEventIn = 300; // 5 minutes
}
Line-by-line explanation (3 lines)
eventActive = false;
Marks that no special event is currently running.
eventMultiplier = 1;
Resets the payout multiplier back to normal (x1) since the event has ended or hasn't started.
nextEventIn = 300; // 5 minutes
Sets a 300-second (5 minute) countdown until the next random event can trigger.

startEvent()

This demonstrates using random() with both an array of objects and a numeric range to create varied, replayable random events.

function startEvent() {
  const evt = random(events);
  eventMultiplier = random(evt.min, evt.max);
  eventName = evt.name;
  eventTimer = 180; // 3 minutes
  eventActive = true;
}
Line-by-line explanation (4 lines)
const evt = random(events);
p5's random() can pick a random element from an array - here it grabs one random event definition like 'Solar Surge'.
eventMultiplier = random(evt.min, evt.max);
Rolls a random multiplier between that event's min and max range, so the same event feels slightly different each time.
eventTimer = 180; // 3 minutes
The event lasts for 180 seconds before it needs to end.
eventActive = true;
Flips the flag so draw()'s UI shows the event banner and coin math applies the multiplier.

updateEvents()

Using dt (real seconds) instead of frameCount keeps timers accurate even if the frame rate dips, which is important for a game meant to run for long idle sessions.

function updateEvents(dt) {
  if (eventActive) {
    eventTimer -= dt;
    if (eventTimer <= 0) scheduleNextEvent();
  } else {
    nextEventIn -= dt;
    if (nextEventIn <= 0) startEvent();
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Active vs Waiting Branch if (eventActive) { ... } else { ... }

Counts down the active event's remaining time, or counts down to the next event, depending on state.

eventTimer -= dt;
Subtracts real elapsed seconds from however long the active event has left.
if (eventTimer <= 0) scheduleNextEvent();
Once the timer runs out, resets everything back to normal and starts the 'waiting' countdown.
nextEventIn -= dt;
Counts down toward the next event while no event is active.
if (nextEventIn <= 0) startEvent();
Once the wait is over, triggers a fresh random event.

initWorkers()

Storing game data as an array of objects (rather than separate arrays for names, costs, rates, etc.) keeps related data together and makes it easy to loop over with forEach/filter/map, as seen in getPassiveRate() and drawShopOverlay().

function initWorkers() {
  workers = [
    { name: "sketch",         rate: 1000,    cost: 10000,     owned: false, hue: 200 },
    { name: "kai",            rate: 1250,    cost: 25000,     owned: false, hue: 320 },
    { name: "kingbob",        rate: 2460,    cost: 40000,     owned: false, hue: 50  },
    { name: "1x1x1x1",        rate: 5000,    cost: 100000,    owned: false, hue: 120 },
    { name: "steve",          rate: 12500,   cost: 250000,    owned: false, hue: 30  },
    { name: "Excalibur",      rate: 16500,   cost: 500000,    owned: false, hue: 280 },
    { name: "Phoenix",        rate: 25000,   cost: 1245500,   owned: false, hue: 15  },
    { name: "Lost Ghost",     rate: 55000,   cost: 5000000,   owned: false, hue: 260 },
    { name: "CatacombDragon", rate: 75000,   cost: 7500000,   owned: false, hue: 100 },
    { name: "warlord",        rate: 100000,  cost: 10000000,  owned: false, hue: 0   },
    { name: "Astrid",         rate: 15000,   cost: 150000,    owned: false, hue: 45  },
    { name: "Nimbus",         rate: 20000,   cost: 330000,    owned: false, hue: 180 },
    { name: "Glitch",         rate: 28000,   cost: 750000,    owned: false, hue: 330 },
    { name: "Volt",           rate: 40000,   cost: 1500000,   owned: false, hue: 60  },
    { name: "Eclipse",        rate: 60000,   cost: 2500000,   owned: false, hue: 250 },
    { name: "Driftwalker",    rate: 85000,   cost: 8000000,   owned: false, hue: 310 },
    { name: "Mythra",         rate: 120000,  cost: 12500000,  owned: false, hue: 25  },
    { name: "Quasar",         rate: 180000,  cost: 18000000,  owned: false, hue: 210 },
    { name: "Axiom",          rate: 260000,  cost: 26000000,  owned: false, hue: 90  },
    { name: "Zenith",         rate: 400000,  cost: 40000000,  owned: false, hue: 300 }
  ];
}
Line-by-line explanation (1 lines)
workers = [ ... ];
Builds an array of 20 plain JavaScript objects, each describing one hireable worker's name, passive coin rate, purchase cost, hired status, and portrait hue.

updatePassiveIncome()

This pattern - accumulate time, then drain it in fixed steps with a while loop - is a common way to run logic at a fixed rate independent of the variable frame rate, and to safely 'catch up' after a pause.

🔬 Payouts currently happen every 3 seconds. What happens to how 'twitchy' the coin counter looks if you change both the while condition and the subtraction to 1 second instead of 3?

  passiveAccumulator += dt;
  while (passiveAccumulator >= 3) {
    coins += getPassiveRate() * 3 * eventMultiplier;
    passiveAccumulator -= 3;
  }
function updatePassiveIncome() {
  const now = millis();
  const dt = (now - lastPassiveUpdate) / 1000;
  lastPassiveUpdate = now;
  if (dt <= 0) return;

  passiveAccumulator += dt;
  while (passiveAccumulator >= 3) {
    coins += getPassiveRate() * 3 * eventMultiplier;
    passiveAccumulator -= 3;
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

while-loop 3-Second Payout Loop while (passiveAccumulator >= 3) { coins += getPassiveRate() * 3 * eventMultiplier; passiveAccumulator -= 3; }

Pays out passive income in fixed 3-second chunks, even catching up multiple payouts if the tab was inactive and a lot of time passed at once.

const dt = (now - lastPassiveUpdate) / 1000;
Measures real seconds elapsed since the last time this function ran.
if (dt <= 0) return;
Safety check - if no time has passed (or clock went backward), skip the rest to avoid weird math.
passiveAccumulator += dt;
Banks the elapsed time into a running total that's checked against the 3-second payout interval.
while (passiveAccumulator >= 3) {
A while loop (not if) so that if more than 3 seconds have accumulated - e.g. after the browser tab was backgrounded - multiple payouts happen in one go instead of losing income.
coins += getPassiveRate() * 3 * eventMultiplier;
Adds coins equal to 3 seconds' worth of the combined passive rate, boosted by any active event multiplier.
passiveAccumulator -= 3;
Removes the 3 seconds that were just paid out, leaving any remainder for the next check.

getPassiveRate()

filter() and reduce() are core JavaScript array methods for querying and summarizing data - here they replace a manual for-loop with counters, making the intent ('sum the rates of owned workers') read almost like a sentence.

function getPassiveRate() {
  const baseRate = workers
    .filter(w => w.owned)
    .reduce((sum, w) => sum + w.rate, 0);
  return baseRate * workerMultiplier;
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

calculation Owned Workers Sum workers.filter(w => w.owned).reduce((sum, w) => sum + w.rate, 0);

Filters the workers array down to only hired ones, then sums their individual coin rates into one total.

.filter(w => w.owned)
Array method that keeps only the worker objects whose owned property is true.
.reduce((sum, w) => sum + w.rate, 0)
Array method that adds up the rate property of every remaining worker, starting the running total at 0.
return baseRate * workerMultiplier;
Applies the purchased worker-upgrade multiplier on top of the raw summed rate.

backgroundGradient()

This is the classic 'draw one line per pixel row' technique for a gradient background, since p5 has no built-in gradient fill - lerpColor() does the color math for you.

🔬 This loop blends navy into green from top to bottom. What happens if you swap the two hex colors so it goes green-to-navy instead?

    const inter = map(y, 0, height, 0, 1);
    const c = lerpColor(color("#0b0f2f"), color("#183d1b"), inter);
    stroke(c);
    line(0, y, width, y);
function backgroundGradient() {
  noFill();
  for (let y = 0; y < height; y++) {
    const inter = map(y, 0, height, 0, 1);
    const c = lerpColor(color("#0b0f2f"), color("#183d1b"), inter);
    stroke(c);
    line(0, y, width, y);
  }
  noStroke();
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

for-loop Row-by-row Gradient for (let y = 0; y < height; y++) { ... }

Draws one horizontal line per pixel row, each blended between two colors, to fake a smooth vertical gradient.

const inter = map(y, 0, height, 0, 1);
Converts the current row number into a 0-to-1 fraction of the way down the screen.
const c = lerpColor(color("#0b0f2f"), color("#183d1b"), inter);
Blends between a dark navy and a dark green based on that fraction - lerpColor() interpolates between two colors.
stroke(c);
Sets the line color for this row to the blended color.
line(0, y, width, y);
Draws a full-width horizontal line at this row, effectively painting one pixel-row of the gradient.

drawGround()

Two overlapping rectangles in slightly different shades is a cheap, effective way to fake depth without needing gradients or textures.

function drawGround() {
  fill("#2b1c0f");
  rect(0, height - 60, width, 60);
  fill("#5b4126");
  rect(0, height - 45, width, 45);
}
Line-by-line explanation (4 lines)
fill("#2b1c0f");
Sets a dark brown fill color for the deeper soil layer.
rect(0, height - 60, width, 60);
Draws a full-width strip 60 pixels tall, starting 60px above the bottom of the canvas.
fill("#5b4126");
Switches to a lighter brown for the topsoil layer.
rect(0, height - 45, width, 45);
Draws a slightly shorter, lighter strip on top, creating a simple two-tone dirt effect.

updateStalk()

This function shows how push()/translate()/pop() let you draw in a convenient local coordinate system (origin at the ground, up is negative y) without having to add width/height offsets to every single shape.

🔬 leafSpacing controls how far apart the leaves are. What happens to the stalk's look if you shrink leafSpacing to 40 - do you get way more leaves, and does performance stay smooth as the stalk grows very tall?

  const leafSpacing = 90;
  for (let y = -30; y > -stalkHeight + 40; y -= leafSpacing) {
    drawLeaf(y, (y / leafSpacing) % 2 === 0);
  }
function updateStalk() {
  const growth = map(energy, 0, maxEnergy, 0, 4);
  stalkHeight += growth * 0.5 * growthMultiplier;
  energy = max(energy - energyDrain, 0);

  push();
  translate(width / 2, height);
  fill("#4bc772");
  noStroke();
  rectMode(CORNERS);
  rect(-15, 0, 15, -stalkHeight);

  const leafSpacing = 90;
  for (let y = -30; y > -stalkHeight + 40; y -= leafSpacing) {
    drawLeaf(y, (y / leafSpacing) % 2 === 0);
  }

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

🔧 Subcomponents:

for-loop Leaf Placement Loop for (let y = -30; y > -stalkHeight + 40; y -= leafSpacing) { drawLeaf(y, (y / leafSpacing) % 2 === 0); }

Walks up the stalk in fixed steps, placing a leaf at each height and alternating which side it flips to.

const growth = map(energy, 0, maxEnergy, 0, 4);
Converts the current energy level (0-100) into a growth amount between 0 and 4 - more energy means more growth this frame.
stalkHeight += growth * 0.5 * growthMultiplier;
Adds that scaled growth amount to the stalk's total height, further scaled by the growthMultiplier tunable.
energy = max(energy - energyDrain, 0);
Drains a little energy every frame but never lets it go below zero.
translate(width / 2, height);
Moves the coordinate origin to the bottom-center of the canvas, so the stalk can be drawn growing straight up from y=0 to y=-stalkHeight.
rectMode(CORNERS);
Changes how rect() interprets its arguments - now the 2nd and 4th arguments are opposite corners instead of x/y/width/height.
rect(-15, 0, 15, -stalkHeight);
Draws the stalk itself as a vertical bar from the ground (y=0) up to the current height (negative y is up).
for (let y = -30; y > -stalkHeight + 40; y -= leafSpacing) {
Starts near the top of the visible stalk and steps upward (more negative y) by leafSpacing each time, stopping once it passes the top of the stalk.
drawLeaf(y, (y / leafSpacing) % 2 === 0);
Draws one leaf at this height; the boolean flips true/false based on whether the step index is even, alternating which side the leaf curves toward.
pop();
Restores the previous coordinate system so later drawing isn't affected by the translate().
rectMode(CORNER);
Resets rect() back to its default mode (x, y, width, height) so other functions aren't affected by the CORNERS change made above.

drawLeaf()

bezierVertex() lets you build smooth, organic curves point by point - here just one curve mirrored by a sign flip is enough to fake a leaf shape on either side of the stalk.

function drawLeaf(y, flip) {
  push();
  translate(0, y);
  const dir = flip ? -1 : 1;
  fill("#69dd8f");
  beginShape();
  vertex(15, 0);
  bezierVertex(80 * dir, -20, 80 * dir, -60, 15, -50);
  endShape(CLOSE);
  pop();
}
Line-by-line explanation (5 lines)
translate(0, y);
Moves the origin up to this leaf's height so the leaf shape can be drawn using simple local coordinates.
const dir = flip ? -1 : 1;
Turns the boolean 'flip' into a direction multiplier: -1 mirrors the leaf to the left, 1 keeps it on the right.
beginShape(); vertex(15, 0);
Starts a custom shape and sets its first anchor point, just off the stalk's edge.
bezierVertex(80 * dir, -20, 80 * dir, -60, 15, -50);
Adds a curved segment using two control points (scaled by dir to flip direction) and an end anchor point, producing the leaf's curved silhouette.
endShape(CLOSE);
Closes the shape back to the first vertex and fills it in, completing the leaf outline.

drawUI()

drawUI() is a coordinator function - it doesn't draw much itself, but calls each specific UI piece in the right order and adds the coin/energy readouts on top.

function drawUI() {
  drawShopButton();
  drawCoinButton();
  drawWorkerButton();

  // Coins label
  fill("#ffd75e");
  textAlign(LEFT, BOTTOM);
  textSize(16);
  text("Coins: " + floor(coins), coinButton.x, coinButton.y - 10);

  // Energy bar
  const barWidth = 220;
  const barHeight = 16;
  stroke(255);
  noFill();
  rect(width - barWidth - 16, 46, barWidth, barHeight, 4);
  noStroke();
  fill("#ff69b4");
  rect(
    width - barWidth - 16,
    46,
    map(energy, 0, maxEnergy, 0, barWidth),
    barHeight,
    4
  );

  drawEventTimer();

  if (eventActive) {
    drawEventBanner();
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Event Banner Display if (eventActive) { drawEventBanner(); }

Only shows the event banner overlay while a special event is currently running.

text("Coins: " + floor(coins), coinButton.x, coinButton.y - 10);
Displays the rounded-down coin total just above the coin upgrade button.
rect(width - barWidth - 16, 46, barWidth, barHeight, 4);
Draws the outline of the energy bar's background track near the top-right.
map(energy, 0, maxEnergy, 0, barWidth)
Converts the current energy (0-100) into a pixel width so the filled bar visually shrinks and grows with energy.

drawEventTimer()

A simple example of using one boolean flag to decide which of two related pieces of text to display.

function drawEventTimer() {
  fill(255);
  textAlign(LEFT, TOP);
  textSize(14);
  if (eventActive) {
    text("Event ends in " + formatTime(eventTimer), 16, 16);
  } else {
    text("Next event in " + formatTime(nextEventIn), 16, 16);
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

conditional Active vs Waiting Label if (eventActive) { ... } else { ... }

Chooses which countdown message to show depending on whether an event is currently active.

text("Event ends in " + formatTime(eventTimer), 16, 16);
Shows how much time is left in the current event, formatted as mm:ss.
text("Next event in " + formatTime(nextEventIn), 16, 16);
Shows the countdown until the next event begins, when no event is currently active.

formatTime()

This is a reusable utility function - it takes raw seconds and returns a friendly mm:ss string, used by both the event and (potentially) any other timer in the game.

function formatTime(seconds) {
  const s = max(0, floor(seconds));
  const m = floor(s / 60);
  const sec = s % 60;
  return nf(m, 2) + ":" + nf(sec, 2);
}
Line-by-line explanation (4 lines)
const s = max(0, floor(seconds));
Rounds down to a whole number of seconds and clamps negative values to 0.
const m = floor(s / 60);
Converts total seconds into whole minutes.
const sec = s % 60;
Finds the remaining seconds after removing full minutes, using the modulo operator.
return nf(m, 2) + ":" + nf(sec, 2);
p5's nf() pads numbers with leading zeros - here it formats minutes and seconds as two-digit strings like '03:07'.

drawEventBanner()

Because rectMode/push/pop are scoped to this function, changing them here doesn't leak out and break other drawing code elsewhere - an important habit in p5 sketches with many drawing functions.

function drawEventBanner() {
  push();
  translate(width / 2, 80);
  rectMode(CENTER);
  noStroke();
  fill(0, 180);
  rect(0, 0, 420, 58, 16);

  fill("#ffd75e");
  textAlign(CENTER, CENTER);
  textSize(20);
  text(eventName + " (x" + eventMultiplier.toFixed(2) + ")", 0, -6);
  textSize(14);
  text("Sun + worker payouts affected!", 0, 14);
  pop();
}
Line-by-line explanation (4 lines)
translate(width / 2, 80);
Moves the origin to the top-center area where the banner should appear.
rectMode(CENTER);
Makes the following rect() draw centered on the current origin instead of from a corner.
fill(0, 180);
A semi-transparent black background for the banner, so text stays readable over the sky.
text(eventName + " (x" + eventMultiplier.toFixed(2) + ")", 0, -6);
Shows the event's name and its rolled multiplier rounded to two decimal places.

drawShopButton()

Drawing a filled rect then an outlined rect on top is a simple way to get both a background color and a visible border without needing an image or SVG.

function drawShopButton() {
  const { x, y, w, h } = shopButton;
  noStroke();
  fill("rgba(30, 30, 60, 0.8)");
  rect(x, y, w, h, 10);
  stroke(255);
  strokeWeight(2);
  noFill();
  rect(x, y, w, h, 10);
  noStroke();
  fill(255);
  textAlign(CENTER, CENTER);
  textSize(16);
  text(shopOpen ? "SHOP" : "SHOP (off)", x + w / 2, y + h / 2);
}
Line-by-line explanation (4 lines)
const { x, y, w, h } = shopButton;
Destructures the shopButton object into individual variables for shorter code below.
rect(x, y, w, h, 10);
Draws the button's filled background with 10px rounded corners (first pass, no stroke).
rect(x, y, w, h, 10);
Redraws just the outline on top (stroke set, fill off) to give the button a bordered look.
text(shopOpen ? "SHOP" : "SHOP (off)", x + w / 2, y + h / 2);
A ternary picks the label text based on whether the shop is currently open.

drawShopOverlay()

This function combines several advanced techniques at once: manual scroll math, raw canvas clipping via drawingContext, and forEach for rendering a data-driven list of UI cards - patterns you'll reuse in almost any scrollable p5 interface.

🔬 This nested ternary picks a card color based on owned/affordable state. What happens if you swap the 'canAfford' color to a bright red - does it help you spot affordable workers faster while scanning the list?

    fill(
      owned
        ? "rgba(30,30,30,0.75)"
        : canAfford
        ? "rgba(40,90,120,0.92)"
        : "rgba(40,30,60,0.85)"
    );
function drawShopOverlay() {
  const cardHeight = 140;
  const spacing = 22;
  const contentHeight = workers.length * (cardHeight + spacing) - spacing;

  const panelMargin = 20;
  const panelX = panelMargin;
  const panelY = panelMargin;
  const panelWidth = width - panelMargin * 2;
  const panelHeight = height - panelMargin * 2;

  const innerPadding = 36;
  const headerHeight = 110;
  const viewX = panelX + innerPadding;
  const viewY = panelY + headerHeight;
  const viewWidth = panelWidth - innerPadding * 2;
  const viewHeight = panelHeight - headerHeight - innerPadding;

  const maxScroll = max(0, contentHeight - viewHeight);
  shopScroll = constrain(shopScroll, 0, maxScroll);

  fill(0, 200);
  rect(0, 0, width, height);

  fill(20, 30, 60, 240);
  rect(panelX, panelY, panelWidth, panelHeight, 24);

  fill(255);
  textAlign(CENTER, TOP);
  textSize(34);
  text("Worker Gallery", width / 2, panelY + 24);
  textSize(16);
  text("Scroll & tap a portrait to hire passive coins.", width / 2, panelY + 64);

  // Close button
  shopCloseButton.x = panelX + panelWidth - shopCloseButton.w - 24;
  shopCloseButton.y = panelY + 24;
  drawShopCloseButton();

  // Clip to scrollable area
  drawingContext.save();
  drawingContext.beginPath();
  drawingContext.rect(viewX, viewY, viewWidth, viewHeight);
  drawingContext.clip();

  layoutWorkerButtons(viewX, viewY - shopScroll, viewWidth, cardHeight, spacing);

  workers.forEach(worker => {
    const btn = worker.button;
    const owned = worker.owned;
    const canAfford = coins >= worker.cost;

    // Card
    noStroke();
    fill(
      owned
        ? "rgba(30,30,30,0.75)"
        : canAfford
        ? "rgba(40,90,120,0.92)"
        : "rgba(40,30,60,0.85)"
    );
    rect(btn.x, btn.y, btn.w, btn.h, 24);

    // Border
    stroke(owned ? "#7c7c7c" : canAfford ? "#93ff97" : "rgba(255,255,255,0.5)");
    strokeWeight(3);
    noFill();
    rect(btn.x, btn.y, btn.w, btn.h, 24);
    noStroke();

    // Portrait
    drawPortrait(btn.x + 90, btn.y + btn.h / 2, worker.hue, owned);

    // Text
    fill(255);
    textAlign(LEFT, TOP);
    textSize(20);
    text(worker.name.toUpperCase(), btn.x + 175, btn.y + 18);

    textSize(15);
    text("Cost: " + worker.cost, btn.x + 175, btn.y + 55);
    text(worker.rate + " coins/s", btn.x + 175, btn.y + 78);

    if (owned) {
      textAlign(RIGHT, TOP);
      textSize(14);
      text("HIRED", btn.x + btn.w - 20, btn.y + 18);
    }
  });

  drawingContext.restore();
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

calculation Scroll Clamping shopScroll = constrain(shopScroll, 0, maxScroll);

Keeps the scroll position from going above the top or past the bottom of the worker list.

for-loop Worker Card Drawing workers.forEach(worker => { ... });

Loops through every worker to draw its card, border, portrait, and text - color-coded by owned/affordable state.

conditional Card Color by State fill(owned ? "rgba(30,30,30,0.75)" : canAfford ? "rgba(40,90,120,0.92)" : "rgba(40,30,60,0.85)");

Picks a different card background color depending on whether the worker is owned, affordable, or too expensive.

const contentHeight = workers.length * (cardHeight + spacing) - spacing;
Calculates the total pixel height of the full scrollable worker list, including spacing between cards.
const maxScroll = max(0, contentHeight - viewHeight);
Figures out how far the list can be scrolled - if the content is shorter than the view, this is 0 (no scrolling allowed).
drawingContext.beginPath(); drawingContext.rect(viewX, viewY, viewWidth, viewHeight); drawingContext.clip();
Uses the raw HTML5 canvas API (drawingContext) to clip all subsequent drawing to a rectangular window, so scrolled-off cards don't spill outside the panel.
layoutWorkerButtons(viewX, viewY - shopScroll, viewWidth, cardHeight, spacing);
Recomputes every worker card's on-screen rectangle, shifted upward by the current scroll amount.
drawPortrait(btn.x + 90, btn.y + btn.h / 2, worker.hue, owned);
Draws a simple procedural face icon for this worker, colored using its unique hue value.
drawingContext.restore();
Undoes the clipping region so drawing outside this function isn't accidentally clipped too.

drawShopCloseButton()

An 'X' icon built from two line() calls is a lightweight alternative to loading an icon image or font glyph.

function drawShopCloseButton() {
  const { x, y, w, h } = shopCloseButton;
  noStroke();
  fill("rgba(200,40,40,0.95)");
  rect(x, y, w, h, 12);
  stroke(255);
  strokeWeight(2);
  noFill();
  rect(x, y, w, h, 12);
  noStroke();
  stroke(255);
  strokeWeight(2.6);
  line(x + 10, y + 10, x + w - 10, y + h - 10);
  line(x + w - 10, y + 10, x + 10, y + h - 10);
  noStroke();
}
Line-by-line explanation (3 lines)
fill("rgba(200,40,40,0.95)"); rect(x, y, w, h, 12);
Draws a rounded red button background.
line(x + 10, y + 10, x + w - 10, y + h - 10);
Draws one diagonal stroke of the 'X' close icon, inset 10px from the corners.
line(x + w - 10, y + 10, x + 10, y + h - 10);
Draws the other diagonal stroke, completing the X shape.

layoutWorkerButtons()

Storing a computed 'button' rectangle on each data object (rather than a separate parallel array) keeps each worker's clickable area bundled with its own data, simplifying both drawing and click detection.

function layoutWorkerButtons(startX, startY, cardWidth, cardHeight, spacing) {
  workers.forEach((worker, idx) => {
    worker.button = {
      x: startX,
      y: startY + idx * (cardHeight + spacing),
      w: cardWidth,
      h: cardHeight
    };
  });
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

for-loop Stacked Card Layout worker.button = { x: startX, y: startY + idx * (cardHeight + spacing), w: cardWidth, h: cardHeight };

Assigns each worker a rectangle stacked vertically below the previous one, using its index to calculate the y offset.

y: startY + idx * (cardHeight + spacing),
Each card's vertical position is the starting y plus its index times one card's height plus the gap - this is what stacks the cards in a neat scrollable column.
worker.button = { ... };
Stores the computed rectangle directly on the worker object so mousePressed() and drawShopOverlay() can both use worker.button for hit-testing and drawing.

drawPortrait()

This function shows how to procedurally generate many distinct-looking icons (one per worker) from a single hue number, instead of loading 20 separate image files - and how colorMode(HSB) makes hue-based color math much easier than RGB.

🔬 The face's saturation/brightness depends on 'owned'. What happens if you swap the two ternary results so OWNED workers look bright and colorful while UNOWNED ones look grey?

  fill(hueVal, owned ? 15 : 60, owned ? 35 : 90, 95);
  stroke(255, 50);
  strokeWeight(5);
  circle(0, 0, 140);
function drawPortrait(cx, cy, hueVal, owned) {
  push();
  translate(cx, cy);
  colorMode(HSB, 360, 100, 100, 100);
  fill(hueVal, owned ? 15 : 60, owned ? 35 : 90, 95);
  stroke(255, 50);
  strokeWeight(5);
  circle(0, 0, 140);
  fill(255, 95);
  noStroke();
  circle(-25, -15, 18);
  circle(25, -15, 18);
  stroke(255, 85);
  strokeWeight(5);
  noFill();
  arc(0, 25, 60, 36, 0, PI);
  pop();
  colorMode(RGB, 255);
}
Line-by-line explanation (6 lines)
colorMode(HSB, 360, 100, 100, 100);
Switches p5's color interpretation to Hue-Saturation-Brightness so a single hue number (0-360) can generate a whole family of related colors.
fill(hueVal, owned ? 15 : 60, owned ? 35 : 90, 95);
Uses the worker's unique hue but desaturates and darkens the face if it's already owned/hired, visually 'greying out' hired workers.
circle(0, 0, 140);
Draws the main circular head shape.
circle(-25, -15, 18); circle(25, -15, 18);
Draws two small white circles as simple eyes.
arc(0, 25, 60, 36, 0, PI);
Draws a half-ellipse arc from angle 0 to PI (half a circle) to form a smiling mouth curve.
colorMode(RGB, 255);
Switches color mode back to standard RGB so it doesn't affect any drawing code elsewhere in the sketch.

drawCoinButton()

Counting a pulse variable down every frame it's drawn (rather than using a timestamp) is a simple 'frames remaining' animation technique - it automatically fades because draw() calls this every frame.

function drawCoinButton() {
  const { x, y, w, h } = coinButton;
  const canAfford = coins >= coinCost;

  let strokeColor = color(255, 200);
  if (coinSuccessPulse > 0) {
    strokeColor = color("#93ff97");
    coinSuccessPulse--;
  } else if (coinErrorPulse > 0) {
    coinErrorPulse--;
    strokeColor = color("#ff6b6b");
  }

  noStroke();
  fill(canAfford ? "rgba(80,70,10,0.9)" : "rgba(60,40,10,0.8)");
  rect(x, y, w, h, 12);

  stroke(strokeColor);
  strokeWeight(2);
  noFill();
  rect(x, y, w, h, 12);
  noStroke();

  fill(255);
  textAlign(LEFT, CENTER);
  textSize(15);
  text("Coins Lv." + coinLevel, x + 12, y + h / 2 - 10);
  textSize(13);
  text("Cost: " + coinCost, x + 12, y + h / 2 + 8);

  textAlign(RIGHT, CENTER);
  textSize(15);
  text("x" + coinMultiplier.toFixed(1), x + w - 12, y + h / 2);
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Success/Error Pulse Border if (coinSuccessPulse > 0) { ... } else if (coinErrorPulse > 0) { ... }

Temporarily colors the button's border green after a successful purchase or red after a failed one, counting down a frame-based timer each time it's drawn.

let strokeColor = color(255, 200);
Default border color is a semi-transparent white.
if (coinSuccessPulse > 0) { strokeColor = color("#93ff97"); coinSuccessPulse--; }
If a purchase just succeeded, flashes the border green and ticks the pulse counter down by one frame.
fill(canAfford ? "rgba(80,70,10,0.9)" : "rgba(60,40,10,0.8)");
Slightly brighter background if the player currently has enough coins to afford this upgrade.
text("x" + coinMultiplier.toFixed(1), x + w - 12, y + h / 2);
Shows the current multiplier (e.g. 'x1.5') right-aligned on the button.

drawWorkerButton()

This function is nearly identical to drawCoinButton() - a good candidate for refactoring into one shared function that takes button/state parameters, which is noted in the improvements section.

function drawWorkerButton() {
  const { x, y, w, h } = workerButton;
  const canAfford = coins >= workerCost;

  let strokeColor = color(255, 200);
  if (workerSuccessPulse > 0) {
    strokeColor = color("#93ff97");
    workerSuccessPulse--;
  } else if (workerErrorPulse > 0) {
    workerErrorPulse--;
    strokeColor = color("#ff6b6b");
  }

  noStroke();
  fill(canAfford ? "rgba(60,40,90,0.9)" : "rgba(40,20,60,0.8)");
  rect(x, y, w, h, 12);

  stroke(strokeColor);
  strokeWeight(2);
  noFill();
  rect(x, y, w, h, 12);
  noStroke();

  fill(255);
  textAlign(LEFT, CENTER);
  textSize(15);
  text("Workers Lv." + workerLevel, x + 12, y + h / 2 - 10);
  textSize(13);
  text("Cost: " + workerCost, x + 12, y + h / 2 + 8);

  textAlign(RIGHT, CENTER);
  textSize(15);
  text("x" + workerMultiplier.toFixed(1), x + w - 12, y + h / 2);
}
Line-by-line explanation (2 lines)
const canAfford = coins >= workerCost;
Checks whether the player currently has enough coins for the next worker-multiplier upgrade level.
text("Workers Lv." + workerLevel, x + 12, y + h / 2 - 10);
Displays the current upgrade level on the button.

mousePressed()

p5.js calls mousePressed() automatically whenever the mouse button is clicked - the entire function is a chain of if-checks that each 'return' once handled, which is a common pattern for routing a single click to exactly one action among many possible UI elements.

🔬 This block rewards coins AND energy on every click. What happens if you remove the energy line entirely - can the beanstalk still grow?

  if (sun.isHit(mouseX, mouseY)) {
    const reward = baseSunReward * coinMultiplier * eventMultiplier;
    coins += reward;
    energy = min(energy + sunBoost, maxEnergy);
    sun.pulse();
  }
function mousePressed() {
  // Shop open interactions
  if (shopOpen) {
    if (isInside(mouseX, mouseY, shopCloseButton)) {
      shopOpen = false;
      return;
    }
    for (let worker of workers) {
      if (isInside(mouseX, mouseY, worker.button)) {
        attemptHireWorker(worker);
        return;
      }
    }
    return;
  }

  // Toggle shop
  if (isInside(mouseX, mouseY, shopButton)) {
    shopOpen = !shopOpen;
    if (shopOpen) shopScroll = 0;
    return;
  }

  // Coin upgrade
  if (isInside(mouseX, mouseY, coinButton)) {
    attemptCoinUpgrade();
    return;
  }

  // Worker upgrade
  if (isInside(mouseX, mouseY, workerButton)) {
    attemptWorkerUpgrade();
    return;
  }

  // Sun click
  if (sun.isHit(mouseX, mouseY)) {
    const reward = baseSunReward * coinMultiplier * eventMultiplier;
    coins += reward;
    energy = min(energy + sunBoost, maxEnergy);
    sun.pulse();
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

conditional Shop-Open Click Routing if (shopOpen) { ... return; }

While the shop overlay is open, all clicks are routed to shop-specific handling (close button or worker hire) and nothing else, so you can't accidentally click through to the sun.

for-loop Worker Hit Test Loop for (let worker of workers) { if (isInside(mouseX, mouseY, worker.button)) { attemptHireWorker(worker); return; } }

Checks every worker card to see if the click landed inside it, stopping as soon as a match is found.

if (isInside(mouseX, mouseY, shopCloseButton)) { shopOpen = false; return; }
Closes the shop overlay if the click landed on its X button, then exits early so no other click logic runs.
for (let worker of workers) { if (isInside(mouseX, mouseY, worker.button)) { attemptHireWorker(worker); return; } }
Loops through every worker card checking for a click match; the moment one is found, tries to hire it and stops checking the rest.
if (isInside(mouseX, mouseY, shopButton)) { shopOpen = !shopOpen; if (shopOpen) shopScroll = 0; return; }
Toggles the shop open/closed, resetting scroll to the top whenever it's freshly opened.
if (sun.isHit(mouseX, mouseY)) {
Only the last check in the chain - clicking the sun only registers if none of the buttons above were clicked first, since each earlier branch returns.
const reward = baseSunReward * coinMultiplier * eventMultiplier;
Combines the base reward with both the purchased coin multiplier and any active random-event multiplier to compute the actual payout.
energy = min(energy + sunBoost, maxEnergy);
Adds the sun-click energy boost but clamps it so energy never exceeds the maximum.

mouseWheel()

p5.js automatically calls mouseWheel(event) when the user scrolls; event.delta tells you how far and in which direction, letting you build custom scrollable panels like this worker gallery.

function mouseWheel(event) {
  if (!shopOpen) return;
  const cardHeight = 140;
  const spacing = 22;
  const contentHeight = workers.length * (cardHeight + spacing) - spacing;

  const panelHeight = height - 40;
  const headerHeight = 110;
  const viewHeight = panelHeight - headerHeight - 36;
  const maxScroll = max(0, contentHeight - viewHeight);

  shopScroll = constrain(shopScroll + event.delta, 0, maxScroll);
  return false;
}
Line-by-line explanation (3 lines)
if (!shopOpen) return;
Ignores scroll wheel input entirely unless the worker shop overlay is currently open.
shopScroll = constrain(shopScroll + event.delta, 0, maxScroll);
Adds the wheel's scroll delta to the current scroll position, clamped between 0 and the maximum scrollable distance.
return false;
Prevents the browser's default page-scrolling behavior so only the shop list scrolls, not the whole webpage.

attemptHireWorker()

Note this reuses coinSuccessPulse/coinErrorPulse rather than dedicated worker-hire pulse variables - see the improvements section for why that's worth fixing.

function attemptHireWorker(worker) {
  if (worker.owned) return;
  if (coins < worker.cost) {
    coinErrorPulse = 15;
    return;
  }
  const cost = worker.cost;
  coins -= cost;
  totalCoinsSpent += cost;
  worker.owned = true;
  coinSuccessPulse = 20;
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Affordability Check if (coins < worker.cost) { coinErrorPulse = 15; return; }

Blocks the purchase and flashes an error pulse if the player doesn't have enough coins.

if (worker.owned) return;
Prevents hiring the same worker twice - clicking an already-hired card does nothing.
coins -= cost; totalCoinsSpent += cost;
Deducts the cost from the player's coins and adds it to a lifetime spending tracker.
worker.owned = true;
Marks this specific worker object as hired, which immediately makes getPassiveRate() include its rate.
coinSuccessPulse = 20;
Triggers a 20-frame green flash on the coin button as visual purchase feedback (reused from the coin upgrade button, slightly misleadingly named).

attemptCoinUpgrade()

This function demonstrates the classic idle-game upgrade pattern: pay a cost, improve a stat by a fixed formula, then raise the cost for next time so upgrades get progressively more expensive.

function attemptCoinUpgrade() {
  if (coins < coinCost) {
    coinErrorPulse = 15;
    return;
  }
  const cost = coinCost;
  coins -= cost;
  totalCoinsSpent += cost;
  coinLevel++;
  coinMultiplier = 1 + (coinLevel - 1) * 0.5;
  coinCost = floor(coinCost * coinCostFactor);
  coinSuccessPulse = 20;
}
Line-by-line explanation (2 lines)
coinMultiplier = 1 + (coinLevel - 1) * 0.5;
Recomputes the multiplier from the new level - each level adds another +0.5x on top of the base 1x.
coinCost = floor(coinCost * coinCostFactor);
Raises the price of the NEXT upgrade by the cost factor (1.5x), creating exponential cost growth.

attemptWorkerUpgrade()

Nearly identical in structure to attemptCoinUpgrade() - together they show the same upgrade formula applied to two different stats (click reward vs passive income).

function attemptWorkerUpgrade() {
  if (coins < workerCost) {
    workerErrorPulse = 15;
    return;
  }
  const cost = workerCost;
  coins -= cost;
  totalCoinsSpent += cost;
  workerLevel++;
  workerMultiplier = 1 + (workerLevel - 1) * 0.5;
  workerCost = floor(workerCost * workerCostFactor);
  workerSuccessPulse = 20;
}
Line-by-line explanation (2 lines)
workerMultiplier = 1 + (workerLevel - 1) * 0.5;
Recalculates the passive-income multiplier applied in getPassiveRate() based on the new worker upgrade level.
workerCost = floor(workerCost * workerCostFactor);
Increases the cost of the next worker-multiplier upgrade exponentially.

isInside()

This is Axis-Aligned Bounding Box (AABB) hit-testing - the fundamental building block behind every clickable button in this sketch, since p5 doesn't have built-in UI buttons.

function isInside(mx, my, rectObj) {
  return (
    mx >= rectObj.x &&
    mx <= rectObj.x + rectObj.w &&
    my >= rectObj.y &&
    my <= rectObj.y + rectObj.h
  );
}
Line-by-line explanation (2 lines)
mx >= rectObj.x && mx <= rectObj.x + rectObj.w
Checks the point's x is between the rectangle's left and right edges.
my >= rectObj.y && my <= rectObj.y + rectObj.h
Checks the point's y is between the rectangle's top and bottom edges.

windowResized()

windowResized() is a p5.js callback that automatically fires whenever the browser window changes size - essential for any full-window sketch to stay usable after resizing.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  sun.setPosition(width - 120, 100);
  positionButtons();
  positionShopButton();
}
Line-by-line explanation (3 lines)
resizeCanvas(windowWidth, windowHeight);
p5's resizeCanvas adjusts the drawing surface to match the new browser window size.
sun.setPosition(width - 120, 100);
Re-anchors the sun to the new top-right corner so it doesn't end up off-screen or in the wrong spot.
positionButtons(); positionShopButton();
Recomputes every button's position based on the new width/height.

positionButtons()

Calculating positions from width/height/margin (rather than hardcoding pixel values) is what makes the layout responsive to different screen sizes.

function positionButtons() {
  const margin = 24;
  coinButton.x = margin;
  coinButton.y = height - coinButton.h - margin;

  workerButton.x = width - workerButton.w - margin;
  workerButton.y = height - workerButton.h - margin;
}
Line-by-line explanation (2 lines)
coinButton.x = margin;
Pins the coin upgrade button a fixed distance from the left edge.
workerButton.x = width - workerButton.w - margin;
Pins the worker upgrade button a fixed distance from the right edge, accounting for its own width so it doesn't hang off-screen.

positionShopButton()

A one-line example of the common 'center an element' formula: (containerSize / 2) - (elementSize / 2).

function positionShopButton() {
  shopButton.y = height / 2 - shopButton.h / 2;
}
Line-by-line explanation (1 lines)
shopButton.y = height / 2 - shopButton.h / 2;
Vertically centers the button by subtracting half its own height from the screen's vertical center.

Sun (class)

The Sun class bundles position, animation state, and behavior (update/display/isHit/pulse) into one reusable object - a great first example of object-oriented design in p5.js, using p5.Vector, lerp() for easing, and dist() for circular collision detection.

🔬 The 0.1 controls how quickly the pulse animation eases back to normal. What happens to the click feedback if you change it to 0.02 (slower) or 0.4 (much snappier)?

  update() {
    this.pulseRadius = lerp(this.pulseRadius, 0, 0.1);
  }
class Sun {
  constructor() {
    this.baseRadius = 45;
    this.pulseRadius = 0;
    this.center = createVector(width - 120, 100);
  }

  setPosition(x, y) {
    this.center.set(x, y);
  }

  update() {
    this.pulseRadius = lerp(this.pulseRadius, 0, 0.1);
  }

  display() {
    push();
    translate(this.center.x, this.center.y);
    noStroke();

    const haloRadius = this.baseRadius + 12 + this.pulseRadius;
    fill(255, 220, 120, 80);
    circle(0, 0, haloRadius * 2);

    fill("#ffd75e");
    circle(0, 0, this.baseRadius * 2 + this.pulseRadius);

    fill("#c66900");
    textAlign(CENTER, CENTER);
    textSize(22);
    text("\u2600", 0, 0);
    pop();
  }

  isHit(mx, my) {
    return dist(mx, my, this.center.x, this.center.y) < this.baseRadius + 10;
  }

  pulse() {
    this.pulseRadius = 20;
  }
}
Line-by-line explanation (7 lines)
this.center = createVector(width - 120, 100);
Uses a p5.Vector to store the sun's x/y position as a single object instead of two separate numbers.
setPosition(x, y) { this.center.set(x, y); }
A method that lets other code (like windowResized) move the sun without touching its internal properties directly.
update() { this.pulseRadius = lerp(this.pulseRadius, 0, 0.1); }
Every frame, eases pulseRadius 10% of the way back toward 0 - this is what makes the click 'pulse' animation smoothly shrink over time instead of snapping instantly.
const haloRadius = this.baseRadius + 12 + this.pulseRadius;
The glowing halo's size grows temporarily with pulseRadius, then shrinks back as update() eases it down.
fill(255, 220, 120, 80);
A low-alpha (mostly transparent) warm yellow for the soft glow halo behind the solid sun.
isHit(mx, my) { return dist(mx, my, this.center.x, this.center.y) < this.baseRadius + 10; }
Uses p5's dist() to check if a point is within a circular radius of the sun's center - circular hit-testing, unlike the rectangular isInside() used for buttons.
pulse() { this.pulseRadius = 20; }
Called when the sun is clicked - instantly jumps pulseRadius up to 20, then update() gradually eases it back down each frame, creating a little 'thump' animation.

📦 Key Variables

stalkHeight number

Current height in pixels of the beanstalk, permanently increases over time as energy is spent growing it.

let stalkHeight = 40;
energy number

Fuel for growth, filled up by clicking the sun and drained a little every frame; more energy means faster stalk growth.

let energy = 0;
maxEnergy number

The cap on how much energy can be stored at once, used to size the energy bar.

const maxEnergy = 100;
energyDrain number

How much energy is lost per frame, controlling how long each growth burst lasts.

let energyDrain = 1;
growthMultiplier number

Scales how much stalkHeight increases for a given energy level - a general growth-speed knob.

let growthMultiplier = 1;
coins number

The player's currency, earned from sun clicks and passive worker income, spent on upgrades and hiring.

let coins = 0;
baseSunReward number

The base number of coins earned per sun click before multipliers are applied.

const baseSunReward = 15;
sunBoost number

How much energy is added to the bar per sun click.

const sunBoost = 40;
totalCoinsSpent number

Lifetime tally of coins spent on any upgrade or worker, tracked but not currently displayed anywhere in the UI.

let totalCoinsSpent = 0;
coinLevel number

How many times the coin-reward upgrade has been purchased, used to compute coinMultiplier and label the button.

let coinLevel = 1;
coinMultiplier number

Multiplies the coins earned per sun click; increases each time the coin upgrade is bought.

let coinMultiplier = 1;
coinCost number

The current price of the next coin-reward upgrade level, which rises exponentially after each purchase.

let coinCost = 250;
coinCostFactor number

The multiplier applied to coinCost after each purchase, controlling how quickly the upgrade escalates in price.

const coinCostFactor = 1.5;
coinButton object

Stores the on-screen rectangle (x, y, w, h) for the coin upgrade button, used for both drawing and click detection.

const coinButton = { x: 0, y: 0, w: 180, h: 56 };
coinSuccessPulse number

Frame countdown that flashes the coin button's border green after a successful purchase or hire.

let coinSuccessPulse = 0;
coinErrorPulse number

Frame countdown that flashes the coin button's border red after a failed (too-expensive) purchase attempt.

let coinErrorPulse = 0;
workerLevel number

How many times the passive-income multiplier upgrade has been purchased.

let workerLevel = 1;
workerMultiplier number

Multiplies total passive income from all owned workers combined.

let workerMultiplier = 1;
workerCost number

The current price of the next worker-multiplier upgrade level.

let workerCost = 15000;
workerCostFactor number

The multiplier applied to workerCost after each purchase.

const workerCostFactor = 1.6;
workerButton object

Stores the rectangle for the worker-multiplier upgrade button.

const workerButton = { x: 0, y: 0, w: 180, h: 56 };
workerSuccessPulse number

Frame countdown that flashes the worker button's border green after a successful upgrade purchase.

let workerSuccessPulse = 0;
workerErrorPulse number

Frame countdown that flashes the worker button's border red after a failed purchase attempt.

let workerErrorPulse = 0;
workers array

Array of worker objects (name, rate, cost, owned, hue) representing every hireable character in the shop.

let workers = [];
lastPassiveUpdate number

Timestamp (from millis()) of the last passive-income calculation, used to measure elapsed real time between updates.

let lastPassiveUpdate = 0;
passiveAccumulator number

Running total of elapsed seconds banked toward the next 3-second passive income payout.

let passiveAccumulator = 0;
shopOpen boolean

Whether the full-screen worker shop overlay is currently visible and intercepting clicks.

let shopOpen = false;
shopScroll number

Current vertical scroll offset (in pixels) of the worker list inside the shop overlay.

let shopScroll = 0;
shopButton object

Rectangle for the always-visible SHOP toggle button on the left edge of the screen.

const shopButton = { x: 16, y: 0, w: 110, h: 44 };
shopCloseButton object

Rectangle for the X button that closes the shop overlay, repositioned each time the shop is drawn.

const shopCloseButton = { x: 0, y: 0, w: 42, h: 42 };
eventMultiplier number

The global payout multiplier applied to sun rewards and passive income while a random event is active.

let eventMultiplier = 1;
eventActive boolean

Whether a random event is currently in effect.

let eventActive = false;
eventName string

Display name of the currently active random event, shown in the banner.

let eventName = "";
eventTimer number

Seconds remaining before the active event ends.

let eventTimer = 0;
nextEventIn number

Seconds remaining until the next random event begins, counted down while no event is active.

let nextEventIn = 0;
eventClock number

Last frame's millis() timestamp, used to compute delta time (dt) for the event timers each frame.

let eventClock = 0;
events array

List of possible random event definitions (name plus min/max multiplier range) that startEvent() picks from.

const events = [ { name: "Solar Surge", min: 1.4, max: 2.2 } ];
sun object

The single Sun class instance representing the clickable sun the player taps to earn coins and energy.

let sun;

🔧 Potential Improvements (5)

Here are some ways this code could be enhanced:

BUG attemptHireWorker()

Hiring a worker triggers coinSuccessPulse (the coin upgrade button's flash), not a dedicated pulse for the shop card or worker button, so the visual feedback for a successful hire appears on an unrelated, possibly off-screen button.

💡 Add a separate pulse state (e.g. per-worker or a shopSuccessPulse) and flash the actual hired card's border instead of reusing coinSuccessPulse.

PERFORMANCE updateStalk()

stalkHeight grows without any upper bound, so the leaf loop in updateStalk() runs more iterations every session the game stays open, and drawShopOverlay()/backgroundGradient() already do per-pixel-row work every frame - together this can slow down over very long idle sessions.

💡 Cap stalkHeight (or the number of leaves actually drawn, e.g. only draw the most recent N leaves) so rendering cost stays bounded no matter how long the player leaves the game running.

STYLE drawShopOverlay() and mouseWheel()

Layout constants like cardHeight, spacing, headerHeight, and innerPadding are duplicated between drawShopOverlay() and mouseWheel(), so changing the shop's layout in one place without updating the other will silently break scrolling.

💡 Move these into shared constants (e.g. a SHOP_LAYOUT object) defined once at the top of the file and referenced by both functions.

FEATURE overall game state

There is no save/load system, so refreshing the browser resets coins, stalk height, and every purchased upgrade back to zero.

💡 Use localStorage.setItem()/getItem() to periodically save the key state variables (coins, stalkHeight, workers' owned flags, upgrade levels) and restore them in setup().

STYLE drawCoinButton() and drawWorkerButton()

These two functions are almost identical except for variable names and colors, which makes future changes error-prone since every tweak has to be made twice.

💡 Refactor into one drawUpgradeButton(buttonRect, level, cost, multiplier, successPulse, errorPulse, colorScheme) function called twice with different arguments.

🔄 Code Flow

Code flow showing setup, draw, scheduleNextEvent, startevent, updateevents, initworkers, updatepassiveincome, getpassiverate, backgroundgradient, drawground, updatestalk, drawleaf, drawui, draweventtimer, formattime, draweventbanner, drawshopbutton, drawshopoverlay, drawshopclosebutton, layoutworkerbuttons, drawportrait, drawcoinbutton, drawworkerbutton, mousepressed, mousewheel, attempthireworker, attemptcoinupgrade, attemptworkerupgrade, isinside, windowresized, positionbuttons, positionshopbutton, sun

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

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> scheduleNextEvent[scheduleNextEvent] draw --> updateevents[updateevents] draw --> initworkers[initworkers] draw --> updatepassiveincome[updatepassiveincome] draw --> backgroundgradient[backgroundgradient] draw --> drawground[drawground] draw --> updatestalk[updatestalk] draw --> drawleaf[drawleaf] draw --> drawui[drawui] draw --> draweventtimer[draweventtimer] draw --> draweventbanner[draweventbanner] draw --> drawshopoverlay[drawshopoverlay] draw --> drawshopbutton[drawshopbutton] draw --> drawshopclosebutton[drawshopclosebutton] draw --> drawcoinbutton[drawcoinbutton] draw --> drawworkerbutton[drawworkerbutton] draw --> mousepressed[mousepressed] draw --> mousewheel[mousewheel] draw --> windowresized[windowresized] subcomponent1[Shop Overlay Toggle] --> shop-conditional[shop-conditional] subcomponent2[Active vs Waiting Branch] --> event-branch[event-branch] subcomponent3[3-Second Payout Loop] --> payout-while[payout-while] subcomponent4[Owned Workers Sum] --> filter-reduce[filter-reduce] subcomponent5[Row-by-row Gradient] --> gradient-loop[gradient-loop] subcomponent6[Leaf Placement Loop] --> leaf-loop[leaf-loop] subcomponent7[Event Banner Display] --> event-banner-check[event-banner-check] subcomponent8[Active vs Waiting Label] --> timer-branch[timer-branch] subcomponent9[Scroll Clamping] --> scroll-clamp[scroll-clamp] subcomponent10[Worker Card Drawing] --> worker-foreach[worker-foreach] subcomponent11[Card Color by State] --> card-color-ternary[card-color-ternary] subcomponent12[Stacked Card Layout] --> layout-foreach[layout-foreach] subcomponent13[Success/Error Pulse Border] --> pulse-check[pulse-check] subcomponent14[Shop-Open Click Routing] --> shop-open-check[shop-open-check] subcomponent15[Worker Hit Test Loop] --> worker-hit-loop[worker-hit-loop] subcomponent16[Affordability Check] --> afford-check[afford-check] click setup href "#fn-setup" click draw href "#fn-draw" click scheduleNextEvent href "#fn-schedulenextevent" click updateevents href "#fn-updateevents" click initworkers href "#fn-initworkers" click updatepassiveincome href "#fn-updatepassiveincome" click backgroundgradient href "#fn-backgroundgradient" click drawground href "#fn-drawground" click updatestalk href "#fn-updatestalk" click drawleaf href "#fn-drawleaf" click drawui href "#fn-drawui" click draweventtimer href "#fn-draweventtimer" click draweventbanner href "#fn-draweventbanner" click drawshopoverlay href "#fn-drawshopoverlay" click drawshopbutton href "#fn-drawshopbutton" click drawshopclosebutton href "#fn-drawshopclosebutton" click drawcoinbutton href "#fn-drawcoinbutton" click drawworkerbutton href "#fn-drawworkerbutton" click shop-conditional href "#sub-shop-conditional" click event-branch href "#sub-event-branch" click payout-while href "#sub-payout-while" click filter-reduce href "#sub-filter-reduce" click gradient-loop href "#sub-gradient-loop" click leaf-loop href "#sub-leaf-loop" click event-banner-check href "#sub-event-banner-check" click timer-branch href "#sub-timer-branch" click scroll-clamp href "#sub-scroll-clamp" click worker-foreach href "#sub-worker-foreach" click card-color-ternary href "#sub-card-color-ternary" click layout-foreach href "#sub-layout-foreach" click pulse-check href "#sub-pulse-check" click shop-open-check href "#sub-shop-open-check" click worker-hit-loop href "#sub-worker-hit-loop" click afford-check href "#sub-afford-check"

❓ Frequently Asked Questions

What visual elements can I expect to see in the beanstalk grower sketch?

The sketch visually represents a growing beanstalk, sun, and various interactive buttons for upgrading and managing resources.

How can I interact with the beanstalk grower sketch to progress in the game?

Users can interact by pressing the sun to earn coins, purchasing upgrades for coin and worker multipliers, and managing the shop for passive income.

What creative coding concepts does the beanstalk grower sketch showcase?

The sketch demonstrates concepts such as resource management, event-driven programming, and dynamic visual updates based on user interaction.

Preview

beanstalk grower - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of beanstalk grower - Code flow showing setup, draw, scheduleNextEvent, startevent, updateevents, initworkers, updatepassiveincome, getpassiverate, backgroundgradient, drawground, updatestalk, drawleaf, drawui, draweventtimer, formattime, draweventbanner, drawshopbutton, drawshopoverlay, drawshopclosebutton, layoutworkerbuttons, drawportrait, drawcoinbutton, drawworkerbutton, mousepressed, mousewheel, attempthireworker, attemptcoinupgrade, attemptworkerupgrade, isinside, windowresized, positionbuttons, positionshopbutton, sun
Code Flow Diagram