The best snake game modded

This sketch is a complete Snake game remix with a menu system, an unlockable outfit shop, a shooting mechanic, chasing zombies, and a live DOM-based admin panel for tweaking the game in real time. The snake moves on a grid, eats fruit to grow and score points, can fire projectiles to snipe food or zombies from a distance, and loses a life when it hits a wall, its own tail, or a zombie.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make the whole game chunkier — Increasing scl makes the grid cells (and therefore the snake, food, and zombies) noticeably bigger and coarser.
  2. Unleash a zombie horde — Lowering the zombie spawn rate makes new zombies appear far more frequently, ramping up the challenge.
  3. Turn on rapid fire — Shrinking the shoot cooldown lets you fire projectiles almost every frame instead of waiting between shots.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch turns classic Snake into a mini game engine: a rainbow HSB main menu, a paginated outfit-unlock shop backed by localStorage, grid-based snake movement with smooth lerp interpolation, a cooldown-based shooting system, zombies that chase the snake using p5.Vector math, and a collapsible admin panel built entirely from p5.js DOM elements (sliders, buttons, inputs) layered on top of the canvas.

The code is organized around a simple state machine (gameState) that decides whether draw() renders the menu, the outfits screen, live gameplay, or the game-over screen. Four ES6 classes - Snake, Food, Projectile, and Zombie - each own their own update/show/collision logic, and a separate 'logic tick' counter decouples the smooth 60fps render loop from the slower, adjustable snake movement speed. Studying this file teaches you how to structure a multi-screen game, mix p5.js DOM elements with canvas rendering, and fake buttery animation on top of grid-locked logic.

⚙️ How It Works

  1. On load, setup() creates a full-window canvas, builds the admin panel's DOM controls, loads any previously unlocked outfits from localStorage, and creates the initial Snake and Food objects.
  2. Every frame, draw() clears the background and calls one of four screen functions - drawMenu, drawOutfitsMenu, drawGameplay, or drawGameOver - depending on the current gameState.
  3. In drawGameplay, zombies update and check collision with the snake's head, projectiles fly forward checking for hits on food or zombies, the snake moves and eats food directly if it touches it, and score/lives are updated accordingly.
  4. Snake movement is grid-locked (one cell per logic tick) but the show() methods use lerp() to interpolate between the previous and current grid position each render frame, so the snake and zombies glide smoothly instead of jumping cell to cell.
  5. Clicking buttons in the menu/outfits/game-over screens is handled by dedicated mousePressed-driven functions that hit-test the mouse position against manually calculated button rectangles.
  6. The admin panel toggle button shows/hides a DOM sidebar built with createSlider/createButton/createInput calls, letting a player change speed, score, lives, zombie behavior, or teleport the snake without touching the code.

🎓 Concepts You'll Learn

Finite state machineES6 classes and OOPp5.Vector math for AI chasingLinear interpolation (lerp) for smooth animationp5.js DOM elements (sliders/buttons/inputs)localStorage persistenceHSB color modeGrid-based collision detection

📝 Code Breakdown

loadUnlockedOutfits()

This function demonstrates how to persist game progress across browser sessions using the Web Storage API (localStorage), a common technique for save systems in browser games.

🔬 This loop restores unlocked outfits by name. What happens if you clear your browser's localStorage (or rename an outfit in the outfits array) - does the game forget your progress?

    for (let outfit of outfits) {
      if (unlockedNames.includes(outfit.name)) {
        outfit.unlocked = true;
      }
    }
function loadUnlockedOutfits() {
  let unlockedData = localStorage.getItem('snakeGameOutfits');
  if (unlockedData) {
    let unlockedNames = JSON.parse(unlockedData);
    for (let outfit of outfits) {
      if (unlockedNames.includes(outfit.name)) {
        outfit.unlocked = true;
      }
    }
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

for-loop Restore Unlocked Flags for (let outfit of outfits) { if (unlockedNames.includes(outfit.name)) { outfit.unlocked = true; } }

Marks each outfit object as unlocked if its name was saved in localStorage from a previous session

let unlockedData = localStorage.getItem('snakeGameOutfits');
Reads a saved string from the browser's localStorage under the key 'snakeGameOutfits' - returns null if nothing was ever saved.
if (unlockedData) {
Only proceeds if something was actually saved before (skips on a brand new browser).
let unlockedNames = JSON.parse(unlockedData);
Converts the saved JSON string back into a real JavaScript array of outfit names.
for (let outfit of outfits) {
Loops through every outfit object defined in the global outfits array.
if (unlockedNames.includes(outfit.name)) { outfit.unlocked = true; }
If this outfit's name is in the saved list, flips its unlocked property to true.

saveUnlockedOutfits()

Paired with loadUnlockedOutfits(), this is the 'write' half of a simple save system - filter/map are handy array methods for transforming data before persisting it.

function saveUnlockedOutfits() {
  let unlockedNames = outfits.filter(o => o.unlocked).map(o => o.name);
  localStorage.setItem('snakeGameOutfits', JSON.stringify(unlockedNames));
}
Line-by-line explanation (2 lines)
let unlockedNames = outfits.filter(o => o.unlocked).map(o => o.name);
Builds a plain array of just the names of outfits that are currently unlocked, using filter() then map().
localStorage.setItem('snakeGameOutfits', JSON.stringify(unlockedNames));
Converts that array to a JSON string and saves it in the browser so it survives a page reload.

setup()

setup() runs once when the sketch starts. It's the right place to create the canvas, build any DOM UI, and initialize your starting objects and variables.

function setup() {
  // Create the canvas to take up the full window height
  gameCanvas = createCanvas(windowWidth, windowHeight);
  gameCanvas.position(0, 0); // Position at the top-left

  // Create the admin panel container and its controls
  createAdminPanel();

  // Load unlocked outfits
  loadUnlockedOutfits();
  // Ensure the currentOutfitIndex is for an unlocked outfit, or default
  if (!outfits[currentOutfitIndex].unlocked) {
    currentOutfitIndex = outfits.findIndex(o => o.unlocked) || 0;
  }

  // Initialize the snake and food (will be reset when game starts)
  snake = new Snake();
  food = new Food();
  food.pickLocation();

  // Set the drawing frame rate for smooth animation
  frameRate(60); // Snake moves 10 times per second
  
  // Hide admin panel toggle icon initially in menu states
  adminPanelToggle.hide();

  // Removed: userStartAudio() call
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Fallback Outfit Selection if (!outfits[currentOutfitIndex].unlocked) { currentOutfitIndex = outfits.findIndex(o => o.unlocked) || 0; }

Guards against starting with a locked outfit selected by falling back to the first unlocked one

gameCanvas = createCanvas(windowWidth, windowHeight);
Creates the drawing surface at the full size of the browser window and stores a reference to it in gameCanvas.
createAdminPanel();
Builds every DOM element (sliders, buttons, inputs) for the admin sidebar - called once at startup.
loadUnlockedOutfits();
Restores which outfits were unlocked in a previous session from localStorage.
snake = new Snake();
Creates the first Snake object using the class defined further down the file.
food.pickLocation();
Places the very first piece of food at a random grid cell.
frameRate(60);
Tells p5.js to try to call draw() 60 times per second - this controls how smooth the animation looks, separate from how fast the snake actually moves.
adminPanelToggle.hide();
Hides the gear-icon button initially since the game starts on the menu screen, not gameplay.

draw()

draw() runs continuously at the frame rate set in setup(). Using a switch statement on a state variable like this is the classic pattern for building multi-screen apps and games in p5.js.

🔬 This is one branch of the state machine. What would happen if you swapped the order so GAMEOVER is checked before GAMEPLAY - would it change anything, given gameState can only ever equal one value at a time?

    case GAME_STATES.GAMEPLAY:
      drawGameplay();
      break;
function draw() {
  // Clear the canvas
  background(220);

  // Render different screens based on the current game state
  switch (gameState) {
    case GAME_STATES.MENU:
      drawMenu();
      break;
    case GAME_STATES.OUTFITS_MENU:
      drawOutfitsMenu();
      break;
    case GAME_STATES.GAMEPLAY:
      drawGameplay();
      break;
    case GAME_STATES.GAMEOVER:
      drawGameOver();
      break;
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

switch-case Screen Router switch (gameState) { case GAME_STATES.MENU: drawMenu(); break; ... }

Decides which screen-drawing function to call based on the current gameState

background(220);
Clears the whole canvas to light gray at the start of every frame so nothing from the previous frame lingers underneath the new screen.
switch (gameState) {
Branches based on the value of the global gameState string, acting as the sketch's screen router.
case GAME_STATES.GAMEPLAY: drawGameplay(); break;
When the player is actively playing, this delegates all rendering and game logic to drawGameplay().

drawMenu()

This function shows how to fake a gradient p5.js doesn't support natively by drawing many thin, incrementally-colored shapes, and how HSB color mode makes rainbow-cycling colors trivial with frameCount.

🔬 This loop fakes a smooth gradient with lots of thin rectangles. What happens visually if you change gradientSteps from 20 down to 3? What if you bump it up to 100?

  let gradientSteps = 20; // Number of rectangles to simulate gradient
  let stepWidth = buttonWidth / gradientSteps;
  for (let i = 0; i < gradientSteps; i++) {
    let hue = (frameCount * 1 + map(i, 0, gradientSteps - 1, 0, 360)) % 360;
    fill(hue, 100, 100);
    rect(playButtonX - buttonWidth/2 + i * stepWidth + stepWidth/2, playButtonY, stepWidth, buttonHeight, 0); // No corner radius for individual rects
  }
function drawMenu() {
  // Show game canvas and admin toggle
  gameCanvas.show();
  adminPanelToggle.hide(); // Hide toggle in menu

  // Temporarily switch to HSB color mode for rainbow effects
  push();
  colorMode(HSB, 360, 100, 100, 100);

  textAlign(CENTER, CENTER);
  // Rainbow "Snake Game" text
  fill((frameCount * 2) % 360, 100, 100); // Cycle hue
  textSize(64);
  text("Snake Game", width / 2, height / 2 - 100);

  // Play Button
  let playButtonX = width / 2;
  let playButtonY = height / 2;
  let buttonWidth = 200;
  let buttonHeight = 60;
  rectMode(CENTER); // Center rectangles for easier positioning

  // Draw rainbow gradient for Play button
  let gradientSteps = 20; // Number of rectangles to simulate gradient
  let stepWidth = buttonWidth / gradientSteps;
  for (let i = 0; i < gradientSteps; i++) {
    let hue = (frameCount * 1 + map(i, 0, gradientSteps - 1, 0, 360)) % 360;
    fill(hue, 100, 100);
    rect(playButtonX - buttonWidth/2 + i * stepWidth + stepWidth/2, playButtonY, stepWidth, buttonHeight, 0); // No corner radius for individual rects
  }
  // Draw text on top
  fill(0, 0, 0); // Black text for readability
  textSize(32);
  text("Play", playButtonX, playButtonY + 8);

  // Outfits Button
  let outfitsButtonY = playButtonY + buttonHeight + 20;
  // Draw rainbow gradient for Outfits button
  for (let i = 0; i < gradientSteps; i++) {
    let hue = (frameCount * 1 + map(i, 0, gradientSteps - 1, 0, 360)) % 360;
    fill(hue, 100, 100);
    rect(playButtonX - buttonWidth/2 + i * stepWidth + stepWidth/2, outfitsButtonY, stepWidth, buttonHeight, 0);
  }
  // Draw text on top
  fill(0, 0, 0); // Black text for readability
  textSize(32);
  text("Outfits", playButtonX, outfitsButtonY + 8);
  rectMode(CORNER); // Reset rectMode

  // Instructions text
  fill((frameCount * 1) % 360, 100, 100); // Cycle hue
  textSize(24); // Increased text size for better gradient visibility
  text("Click to interact", width / 2, height / 2 + 180);

  pop(); // Restore previous drawing style (RGB color mode)
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

push(); colorMode(HSB, 360, 100, 100, 100);
Saves the current drawing style and switches to Hue-Saturation-Brightness color mode, which makes cycling rainbow colors with frameCount much easier than RGB.
fill((frameCount * 2) % 360, 100, 100); // Cycle hue
Picks a hue that shifts every frame (frameCount increases every frame) and wraps around at 360 using modulo, creating a color-cycling effect.
rectMode(CENTER); // Center rectangles for easier positioning
Changes rect() so its x,y arguments refer to the shape's center instead of its top-left corner, simplifying button placement math.
let gradientSteps = 20; // Number of rectangles to simulate gradient
How many thin slices make up the fake gradient - more slices look smoother but cost more draw calls.
for (let i = 0; i < gradientSteps; i++) {
Loops once per gradient slice to draw a series of colored rectangles that together look like a smooth gradient.
let hue = (frameCount * 1 + map(i, 0, gradientSteps - 1, 0, 360)) % 360;
map() spreads slice index i across the full 0-360 hue range, and adding frameCount makes the whole gradient scroll over time.
pop(); // Restore previous drawing style (RGB color mode)
Restores the drawing style saved by push(), so code outside this function goes back to normal RGB color mode.

drawOutfitsMenu()

This function demonstrates canvas-based UI pagination: slicing an array into pages with startIndex/endIndex math instead of using any built-in scrolling widget, which is a common pattern in custom canvas interfaces.

function drawOutfitsMenu() {
  gameCanvas.show();
  adminPanelToggle.hide();

  textAlign(CENTER, CENTER);
  fill(0);
  textSize(48);
  text("Choose Your Snake!", width / 2, height / 2 - 200);

  let outfitsPerPage = 4;
  let startY = height / 2 - 100;
  let itemHeight = 100;
  let itemSpacing = 20;
  let totalOutfits = outfits.length;
  let maxPage = floor((totalOutfits - 1) / outfitsPerPage);
  outfitsMenuPage = constrain(outfitsMenuPage, 0, maxPage);
  let startIndex = outfitsMenuPage * outfitsPerPage;
  let endIndex = min(startIndex + outfitsPerPage, totalOutfits);

  for (let i = startIndex; i < endIndex; i++) {
    let outfit = outfits[i];
    let isCurrent = (i === currentOutfitIndex);
    let isUnlocked = outfit.unlocked;
    let pageIndex = i - startIndex;
    let itemY = startY + pageIndex * (itemHeight + itemSpacing);

    fill(240);
    rect(width / 2 - 150, itemY, 300, itemHeight, 10);

    noStroke();
    fill(outfit.bodyColor);
    ellipse(width / 2 - 110, itemY + itemHeight / 2, scl * 2);
    fill(outfit.headColor);
    ellipse(width / 2 - 110 + scl, itemY + itemHeight / 2, scl * 1.5);

    fill(0);
    textAlign(LEFT, CENTER);
    textSize(24);
    text(outfit.name, width / 2 - 60, itemY + itemHeight / 2 - 20);

    textAlign(LEFT, TOP);
    textSize(16);
    if (isUnlocked) {
      fill(0, 150, 0);
      text("UNLOCKED", width / 2 - 60, itemY + itemHeight / 2 + 10);
      if (isCurrent) {
        fill(0, 0, 150);
        text("CURRENT", width / 2 + 30, itemY + itemHeight / 2 + 10);
      }
    } else {
      fill(150, 0, 0);
      text("Locked (Score " + outfit.unlockScore + ")", width / 2 - 60, itemY + itemHeight / 2 + 10);
    }

    if (isUnlocked && !isCurrent) {
      let selectButtonX = width / 2 + 80;
      let selectButtonY = itemY + itemHeight / 2;
      let selectButtonWidth = 80;
      let selectButtonHeight = 40;
      rectMode(CENTER);
      fill(0, 100, 150);
      rect(selectButtonX, selectButtonY, selectButtonWidth, selectButtonHeight, 5);
      fill(255);
      textSize(20);
      textAlign(CENTER, CENTER);
      text("Select", selectButtonX, selectButtonY + 5);
      rectMode(CORNER);
    }
  }

  let backButtonX = width / 2;
  let backButtonY = height - 50;
  let buttonWidth = 150;
  let buttonHeight = 50;
  rectMode(CENTER);
  fill(120);
  rect(backButtonX, backButtonY, buttonWidth, buttonHeight, 10);
  fill(0);
  textSize(24);
  textAlign(CENTER, CENTER);
  text("Go back to Lobby", backButtonX, backButtonY + 6);
  rectMode(CORNER);

  if (totalOutfits > outfitsPerPage) {
    let arrowSize = 30;
    let arrowOffset = 200;
    if (outfitsMenuPage > 0) {
      rectMode(CENTER);
      fill(100);
      rect(width / 2 - arrowOffset, backButtonY, arrowSize, arrowSize, 5);
      fill(255);
      text("<", width / 2 - arrowOffset, backButtonY + 5);
      rectMode(CORNER);
    }
    if (outfitsMenuPage < maxPage) {
      rectMode(CENTER);
      fill(100);
      rect(width / 2 + arrowOffset, backButtonY, arrowSize, arrowSize, 5);
      fill(255);
      text(">", width / 2 + arrowOffset, backButtonY + 5);
      rectMode(CORNER);
    }
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

for-loop Draw One Page Of Outfits for (let i = startIndex; i < endIndex; i++) { ... }

Draws only the outfits belonging to the current page, based on startIndex/endIndex calculated from outfitsMenuPage

conditional Locked vs Unlocked Label if (isUnlocked) { ... } else { ... }

Chooses whether to show 'UNLOCKED'/'CURRENT' text in green/blue or a red 'Locked (Score X)' message

conditional Pagination Arrows if (outfitsMenuPage > 0) { ... } if (outfitsMenuPage < maxPage) { ... }

Only draws the previous/next arrow buttons when there is actually another page in that direction

let maxPage = floor((totalOutfits - 1) / outfitsPerPage);
Calculates the index of the last page based on how many outfits exist and how many fit per page.
outfitsMenuPage = constrain(outfitsMenuPage, 0, maxPage);
Clamps the current page number so it can never go below 0 or above the last valid page.
let pageIndex = i - startIndex;
Converts the global outfit index into a 0-based position within just this page, used to stack items vertically.
let itemY = startY + pageIndex * (itemHeight + itemSpacing);
Calculates the vertical position of each outfit card by stacking them with a fixed spacing.
if (isUnlocked && !isCurrent) {
Only draws a clickable 'Select' button for outfits that are unlocked but not already equipped.

drawGameplay()

drawGameplay is the heart of the sketch - it shows how to combine multiple independent systems (zombies, projectiles, food, the snake) inside a single frame, and how looping backward with splice() is the standard safe way to remove items from an array while iterating it.

function drawGameplay() {
  gameCanvas.show();
  adminPanelToggle.show();

  push();
  colorMode(HSB, 360, 100, 100, 100);
  fill((frameCount * 0.5) % 360, 80, 90); // Cycle hue slowly for background
  noStroke();
  rect(0, 0, width, height);
  pop();

  textSize(24);
  fill(0);
  textAlign(LEFT, TOP);
  text("Score: " + score, 10, 30);

  textAlign(RIGHT, TOP);
  text("Lives: " + max(0, lives), width - 10, 30);

  if (adminPanelExpanded) {
    frameCountForSpeedDisplay++;
    if (frameCountForSpeedDisplay % speedDisplayUpdateInterval === 0) {
      select('#speed-display').html(frameRate().toFixed(0) + ' fps');
      frameCountForSpeedDisplay = 0;
    }
  }

  let snakeHeadPos = createVector(snake.x + scl / 2, snake.y + scl / 2);
  let snakeHitByZombie = false;

  for (let i = zombies.length - 1; i >= 0; i--) {
    zombies[i].update(snake);
    zombies[i].show();

    if (zombies[i].hits(snakeHeadPos)) {
      snakeHitByZombie = true;
      zombies.splice(i, 1);
      break;
    }
  }

  if (snake.checkDeath(snakeHitByZombie)) {
    if (lives < 0) {
      gameOver = true;
      gameState = GAME_STATES.GAMEOVER;
    } else {
      snake = new Snake();
      food.pickLocation();
      frameRate(10);
      if (adminPanelExpanded) {
        select('#speed-slider').value(10);
      }
    }
    return;
  }

  snake.update();
  snake.show();

  if (frameCount - lastZombieSpawnFrame > zombieSpawnRate) {
    zombies.push(new Zombie());
    lastZombieSpawnFrame = frameCount;
  }

  for (let i = projectiles.length - 1; i >= 0; i--) {
    projectiles[i].update();
    projectiles[i].show();

    if (projectiles[i].hits(food.pos)) {
      score += 10;
      food.pickLocation();
      checkOutfitUnlocks();
      projectiles.splice(i, 1);
      continue;
    }

    let hitZombie = false;
    for (let j = zombies.length - 1; j >= 0; j--) {
      if (zombies[j].isHit(projectiles[i].pos)) {
        zombies.splice(j, 1);
        score += 20;
        hitZombie = true;
        break;
      }
    }

    if (hitZombie) {
      projectiles.splice(i, 1);
    } else if (projectiles[i].offScreen()) {
      projectiles.splice(i, 1);
    }
  }

  if (snake.eat(food.pos)) {
    score += 10;
    food.pickLocation();
    checkOutfitUnlocks();
  }

  food.show();

  textSize(16);
  fill(0, 100, 0);
  textAlign(LEFT, BOTTOM);
  text("Controls: WASD, Arrow Keys, X (Shoot). Avoid Zombies!", 10, height - 10);
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

for-loop Update & Collide Zombies for (let i = zombies.length - 1; i >= 0; i--) { zombies[i].update(snake); zombies[i].show(); ... }

Moves every zombie toward the snake, draws it, and flags a hit if it touches the snake's head

conditional Handle Snake Death if (snake.checkDeath(snakeHitByZombie)) { ... }

Checks all death conditions at once and either ends the game or resets the snake for another try

for-loop Update Projectiles & Check Hits for (let i = projectiles.length - 1; i >= 0; i--) { ... nested zombie loop ... }

Moves each bullet, checks if it struck food or a zombie, and removes it if it hit something or left the screen

let snakeHeadPos = createVector(snake.x + scl / 2, snake.y + scl / 2);
Calculates the pixel-center position of the snake's head (grid cells are stored by top-left corner, so we add half a cell).
for (let i = zombies.length - 1; i >= 0; i--) {
Loops backward through the zombies array - this is important because the loop can splice() (remove) items mid-loop without skipping the next one.
if (snake.checkDeath(snakeHitByZombie)) {
Asks the Snake object whether it died this frame (wall, self, or zombie collision) - checkDeath() also decrements lives internally.
if (lives < 0) { gameOver = true; gameState = GAME_STATES.GAMEOVER; }
Once all extra lives are used up, permanently switches to the Game Over screen.
if (frameCount - lastZombieSpawnFrame > zombieSpawnRate) { zombies.push(new Zombie()); lastZombieSpawnFrame = frameCount; }
A simple frame-based timer: spawns a fresh zombie only once every zombieSpawnRate frames.
for (let j = zombies.length - 1; j >= 0; j--) {
A nested loop inside the projectile loop, checking each bullet against every zombie to detect a hit.
return; // Exit drawGameplay immediately after death/reset
Stops the rest of the function from running this frame, since the snake was just replaced or the game just ended.

drawGameOver()

A minimal 'end screen' - shows how little code is needed to build a functional game-over screen once you already have text() and rect() basics down.

function drawGameOver() {
  gameCanvas.show();
  adminPanelToggle.hide();

  textAlign(CENTER, CENTER);
  fill(255, 0, 0);
  textSize(64);
  text("Return back to menu", width / 2, height / 2 - 40);
  fill(0);
  textSize(24);

  let returnButtonX = width / 2;
  let returnButtonY = height / 2 + 60;
  let buttonWidth = 200;
  let buttonHeight = 60;
  rectMode(CENTER);
  fill(120);
  rect(returnButtonX, returnButtonY, buttonWidth, buttonHeight, 10);
  fill(0);
  textSize(24);
  text("Return to Menu", returnButtonX, returnButtonY + 6);
  rectMode(CORNER);
}
Line-by-line explanation (3 lines)
fill(255, 0, 0);
Sets the text color to red for the 'Return back to menu' heading, signaling the game has ended.
rectMode(CENTER);
Switches rectangle positioning to center-based so the button can be placed using its middle x,y coordinates.
rect(returnButtonX, returnButtonY, buttonWidth, buttonHeight, 10);
Draws the clickable button background with rounded corners (the last argument, 10, is the corner radius).

keyPressed()

keyPressed() is a p5.js event function that fires once every time a key goes down. Using keyCode with named constants like UP_ARROW alongside numeric codes for letter keys is the standard way to support multiple control schemes.

function keyPressed() {
  if (gameState === GAME_STATES.GAMEOVER) {
    gameState = GAME_STATES.MENU;
    if (adminPanelExpanded) {
      toggleAdminPanel();
    }
    adminPanelToggle.hide();
    return;
  }

  if (gameState === GAME_STATES.GAMEPLAY && keyCode === 88 && frameCount - lastShootFrame > shootCooldown) { // 'X' key (keyCode 88)
    let bulletX = snake.x + scl / 2 + snake.xspeed * scl;
    let bulletY = snake.y + scl / 2 + snake.yspeed * scl;
    projectiles.push(new Projectile(bulletX, bulletY, snake.xspeed, snake.yspeed));
    lastShootFrame = frameCount;
    return;
  }

  if (gameState !== GAME_STATES.GAMEPLAY) {
    return;
  }

  switch (keyCode) {
    case 87: // W key
    case UP_ARROW:
      if (snake.yspeed !== 1) snake.setDir(0, -1);
      break;
    case 83: // S key
    case DOWN_ARROW:
      if (snake.yspeed !== -1) snake.setDir(0, 1);
      break;
    case 68: // D key
    case RIGHT_ARROW:
      if (snake.xspeed !== -1) snake.setDir(1, 0);
      break;
    case 65: // A key
    case LEFT_ARROW:
      if (snake.xspeed !== 1) snake.setDir(-1, 0);
      break;
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Fire Projectile if (gameState === GAME_STATES.GAMEPLAY && keyCode === 88 && frameCount - lastShootFrame > shootCooldown) { ... }

Only allows shooting during gameplay and only after the cooldown period has passed since the last shot

switch-case Direction Controls switch (keyCode) { case 87: case UP_ARROW: ... }

Maps WASD and arrow keys to the four movement directions, blocking 180-degree reversals into the snake's own body

if (gameState === GAME_STATES.GAMEOVER) {
Pressing literally any key on the game-over screen sends the player back to the main menu.
if (gameState === GAME_STATES.GAMEPLAY && keyCode === 88 && frameCount - lastShootFrame > shootCooldown) {
Checks three conditions at once: are we playing, was the 'X' key pressed (keyCode 88), and has enough time passed since the last shot?
let bulletX = snake.x + scl / 2 + snake.xspeed * scl;
Spawns the bullet one grid cell ahead of the snake's head, in whatever direction it's currently moving, so it doesn't instantly collide with the snake itself.
if (snake.yspeed !== 1) snake.setDir(0, -1);
Moving up is only allowed if the snake isn't currently moving down (yspeed of 1), preventing an instant reverse into its own body.

mousePressed()

mousePressed() is a p5.js event function called once per click. Splitting click logic per game state mirrors the same pattern used for drawing, keeping each screen's logic isolated.

function mousePressed() {
  if (gameState === GAME_STATES.MENU) {
    handleMenuClicks();
  } else if (gameState === GAME_STATES.OUTFITS_MENU) {
    handleOutfitsMenuClicks();
  } else if (gameState === GAME_STATES.GAMEOVER) {
    handleGameOverClicks();
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

conditional Route Click By Screen if (gameState === GAME_STATES.MENU) { handleMenuClicks(); } else if ...

Delegates click handling to a different function depending on which screen is currently showing

if (gameState === GAME_STATES.MENU) { handleMenuClicks(); }
On the main menu, clicks are tested against the Play and Outfits button rectangles.
} else if (gameState === GAME_STATES.OUTFITS_MENU) { handleOutfitsMenuClicks(); }
On the outfits screen, delegates to a separate click handler for that screen's buttons.

handleMenuClicks()

Since p5.js doesn't have built-in clickable buttons drawn on canvas, this is the standard manual technique: store a shape's bounding box in variables, then compare mouseX/mouseY against it inside mousePressed().

function handleMenuClicks() {
  let playButtonX = width / 2;
  let playButtonY = height / 2;
  let buttonWidth = 200;
  let buttonHeight = 60;

  if (mouseX > playButtonX - buttonWidth / 2 && mouseX < playButtonX + buttonWidth / 2 &&
    mouseY > playButtonY - buttonHeight / 2 && mouseY < playButtonY + buttonHeight / 2) {
    gameState = GAME_STATES.GAMEPLAY;
    restartGame();
    return;
  }

  let outfitsButtonY = playButtonY + buttonHeight + 20;
  if (mouseX > playButtonX - buttonWidth / 2 && mouseX < playButtonX + buttonWidth / 2 &&
    mouseY > outfitsButtonY - buttonHeight / 2 && mouseY < outfitsButtonY + buttonHeight / 2) {
    gameState = GAME_STATES.OUTFITS_MENU;
    return;
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Play Button Hit Test if (mouseX > playButtonX - buttonWidth / 2 && mouseX < playButtonX + buttonWidth / 2 && mouseY > playButtonY - buttonHeight / 2 && mouseY < playButtonY + buttonHeight / 2) { ... }

Manually checks whether the mouse click fell inside the Play button's rectangle

if (mouseX > playButtonX - buttonWidth / 2 && mouseX < playButtonX + buttonWidth / 2 &&
Checks that the mouse's horizontal position is between the button's left and right edges.
mouseY > playButtonY - buttonHeight / 2 && mouseY < playButtonY + buttonHeight / 2) {
Checks that the mouse's vertical position is between the button's top and bottom edges - together with the x-check, this confirms the click landed inside the button.
gameState = GAME_STATES.GAMEPLAY; restartGame(); return;
Switches to the gameplay screen and resets all game variables to fresh starting values.

handleOutfitsMenuClicks()

This function is meant to detect clicks on outfit Select buttons, pagination arrows, and the Back button - compare it closely with drawOutfitsMenu() to see how canvas click detection typically needs the exact same coordinate math as the drawing code, just paired with an if-statement testing mouseX/mouseY instead of calling fill()/rect().

function handleOutfitsMenuClicks() {
  let outfitsPerPage = 4;
  let startY = height / 2 - 100;
  let itemHeight = 100;
  let itemSpacing = 20;
  let totalOutfits = outfits.length;
  let maxPage = floor((totalOutfits - 1) / outfitsPerPage);
  outfitsMenuPage = constrain(outfitsMenuPage, 0, maxPage);
  let startIndex = outfitsMenuPage * outfitsPerPage;
  let endIndex = min(startIndex + outfitsPerPage, totalOutfits);

  for (let i = startIndex; i < endIndex; i++) {
    let outfit = outfits[i];
    let isCurrent = (i === currentOutfitIndex);
    let isUnlocked = outfit.unlocked;
    let pageIndex = i - startIndex;
    let itemY = startY + pageIndex * (itemHeight + itemSpacing);
    // ... re-draws the same outfit cards, back button, and pagination arrows as drawOutfitsMenu() ...
  }

  let backButtonX = width / 2;
  let backButtonY = height - 50;
  // ... draws back button and pagination arrows again, but never compares mouseX/mouseY to anything ...
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

for-loop Recomputed Layout Loop for (let i = startIndex; i < endIndex; i++) { ... }

Recomputes the same layout math as drawOutfitsMenu() so button positions line up, but currently re-draws shapes instead of hit-testing them

let maxPage = floor((totalOutfits - 1) / outfitsPerPage);
Recalculates the same pagination math used for drawing, so this function knows where each button currently sits.
for (let i = startIndex; i < endIndex; i++) {
Loops through the outfits on the current page - in a correctly finished version, this is where mouseX/mouseY would be compared to each Select button's rectangle.
let backButtonX = width / 2; let backButtonY = height - 50;
Recomputes the Back button's position, which is where a click check against this rectangle would belong.

handleGameOverClicks()

A short, focused example of the same manual button hit-testing pattern seen in handleMenuClicks(), reused for the Game Over screen.

function handleGameOverClicks() {
  let returnButtonX = width / 2;
  let returnButtonY = height / 2 + 60;
  let buttonWidth = 200;
  let buttonHeight = 60;

  if (mouseX > returnButtonX - buttonWidth / 2 && mouseX < returnButtonX + buttonWidth / 2 &&
    mouseY > returnButtonY - buttonHeight / 2 && mouseY < returnButtonY + buttonHeight / 2) {
    gameState = GAME_STATES.MENU;
    if (adminPanelExpanded) {
      toggleAdminPanel();
    }
    adminPanelToggle.hide();
    return;
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

conditional Return Button Hit Test if (mouseX > returnButtonX - buttonWidth / 2 && mouseX < returnButtonX + buttonWidth / 2 && mouseY > returnButtonY - buttonHeight / 2 && mouseY < returnButtonY + buttonHeight / 2) { ... }

Checks if the click landed on the 'Return to Menu' button and switches state back to MENU

if (mouseX > returnButtonX - buttonWidth / 2 && mouseX < returnButtonX + buttonWidth / 2 &&
Same manual bounding-box hit test pattern used throughout the sketch, checking the click's x position against the button's edges.
gameState = GAME_STATES.MENU;
Switches the state machine back to the main menu once the button is clicked.

restartGame()

restartGame() shows how to fully reset a game's state - not just game objects, but also DOM UI elements (sliders/inputs) that might be showing stale values from a previous playthrough.

function restartGame() {
  gameOver = false;
  score = 0;
  lives = 1; // Reset lives on restart
  snake = new Snake();
  food = new Food(); // Also reset food position
  food.pickLocation();
  frameRate(10); // Reset speed on restart
  
  projectiles = [];
  lastShootFrame = -shootCooldown; // Reset shoot cooldown

  zombies = [];
  lastZombieSpawnFrame = -zombieSpawnRate; // Reset spawn timer

  if (adminPanelExpanded) {
    toggleAdminPanel();
  }
  adminPanelToggle.hide();

  select('#speed-slider').value(10);
  select('#score-input').value(0);
  select('#lives-input').value(1);
  
  frameCountForSpeedDisplay = 0;
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Close Open Admin Panel if (adminPanelExpanded) { toggleAdminPanel(); }

Ensures the admin panel is always closed when a fresh game starts, keeping canvas size consistent

snake = new Snake();
Replaces the old snake object with a brand new one, resetting its position, length, and direction.
projectiles = [];
Empties the projectiles array so leftover bullets from a previous game don't carry over.
lastShootFrame = -shootCooldown; // Reset shoot cooldown
Sets the last-shot timer far enough in the past that the player can fire immediately in the new game.
select('#speed-slider').value(10);
Uses p5.js's select() to grab the actual HTML slider element by its id and reset its displayed value to match the reset frame rate.

checkOutfitUnlocks()

This function is called every time the score changes (eating food) and demonstrates a simple, reusable 'check all achievements' pattern found in countless games.

🔬 This loop checks every outfit's unlockScore against the current score. What happens if you lower every unlockScore in the outfits array by 40 - how much faster do you unlock the Rainbow Serpent?

  for (let outfit of outfits) {
    if (!outfit.unlocked && score >= outfit.unlockScore) {
      outfit.unlocked = true;
      newUnlock = true;
      console.log("Unlocked new outfit: " + outfit.name);
    }
  }
function checkOutfitUnlocks() {
  let newUnlock = false;
  for (let outfit of outfits) {
    if (!outfit.unlocked && score >= outfit.unlockScore) {
      outfit.unlocked = true;
      newUnlock = true;
      console.log("Unlocked new outfit: " + outfit.name);
    }
  }
  if (newUnlock) {
    saveUnlockedOutfits();
    if (gameState === GAME_STATES.OUTFITS_MENU) {
      // No need to call updateOutfitsList() explicitly here,
      // as drawOutfitsMenu() will call it next frame anyway.
    }
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

for-loop Check Every Outfit's Score Requirement for (let outfit of outfits) { if (!outfit.unlocked && score >= outfit.unlockScore) { outfit.unlocked = true; newUnlock = true; } }

Compares the current score against every outfit's unlockScore and flips unlocked to true the moment the player qualifies

let newUnlock = false;
Tracks whether ANY outfit was newly unlocked this call, so we only bother saving to localStorage when something actually changed.
if (!outfit.unlocked && score >= outfit.unlockScore) {
Only unlocks outfits that aren't already unlocked and whose score requirement has now been met.
if (newUnlock) { saveUnlockedOutfits(); }
Persists the updated unlock list to localStorage, but only when something changed, avoiding unnecessary writes every frame.

toggleAdminPanel()

This function shows how p5.js DOM elements (created with createCanvas, select, etc.) expose methods like .size(), .position(), .addClass() that let you manipulate the canvas just like any other HTML element.

function toggleAdminPanel() {
  adminPanelExpanded = !adminPanelExpanded;

  if (adminPanelExpanded) {
    adminPanelContainer.addClass('admin-panel-expanded');
    adminPanelToggle.hide();
    gameCanvas.size(windowWidth - adminPanelWidth, windowHeight);
    gameCanvas.position(0, 0);
  } else {
    adminPanelContainer.removeClass('admin-panel-expanded');
    adminPanelToggle.show();
    gameCanvas.size(windowWidth, windowHeight);
    gameCanvas.position(0, 0);
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Expand Or Collapse Panel if (adminPanelExpanded) { ... } else { ... }

Shrinks the canvas and reveals the sidebar when expanding, or restores full width when collapsing

adminPanelExpanded = !adminPanelExpanded;
Flips the boolean state - if it was true it becomes false and vice versa, a classic 'toggle' pattern.
gameCanvas.size(windowWidth - adminPanelWidth, windowHeight);
Shrinks the p5.js canvas horizontally by exactly the admin panel's fixed width, so the sidebar doesn't cover the game.
gameCanvas.size(windowWidth, windowHeight);
Restores the canvas to the full window width once the panel is hidden again.

windowResized()

windowResized() is a p5.js event function called automatically whenever the browser window changes size. Wrapping the actual work in setTimeout like this is called 'debouncing' - a common performance technique for expensive operations triggered by rapid-fire events.

let resizeTimeout;
function windowResized() {
  clearTimeout(resizeTimeout);
  resizeTimeout = setTimeout(() => {
    let canvasHeight = windowHeight; // Now uses full window height

    if (adminPanelExpanded) {
      resizeCanvas(windowWidth - adminPanelWidth, canvasHeight);
      gameCanvas.position(0, 0);
    } else {
      resizeCanvas(windowWidth, canvasHeight);
      gameCanvas.position(0, 0);
    }
  }, 200); // Wait 200ms after the last resize event
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

calculation Debounce Timer clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { ... }, 200);

Delays the actual canvas resize until 200ms after the user stops dragging the window edge, avoiding expensive resize calls on every pixel of movement

clearTimeout(resizeTimeout);
Cancels any pending resize that was scheduled from an earlier, still-in-progress window drag.
resizeTimeout = setTimeout(() => { ... }, 200);
Schedules the actual resize logic to run 200 milliseconds from now - if the window keeps resizing, this gets cancelled and rescheduled repeatedly, so it only truly runs once resizing stops.
resizeCanvas(windowWidth - adminPanelWidth, canvasHeight);
p5.js's built-in function to change the canvas dimensions, accounting for the admin panel's width if it's currently open.

createAdminPanel()

This function is a tour of p5.js's DOM API: createSlider, createButton, createInput, createP, createElement, and .child() let you build a full interactive control panel without writing any raw HTML, and attaching callbacks with .mousePressed()/.input() lets the DOM directly manipulate your sketch's global variables.

function createAdminPanel() {
  adminPanelContainer = select('#admin-panel-container');

  adminPanelToggle = select('#admin-panel-toggle');
  adminPanelToggle.mousePressed(toggleAdminPanel);

  let closeButton = createButton('Close Panel');
  closeButton.id('admin-panel-close');
  closeButton.mousePressed(toggleAdminPanel);
  adminPanelContainer.child(closeButton);

  adminPanelContainer.html('<h2>Admin Panel</h2>', true);

  adminPanelContainer.child(createP('Snake Speed: <span id="speed-display">10 fps</span>'));
  let speedSlider = createSlider(1, 60, 10, 1);
  speedSlider.id('speed-slider');
  speedSlider.style('width', '90%');
  speedSlider.input(() => frameRate(speedSlider.value()));
  adminPanelContainer.child(speedSlider);

  adminPanelContainer.child(createP('Set Score:'));
  let scoreInput = createInput(score, 'number');
  scoreInput.id('score-input');
  scoreInput.style('width', '80px');
  adminPanelContainer.child(scoreInput);
  let scoreButton = createButton('Set Score');
  scoreButton.mousePressed(() => score = int(scoreInput.value()));
  adminPanelContainer.child(scoreButton);
  
  adminPanelContainer.child(createP('Set Lives:'));
  let livesInput = createInput(lives, 'number');
  livesInput.id('lives-input');
  livesInput.style('width', '80px');
  adminPanelContainer.child(livesInput);
  let livesButton = createButton('Set Lives');
  livesButton.mousePressed(() => lives = int(livesInput.value()));
  adminPanelContainer.child(livesButton);

  adminPanelContainer.child(createElement('hr'));

  let addLengthButton = createButton('Add 5 Segments');
  addLengthButton.mousePressed(() => snake.total += 5);
  adminPanelContainer.child(addLengthButton);

  let gameOverButton = createButton('Force Game Over');
  gameOverButton.mousePressed(() => {
    gameOver = true;
    gameState = GAME_STATES.GAMEOVER;
  });
  adminPanelContainer.child(gameOverButton);

  let restartButton = createButton('Restart Game');
  restartButton.mousePressed(restartGame);
  adminPanelContainer.child(restartButton);

  adminPanelContainer.child(createElement('hr'));

  let spawnFoodButton = createButton('Spawn Food');
  spawnFoodButton.mousePressed(() => food.pickLocation());
  adminPanelContainer.child(spawnFoodButton);

  adminPanelContainer.child(createElement('hr'));

  let spawnZombieButton = createButton('Spawn Zombie');
  spawnZombieButton.mousePressed(() => zombies.push(new Zombie()));
  adminPanelContainer.child(spawnZombieButton);

  adminPanelContainer.child(createP('Zombie Spawn Rate (frames):'));
  let zombieSpawnRateSlider = createSlider(30, 300, zombieSpawnRate, 30);
  zombieSpawnRateSlider.id('zombie-spawn-rate-slider');
  zombieSpawnRateSlider.style('width', '90%');
  zombieSpawnRateSlider.input(() => zombieSpawnRate = zombieSpawnRateSlider.value());
  adminPanelContainer.child(zombieSpawnRateSlider);

  adminPanelContainer.child(createP('Zombie Speed (relative to scl):'));
  let zombieSpeedSlider = createSlider(0.05, 0.5, 0.1, 0.05);
  zombieSpeedSlider.id('zombie-speed-slider');
  zombieSpeedSlider.style('width', '90%');
  zombieSpeedSlider.input(() => {
    let newSpeed = zombieSpeedSlider.value() * scl;
    Zombie.prototype.speed = newSpeed;
    for (let zombie of zombies) {
      zombie.speed = newSpeed;
    }
  });
  adminPanelContainer.child(zombieSpeedSlider);

  adminPanelContainer.child(createElement('hr'));

  adminPanelContainer.child(createP('Teleport Snake (X, Y):'));
  let teleportXInput = createInput('0', 'number');
  teleportXInput.id('teleport-x');
  teleportXInput.style('width', '60px');
  adminPanelContainer.child(teleportXInput);
  adminPanelContainer.child(createElement('span', ', '));
  let teleportYInput = createInput('0', 'number');
  teleportYInput.id('teleport-y');
  teleportYInput.style('width', '60px');
  adminPanelContainer.child(teleportYInput);
  adminPanelContainer.child(createElement('br'));
  let teleportButton = createButton('Teleport');
  teleportButton.mousePressed(() => {
    snake.x = int(teleportXInput.value()) * scl;
    snake.y = int(teleportYInput.value()) * scl;
  });
  adminPanelContainer.child(teleportButton);
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

calculation Update All Zombies' Speed zombieSpeedSlider.input(() => { let newSpeed = zombieSpeedSlider.value() * scl; Zombie.prototype.speed = newSpeed; for (let zombie of zombies) { zombie.speed = newSpeed; } });

When the slider moves, updates both the shared prototype default AND every currently-alive zombie's speed at once

adminPanelContainer = select('#admin-panel-container');
Grabs the empty <div> already defined in index.html so p5.js DOM elements can be added inside it.
adminPanelToggle.mousePressed(toggleAdminPanel);
Attaches a click handler directly to the gear-icon button so clicking it calls toggleAdminPanel().
let speedSlider = createSlider(1, 60, 10, 1);
Creates an HTML range slider with p5.js: minimum 1, maximum 60, starting value 10, step size 1.
speedSlider.input(() => frameRate(speedSlider.value()));
Attaches a callback that runs every time the slider moves, immediately updating the game's frame rate to match.
scoreButton.mousePressed(() => score = int(scoreInput.value()));
Reads whatever number is typed into the score input box, converts it to an integer with int(), and overwrites the global score variable.
let addLengthButton = createButton('Add 5 Segments');
Creates a plain HTML button using p5.js and immediately wires up a one-line arrow function to grow the snake by 5 segments.
zombieSpeedSlider.input(() => { ... Zombie.prototype.speed = newSpeed; for (let zombie of zombies) { zombie.speed = newSpeed; } });
Updates the shared prototype speed (affecting all future zombies) AND loops through existing zombies to update them too, since JS objects created earlier don't automatically pick up later prototype changes if they have their own .speed property.

class Snake

The Snake class is the star of the show - notice how movement is entirely grid-based (always exactly one scl per update), while show() fakes smooth motion with lerp() using a separate interpFactor. This 'simulate on a fixed tick, render every frame' split is used throughout professional game engines.

🔬 This loop only checks the HEAD's current position against every tail segment. What do you think would happen to gameplay if you started the loop at i = 1 instead of i = 0's neighbor - would the very first segment stop counting as a collision?

    for (let i = 0; i < this.tail.length; i++) {
      let pos = this.tail[i];
      if (this.x === pos.x && this.y === pos.y) {
        died = true;
        break;
      }
    }
class Snake {
  constructor() {
    this.x = 0;
    this.y = 0;
    this.xspeed = 1;
    this.yspeed = 0;
    this.tail = [];
    this.total = 0;
    this.prevX = this.x;
    this.prevY = this.y;
  }

  setDir(x, y) {
    this.xspeed = x;
    this.yspeed = y;
  }

  eat(pos) {
    if (this.x === pos.x && this.y === pos.y) {
      this.total++;
      return true;
    }
    return false;
  }

  update() {
    this.prevX = this.x;
    this.prevY = this.y;

    this.x += this.xspeed * scl;
    this.y += this.yspeed * scl;

    this.tail.unshift(createVector(this.prevX, this.prevY));

    if (this.tail.length > this.total) {
      this.tail.pop();
    }
  }

  show() {
    noStroke();
    let bodyColor = outfits[currentOutfitIndex].bodyColor;
    let headColor = outfits[currentOutfitIndex].headColor;

    let interpFactor = logicFrameCounter / (60 / logicFrameRate);

    let drawHeadX = lerp(this.prevX, this.x, interpFactor);
    let drawHeadY = lerp(this.prevY, this.y, interpFactor);

    fill(bodyColor);
    for (let i = 0; i < this.tail.length; i++) {
      let segmentX, segmentY;
      if (i === 0) {
        segmentX = lerp(this.tail[i].x, this.x, interpFactor);
        segmentY = lerp(this.tail[i].y, this.y, interpFactor);
      } else {
        segmentX = lerp(this.tail[i].x, this.tail[i-1].x, interpFactor);
        segmentY = lerp(this.tail[i].y, this.tail[i-1].y, interpFactor);
      }
      ellipse(segmentX + scl / 2, segmentY + scl / 2, scl);
    }

    fill(headColor);
    ellipse(drawHeadX + scl / 2, drawHeadY + scl / 2, scl * 1.2);

    fill(100);
    noStroke();
    rectMode(CORNER);

    let gunLength = scl * 1.5;
    let gunWidth = scl * 0.3;
    let handleLength = scl * 0.7;
    let handleWidth = scl * 0.2;

    push();
    translate(drawHeadX + scl / 2, drawHeadY + scl / 2);
    rotate(atan2(this.yspeed, this.xspeed));

    rect(scl * 0.6, -gunWidth / 2, gunLength, gunWidth);
    rect(scl * 0.6 - handleLength, gunWidth / 2, handleWidth, handleLength);

    pop();
  }

  checkDeath(hitByZombie = false) {
    let died = false;

    if (this.x < 0 || this.x >= width || this.y < 0 || this.y >= height) {
      died = true;
    }

    for (let i = 0; i < this.tail.length; i++) {
      let pos = this.tail[i];
      if (this.x === pos.x && this.y === pos.y) {
        died = true;
        break;
      }
    }

    if (hitByZombie) {
      died = true;
    }

    if (died) {
      lives--;
      return true;
    }
    return false;
  }
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

for-loop Draw Interpolated Tail for (let i = 0; i < this.tail.length; i++) { ... }

Draws every body segment, smoothly lerping each one toward the segment ahead of it so the whole body appears to glide rather than teleport

for-loop Self-Collision Check for (let i = 0; i < this.tail.length; i++) { let pos = this.tail[i]; if (this.x === pos.x && this.y === pos.y) { died = true; break; } }

Compares the head's grid position against every tail segment to detect the snake biting itself

this.tail = [];
An empty array that will hold a p5.Vector for every body segment behind the head.
setDir(x, y) { this.xspeed = x; this.yspeed = y; }
Changes the snake's velocity - called from keyPressed() whenever a direction key is hit.
this.x += this.xspeed * scl;
Moves the head exactly one grid cell (scl pixels) in the current direction every logic update.
this.tail.unshift(createVector(this.prevX, this.prevY));
Adds the head's PREVIOUS position to the very front of the tail array - this is how the body 'follows' where the head used to be.
if (this.tail.length > this.total) { this.tail.pop(); }
Removes the oldest tail segment (the last one in the array) once the tail is longer than the snake's current length, keeping the body a fixed size unless it just ate food.
let interpFactor = logicFrameCounter / (60 / logicFrameRate);
Calculates how far through the current logic tick we are (0 to 1), used to smoothly blend between the previous grid position and the new one for rendering.
rotate(atan2(this.yspeed, this.xspeed));
atan2 converts the snake's x/y velocity into an angle in radians, so the gun sprite automatically points whichever way the snake is heading.
if (this.x < 0 || this.x >= width || this.y < 0 || this.y >= height) { died = true; }
Checks if the head has moved past any of the four canvas edges.
lives--;
Every time any death condition is true, this deducts exactly one life, whether the cause was a wall, self-collision, or a zombie.

class Projectile

Projectile is a compact example of p5.Vector-based motion (position + velocity) combined with distance-based collision detection, a pattern reused for both zombie hits and food pickups.

class Projectile {
  constructor(x, y, dirX, dirY) {
    this.pos = createVector(x, y);
    this.vel = createVector(dirX, dirY);
    this.vel.mult(scl * 0.75); // Make it move faster than the snake
    this.size = scl / 2;
    this.lifespan = 60; // Bullet disappears after 60 frames (e.g., 6 seconds at 10 fps)
  }

  update() {
    this.pos.add(this.vel);
    this.lifespan--;
  }

  show() {
    fill(255, 200, 0);
    noStroke();
    ellipse(this.pos.x, this.pos.y, this.size);
  }

  hits(foodPos) {
    let d = dist(this.pos.x, this.pos.y, foodPos.x + scl/2, foodPos.y + scl/2);
    return d < scl / 2 + this.size / 2;
  }

  offScreen() {
    return this.pos.x < -scl || this.pos.x > width + scl || this.pos.y < -scl || this.pos.y > height + scl || this.lifespan <= 0;
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Off-Screen Or Expired Check return this.pos.x < -scl || this.pos.x > width + scl || this.pos.y < -scl || this.pos.y > height + scl || this.lifespan <= 0;

A single boolean expression testing five different ways a bullet should be removed - four screen edges plus running out of lifespan

this.vel = createVector(dirX, dirY); this.vel.mult(scl * 0.75);
Takes the snake's current direction (a unit-ish vector like (1,0)) and scales it up so the bullet travels noticeably faster than the snake each frame.
this.lifespan = 60;
Gives the bullet a countdown timer in frames so it automatically disappears even if it never hits anything or leaves the screen.
this.pos.add(this.vel);
p5.Vector's add() method moves the bullet's position by its velocity vector every update - simple vector-based motion.
let d = dist(this.pos.x, this.pos.y, foodPos.x + scl/2, foodPos.y + scl/2);
Calculates the straight-line distance between the bullet and the center of the food's grid cell using p5's dist().
return d < scl / 2 + this.size / 2;
A classic circle-circle collision check: if the distance between two centers is less than the sum of their radii, the circles overlap.

class Zombie

Zombie demonstrates classic 'seek' steering behavior using p5.Vector: subtract positions to get a direction, normalize it, then scale by speed. This three-step pattern is the foundation of almost all chase/flee AI in 2D games.

🔬 normalize() forces the zombie to move at a constant speed no matter how far away the snake is. What happens to the zombie's movement if you delete the dir.normalize() line entirely?

    let dir = p5.Vector.sub(snakeHead, zombiePos);
    dir.normalize();
    dir.mult(this.speed);
    
    this.x += dir.x;
    this.y += dir.y;
class Zombie {
  constructor() {
    this.size = scl * 1.5;
    this.speed = Zombie.prototype.speed || scl * 0.1;
    this.bodyColor = [120, 100, 80];
    this.eyeColor = [50];
    this.mouthColor = [50];

    let side = floor(random(4));
    switch (side) {
      case 0:
        this.x = random(width);
        this.y = -this.size;
        break;
      case 1:
        this.x = width + this.size;
        this.y = random(height);
        break;
      case 2:
        this.x = random(width);
        this.y = height + this.size;
        break;
      case 3:
        this.x = -this.size;
        this.y = random(height);
        break;
    }
    this.prevX = this.x;
    this.prevY = this.y;
  }

  update(snake) {
    this.prevX = this.x;
    this.prevY = this.y;

    let snakeHead = createVector(snake.x + scl / 2, snake.y + scl / 2);
    let zombiePos = createVector(this.x + this.size / 2, this.y + this.size / 2);
    
    let dir = p5.Vector.sub(snakeHead, zombiePos);
    dir.normalize();
    dir.mult(this.speed);
    
    this.x += dir.x;
    this.y += dir.y;
  }

  show() {
    let interpFactor = logicFrameCounter / (60 / logicFrameRate);
    let drawZombieX = lerp(this.prevX, this.x, interpFactor);
    let drawZombieY = lerp(this.prevY, this.y, interpFactor);

    fill(this.bodyColor);
    noStroke();
    rectMode(CENTER);
    let bodyWidth = this.size * 1.1;
    let bodyHeight = this.size * 0.9;
    rect(drawZombieX + this.size / 2, drawZombieY + this.size / 2, bodyWidth, bodyHeight);

    fill(this.eyeColor);
    let eyeSize = this.size * 0.15;
    let eyeOffset = this.size * 0.25;
    ellipse(drawZombieX + this.size / 2 - eyeOffset, drawZombieY + this.size / 2 - eyeOffset, eyeSize);
    ellipse(drawZombieX + this.size / 2 + eyeOffset, drawZombieY + this.size / 2 - eyeOffset, eyeSize);

    stroke(this.mouthColor);
    strokeWeight(eyeSize / 2);
    line(drawZombieX + this.size / 2 - eyeOffset, drawZombieY + this.size / 2 + eyeOffset,
         drawZombieX + this.size / 2 + eyeOffset, drawZombieY + this.size / 2 + eyeOffset);
    noStroke();

    rectMode(CORNER);
  }

  hits(snakeHeadPos) {
    let d = dist(this.x + this.size / 2, this.y + this.size / 2, snakeHeadPos.x, snakeHeadPos.y);
    return d < this.size / 2 + scl / 2;
  }

  isHit(projectilePos) {
    let d = dist(this.x + this.size / 2, this.y + this.size / 2, projectilePos.x, projectilePos.y);
    return d < this.size / 2 + scl / 4;
  }
}

Zombie.prototype.speed = scl * 0.1;
Line-by-line explanation (5 lines)

🔧 Subcomponents:

switch-case Random Edge Spawn switch (side) { case 0: this.x = random(width); this.y = -this.size; break; ... }

Picks one of the four canvas edges at random and places the new zombie just outside it

let side = floor(random(4));
random(4) returns a decimal between 0 and 4, and floor() rounds it down to a whole number 0, 1, 2, or 3 - one for each edge of the canvas.
let dir = p5.Vector.sub(snakeHead, zombiePos);
Subtracting the zombie's position from the snake's head position gives a vector pointing FROM the zombie TOWARD the snake.
dir.normalize();
Shrinks the direction vector down to a length of exactly 1, keeping only its direction - this ensures the zombie always moves at a constant speed regardless of how far away the snake is.
dir.mult(this.speed);
Scales the normalized direction vector by the zombie's speed value, turning a 'direction' into an actual per-frame movement step.
return d < this.size / 2 + scl / 2;
Circle-circle collision check between the zombie's radius and the snake head's approximate radius.

class Food

Food shows how to build varied, recognizable pixel-art-style icons out of just a handful of primitive shapes (ellipse, arc, rect, triangle) combined with translate() to keep the drawing code readable in local coordinates.

🔬 This case draws a red circle plus a small brown stem rectangle. What happens if you change the fill(200, 0, 0) color to fill(0, 255, 0) - does it still look like fruit?

      case 'apple':
        fill(200, 0, 0); // Red apple
        ellipse(scl / 2, scl / 2, scl, scl);
        fill(100, 50, 0); // Stem
        rect(scl / 2 - scl / 20, scl / 5, scl / 10, scl / 5);
        break;
class Food {
  constructor() {
    this.pos = createVector();
    this.fruitType = '';
    this.pickLocation();
  }

  pickLocation() {
    let cols = floor(width / scl);
    let rows = floor(height / scl);
    this.pos = createVector(floor(random(cols)) * scl, floor(random(rows)) * scl);

    let fruits = ['apple', 'banana', 'grapes', 'strawberry', 'orange', 'watermelon'];
    this.fruitType = random(fruits);
  }

  show() {
    push();
    noStroke();
    translate(this.pos.x, this.pos.y);

    switch (this.fruitType) {
      case 'apple':
        fill(200, 0, 0);
        ellipse(scl / 2, scl / 2, scl, scl);
        fill(100, 50, 0);
        rect(scl / 2 - scl / 20, scl / 5, scl / 10, scl / 5);
        break;
      case 'banana':
        fill(255, 200, 0);
        arc(scl / 4, scl / 2, scl * 0.8, scl, PI / 2, -PI / 2, CHORD);
        arc(scl * 0.75, scl / 2, scl * 0.8, scl, -PI / 2, PI / 2, CHORD);
        fill(150, 100, 0);
        ellipse(scl * 0.15, scl / 2, scl / 4, scl / 4);
        ellipse(scl * 0.85, scl / 2, scl / 4, scl / 4);
        break;
      case 'grapes':
        fill(150, 0, 150);
        let grapeSize = scl * 0.4;
        ellipse(scl / 2, scl / 3, grapeSize);
        ellipse(scl / 3, scl * 0.6, grapeSize);
        ellipse(scl * 0.7, scl * 0.6, grapeSize);
        ellipse(scl / 2, scl * 0.9, grapeSize);
        fill(100, 50, 0);
        rect(scl / 2 - scl / 20, scl / 10, scl / 10, scl / 5);
        break;
      case 'strawberry':
        fill(255, 0, 100);
        triangle(scl / 2, scl * 0.9, scl * 0.2, scl * 0.4, scl * 0.8, scl * 0.4);
        ellipse(scl / 2, scl * 0.4, scl * 0.7, scl * 0.6);
        fill(0, 150, 0);
        rect(scl / 2 - scl / 20, scl / 10, scl / 10, scl / 5);
        break;
      case 'orange':
        fill(255, 165, 0);
        ellipse(scl / 2, scl / 2, scl * 0.9, scl * 0.9);
        fill(100, 50, 0);
        rect(scl / 2 - scl / 20, scl / 5, scl / 10, scl / 10);
        break;
      case 'watermelon':
        fill(0, 150, 0);
        arc(scl / 2, scl / 2, scl, scl, PI, TWO_PI, CHORD);
        fill(255, 100, 100);
        arc(scl / 2, scl / 2, scl * 0.8, scl * 0.8, PI, TWO_PI, CHORD);
        fill(0);
        ellipse(scl * 0.35, scl * 0.6, scl / 10);
        ellipse(scl * 0.65, scl * 0.6, scl / 10);
        ellipse(scl / 2, scl * 0.75, scl / 10);
        break;
      default:
        fill(255, 0, 100);
        rect(0, 0, scl, scl);
    }
    pop();
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

switch-case Draw The Right Fruit Shape switch (this.fruitType) { case 'apple': ... case 'banana': ... }

Chooses which set of ellipse/arc/rect/triangle calls to run based on which fruit was randomly picked

let cols = floor(width / scl); let rows = floor(height / scl);
Figures out how many grid columns and rows fit on the current canvas, based on the cell size scl.
this.pos = createVector(floor(random(cols)) * scl, floor(random(rows)) * scl);
Picks a random column and row, then multiplies by scl to convert that grid coordinate into an actual pixel position - this guarantees food always lands exactly on the grid, same as the snake.
this.fruitType = random(fruits);
p5's random() can pick a random ELEMENT from an array (not just a random number), used here to randomly select a fruit name.
translate(this.pos.x, this.pos.y);
Shifts the drawing origin to the food's grid cell so every shape below can be drawn using simple local coordinates like (scl/2, scl/2) instead of recalculating the position each time.
case 'watermelon': fill(0, 150, 0); arc(scl / 2, scl / 2, scl, scl, PI, TWO_PI, CHORD);
Draws half of a circle (from angle PI to TWO_PI) as the dark green rind, then layers a smaller red arc + black seed dots on top to build up a watermelon slice out of basic shapes.

📦 Key Variables

snake object

Holds the current Snake instance controlling position, tail segments, and direction.

let snake;
food object

Holds the current Food instance and its randomly chosen grid position and fruit type.

let food;
scl number

The pixel size of one grid cell - determines snake, food, and zombie scale, and how far each move shifts the snake.

let scl = 20;
score number

The player's current score, increased by eating or shooting food and shooting zombies; also used to unlock outfits.

let score = 0;
gameOver boolean

Flags whether the player has run out of lives entirely, ending the game.

let gameOver = false;
lives number

How many extra chances the player has left before a true Game Over.

let lives = 1;
gameState string

The current screen of the state machine - one of MENU, OUTFITS_MENU, GAMEPLAY, or GAMEOVER - decides what draw() renders.

let gameState = GAME_STATES.MENU;
outfits array

A list of outfit objects (name, colors, unlock status, unlock score) representing the snake skins players can earn.

let outfits = [ { name: 'Default Green', ... } ];
currentOutfitIndex number

The index into the outfits array of the skin currently equipped by the snake.

let currentOutfitIndex = 0;
projectiles array

Holds every active Projectile object currently flying across the screen.

let projectiles = [];
shootCooldown number

Minimum number of frames required between two shots, preventing the player from firing every frame.

let shootCooldown = 20;
zombies array

Holds every active Zombie object currently chasing the snake.

let zombies = [];
zombieSpawnRate number

How many frames pass between automatic zombie spawns.

let zombieSpawnRate = 120;
logicFrameRate number

How many times per second the snake's actual grid movement updates, independent of the 60fps render loop.

let logicFrameRate = 10;
logicFrameCounter number

Tracks progress within the current logic tick, used to compute the lerp interpolation factor for smooth rendering.

let logicFrameCounter = 0;
adminPanelExpanded boolean

Whether the DOM admin sidebar is currently open, used to resize the canvas and show/hide the toggle button.

let adminPanelExpanded = false;
gameCanvas object

Reference to the p5.Canvas object returned by createCanvas, used to resize/reposition/show/hide it later.

let gameCanvas;

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

BUG handleOutfitsMenuClicks()

This function recomputes the exact same drawing/layout code as drawOutfitsMenu() but never actually compares mouseX/mouseY against any button rectangle, so clicking 'Select', the pagination arrows, or possibly the Back button may not work as intended.

💡 Refactor so the layout math lives in one shared function that returns button rectangles, then have handleOutfitsMenuClicks() only run hit-tests against those rectangles (no drawing calls), separate from drawOutfitsMenu() which only draws.

PERFORMANCE drawOutfitsMenu() / handleOutfitsMenuClicks()

Roughly 80 lines of pagination and layout logic are duplicated verbatim between these two functions, so any layout tweak has to be made twice or the draw/click positions will drift out of sync.

💡 Extract the shared math (startIndex, endIndex, itemY calculations, button positions) into a helper function that both drawOutfitsMenu() and handleOutfitsMenuClicks() call.

BUG Snake.checkDeath()

The boundary check compares this.x/this.y (the snake's top-left grid corner) against 0/width/0/height, but the head is actually drawn as a circle centered at x + scl/2, so the visible head can appear to touch or cross the wall slightly before/after the logic officially registers death.

💡 Compare against the head's center (this.x + scl/2) or shrink the bounds by scl/2 for a more visually accurate collision boundary.

STYLE Throughout drawMenu, drawOutfitsMenu, drawGameplay, drawGameOver

Button dimensions and positions (buttonWidth = 200, buttonHeight = 60, etc.) are hardcoded as local variables repeated in multiple functions, making it easy for draw code and click-detection code to fall out of sync.

💡 Define a single shared object or set of constants for each button's position/size once, and reuse it in both the drawing function and its matching click handler.

🔄 Code Flow

Code flow showing loadunlockedoutfits, saveunlockedoutfits, setup, draw, drawmenu, drawoutfitsmenu, drawgameplay, drawgameover, keypressed, mousepressed, handlemenuclicks, handleoutfitsmenuclicks, handlegameoverclicks, restartgame, checkoutfitunlocks, toggleadminpanel, windowresized, createadminpanel, snake, projectile, zombie, food

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

graph TD start[Start] --> setup[setup] setup --> loadunlockedoutfits[loadUnlockedOutfits] loadunlockedoutfits --> load-outfit-loop[load-outfit-loop] load-outfit-loop --> setup-outfit-check[setup-outfit-check] setup-outfit-check --> draw[draw loop] draw --> draw-switch[draw-switch] draw-switch --> drawmenu[drawmenu] draw-switch --> drawoutfitsmenu[drawOutfitsMenu] draw-switch --> drawgameplay[drawGameplay] draw-switch --> drawgameover[drawGameOver] draw --> keypressed[keyPressed] draw --> mousepressed[mousePressed] drawgameplay --> zombie-update-loop[zombie-update-loop] zombie-update-loop --> death-conditional[death-conditional] death-conditional --> projectile-loop[projectile-loop] projectile-loop --> shoot-conditional[shoot-conditional] projectile-loop --> tail-draw-loop[tail-draw-loop] tail-draw-loop --> self-collision-loop[self-collision-loop] drawmenu --> menu-gradient-loop[menu-gradient-loop] drawoutfitsmenu --> outfits-page-loop[outfits-page-loop] outfits-page-loop --> outfit-status-conditional[outfit-status-conditional] outfits-page-loop --> pagination-conditional[pagination-conditional] mousepressed --> mouse-state-router[mouse-state-router] mouse-state-router --> play-hit-test[play-hit-test] mouse-state-router --> outfits-click-loop[outfits-click-loop] mouse-state-router --> return-hit-test[return-hit-test] click setup href "#fn-setup" click loadunlockedoutfits href "#fn-loadunlockedoutfits" click draw href "#fn-draw" click drawmenu href "#fn-drawmenu" click drawoutfitsmenu href "#fn-drawoutfitsmenu" click drawgameplay href "#fn-drawgameplay" click drawgameover href "#fn-drawgameover" click keypressed href "#fn-keypressed" click mousepressed href "#fn-mousepressed" click zombie-update-loop href "#sub-zombie-update-loop" click death-conditional href "#sub-death-conditional" click projectile-loop href "#sub-projectile-loop" click shoot-conditional href "#sub-shoot-conditional" click tail-draw-loop href "#sub-tail-draw-loop" click self-collision-loop href "#sub-self-collision-loop" click menu-gradient-loop href "#sub-menu-gradient-loop" click outfits-page-loop href "#sub-outfits-page-loop" click outfit-status-conditional href "#sub-outfit-status-conditional" click pagination-conditional href "#sub-pagination-conditional" click mouse-state-router href "#sub-mouse-state-router" click play-hit-test href "#sub-play-hit-test" click outfits-click-loop href "#sub-outfits-click-loop" click return-hit-test href "#sub-return-hit-test"

❓ Frequently Asked Questions

What visual elements are featured in this p5.js snake game sketch?

This sketch visually represents a classic snake game with colorful outfits for the snake, a grid-based layout, and a dynamic score display.

How can players interact with the snake game created in this sketch?

Players can control the snake's movement using keyboard arrows, navigate through menus to select outfits, and track their score and lives during gameplay.

What creative coding concepts does this snake game sketch showcase?

The sketch demonstrates object-oriented programming with the snake and outfit systems, as well as game state management to enhance user experience.

Preview

The best snake game modded - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of The best snake game modded - Code flow showing loadunlockedoutfits, saveunlockedoutfits, setup, draw, drawmenu, drawoutfitsmenu, drawgameplay, drawgameover, keypressed, mousepressed, handlemenuclicks, handleoutfitsmenuclicks, handlegameoverclicks, restartgame, checkoutfitunlocks, toggleadminpanel, windowresized, createadminpanel, snake, projectile, zombie, food
Code Flow Diagram