Sketch 2026-03-12 17:48

This is a fast-paced clicking game where players must click randomly spawned black squares as quickly as possible within a 45-second timer. The game tracks scores, calculates player ranks based on recent match history, and maintains a leaderboard of top 10 scores with a beautiful hexagon-tiled background.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make boxes disappear in color — Change box color from black to a random color each time one is created—watch the screen fill with variety instead of boring black squares
  2. Speed up the game timer — Change the game duration from 45 seconds to 20 seconds—watch how this completely changes the difficulty and pacing
  3. Award bonus points per click — Change score increment from +1 to +5 per box clicked—players will rack up points much faster and feel more rewarded
  4. Start with more boxes on screen — Increase from 3 initial boxes to 5—suddenly the game becomes much harder because there are more targets to choose from
  5. Change background color scheme to purple
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch creates an engaging clicker game called 'Aim Tester' where players race against a 45-second timer to click as many randomly positioned black squares as possible. What makes this sketch visually interesting is the animated hexagon background, the rank system with custom-drawn icons (circles, crowns, stars), and smooth state transitions between screens. The code demonstrates key p5.js techniques including the draw loop for continuous animation, collision detection for click interactions, off-screen graphics buffers for performance, and the native localStorage API for persisting game data.

The code is organized into several distinct sections: game state management (which screen to show), box creation and interaction logic, a leaderboard system with persistent storage, and a rank calculation engine based on average match scores. By studying this sketch, you will learn how to build a complete game with multiple screens, handle user input both through clicks and text fields, manage complex game data with objects and arrays, and optimize rendering by pre-computing expensive graphics like the background once and reusing them every frame.

⚙️ How It Works

  1. When the sketch loads, setup() initializes the canvas, clears any previous game data from localStorage, creates the hexagon background once as an off-screen graphics buffer, and creates all UI buttons and input fields—these are hidden or shown based on game state.
  2. The draw() function runs at 60 frames per second and displays the pre-rendered background, then uses a switch statement to check the current gameState and call the appropriate drawing function (drawStartScreen, drawPlayingScreen, drawLeaderboardScreen, etc.).
  3. On the start screen, players see the title and their current rank (calculated from the average of their last 3 game scores). When they click 'Start Game', startGame() resets the score, creates 3 initial boxes at random positions, and records the start time.
  4. During gameplay, mousePressed() checks whether the click hit any box by testing if the mouse position falls within its rectangular bounds. If it does, the score increments, that box is removed, and a new box is immediately spawned to keep exactly 3 boxes on screen at all times.
  5. The playing screen displays the score, current rank, and a countdown timer using the recorded game start time and the 45-second duration constant. When time runs out, endGame() transitions to the post-game options screen.
  6. If the final score qualifies for the leaderboard (top 10), players can enter their name, which is then added to the leaderboard and saved to localStorage. The rank system calculates a player's overall rank by averaging their last 3 match scores and comparing to rank thresholds (Rookie at 0+, Copper at 21+, Champion at 61+, etc.), and draws unique rank icons for each tier.

🎓 Concepts You'll Learn

Game state managementCollision detectionArrays and object data structureslocalStorage persistenceOff-screen graphics buffersRank calculation and displayTimer and time-based logicMouse interaction

📝 Code Breakdown

setup()

setup() runs once when the sketch first loads. It initializes the canvas size, loads saved data from the browser's localStorage, pre-renders expensive graphics like the background pattern, and creates all UI elements. Notice that the background is drawn once onto hexBackgroundGraphics and then reused every frame in draw()—this is a key optimization technique for p5.js sketches.

function setup() {
  clearGameData();
  createCanvas(windowWidth, windowHeight);
  rectMode(CENTER);
  textAlign(CENTER, CENTER);
  loadGameData();
  textFont('Bebas Neue');
  drawingContext.shadowBlur = 10;
  drawingContext.shadowColor = color(0, 162, 255);
  drawingContext.shadowOffsetX = 0;
  drawingContext.shadowOffsetY = 0;
  hexBackgroundGraphics = createGraphics(windowWidth, windowHeight);
  drawHexagonBackground(hexBackgroundGraphics);
  createUI();
  updateUI();
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

initialization Canvas and text configuration createCanvas(windowWidth, windowHeight);

Creates a full-screen canvas and configures how text and rectangles will be drawn

initialization Text glow effect drawingContext.shadowBlur = 10;

Applies a blue glowing shadow effect to all text drawn on the canvas

initialization Background graphics preparation hexBackgroundGraphics = createGraphics(windowWidth, windowHeight);

Creates an off-screen graphics buffer and draws the hexagon pattern once for performance

clearGameData();
Clears all saved leaderboard and match history data from localStorage at startup—the sketch resets to a fresh state each time
createCanvas(windowWidth, windowHeight);
Creates a canvas that fills the entire window; windowWidth and windowHeight are p5.js variables that update if the window resizes
rectMode(CENTER);
Changes how rectangles are drawn—instead of the top-left corner, the x,y coordinate now refers to the center of the rectangle
textAlign(CENTER, CENTER);
Centers text both horizontally and vertically around the x,y coordinates you provide to the text() function
textFont('Bebas Neue');
Sets a custom font (loaded in the HTML file) for all text drawn on the canvas
drawingContext.shadowBlur = 10;
Uses the underlying Canvas API to apply a blur effect to the text glow (this happens after each line of shadow setup)
hexBackgroundGraphics = createGraphics(windowWidth, windowHeight);
Creates an invisible off-screen drawing surface (a graphics object) that will hold the pre-rendered hexagon background
drawHexagonBackground(hexBackgroundGraphics);
Draws the entire hexagon tiling pattern onto the graphics buffer once—this expensive operation happens only once, then the buffer is reused every frame
createUI();
Calls a function that creates all button and input field elements (these p5.Element objects are hidden/shown later based on game state)

draw()

draw() is the heartbeat of p5.js—it runs 60 times per second by default and is responsible for rendering everything the user sees. In this sketch, draw() is very simple: it displays the background and uses a switch statement to delegate the actual drawing to state-specific functions. This organization makes it easy to add new screens without cluttering the draw() function.

🔬 This switch statement is like a dispatcher that chooses which screen to draw based on gameState. What happens if you add a new case for gameState === 'paused' and call a new drawPausedScreen() function? (You'd also need to define that function and set gameState = 'paused' somewhere.)

  switch (gameState) {
    case 'start':
      drawStartScreen();
      break;
    case 'playing':
      drawPlayingScreen();
      break;
function draw() {
  image(hexBackgroundGraphics, 0, 0);
  switch (gameState) {
    case 'start':
      drawStartScreen();
      break;
    case 'playing':
      drawPlayingScreen();
      break;
    case 'postGameOptions':
      drawPostGameOptionsScreen();
      break;
    case 'leaderboard':
      drawLeaderboardScreen();
      break;
    case 'nameEntry':
      drawNameEntryScreen();
      break;
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

initialization Display pre-rendered background image(hexBackgroundGraphics, 0, 0);

Draws the off-screen hexagon background buffer onto the main canvas at position (0,0) every frame

switch-case Game state dispatcher switch (gameState) { ... }

Checks the current game state and calls the appropriate drawing function to render the correct screen

image(hexBackgroundGraphics, 0, 0);
Displays the pre-rendered hexagon background at the top-left of the canvas—this is fast because the expensive hexagon drawing happens only once in setup()
switch (gameState) {
Evaluates the current gameState variable and branches to the appropriate case below
case 'start': drawStartScreen(); break;
If gameState is 'start', call drawStartScreen() to display the title and rank, then break out of the switch
case 'playing': drawPlayingScreen(); break;
If gameState is 'playing', call drawPlayingScreen() to display the game board with boxes, score, and timer
case 'nameEntry': drawNameEntryScreen(); break;
If gameState is 'nameEntry', call drawNameEntryScreen() to show the high score message and name input prompt

drawStartScreen()

drawStartScreen() displays the home screen that players see when they first load the game or click 'Back to Start'. It shows the game title, calculates the player's current rank based on their history, and draws the rank icon. This function demonstrates text sizing, positioning, and the use of calculateRank() which looks at the average of recent match scores.

function drawStartScreen() {
  fill(255);
  textSize(64);
  text('Aim Tester', width / 2, height / 2 - 100);
  const currentRank = calculateRank();
  textSize(20);
  drawRankIcon(currentRank, width / 2 - textWidth(`Your Current Rank: ${currentRank}`) / 2 - 20, height / 2 + 50, 20);
  text(`Your Current Rank: ${currentRank}`, width / 2, height / 2 + 50);
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

text-rendering Game title text('Aim Tester', width / 2, height / 2 - 100);

Displays the game title in large white text centered on the screen

calculation Current rank lookup const currentRank = calculateRank();

Calculates the player's rank based on the average of their last 3 match scores

rendering Rank icon and text display drawRankIcon(currentRank, ...);

Draws the rank icon (circle, crown, etc.) next to the rank name

fill(255);
Sets the fill color to white (255 in grayscale) for all subsequent shapes and text
textSize(64);
Sets the font size to 64 pixels for the title
text('Aim Tester', width / 2, height / 2 - 100);
Draws 'Aim Tester' centered horizontally (width / 2) and 100 pixels above the vertical center
const currentRank = calculateRank();
Calls calculateRank() to compute the player's rank from their last 3 match scores and stores it in a constant
textSize(20);
Reduces the font size to 20 pixels for the rank text
drawRankIcon(currentRank, width / 2 - textWidth(...) / 2 - 20, height / 2 + 50, 20);
Calculates the position for the rank icon so it appears to the left of the rank text, then draws it with a size of 20 pixels

drawPlayingScreen()

drawPlayingScreen() is the core game loop renderer. It calculates elapsed and remaining time, draws all active boxes, displays the score and current rank with an icon, shows the countdown timer, and checks whether time has run out to trigger game over. The backwards loop (i--) is important: if the game logic removes boxes during iteration, iterating backwards prevents index issues. The time formatting using nf() demonstrates how to display human-readable timers.

function drawPlayingScreen() {
  let timeElapsed = millis() - gameStartTime;
  let timeLeft = max(0, GAME_DURATION - timeElapsed);
  let minutes = floor(timeLeft / 60000);
  let seconds = floor((timeLeft % 60000) / 1000);
  let timeString = `${nf(minutes, 2)}:${nf(seconds, 2)}`;
  for (let i = boxes.length - 1; i >= 0; i--) {
    let box = boxes[i];
    fill(box.color);
    rect(box.x, box.y, box.width, box.height);
  }
  fill(255);
  textAlign(CENTER, TOP);
  textSize(24);
  text(`Score: ${score}`, width / 2, 20);
  const currentRank = calculateRank();
  textSize(20);
  drawRankIcon(currentRank, width / 2 - textWidth(`Rank: ${currentRank}`) / 2 - 20, 80, 20);
  text(`Rank: ${currentRank}`, width / 2, 80);
  textSize(24);
  text(`Time: ${timeString}`, width / 2, 110);
  if (timeLeft <= 0) {
    endGame();
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

calculation Countdown timer math let timeLeft = max(0, GAME_DURATION - timeElapsed);

Calculates remaining time by subtracting elapsed time from the total game duration, preventing negative values

calculation MM:SS time formatting let timeString = `${nf(minutes, 2)}:${nf(seconds, 2)}`;

Converts milliseconds into a readable MM:SS format using nf() to pad single-digit numbers with zeros

for-loop Draw all active boxes for (let i = boxes.length - 1; i >= 0; i--) { ... }

Loops through every box in the boxes array and draws it as a rectangle at its position

conditional Check if time has run out if (timeLeft <= 0) { endGame(); }

Detects when the timer reaches zero and ends the game

let timeElapsed = millis() - gameStartTime;
Calculates how many milliseconds have passed since the game started by subtracting the start time from the current time (millis() returns current milliseconds)
let timeLeft = max(0, GAME_DURATION - timeElapsed);
Calculates remaining time; max(0, ...) ensures the result never goes negative
let minutes = floor(timeLeft / 60000);
Converts milliseconds to minutes by dividing by 60000 and rounding down with floor()
let seconds = floor((timeLeft % 60000) / 1000);
Uses the modulo operator (%) to get remaining milliseconds after minutes are removed, then divides by 1000 to convert to seconds
let timeString = `${nf(minutes, 2)}:${nf(seconds, 2)}`;
nf(number, digits) formats numbers as strings with a minimum of 2 digits (padding with zeros if needed), so 5 becomes '05' and 45 stays '45'
for (let i = boxes.length - 1; i >= 0; i--) {
Loops backwards through the boxes array (from last to first) so that if boxes are removed during iteration, the indices stay valid
fill(box.color); rect(box.x, box.y, box.width, box.height);
Sets the fill color to the box's color (black) and draws a rectangle centered at the box's x,y position with its specified width and height
if (timeLeft <= 0) { endGame(); }
When time runs out, calls endGame() to stop the game and show the game over screen

drawPostGameOptionsScreen()

drawPostGameOptionsScreen() is intentionally minimal—it delegates to drawPostGameScreenContent(), which displays the game over information, and the buttons are managed by updateUI(). This separation makes it easy to display the same content in multiple game states without code duplication.

function drawPostGameOptionsScreen() {
  drawPostGameScreenContent();
}
Line-by-line explanation (1 lines)
drawPostGameScreenContent();
Calls a helper function that renders the 'Game Over' title, final score, and new rank—this logic is separated so it can be reused

drawPostGameScreenContent()

drawPostGameScreenContent() displays the game over summary: the 'Game Over!' title, the final score, and the player's updated rank with its icon. This function is called by drawPostGameOptionsScreen() and demonstrates how to display calculated information (the new rank) alongside static text.

function drawPostGameScreenContent() {
  fill(255);
  textSize(64);
  text('Game Over!', width / 2, height / 2 - 100);
  textSize(32);
  text(`Final Score: ${score}`, width / 2, height / 2);
  const newRank = calculateRank();
  textSize(24);
  drawRankIcon(newRank, width / 2 - textWidth(`Your New Rank: ${newRank}`) / 2 - 20, height / 2 + 50, 24);
  text(`Your New Rank: ${newRank}`, width / 2, height / 2 + 50);
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Rank recalculation at game end const newRank = calculateRank();

Recalculates the player's rank after the current score has been added to match history

fill(255);
Sets text color to white
text('Game Over!', width / 2, height / 2 - 100);
Displays the 'Game Over!' message in large text at the top center of the screen
text(`Final Score: ${score}`, width / 2, height / 2);
Displays the final score earned in this game at the vertical center of the screen
const newRank = calculateRank();
Calculates the updated rank (the current score was already added to match history in endGame())
drawRankIcon(newRank, ...);
Draws the rank icon corresponding to the new rank

drawLeaderboardScreen()

drawLeaderboardScreen() displays the leaderboard with up to 10 top scores. It handles the empty case gracefully and for each entry displays the rank number, player name (or 'Anonymous'), score, rank icon, and the date the score was achieved. The code uses min() to avoid iterating beyond the available entries, and it uses the || operator to provide a default 'Anonymous' name if the player didn't enter one.

function drawLeaderboardScreen() {
  fill(255);
  textSize(64);
  text('Leaderboard', width / 2, height / 2 - 200);
  textAlign(LEFT, TOP);
  textSize(24);
  let yPos = height / 2 - 150;
  if (leaderboard.length === 0) {
    textAlign(CENTER, CENTER);
    text('No scores yet!', width / 2, yPos);
    textAlign(LEFT, TOP);
  } else {
    for (let i = 0; i < min(10, leaderboard.length); i++) {
      let entry = leaderboard[i];
      let scoreRank = getRankFromScore(entry.score);
      let dateString = new Date(entry.timestamp).toLocaleDateString();
      let rankText = `${i + 1}. ${entry.name || 'Anonymous'}: ${entry.score}`;
      let xText = width / 2 - 150;
      text(rankText, xText, yPos);
      let textWidthForRank = textWidth(rankText + ' (');
      drawRankIcon(scoreRank, xText + textWidthForRank, yPos, 20);
      text(`(${scoreRank}) - ${dateString}`, xText + textWidthForRank + 25, yPos);
      yPos += 40;
    }
  }
  textAlign(CENTER, CENTER);
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional Leaderboard empty check if (leaderboard.length === 0) { ... }

Displays a message if no scores have been saved yet

for-loop Display top 10 scores for (let i = 0; i < min(10, leaderboard.length); i++) { ... }

Iterates through the top 10 leaderboard entries and draws each one with rank, name, score, icon, and date

text('Leaderboard', width / 2, height / 2 - 200);
Displays the 'Leaderboard' title in large text at the top of the screen
textAlign(LEFT, TOP);
Changes text alignment to left-aligned so leaderboard entries line up vertically
if (leaderboard.length === 0) { ... }
If the leaderboard array is empty, displays 'No scores yet!' message; otherwise, loops through entries
for (let i = 0; i < min(10, leaderboard.length); i++) {
Loops through up to 10 entries (min() picks the smaller of 10 or the actual number of entries)
let entry = leaderboard[i];
Gets the current leaderboard entry object containing score, timestamp, and name properties
let scoreRank = getRankFromScore(entry.score);
Calculates what rank this individual score corresponds to (different from the player's overall rank)
let dateString = new Date(entry.timestamp).toLocaleDateString();
Converts the timestamp (stored as a date string) into a human-readable local date format
text(rankText, xText, yPos); ... yPos += 40;
Draws the rank position, name, and score on one line, then increments yPos by 40 pixels to move to the next line

drawNameEntryScreen()

drawNameEntryScreen() displays the high score congratulation screen with instructions to enter a name. This is a simple display function—the actual input field (nameInput) and submit button (submitNameButton) are created in createUI() and positioned by updateUI(). This separation keeps the drawing code separate from the UI management.

function drawNameEntryScreen() {
  fill(255);
  textSize(32);
  text('You got a high score!', width / 2, height / 2 - 50);
  textSize(24);
  text(`Final Score: ${score}`, width / 2, height / 2);
  textSize(20);
  text('Enter your name for the leaderboard:', width / 2, height / 2 + 20);
}
Line-by-line explanation (3 lines)
text('You got a high score!', width / 2, height / 2 - 50);
Displays a congratulation message to the player
text(`Final Score: ${score}`, width / 2, height / 2);
Shows the final score achieved in the center of the screen
text('Enter your name for the leaderboard:', width / 2, height / 2 + 20);
Prompts the player to enter their name; the input field itself is positioned and shown by updateUI()

createBox()

createBox() returns a plain JavaScript object with properties for position (x, y), size (width, height), color, and creation time. The position constraints ensure boxes never spawn partially off-screen. This function is called when the game starts (3 times) and every time a box is clicked (to maintain exactly 3 boxes on screen).

🔬 This object defines the box's position (x, y) and size (width, height). What happens if you change width and height to different values, like width: boxSize and height: boxSize / 2? Try it and see what shape appears on screen.

  return {
    x: random(boxSize / 2, width - boxSize / 2),
    y: random(boxSize / 2, height - boxSize / 2),
    width: boxSize,
    height: boxSize,
function createBox() {
  let boxSize = random(40, 80);
  return {
    x: random(boxSize / 2, width - boxSize / 2),
    y: random(boxSize / 2, height - boxSize / 2),
    width: boxSize,
    height: boxSize,
    color: color(0),
    creationTime: millis()
  };
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Random box size let boxSize = random(40, 80);

Picks a random size between 40 and 80 pixels so not all boxes are the same size

calculation Random position within bounds x: random(boxSize / 2, width - boxSize / 2),

Places the box at a random x position while keeping it fully on-screen (accounting for the box size)

let boxSize = random(40, 80);
Uses random(min, max) to pick a random integer between 40 and 80 pixels for the box's width and height
x: random(boxSize / 2, width - boxSize / 2),
Picks a random x position; boxSize / 2 as the minimum and width - boxSize / 2 as the maximum ensures the box stays fully within the canvas
y: random(boxSize / 2, height - boxSize / 2),
Similarly picks a random y position that keeps the box fully on-screen vertically
color: color(0),
Sets the box color to black (RGB value of 0, 0, 0)
creationTime: millis()
Records the current time in milliseconds; this was originally used to auto-remove boxes, but that logic is now removed

mousePressed()

mousePressed() is a built-in p5.js function called whenever the mouse is pressed. Here it implements collision detection to check if the click hit any box, and if so, increments the score, removes the clicked box, and spawns a new one. The backwards loop ensures safe removal even if multiple boxes overlap. The return statement ensures only one box is counted per click.

🔬 This is axis-aligned bounding box (AABB) collision detection. It checks if the mouse is within the rectangle. What happens if you change all four comparisons from > / < to >= / <= (greater/less-than-or-equal)? Does it change how the collision works?

      if (mouseX > box.x - box.width / 2 &&
          mouseX < box.x + box.width / 2 &&
          mouseY > box.y - box.height / 2 &&
          mouseY < box.y + box.height / 2) {
function mousePressed() {
  if (gameState === 'playing') {
    for (let i = boxes.length - 1; i >= 0; i--) {
      let box = boxes[i];
      if (mouseX > box.x - box.width / 2 &&
          mouseX < box.x + box.width / 2 &&
          mouseY > box.y - box.height / 2 &&
          mouseY < box.y + box.height / 2) {
        score++;
        boxes.splice(i, 1);
        boxes.push(createBox());
        return;
      }
    }
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional Game state validation if (gameState === 'playing') {

Only processes clicks during gameplay, ignoring clicks on other screens

conditional Box collision test if (mouseX > box.x - box.width / 2 && mouseX < box.x + box.width / 2 && ...

Tests whether the click position falls within the rectangle boundaries of this box

calculation Score increment and box removal score++; boxes.splice(i, 1); boxes.push(createBox());

Increments the score, removes the clicked box, and immediately spawns a new box to maintain exactly 3 boxes

if (gameState === 'playing') {
Only processes clicks if the game is currently running; other screens ignore clicks
for (let i = boxes.length - 1; i >= 0; i--) {
Loops backwards through the boxes array to check each one for collision
if (mouseX > box.x - box.width / 2 && mouseX < box.x + box.width / 2 &&
Tests if the click's x position is between the left edge (x - width/2) and right edge (x + width/2) of the box
mouseY > box.y - box.height / 2 && mouseY < box.y + box.height / 2) {
Tests if the click's y position is between the top and bottom edges of the box—all four conditions must be true for a hit
score++;
Increments the score by 1
boxes.splice(i, 1);
Removes the clicked box from the boxes array at index i
boxes.push(createBox());
Immediately creates and adds a new box to keep the total at 3 boxes on screen
return;
Exits the function so only one box is processed per click, even if multiple boxes overlap

startGame()

startGame() is called when the player clicks the 'Start Game' button. It resets all game variables, creates 3 initial boxes, records the start time for the timer, and transitions to the 'playing' state. This clean initialization ensures every game starts in a consistent state.

function startGame() {
  gameState = 'playing';
  score = 0;
  missedBoxes = 0;
  boxes = [];
  for (let i = 0; i < 3; i++) {
      boxes.push(createBox());
  }
  gameStartTime = millis();
  updateUI();
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

calculation Game state change gameState = 'playing';

Changes the game state so draw() will call drawPlayingScreen()

for-loop Initial box spawn for (let i = 0; i < 3; i++) { boxes.push(createBox()); }

Creates exactly 3 boxes at the start of the game

calculation Record game start time gameStartTime = millis();

Stores the current time so drawPlayingScreen() can calculate elapsed and remaining time

gameState = 'playing';
Changes the game state from 'start' to 'playing', which causes draw() to start calling drawPlayingScreen()
score = 0;
Resets the score to 0 at the start of a new game
missedBoxes = 0;
Resets missed boxes count (this variable is now unused but kept for compatibility)
boxes = [];
Clears the boxes array so no old boxes from a previous game remain
for (let i = 0; i < 3; i++) { boxes.push(createBox()); }
Creates and adds 3 new boxes to start the game with the correct number of targets
gameStartTime = millis();
Records the current time; used by drawPlayingScreen() to calculate the countdown timer
updateUI();
Calls updateUI() to hide the start button and show any gameplay UI elements

endGame()

endGame() is called when the timer runs out. It updates the match history (used for rank calculation), checks if the score qualifies for the leaderboard, transitions to the post-game options screen, saves data to localStorage, and updates the UI. The qualification check determines whether to show the name entry screen or just the 'Return to Home' button.

function endGame() {
  updateMatchHistory(score);
  lastGameQualifiedForLeaderboard = leaderboard.length < 10 || score > leaderboard[leaderboard.length - 1].score;
  gameState = 'postGameOptions';
  saveGameData();
  updateUI();
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Match history tracking updateMatchHistory(score);

Adds the current game score to the match history (kept to last 3) for rank calculation

conditional High score qualification test lastGameQualifiedForLeaderboard = leaderboard.length < 10 || score > leaderboard[leaderboard.length - 1].score;

Determines if the score qualifies for the leaderboard (in top 10 or higher than the lowest top-10 score)

calculation Post-game state transition gameState = 'postGameOptions';

Moves to the post-game screen so the player sees game over content and options

updateMatchHistory(score);
Calls updateMatchHistory() to add the current score to the match history array; this is used by calculateRank() to determine the player's overall rank
lastGameQualifiedForLeaderboard = leaderboard.length < 10 || score > leaderboard[leaderboard.length - 1].score;
Checks if the leaderboard has fewer than 10 scores (so any score qualifies) OR if the current score is higher than the last (lowest) score on the leaderboard; stores the result to control which buttons appear in post-game
gameState = 'postGameOptions';
Changes the game state to 'postGameOptions' so the player sees the game over screen with options
saveGameData();
Calls saveGameData() to persist the updated leaderboard and match history to localStorage
updateUI();
Calls updateUI() to show the appropriate buttons for the post-game options screen

showLeaderboard()

showLeaderboard() is a simple state transition function called when the player clicks the 'Leaderboard' button. It changes the game state and updates the UI to display the leaderboard screen.

function showLeaderboard() {
  gameState = 'leaderboard';
  updateUI();
}
Line-by-line explanation (2 lines)
gameState = 'leaderboard';
Changes the game state so draw() calls drawLeaderboardScreen()
updateUI();
Updates the UI to show the 'Back to Start' button and hide all other buttons

showStartScreen()

showStartScreen() returns the player to the home screen by changing the game state and updating the UI. It's called by the 'Back to Start' button on the leaderboard and by the post-game options screen.

function showStartScreen() {
  gameState = 'start';
  updateUI();
}
Line-by-line explanation (2 lines)
gameState = 'start';
Changes the game state back to 'start' so draw() calls drawStartScreen()
updateUI();
Updates the UI to show the 'Start Game' and 'Leaderboard' buttons

handleNameSubmission()

handleNameSubmission() is called when the player clicks 'Submit Name' after a high score. It reads the name from the input field, defaults to 'Anonymous' if blank, adds the score to the leaderboard, saves to localStorage, and returns to the start screen. This function demonstrates form input handling and data validation.

function handleNameSubmission() {
  let playerName = nameInput.value().trim();
  if (playerName === '') {
    playerName = 'Anonymous';
  }
  addToLeaderboard(score, playerName);
  saveGameData();
  nameInput.value('');
  nameInput.hide();
  submitNameButton.hide();
  gameState = 'start';
  updateUI();
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

initialization Read and validate name let playerName = nameInput.value().trim();

Gets the text the player typed, removes leading/trailing whitespace, and stores it

conditional Default to Anonymous if (playerName === '') { playerName = 'Anonymous'; }

If the player didn't enter a name, defaults to 'Anonymous'

calculation Add score to leaderboard addToLeaderboard(score, playerName);

Adds the final score with the player's name to the leaderboard and sorts it

let playerName = nameInput.value().trim();
Gets the text from the name input field, calls trim() to remove extra spaces at the start and end, and stores it in playerName
if (playerName === '') { playerName = 'Anonymous'; }
If the player left the name field blank (empty string), sets the name to 'Anonymous'
addToLeaderboard(score, playerName);
Calls addToLeaderboard() to add the final score with the player's name to the leaderboard list
saveGameData();
Saves the updated leaderboard and match history to localStorage so it persists between sessions
nameInput.value('');
Clears the name input field by setting its value to an empty string
nameInput.hide(); submitNameButton.hide();
Hides the name input field and submit button from the screen
gameState = 'start';
Transitions back to the start screen after the name has been submitted
updateUI();
Updates the UI to show the start screen buttons

handleNameEnter()

handleNameEnter() is attached to the nameInput field as a keydown listener and allows players to submit their name by pressing Enter instead of clicking the button. The keyCode 13 corresponds to the Enter key. This is a common UX improvement for text input forms.

function handleNameEnter(event) {
  if (event.keyCode === 13) {
    handleNameSubmission();
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

conditional Detect Enter key press if (event.keyCode === 13) {

Checks if the pressed key is the Enter key (key code 13)

if (event.keyCode === 13) {
Checks if the keyCode of the pressed key is 13, which corresponds to the Enter key
handleNameSubmission();
If Enter was pressed, calls handleNameSubmission() to submit the name, so players can press Enter instead of clicking the button

loadGameData()

loadGameData() runs in setup() to retrieve previously saved leaderboard and match history from localStorage (the browser's persistent storage). This allows player data to survive between browser sessions. The JSON.parse() method converts stringified JSON back into usable JavaScript objects and arrays.

function loadGameData() {
  let savedLeaderboard = localStorage.getItem('aimTesterLeaderboard');
  if (savedLeaderboard) {
    leaderboard = JSON.parse(savedLeaderboard);
  }
  let savedMatchHistory = localStorage.getItem('aimTesterMatchHistory');
  if (savedMatchHistory) {
    matchHistory = JSON.parse(savedMatchHistory);
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

initialization Load leaderboard from storage let savedLeaderboard = localStorage.getItem('aimTesterLeaderboard');

Retrieves the leaderboard data from browser local storage if it exists

initialization Load match history from storage let savedMatchHistory = localStorage.getItem('aimTesterMatchHistory');

Retrieves the match history from browser local storage if it exists

let savedLeaderboard = localStorage.getItem('aimTesterLeaderboard');
Retrieves the saved leaderboard string from localStorage using the key 'aimTesterLeaderboard'; returns null if nothing was saved
if (savedLeaderboard) {
Only proceeds if something was found (not null); this prevents errors when trying to parse non-existent data
leaderboard = JSON.parse(savedLeaderboard);
Converts the saved JSON string back into a JavaScript array of score objects
let savedMatchHistory = localStorage.getItem('aimTesterMatchHistory');
Similarly retrieves the saved match history from localStorage
matchHistory = JSON.parse(savedMatchHistory);
Converts the saved match history JSON string back into an array of score numbers

saveGameData()

saveGameData() is called after games end and after names are submitted. It persists the leaderboard and match history to localStorage by converting them to JSON strings. This data survives browser refresh and reopening, allowing long-term tracking of player progress.

function saveGameData() {
  localStorage.setItem('aimTesterLeaderboard', JSON.stringify(leaderboard));
  localStorage.setItem('aimTesterMatchHistory', JSON.stringify(matchHistory));
}
Line-by-line explanation (2 lines)
localStorage.setItem('aimTesterLeaderboard', JSON.stringify(leaderboard));
Converts the leaderboard array to a JSON string and saves it to localStorage with the key 'aimTesterLeaderboard'
localStorage.setItem('aimTesterMatchHistory', JSON.stringify(matchHistory));
Similarly converts and saves the match history array to localStorage

clearGameData()

clearGameData() is called in setup() to reset all saved game data at the start of each session. This ensures the game always starts fresh. In production, you might move this call out of setup() and only trigger it with a 'Reset Data' button so players can choose whether to keep or clear their history.

function clearGameData() {
  localStorage.removeItem('aimTesterLeaderboard');
  localStorage.removeItem('aimTesterMatchHistory');
  leaderboard = [];
  matchHistory = [];
}
Line-by-line explanation (4 lines)
localStorage.removeItem('aimTesterLeaderboard');
Removes the leaderboard data from localStorage, deleting any saved high scores
localStorage.removeItem('aimTesterMatchHistory');
Removes the match history from localStorage
leaderboard = [];
Resets the in-memory leaderboard array to empty so the game starts fresh
matchHistory = [];
Resets the in-memory match history array to empty

addToLeaderboard()

addToLeaderboard() adds a new score entry to the leaderboard array, sorts the entire list by score in descending order (highest first), and trims it to keep only the top 10. Each entry is an object containing the score, a timestamp, and the player's name. This function is called when a high score qualifies and the player submits their name.

function addToLeaderboard(newScore, playerName) {
  leaderboard.push({ score: newScore, timestamp: new Date(), name: playerName });
  leaderboard.sort((a, b) => b.score - a.score);
  if (leaderboard.length > 10) {
    leaderboard = leaderboard.slice(0, 10);
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

calculation Create and add entry object leaderboard.push({ score: newScore, timestamp: new Date(), name: playerName });

Creates a new entry object with score, timestamp, and name, and adds it to the leaderboard

calculation Sort by score descending leaderboard.sort((a, b) => b.score - a.score);

Sorts the leaderboard so highest scores appear first

conditional Keep only top 10 if (leaderboard.length > 10) { leaderboard = leaderboard.slice(0, 10); }

Removes entries beyond the top 10 to keep the leaderboard manageable

leaderboard.push({ score: newScore, timestamp: new Date(), name: playerName });
Creates a new object with the score, current date/time, and player name, and appends it to the leaderboard array
leaderboard.sort((a, b) => b.score - a.score);
Uses the sort method with a compare function that returns b.score - a.score; higher scores come first (descending order)
if (leaderboard.length > 10) { leaderboard = leaderboard.slice(0, 10); }
If the leaderboard now has more than 10 entries, slice() keeps only the first 10 (the highest scores)

updateMatchHistory()

updateMatchHistory() is called when a game ends and adds the final score to the match history. It keeps only the last 3 scores by using slice(-3), which means the player's rank is based on their 3 most recent games. This rolling window encourages improvement and prevents old scores from permanently defining a player's rank.

function updateMatchHistory(matchScore) {
  matchHistory.push(matchScore);
  if (matchHistory.length > 3) {
    matchHistory = matchHistory.slice(-3);
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

calculation Add score to history matchHistory.push(matchScore);

Appends the current game score to the match history array

conditional Trim to last 3 matches if (matchHistory.length > 3) { matchHistory = matchHistory.slice(-3); }

Keeps only the last 3 scores; older scores are discarded

matchHistory.push(matchScore);
Adds the current game's score to the end of the matchHistory array
if (matchHistory.length > 3) {
Checks if the array now has more than 3 scores
matchHistory = matchHistory.slice(-3);
slice(-3) returns only the last 3 elements; this replaces the entire array with just the 3 most recent scores

calculateRank()

calculateRank() computes the player's rank by averaging their last 3 match scores and comparing to the RANKS threshold array. It loops backwards through RANKS (from highest to lowest) and returns the first rank whose threshold the average meets or exceeds. This ensures the player gets the best possible rank they qualify for. The reduce() method is a powerful array function that combines all elements into a single value.

🔬 This code calculates the average of the last 3 scores. What happens if you remove the division and just return the totalScore instead of averageScore? Now rank would be based on total points rather than average. How would that change the ranks?

  const totalScore = matchHistory.reduce((sum, score) => sum + score, 0);
  const averageScore = totalScore / matchHistory.length;
function calculateRank() {
  if (matchHistory.length === 0) {
    return 'Rookie';
  }
  const totalScore = matchHistory.reduce((sum, score) => sum + score, 0);
  const averageScore = totalScore / matchHistory.length;
  for (let i = RANKS.length - 1; i >= 0; i--) {
    if (averageScore >= RANKS[i].threshold) {
      return RANKS[i].name;
    }
  }
  return 'Rookie';
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

conditional No matches check if (matchHistory.length === 0) { return 'Rookie'; }

Returns 'Rookie' if no matches have been played yet

calculation Calculate average score const averageScore = totalScore / matchHistory.length;

Divides the sum of all match scores by the count to get the average

for-loop Find highest qualifying rank for (let i = RANKS.length - 1; i >= 0; i--) { ... }

Loops backwards through the RANKS array to find the highest rank whose threshold the average score meets

if (matchHistory.length === 0) { return 'Rookie'; }
If no games have been played yet, immediately return 'Rookie' as the default rank
const totalScore = matchHistory.reduce((sum, score) => sum + score, 0);
Uses reduce() to sum all scores in the match history; it starts at 0 and adds each score to a running total
const averageScore = totalScore / matchHistory.length;
Divides the total by the number of matches to calculate the average score
for (let i = RANKS.length - 1; i >= 0; i--) {
Loops backwards through the RANKS array (from highest rank to lowest) to find the best rank the player qualifies for
if (averageScore >= RANKS[i].threshold) { return RANKS[i].name; }
If the average score meets or exceeds this rank's threshold, return that rank's name and stop looping
return 'Rookie';
Fallback return in case no rank threshold is met (though this shouldn't happen since Rookie has threshold 0)

getRankFromScore()

getRankFromScore() is different from calculateRank(): it determines the rank for a single score (used in the leaderboard) rather than an average of recent scores. It also loops backwards through RANKS to find the highest matching rank. This is used when displaying each leaderboard entry's rank icon.

function getRankFromScore(score) {
  for (let i = RANKS.length - 1; i >= 0; i--) {
    if (score >= RANKS[i].threshold) {
      return RANKS[i].name;
    }
  }
  return 'Rookie';
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

for-loop Find rank for single score for (let i = RANKS.length - 1; i >= 0; i--) { ... }

Loops backwards through RANKS to find the highest rank that this single score qualifies for

for (let i = RANKS.length - 1; i >= 0; i--) {
Loops backwards through the RANKS array (from highest rank to lowest)
if (score >= RANKS[i].threshold) {
Tests if the provided score meets or exceeds the threshold for this rank
return RANKS[i].name;
Returns the rank name immediately if the threshold is met (stops looping since this is the highest qualifying rank)
return 'Rookie';
Fallback: if no rank threshold is met, returns 'Rookie'

drawRankIcon()

drawRankIcon() is a flexible function that retrieves the icon definition for a rank (from the RANK_ICONS object) and calls its custom draw function. Each rank has a different shape (circle for Rookie, crown for Champion, etc.) and color. This demonstrates how to store functions inside objects and call them dynamically, allowing easy addition of new ranks without changing this function.

function drawRankIcon(rankName, x, y, size) {
  const iconData = RANK_ICONS[rankName];
  if (iconData) {
    noStroke();
    fill(iconData.color);
    push();
    textAlign(CENTER, CENTER);
    iconData.draw(this, x, y, size);
    pop();
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

calculation Retrieve icon data const iconData = RANK_ICONS[rankName];

Looks up the icon definition (color and draw function) for the given rank name

initialization Draw the icon iconData.draw(this, x, y, size);

Calls the custom draw function stored in iconData to render the rank icon at the specified position and size

const iconData = RANK_ICONS[rankName];
Looks up the rank name in the RANK_ICONS object to retrieve the icon's color and draw function
if (iconData) {
Only proceeds if the rank name exists in RANK_ICONS (safety check)
noStroke();
Disables outline drawing so only the filled shape appears
fill(iconData.color);
Sets the fill color to the color defined in the icon data
push(); ... pop();
Saves and restores the drawing state (push/pop) so the textAlign change doesn't affect other text
iconData.draw(this, x, y, size);
Calls the custom draw function stored in the icon data, passing the current context (this), position (x, y), and size

drawHexagon()

drawHexagon() uses trigonometry to generate a regular hexagon by calculating 6 evenly-spaced vertices around a circle. The loop divides 360 degrees (TWO_PI radians) into 6 equal angles, and cos/sin calculate the x,y position of each vertex. This function is called by drawHexagonBackground() for every hexagon in the background pattern. This is a classic technique for drawing regular polygons in p5.js.

🔬 This loop uses cos() and sin() to place 6 points evenly around a circle. What happens if you change TWO_PI / 6 to TWO_PI / 12? Now you'll have 12 points instead of 6, making a 12-sided shape. The angles become 30 degrees apart instead of 60 degrees.

  for (let a = 0; a < TWO_PI; a += TWO_PI / 6) {
    let sx = x + cos(a) * radius;
    let sy = y + sin(a) * radius;
    buffer.vertex(sx, sy);
  }
function drawHexagon(buffer, x, y, radius, fillColor) {
  buffer.noStroke();
  buffer.fill(fillColor);
  buffer.beginShape();
  for (let a = 0; a < TWO_PI; a += TWO_PI / 6) {
    let sx = x + cos(a) * radius;
    let sy = y + sin(a) * radius;
    buffer.vertex(sx, sy);
  }
  buffer.endShape(CLOSE);
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

for-loop Generate hexagon vertices for (let a = 0; a < TWO_PI; a += TWO_PI / 6) { ... }

Loops 6 times (once per 60 degrees) to calculate vertices evenly spaced around a circle, forming a regular hexagon

buffer.noStroke();
Removes the outline stroke from the hexagon (only the fill will be visible)
buffer.fill(fillColor);
Sets the fill color using the provided fillColor parameter
buffer.beginShape();
Starts defining a custom polygon shape
for (let a = 0; a < TWO_PI; a += TWO_PI / 6) {
Loops from 0 to TWO_PI (360 degrees) in 6 equal steps of TWO_PI / 6 (60 degrees each)
let sx = x + cos(a) * radius;
Uses trigonometry: cos(angle) gives an x-offset from the center, multiplied by radius to get the actual position
let sy = y + sin(a) * radius;
Similarly, sin(angle) gives a y-offset, creating a vertex on the circle's circumference
buffer.vertex(sx, sy);
Adds this vertex to the shape being defined
buffer.endShape(CLOSE);
Finishes the shape definition and closes it by connecting the last vertex back to the first

drawHexagonBackground()

drawHexagonBackground() creates a honeycomb-patterned background by calculating grid spacing for hexagons and drawing them in offset rows. It demonstrates nested loops for 2D grid iteration, geometric calculations for regular hexagon spacing, color randomization with lerpColor(), and transparency with setAlpha(). This function is called once in setup() and the result is saved to hexBackgroundGraphics, which is then reused every frame in draw()—a key optimization for performance.

function drawHexagonBackground(buffer) {
  buffer.clear();
  buffer.background(30);
  let hexRadius = 30;
  let hexHeight = sqrt(3) * hexRadius;
  let hSpacing = hexRadius * 2;
  let vSpacing = hexHeight;
  let numCols = ceil(width / hSpacing) + 1;
  let numRows = ceil(height / vSpacing) + 1;
  let blue1 = color(50, 50, 150);
  let blue2 = color(100, 100, 200);
  for (let row = 0; row < numRows; row++) {
    let y = row * vSpacing;
    let xOffset = (row % 2 === 0) ? hexRadius : 0;
    for (let col = 0; col < numCols; col++) {
      let x = col * hSpacing + xOffset;
      let shade = lerpColor(blue1, blue2, random());
      shade.setAlpha(80);
      drawHexagon(buffer, x, y, hexRadius, shade);
    }
  }
}
Line-by-line explanation (15 lines)

🔧 Subcomponents:

calculation Calculate grid spacing let hexHeight = sqrt(3) * hexRadius;

Uses the geometric relationship in regular hexagons to calculate vertical spacing from the radius

for-loop Nested loop for honeycomb pattern for (let row = 0; row < numRows; row++) { ... for (let col = 0; col < numCols; col++) { ... } }

Loops through all grid positions and draws a hexagon at each, with every other row offset to create a honeycomb pattern

calculation Randomize hexagon colors let shade = lerpColor(blue1, blue2, random());

Blends between two blue shades randomly for visual variation

buffer.clear();
Clears any previous content from the graphics buffer before drawing the new background
buffer.background(30);
Fills the entire buffer with a dark gray background (RGB 30, 30, 30)
let hexRadius = 30;
Sets the radius of each hexagon to 30 pixels
let hexHeight = sqrt(3) * hexRadius;
Calculates hexagon height using the mathematical relationship for regular hexagons (height = √3 × radius ≈ 1.73 × radius)
let hSpacing = hexRadius * 2;
Horizontal spacing between hexagon centers is twice the radius (accounting for the hexagon's width)
let vSpacing = hexHeight;
Vertical spacing between hexagon rows equals the hexagon height for a tight honeycomb pattern
let numCols = ceil(width / hSpacing) + 1;
Calculates how many hexagon columns fit across the canvas width, adding 1 to ensure full coverage with margin
let numRows = ceil(height / vSpacing) + 1;
Similarly calculates the number of rows needed to cover the full height
let blue1 = color(50, 50, 150); let blue2 = color(100, 100, 200);
Defines two shades of blue—a darker and lighter blue—for color variation
for (let row = 0; row < numRows; row++) {
Outer loop iterates through each row
let xOffset = (row % 2 === 0) ? hexRadius : 0;
Uses modulo to offset every other row by one hexRadius, creating the characteristic honeycomb pattern
for (let col = 0; col < numCols; col++) {
Inner loop iterates through each column in this row
let shade = lerpColor(blue1, blue2, random());
Uses lerpColor() to blend between the two blue shades at a random position (0 to 1), creating variation
shade.setAlpha(80);
Sets the alpha (transparency) to 80 out of 255, making the hexagons slightly see-through
drawHexagon(buffer, x, y, hexRadius, shade);
Calls drawHexagon() to draw a single hexagon at this position with this color

createUI()

createUI() creates all p5.Element button and input objects in one place. Each button is created, styled, and configured with a callback function. The nameInput field is created, styled to match the game's aesthetic, and set up with a keydown listener for the Enter key. All elements start hidden and are shown/hidden by updateUI() based on the current game state. This centralized creation makes it easy to modify the UI.

function createUI() {
  startButton = createButton('Start Game');
  startButton.mousePressed(startGame);
  styleButton(startButton, 'white', 24, 'black');
  restartButton = createButton('Restart Game');
  restartButton.mousePressed(startGame);
  styleButton(restartButton, '#008CBA', 24);
  leaderboardButton = createButton('Leaderboard');
  leaderboardButton.mousePressed(showLeaderboard);
  styleButton(leaderboardButton, '#00008B', 24, 'white');
  backButton = createButton('Back to Start');
  backButton.mousePressed(showStartScreen);
  styleButton(backButton, '#555555', 20);
  nameInput = createInput('');
  nameInput.attribute('placeholder', 'Enter your name...');
  nameInput.style('font-size', '20px');
  nameInput.style('padding', '10px');
  nameInput.style('border-radius', '5px');
  nameInput.style('border', '2px solid #555');
  nameInput.style('text-align', 'center');
  nameInput.style('font-family', "'Bebas Neue', sans-serif");
  nameInput.style('text-shadow', '0 0 8px #00a2ff');
  nameInput.hide();
  nameInput.elt.addEventListener('keydown', handleNameEnter);
  submitNameButton = createButton('Submit Name');
  submitNameButton.mousePressed(handleNameSubmission);
  styleButton(submitNameButton, '#4CAF50', 20);
  submitNameButton.hide();
  submitScoreOptionButton = createButton('Submit Score');
  submitScoreOptionButton.mousePressed(() => {
    if (lastGameQualifiedForLeaderboard) {
      gameState = 'nameEntry';
      updateUI();
    } else {
      showStartScreen();
    }
  });
  styleButton(submitScoreOptionButton, '#4CAF50', 20);
  submitScoreOptionButton.hide();
  returnHomeOptionButton = createButton('Return to Home');
  returnHomeOptionButton.mousePressed(showStartScreen);
  styleButton(returnHomeOptionButton, '#555555', 20);
  returnHomeOptionButton.hide();
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

initialization Create all buttons startButton = createButton('Start Game');

Creates p5.Element button objects for all UI buttons throughout the game

initialization Style buttons styleButton(startButton, 'white', 24, 'black');

Applies consistent styling to each button using the styleButton helper function

initialization Configure name input nameInput = createInput('');

Creates an input field for the player name and applies styling and event listeners

startButton = createButton('Start Game');
Creates a new button element with the label 'Start Game' and stores it in startButton
startButton.mousePressed(startGame);
Registers the startGame() function to be called when this button is clicked
styleButton(startButton, 'white', 24, 'black');
Applies styling (white background, 24px font, black text) using the helper function
nameInput = createInput('');
Creates an input field with an empty initial value
nameInput.attribute('placeholder', 'Enter your name...');
Sets placeholder text that appears faintly in the input field when it's empty
nameInput.style('font-size', '20px');
Applies inline CSS styling to the input field (these can be chained)
nameInput.hide();
Hides the input field initially; it's shown later when needed in drawNameEntryScreen()
nameInput.elt.addEventListener('keydown', handleNameEnter);
Attaches a JavaScript event listener to the input element's DOM element (.elt) so pressing Enter calls handleNameEnter()

styleButton()

styleButton() is a helper function that applies consistent styling to all buttons throughout the game. It demonstrates how to use p5.Element's style() method to apply CSS properties, and shows how default parameters (textColor = 'white') provide sensible defaults while allowing customization. This function keeps button styling DRY (Don't Repeat Yourself) and makes it easy to update the look of all buttons in one place.

function styleButton(button, bgColor, fontSize, textColor = 'white') {
  button.style('font-size', `${fontSize}px`);
  button.style('padding', '15px 30px');
  button.style('border-radius', '10px');
  button.style('background-color', bgColor);
  button.style('color', textColor);
  button.style('border', 'none');
  button.style('cursor', 'pointer');
  button.style('outline', 'none');
  button.style('font-family', "'Bebas Neue', sans-serif");
  button.style('text-shadow', '0 0 8px #00a2ff');
}
Line-by-line explanation (11 lines)
function styleButton(button, bgColor, fontSize, textColor = 'white') {
Function takes a button element and styling parameters; textColor defaults to 'white' if not provided
button.style('font-size', `${fontSize}px`);
Sets the font size using template literals to convert the number parameter to a pixel string
button.style('padding', '15px 30px');
Applies padding (15px vertical, 30px horizontal) to make buttons easier to click
button.style('border-radius', '10px');
Rounds the corners of the button (10px radius)
button.style('background-color', bgColor);
Sets the button's background color using the provided bgColor parameter
button.style('color', textColor);
Sets the text color using the textColor parameter
button.style('border', 'none');
Removes the default button border for a clean look
button.style('cursor', 'pointer');
Changes the mouse cursor to a pointer when hovering over the button
button.style('outline', 'none');
Removes the browser's default focus outline (for a custom look)
button.style('font-family', "'Bebas Neue', sans-serif");
Applies the custom Bebas Neue font to match the game's aesthetic
button.style('text-shadow', '0 0 8px #00a2ff');
Applies a glowing blue text shadow effect to all buttons

updateUI()

updateUI() is called whenever the game state changes (in startGame(), endGame(), showLeaderboard(), etc.). It hides all UI elements and then uses a switch statement to show only the relevant buttons and input fields for the current state. This keeps the UI clean and prevents button overlap. The function also positions elements dynamically using width and height to ensure they're centered regardless of screen size.

🔬 This case shows two buttons stacked vertically. The second position uses (height / 2 + 150 + buttonHeight + spacing) to place it below the first. What happens if you change 'spacing' to a larger value like 50? The buttons will move further apart.

  switch (gameState) {
    case 'start':
      startButton.show();
      startButton.position(width / 2 - buttonWidth / 2, height / 2 + 150);
      leaderboardButton.show();
      leaderboardButton.position(width / 2 - buttonWidth / 2, height / 2 + 150 + buttonHeight + spacing);
function updateUI() {
  let buttonWidth = 200;
  let buttonHeight = 60;
  let spacing = 20;
  startButton.hide();
  restartButton.hide();
  leaderboardButton.hide();
  backButton.hide();
  nameInput.hide();
  submitNameButton.hide();
  submitScoreOptionButton.hide();
  returnHomeOptionButton.hide();
  switch (gameState) {
    case 'start':
      startButton.show();
      startButton.position(width / 2 - buttonWidth / 2, height / 2 + 150);
      leaderboardButton.show();
      leaderboardButton.position(width / 2 - buttonWidth / 2, height / 2 + 150 + buttonHeight + spacing);
      break;
    case 'playing':
      break;
    case 'postGameOptions':
      if (lastGameQualifiedForLeaderboard) {
        submitScoreOptionButton.show();
        submitScoreOptionButton.position(width / 2 - buttonWidth / 2, height / 2 + 100);
        returnHomeOptionButton.show();
        returnHomeOptionButton.position(width / 2 - buttonWidth / 2, height / 2 + 100 + buttonHeight + spacing);
      } else {
        returnHomeOptionButton.show();
        returnHomeOptionButton.position(width / 2 - buttonWidth / 2, height / 2 + 100);
      }
      break;
    case 'leaderboard':
      backButton.show();
      backButton.position(width / 2 - 100, height - 100);
      break;
    case 'nameEntry':
      nameInput.show();
      nameInput.position(width / 2 - nameInput.width / 2, height / 2 + 50);
      submitNameButton.show();
      submitNameButton.position(width / 2 - submitNameButton.width / 2, height / 2 + 120);
      break;
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

initialization Hide all UI elements startButton.hide(); restartButton.hide(); ...

Clears the screen by hiding all buttons and input fields before showing only the ones needed for the current state

switch-case Show UI based on state switch (gameState) { ... }

Displays the appropriate buttons and input fields for each game state

let buttonWidth = 200; let buttonHeight = 60; let spacing = 20;
Defines constants for button dimensions and spacing used to position elements
startButton.hide(); ... returnHomeOptionButton.hide();
Hides all UI elements by default; only the relevant ones for the current state are shown
switch (gameState) {
Evaluates the current game state and branches to the appropriate case
case 'start': startButton.show(); startButton.position(...);
On the start screen, shows the 'Start Game' button and positions it below the title
case 'playing': break;
During gameplay, no buttons are shown; the break immediately exits the switch
case 'postGameOptions': if (lastGameQualifiedForLeaderboard) { ... }
Conditionally shows 'Submit Score' button only if the score qualifies; always shows 'Return to Home'
case 'leaderboard': backButton.show(); backButton.position(...);
On the leaderboard, shows the 'Back to Start' button in the lower center
case 'nameEntry': nameInput.show(); nameInput.position(...);
Shows the name input field and submit button below the text, centered horizontally

windowResized()

windowResized() is a built-in p5.js function called automatically whenever the browser window is resized. It ensures the canvas and all elements scale responsively. This sketch resizes the canvas, regenerates the hexagon background, and repositions UI elements. Without this function, the canvas would stay its original size and buttons would be positioned incorrectly on resized windows.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  hexBackgroundGraphics = createGraphics(windowWidth, windowHeight);
  drawHexagonBackground(hexBackgroundGraphics);
  updateUI();
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

initialization Resize canvas to window resizeCanvas(windowWidth, windowHeight);

Updates the p5.js canvas dimensions to match the new window size

initialization Redraw background for new size drawHexagonBackground(hexBackgroundGraphics);

Regenerates the hexagon background to fit the new canvas size

initialization Reposition UI elements updateUI();

Recalculates button positions based on new canvas dimensions

resizeCanvas(windowWidth, windowHeight);
Resizes the p5.js canvas to match the new window dimensions
hexBackgroundGraphics = createGraphics(windowWidth, windowHeight);
Recreates the graphics buffer with the new window dimensions
drawHexagonBackground(hexBackgroundGraphics);
Redraws the hexagon background at the new size
updateUI();
Calls updateUI() to reposition all buttons and input fields based on the new window size

📦 Key Variables

gameState string

Tracks which screen is currently displayed: 'start', 'playing', 'postGameOptions', 'leaderboard', or 'nameEntry'

let gameState = 'start';
score number

Counts the number of boxes the player has successfully clicked in the current game

let score = 0;
boxes array

Array of box objects currently on screen; each has x, y, width, height, color, and creationTime properties

let boxes = [];
GAME_DURATION number

The total game time in milliseconds (45 * 1000 = 45 seconds)

const GAME_DURATION = 45 * 1000;
gameStartTime number

Stores the timestamp (from millis()) when the current game started, used to calculate remaining time

let gameStartTime = 0;
leaderboard array

Array of top 10 score entries, each containing {score, timestamp, name} properties, sorted by score descending

let leaderboard = [];
matchHistory array

Array of the player's last 3 game scores, used to calculate their overall rank

let matchHistory = [];
RANKS array

Array of rank objects, each with name and threshold properties, defining rank tiers based on average score

const RANKS = [{ name: 'Rookie', threshold: 0 }, ...];
lastGameQualifiedForLeaderboard boolean

Flag indicating whether the final score qualified for the leaderboard (top 10), used to decide whether to show name entry

let lastGameQualifiedForLeaderboard = false;
hexBackgroundGraphics object

A p5.Renderer graphics buffer (off-screen canvas) holding the pre-rendered hexagon background pattern for performance

let hexBackgroundGraphics;
startButton, restartButton, leaderboardButton, backButton object

p5.Element button objects created by createButton() and positioned/shown/hidden based on game state

let startButton;
nameInput object

p5.Element input field for the player to enter their name after achieving a high score

let nameInput;

🔧 Potential Improvements (6)

Here are some ways this code could be enhanced:

BUG clearGameData() in setup()

The game resets all data every time it loads, so players cannot persist their progress between browser sessions. This defeats the purpose of using localStorage.

💡 Move the clearGameData() call out of setup() or remove it entirely. Consider adding a 'Reset Game Data' button in settings if players need to clear manually. The loadGameData() call should be the only data-loading line in setup().

PERFORMANCE drawPlayingScreen()

The game calculates time formatting, rank, and text width on every frame (60 times per second), even though the timer only updates every second and the rank rarely changes

💡 Cache the calculated rank and timer string, only recalculate when time changes by a full second. Use a variable like lastSecondDisplayed to track this.

STYLE createUI()

The nameInput field uses inline style() calls for every property, making the code verbose and hard to maintain if styling needs to change

💡 Create a separate styleInput() helper function similar to styleButton(), or move all styles to an external CSS file.

FEATURE Game mechanics

The game always maintains exactly 3 boxes on screen, which becomes predictable after a few plays. Players have no difficulty progression.

💡 Gradually increase the number of boxes as the player scores more points (e.g., add a 4th box at score 10, a 5th at score 20). This provides natural difficulty scaling.

BUG drawHexagonBackground()

The hexagon background uses random() to pick colors every time the background is drawn, but it's only drawn once in setup(). If the player resizes the window, a completely different random background is generated, breaking visual consistency.

💡 Use Perlin noise (noise()) instead of random() to generate colors based on position, so the pattern stays consistent even when redrawn at different sizes.

FEATURE Leaderboard

The leaderboard stores up to 10 scores globally, but doesn't distinguish between different players or sessions. If multiple people use the same browser, their scores are mixed together.

💡 Add player names that are remembered in localStorage, and display per-player leaderboards. Or add filtering by date to show 'This Week's Top Scores' vs. 'All Time'.

🔄 Code Flow

Code flow showing setup, draw, drawstartscreen, drawplayingscreen, drawpostgameoptionsscreen, drawpostgamescreencontent, drawleaderboardscreen, drawnameentryscreen, createbox, mousepressed, startgame, endgame, showleaderboard, showstartscreen, handlenamesubmission, handlenameenter, loadgamedata, savegamedata, cleargamedata, addtoleaderboard, updatecmatchhistory, calculaterank, getrankfromscore, drawrankicon, drawhexagon, drawhexagonbackground, createui, stylebutton, updateui, windowresized

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

graph TD start[Start] --> setup[setup] setup --> canvas-setup[Canvas and text configuration] setup --> glow-effect[Text glow effect] setup --> background-creation[Background graphics preparation] background-creation --> background-display[Display pre-rendered background] setup --> createui[createUI] setup --> loadgamedata[loadGameData] setup --> cleargamedata[clearGameData] setup --> updateui[updateUI] setup --> windowresized[windowResized] setup --> draw[draw loop] draw --> state-switch[Game state dispatcher] state-switch --> drawstartscreen[drawStartScreen] state-switch --> drawplayingscreen[drawPlayingScreen] state-switch --> drawpostgameoptionsscreen[drawPostGameOptionsScreen] state-switch --> drawleaderboardscreen[drawLeaderboardScreen] state-switch --> drawnameentryscreen[drawNameEntryScreen] click setup href "#fn-setup" click canvas-setup href "#sub-canvas-setup" click glow-effect href "#sub-glow-effect" click background-creation href "#sub-background-creation" click background-display href "#sub-background-display" click createui href "#fn-createui" click loadgamedata href "#fn-loadgamedata" click cleargamedata href "#fn-cleargamedata" click updateui href "#fn-updateui" click windowresized href "#fn-windowresized" click draw href "#fn-draw" draw --> title-display[Game title] draw --> rank-calculation[Current rank lookup] draw --> rank-icon-and-text[Rank icon and text display] draw --> timer-calculation[Countdown timer math] draw --> time-formatting[MM:SS time formatting] draw --> box-rendering[Draw all active boxes] box-rendering --> size-randomization[Random box size] box-rendering --> position-randomization[Random position within bounds] draw --> game-end-check[Check if time has run out] game-end-check -->|if time runs out| endgame[endGame] endgame --> rank-recalc[Rank recalculation at game end] endgame --> qualification-check[High score qualification test] qualification-check -->|if qualifies| drawnameentryscreen qualification-check -->|if not qualifies| showstartscreen[showStartScreen] click draw href "#fn-draw" click title-display href "#sub-title-display" click rank-calculation href "#sub-rank-calculation" click rank-icon-and-text href "#sub-rank-icon-and-text" click timer-calculation href "#sub-timer-calculation" click time-formatting href "#sub-time-formatting" click box-rendering href "#sub-box-rendering" click size-randomization href "#sub-size-randomization" click position-randomization href "#sub-position-randomization" click game-end-check href "#sub-game-end-check" click rank-recalc href "#sub-rank-recalc" click qualification-check href "#sub-qualification-check" click endgame href "#fn-endgame" drawplayingscreen --> state-check[Game state validation] state-check --> mousepressed[mousePressed] mousepressed --> collision-detection[Box collision test] collision-detection -->|if hit| score-update[Score increment and box removal] score-update --> box-initialization[Initial box spawn] score-update --> state-transition[Game state change] state-transition --> drawplayingscreen click drawplayingscreen href "#fn-drawplayingscreen" click state-check href "#sub-state-check" click mousepressed href "#fn-mousepressed" click collision-detection href "#sub-collision-detection" click score-update href "#sub-score-update" click box-initialization href "#sub-box-initialization" click state-transition href "#sub-state-transition" drawpostgameoptionsscreen --> drawpostgamescreencontent[drawPostGameScreenContent] drawpostgamescreencontent --> updateui[updateUI] drawleaderboardscreen --> empty-check[Leaderboard empty check] empty-check -->|if empty| showstartscreen empty-check --> leaderboard-loop[Display top 10 scores] leaderboard-loop -->|for each entry| push-entry[Create and add entry object] push-entry --> sort-descending[Sort by score descending] sort-descending --> trim-top-10[Keep only top 10] click drawpostgameoptionsscreen href "#fn-drawpostgameoptionsscreen" click drawpostgamescreencontent href "#fn-drawpostgamescreencontent" click updateui href "#fn-updateui" click drawleaderboardscreen href "#fn-drawleaderboardscreen" click empty-check href "#sub-empty-check" click leaderboard-loop href "#sub-leaderboard-loop" click push-entry href "#sub-push-entry" click sort-descending href "#sub-sort-descending" click trim-top-10 href "#sub-trim-top-10" drawnameentryscreen --> name-input[Read and validate name] name-input --> empty-default[Default to Anonymous] empty-default --> leaderboard-add[Add score to leaderboard] leaderboard-add --> savegamedata[saveGameData] savegamedata --> showstartscreen click drawnameentryscreen href "#fn-drawnameentryscreen" click name-input href "#sub-name-input" click empty-default href "#sub-empty-default" click leaderboard-add href "#sub-leaderboard-add" drawstartscreen --> startgame[startGame] startgame --> box-initialization box-initialization --> timer-start[Record game start time] timer-start --> state-transition click drawstartscreen href "#fn-drawstartscreen" click startgame href "#fn-startgame" draw --> windowresized windowresized --> canvas-resize[Resize canvas to window] canvas-resize --> background-recreate[Redraw background for new size] background-recreate --> ui-reposition[Reposition UI elements] click windowresized href "#fn-windowresized" click canvas-resize href "#sub-canvas-resize" click background-recreate href "#sub-background-recreate" click ui-reposition href "#sub-ui-reposition"

❓ Frequently Asked Questions

What visual elements are created in the p5.js sketch titled 'Sketch 2026-03-12 17:48'?

The sketch generates interactive boxes that appear on the screen, which users can click to score points.

How can users engage with the 'Sketch 2026-03-12 17:48' game?

Users interact by clicking on the boxes that appear on the screen, aiming to maximize their score within a limited time.

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

The sketch demonstrates concepts such as game state management, real-time scoring, and dynamic object interaction.

Preview

Sketch 2026-03-12 17:48 - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Sketch 2026-03-12 17:48 - Code flow showing setup, draw, drawstartscreen, drawplayingscreen, drawpostgameoptionsscreen, drawpostgamescreencontent, drawleaderboardscreen, drawnameentryscreen, createbox, mousepressed, startgame, endgame, showleaderboard, showstartscreen, handlenamesubmission, handlenameenter, loadgamedata, savegamedata, cleargamedata, addtoleaderboard, updatecmatchhistory, calculaterank, getrankfromscore, drawrankicon, drawhexagon, drawhexagonbackground, createui, stylebutton, updateui, windowresized
Code Flow Diagram