The best snake game

This sketch builds a complete Snake game inside a single p5.js canvas, including a clickable main menu, a paginated outfit-selection screen with unlockable skins, core snake gameplay, and a game-over screen. It also wires up an HTML/CSS admin panel that lets you tweak speed, score, lives, and snake length live while the game runs.

🧪 Try This!

Experiment with the code by making these changes:

  1. Speed up the snake — Raising the frame rate makes the snake move more times per second, making the whole game noticeably faster and harder.
  2. Give yourself more lives — Increasing the starting lives value means you'll get more extra tries before hitting the true Game Over screen.
  3. Recolor the food — The fill() color right before the food's rect() call controls what the pellet looks like on screen.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch is a complete Snake game built almost entirely with p5.js canvas drawing rather than HTML menus - the main menu, outfit-selection screen, gameplay, and game-over screen are all just different things drawn inside the same draw() loop based on a gameState variable. It layers in real game-design touches you rarely see in tutorial snake games: an extra-life system, unlockable snake skins that persist between sessions using localStorage, and an HTML/CSS admin panel with sliders and buttons for live-debugging speed, score, and snake length.

The code is organized around a simple state machine (GAME_STATES.MENU, OUTFITS_MENU, GAMEPLAY, GAMEOVER) that decides which drawX() function runs each frame, plus two ES6 classes - Snake and Food - that encapsulate movement, growth, collision, and rendering. By studying it you'll learn how to structure a multi-screen p5.js app without a single extra HTML page, how a grid-based movement system works with vectors and a scale constant, and how to mix p5.js DOM helpers (createSlider, createButton) with plain canvas drawing in the same sketch.

⚙️ How It Works

  1. When the sketch loads, setup() creates the canvas, builds the admin panel DOM elements, loads any previously unlocked outfits from localStorage, creates the Snake and Food objects, and sets the frame rate to 10 (the snake's speed)
  2. Every frame, draw() clears the background and then uses a switch statement on the gameState variable to call exactly one of drawMenu(), drawOutfitsMenu(), drawGameplay(), or drawGameOver()
  3. In GAMEPLAY, drawGameplay() checks snake.checkDeath() for wall or self collisions, updates and redraws the snake, checks snake.eat(food.pos) to award points and relocate the food, and redraws the score and lives text
  4. Mouse clicks are routed through mousePressed() to handleMenuClicks(), handleOutfitsMenuClicks(), or handleGameOverClicks() depending on gameState, since every button is just a rectangle being manually hit-tested with mouseX/mouseY
  5. Arrow key presses in keyPressed() change the snake's xspeed/yspeed, with guards that stop the snake from reversing directly into itself
  6. Reaching certain score thresholds calls checkOutfitUnlocks(), which flips outfit.unlocked flags and saves them to localStorage so new skins stay unlocked on future visits, while the admin panel toggle button can resize the canvas and let you edit game variables in real time

🎓 Concepts You'll Learn

Finite state machinesES6 classeslocalStorage persistenceGrid-based movement with vectorsManual UI hit-testingMixing p5.js DOM elements with canvas drawingCollision detection

📝 Code Breakdown

loadUnlockedOutfits()

This function shows how to restore game progress using the browser's localStorage API, which lets data survive page refreshes and browser restarts.

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 Mark Unlocked Outfits for (let outfit of outfits) {

Loops through every outfit and flips its unlocked flag on if its name was saved previously

let unlockedData = localStorage.getItem('snakeGameOutfits');
Reads a saved string from the browser's localStorage under the key 'snakeGameOutfits' - this persists even after the page is closed
if (unlockedData) {
Only proceeds if something was actually saved before (first-time players will have nothing stored)
let unlockedNames = JSON.parse(unlockedData);
Converts the saved string back into a real JavaScript array of outfit names
if (unlockedNames.includes(outfit.name)) {
Checks if this outfit's name is in the saved list of unlocked names
outfit.unlocked = true;
Marks the outfit object as unlocked so the outfits menu shows it as available

saveUnlockedOutfits()

Pairs with loadUnlockedOutfits() to create a simple save system - array methods like filter() and map() are a clean way to transform data before saving 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);
Filters the outfits array down to only unlocked ones, then maps that list to just their names, producing a simple array of strings
localStorage.setItem('snakeGameOutfits', JSON.stringify(unlockedNames));
Converts the array to a JSON string and saves it under the key 'snakeGameOutfits' so it can be read back later

setup()

setup() runs once when the page loads. It's the place to size the canvas, build any DOM UI, and initialize game objects before the draw loop starts.

🔬 This is meant to fall back to outfit 0 if nothing is unlocked. What happens if you replace `outfits.findIndex(o => o.unlocked) || 0` with `outfits.findIndex(o => o.unlocked)` and manually clear localStorage - does the game still start safely?

  if (!outfits[currentOutfitIndex].unlocked) {
    currentOutfitIndex = outfits.findIndex(o => o.unlocked) || 0;
  }
function setup() {
  gameCanvas = createCanvas(windowWidth, windowHeight * 0.85);
  gameCanvas.position(0, (windowHeight - gameCanvas.height) / 2);

  createAdminPanel();

  loadUnlockedOutfits();
  if (!outfits[currentOutfitIndex].unlocked) {
    currentOutfitIndex = outfits.findIndex(o => o.unlocked) || 0;
  }

  snake = new Snake();
  food = new Food();
  food.pickLocation();

  frameRate(10); // Snake moves 10 times per second

  adminPanelToggle.hide();
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

conditional Default Outfit Fallback if (!outfits[currentOutfitIndex].unlocked) {

Makes sure the game never starts with a locked outfit selected

gameCanvas = createCanvas(windowWidth, windowHeight * 0.85);
Creates the canvas sized to the full browser width and 85% of its height, leaving room at top/bottom
gameCanvas.position(0, (windowHeight - gameCanvas.height) / 2);
Vertically centers the canvas in the window by positioning it with equal empty space above and below
createAdminPanel();
Builds all the HTML sliders, inputs, and buttons for the debug panel (defined later in the file)
loadUnlockedOutfits();
Restores any outfits the player unlocked in previous sessions
if (!outfits[currentOutfitIndex].unlocked) {
Guards against starting on a locked skin if the saved progress doesn't include it
currentOutfitIndex = outfits.findIndex(o => o.unlocked) || 0;
Finds the index of the first unlocked outfit to fall back to (this line has a subtle bug - see improvements)
snake = new Snake();
Creates a fresh Snake object using the class defined lower in the file
food = new Food();
Creates the Food object that will track where the next piece of food is
food.pickLocation();
Places the food at a random grid position for the first time
frameRate(10); // Snake moves 10 times per second
Sets how many times per second draw() runs - since the snake moves once per frame, this directly controls its speed
adminPanelToggle.hide();
Hides the gear icon button while the player is still on the menu screen

draw()

draw() is the heartbeat of any p5.js sketch, running continuously at the frame rate. Here it acts as a simple dispatcher, delegating actual drawing work to other functions based on state.

🔬 This switch statement is the entire routing system for the game's screens. What happens if you swap the order of the MENU and GAMEPLAY cases - does anything visually change (hint: switch-case doesn't care about order unless cases fall through)?

  switch (gameState) {
    case GAME_STATES.MENU:
      drawMenu();
      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 Game State Router switch (gameState) {

Decides which screen-drawing function to call this frame based on the current game state

background(220);
Paints over the entire canvas with light grey every frame, which erases whatever was drawn last frame
switch (gameState) {
Checks the value of the gameState string variable to decide what to draw this frame
case GAME_STATES.MENU: drawMenu(); break;
If we're in the menu state, draws the main menu and stops checking other cases

drawMenu()

Since there's no HTML menu, every button here is just a rectangle plus text drawn at specific coordinates - the matching click detection lives in handleMenuClicks().

function drawMenu() {
  // Show game canvas and admin toggle
  gameCanvas.show();
  adminPanelToggle.hide(); // Hide toggle in menu

  textAlign(CENTER, CENTER);
  fill(0);
  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
  fill(50, 150, 0); // Green
  rect(playButtonX, playButtonY, buttonWidth, buttonHeight, 10);
  fill(255);
  textSize(32);
  text("Play", playButtonX, playButtonY + 8);

  // Outfits Button
  let outfitsButtonY = playButtonY + buttonHeight + 20;
  fill(0, 100, 150); // Blue
  rect(playButtonX, outfitsButtonY, buttonWidth, buttonHeight, 10);
  fill(255);
  textSize(32);
  text("Outfits", playButtonX, outfitsButtonY + 8);
  rectMode(CORNER); // Reset rectMode

  // Instructions
  fill(0);
  textSize(18);
  text("Click to interact", width / 2, height / 2 + 180);
}
Line-by-line explanation (4 lines)
rectMode(CENTER); // Center rectangles for easier positioning
Changes how rect() interprets its x/y arguments - now they mean the center of the rectangle instead of the top-left corner, which makes centering buttons much easier
rect(playButtonX, playButtonY, buttonWidth, buttonHeight, 10);
Draws the Play button as a rounded rectangle (the last argument, 10, is the corner radius)
let outfitsButtonY = playButtonY + buttonHeight + 20;
Calculates the Outfits button's vertical position by stacking it below the Play button with a 20px gap
rectMode(CORNER); // Reset rectMode
Switches rectMode back to the default so other parts of the sketch that expect top-left positioning aren't affected

drawOutfitsMenu()

This function demonstrates manual pagination math (startIndex/endIndex/maxPage) that's common in any canvas-based list UI where you can't rely on native scrollbars.

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 (6 lines)

🔧 Subcomponents:

for-loop Draw Visible Outfit Cards for (let i = startIndex; i < endIndex; i++) {

Draws one card per outfit on the current page, including preview, name, lock status, and a select button

conditional Locked vs Unlocked Display if (isUnlocked) {

Shows green UNLOCKED text or red locked-with-score-requirement text depending on the outfit's state

conditional Pagination Arrows if (totalOutfits > outfitsPerPage) {

Only draws left/right page arrows if there are enough outfits to need more than one page

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 never goes below 0 or above the last valid page
let startIndex = outfitsMenuPage * outfitsPerPage;
Calculates which outfit array index the current page should start displaying from
let pageIndex = i - startIndex;
Converts the global outfit index into a 0-3 position within the current page, used to stack cards vertically
fill(outfit.bodyColor);
Uses the outfit object's stored RGB array directly as a fill color for the preview body ellipse
if (isUnlocked && !isCurrent) {
Only shows a Select button if the outfit is both unlocked and not already the one currently equipped

drawGameplay()

This is the main gameplay loop, run once per frame while gameState is GAMEPLAY. It coordinates the Snake and Food classes and translates their state into score, lives, and game-over transitions.

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

  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;
    }
  }

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

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

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

  food.show();

  textSize(16);
  fill(0, 100, 0);
  textAlign(LEFT, BOTTOM);
  text("Controls: Arrow Keys (Up, Down, Left, Right)", 10, height - 10);
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Throttled Speed Readout if (frameCountForSpeedDisplay % speedDisplayUpdateInterval === 0) {

Only updates the admin panel's fps text every N frames instead of every single frame, saving DOM work

conditional Death and Life Handling if (snake.checkDeath()) {

Detects if the snake died this frame and either ends the game for good or resets it for another try

conditional Food Collision if (snake.eat(food.pos)) {

Awards points and relocates the food when the snake's head reaches the food's grid position

text("Lives: " + max(0, lives), width - 10, 30);
Displays the lives count but never lets it show a negative number to the player, using max()
if (snake.checkDeath()) {
Calls the Snake's own checkDeath() method (which also decrements lives internally) to see if this frame ended in a collision
if (lives < 0) {
Only truly ends the game once lives has dropped below zero, meaning all extra chances have been used
return;
Exits drawGameplay() immediately after handling a death so the rest of the function (which assumes a living snake) doesn't run on the same frame
snake.update();
Moves the snake forward one grid cell and shifts its tail
if (snake.eat(food.pos)) {
Checks if the snake's head is exactly on the food's position this frame
score += 10;
Adds 10 points to the score every time food is eaten

drawGameOver()

A simple end screen that reuses the same manual rectangle-button pattern seen in drawMenu() and drawOutfitsMenu().

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 (2 lines)
fill(255, 0, 0);
Sets the big header text to red to signal the game has ended
rect(returnButtonX, returnButtonY, buttonWidth, buttonHeight, 10);
Draws the clickable 'Return to Menu' button as a rounded rectangle

keyPressed()

keyPressed() is a p5.js event function that fires once per key press. Comparing keyCode against p5's built-in constants (UP_ARROW, DOWN_ARROW, etc.) is the standard way to detect arrow keys.

🔬 The condition `snake.yspeed !== 1` stops you from reversing directly into your own tail. What happens if you remove that check entirely and just call snake.setDir(0, -1)?

    case UP_ARROW:
      if (snake.yspeed !== 1) snake.setDir(0, -1);
      break;
function keyPressed() {
  if (gameState === GAME_STATES.GAMEOVER) {
    gameState = GAME_STATES.MENU;
    return;
  }
  if (gameState !== GAME_STATES.GAMEPLAY) {
    return;
  }

  switch (keyCode) {
    case UP_ARROW:
      if (snake.yspeed !== 1) snake.setDir(0, -1);
      break;
    case DOWN_ARROW:
      if (snake.yspeed !== -1) snake.setDir(0, 1);
      break;
    case RIGHT_ARROW:
      if (snake.xspeed !== -1) snake.setDir(1, 0);
      break;
    case LEFT_ARROW:
      if (snake.xspeed !== 1) snake.setDir(-1, 0);
      break;
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

switch-case Arrow Key Direction Switch switch (keyCode) {

Maps each arrow key to a direction, while guarding against reversing straight into the snake's own body

if (gameState === GAME_STATES.GAMEOVER) {
Lets any key press on the Game Over screen act as a shortcut back to the main menu
if (gameState !== GAME_STATES.GAMEPLAY) {
Ignores arrow key input entirely unless the game is actually being played
case UP_ARROW: if (snake.yspeed !== 1) snake.setDir(0, -1); break;
Only allows turning up if the snake isn't currently moving down (yspeed of 1), preventing an instant 180-degree turn into itself

mousePressed()

mousePressed() is a p5.js event function called automatically on every click. Here it acts purely as a router, similar in spirit to how draw() routes to drawX() functions based on state.

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)
if (gameState === GAME_STATES.MENU) {
Routes the click to the right handler function based on which screen is currently showing
handleMenuClicks();
Delegates the actual hit-testing logic to a dedicated function, keeping mousePressed() short and readable

handleMenuClicks()

Since this game has no real DOM buttons, click detection is done manually by comparing mouseX/mouseY against rectangle bounds - a pattern you'll see repeated in every handleXClicks() function.

🔬 This hit-box math mirrors the rectangle drawn in drawMenu(). What happens if buttonWidth here doesn't match the buttonWidth used to draw the button - can you click a button that looks smaller/bigger than its real clickable area?

  if (mouseX > playButtonX - buttonWidth / 2 && mouseX < playButtonX + buttonWidth / 2 &&
    mouseY > playButtonY - buttonHeight / 2 && mouseY < playButtonY + buttonHeight / 2) {
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 &&

Checks if the mouse click fell within the Play button's rectangle bounds

if (mouseX > playButtonX - buttonWidth / 2 && mouseX < playButtonX + buttonWidth / 2 &&
Since rectMode was CENTER when drawing, this checks the click's x is between the button's left and right edges (center minus/plus half width)
gameState = GAME_STATES.GAMEPLAY;
Switches the game state so draw() starts calling drawGameplay() next frame
restartGame();
Resets score, lives, snake, and food before gameplay begins

handleOutfitsMenuClicks()

This function must recompute the exact same layout math as drawOutfitsMenu() so its hit-boxes line up with what's drawn - a common challenge in canvas-based UIs without a retained DOM structure.

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);

    if (isUnlocked && !isCurrent) {
      let selectButtonX = width / 2 + 80;
      let selectButtonY = itemY + itemHeight / 2;
      let selectButtonWidth = 80;
      let selectButtonHeight = 40;

      if (mouseX > selectButtonX - selectButtonWidth / 2 && mouseX < selectButtonX + selectButtonWidth / 2 &&
        mouseY > selectButtonY - selectButtonHeight / 2 && mouseY < selectButtonY + selectButtonHeight / 2) {
        currentOutfitIndex = i;
        return;
      }
    }
  }

  let backButtonX = width / 2;
  let backButtonY = height - 50;
  let buttonWidth = 150;
  let buttonHeight = 50;

  if (mouseX > backButtonX - buttonWidth / 2 && mouseX < backButtonX + buttonWidth / 2 &&
    mouseY > backButtonY - buttonHeight / 2 && mouseY < backButtonY + buttonHeight / 2) {
    gameState = GAME_STATES.MENU;
    return;
  }

  let arrowSize = 30;
  let arrowOffset = 200;

  if (outfitsMenuPage > 0) {
    if (mouseX > width / 2 - arrowOffset - arrowSize / 2 && mouseX < width / 2 - arrowOffset + arrowSize / 2 &&
      mouseY > backButtonY - arrowSize / 2 && mouseY < backButtonY + arrowSize / 2) {
      outfitsMenuPage--;
      return;
    }
  }

  if (outfitsMenuPage < maxPage) {
    if (mouseX > width / 2 + arrowOffset - arrowSize / 2 && mouseX < width / 2 + arrowOffset + arrowSize / 2 &&
      mouseY > backButtonY - arrowSize / 2 && mouseY < backButtonY + arrowSize / 2) {
      outfitsMenuPage++;
      return;
    }
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

for-loop Select Button Hit Test Loop for (let i = startIndex; i < endIndex; i++) {

Checks each visible outfit card's Select button to see if it was clicked

conditional Pagination Arrow Clicks if (outfitsMenuPage > 0) {

Detects clicks on the previous/next page arrows and updates outfitsMenuPage

currentOutfitIndex = i;
Changes which outfit is equipped by updating the global index used everywhere the snake is drawn
gameState = GAME_STATES.MENU;
Sends the player back to the main menu when the back button is clicked
outfitsMenuPage--;
Moves to the previous page of outfits when the left arrow is clicked

handleGameOverClicks()

The simplest of the three click handlers, since the Game Over screen only has one button to test against.

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;
    return;
  }
}
Line-by-line explanation (2 lines)
if (mouseX > returnButtonX - buttonWidth / 2 && mouseX < returnButtonX + buttonWidth / 2 &&
Tests whether the click's x-position falls inside the Return to Menu button's width
gameState = GAME_STATES.MENU;
Switches back to the main menu state so draw() shows drawMenu() next frame

restartGame()

restartGame() centralizes all the state resets needed to start a fresh round, called both from the Play button and the admin panel's Restart Game button.

function restartGame() {
  gameOver = false;
  score = 0;
  lives = 1; // Reset lives on restart
  snake = new Snake();
  food.pickLocation();
  frameRate(10); // Reset speed on restart
  if (adminPanelExpanded) {
    select('#speed-slider').value(10);
    select('#score-input').value(0);
    select('#lives-input').value(1); // Update lives input field
  }
  frameCountForSpeedDisplay = 0;
}
Line-by-line explanation (4 lines)
score = 0;
Resets the score counter back to zero for a new game
lives = 1; // Reset lives on restart
Gives the player one extra life again at the start of a fresh game
snake = new Snake();
Replaces the old snake object with a brand new one, resetting position, direction, and length
if (adminPanelExpanded) {
Only bothers updating the admin panel's input fields if that panel is currently visible, avoiding unnecessary DOM work

checkOutfitUnlocks()

This function is called every time food is eaten, tying score progress directly to the outfit-unlocking reward system, and only persisting to localStorage when there's actually something new to save.

🔬 This loop checks score against each outfit's unlockScore property in the outfits array at the top of the file. What happens if you lower every unlockScore value to something like 10 - how fast do you unlock everything now?

  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) {
    }
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

for-loop Score Threshold Check for (let outfit of outfits) {

Compares the current score against every outfit's unlockScore requirement

if (!outfit.unlocked && score >= outfit.unlockScore) {
Only unlocks outfits that aren't already unlocked and whose score requirement has now been met
newUnlock = true;
Flags that at least one new outfit was unlocked this call, so we know to save progress afterward
if (newUnlock) { saveUnlockedOutfits();
Only writes to localStorage if something actually changed, avoiding unnecessary saves every time food is eaten

toggleAdminPanel()

This function shows how p5.js DOM elements (like the canvas returned by createCanvas) can be resized and repositioned at runtime with .size() and .position(), not just at setup time.

function toggleAdminPanel() {
  adminPanelExpanded = !adminPanelExpanded;

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

🔧 Subcomponents:

conditional Expand vs Collapse Panel if (adminPanelExpanded) {

Resizes the canvas smaller to make room for the panel when expanded, or back to full width when collapsed

adminPanelExpanded = !adminPanelExpanded;
Flips the boolean state - true becomes false and vice versa - a classic toggle pattern
gameCanvas.size(windowWidth - adminPanelWidth, windowHeight * 0.85);
Shrinks the canvas horizontally by exactly the admin panel's width so the two don't overlap
gameCanvas.position(0, (windowHeight - gameCanvas.height) / 2);
Re-centers the canvas vertically after its height may have changed

windowResized()

windowResized() is a p5.js callback that fires automatically whenever the browser window changes size, letting you keep the canvas responsive.

function windowResized() {
  let canvasHeight = windowHeight * 0.85; // Game area canvas height

  if (adminPanelExpanded) {
    resizeCanvas(windowWidth - adminPanelWidth, canvasHeight);
    gameCanvas.position(0, (windowHeight - canvasHeight) / 2);
  } else {
    resizeCanvas(windowWidth, canvasHeight);
    gameCanvas.position(0, (windowHeight - canvasHeight) / 2);
  }

  updateOutfitsList();
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Panel-Aware Resize if (adminPanelExpanded) {

Keeps the canvas width correct relative to whether the admin panel is currently taking up space

let canvasHeight = windowHeight * 0.85; // Game area canvas height
Recalculates the target canvas height whenever the browser window changes size
resizeCanvas(windowWidth - adminPanelWidth, canvasHeight);
p5.js's built-in function to actually change the canvas's pixel dimensions, accounting for the admin panel's width
updateOutfitsList();
Calls a now-empty legacy function left over from an older DOM-based outfits list (see improvements)

createAdminPanel()

This function shows how to mix p5.js's DOM helper functions (createSlider, createButton, createInput) with arrow-function callbacks to build a real debugging UI without writing any raw HTML by hand.

function createAdminPanel() {
  adminPanelContainer = select('#admin-panel-container');
  adminPanelContent = createElement('div');
  adminPanelContent.id('admin-panel');
  adminPanelContainer.child(adminPanelContent);

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

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

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

  adminPanelContent.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()));
  adminPanelContent.child(speedSlider);

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

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

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

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

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

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

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

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

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

  adminPanelContent.child(createP('Teleport Snake (X, Y):'));
  let teleportXInput = createInput('0', 'number');
  teleportXInput.id('teleport-x');
  teleportXInput.style('width', '60px');
  adminPanelContent.child(teleportXInput);
  adminPanelContent.child(createElement('span', ', '));
  let teleportYInput = createInput('0', 'number');
  teleportYInput.id('teleport-y');
  teleportYInput.style('width', '60px');
  adminPanelContent.child(teleportYInput);
  adminPanelContent.child(createElement('br'));
  let teleportButton = createButton('Teleport');
  teleportButton.mousePressed(() => {
    snake.x = int(teleportXInput.value()) * scl;
    snake.y = int(teleportYInput.value()) * scl;
  });
  adminPanelContent.child(teleportButton);
}
Line-by-line explanation (6 lines)
adminPanelContainer = select('#admin-panel-container');
Grabs the empty <div> already sitting in index.html and stores a reference to it
let speedSlider = createSlider(1, 60, 10, 1);
Creates an HTML range slider with min 1, max 60, starting value 10, and step size 1
speedSlider.input(() => frameRate(speedSlider.value()));
Attaches a listener that calls frameRate() with the slider's current value every time it's dragged, instantly changing snake speed
scoreButton.mousePressed(() => score = int(scoreInput.value()));
Reads whatever number is typed into the score input box and overwrites the global score variable when the button is clicked
addLengthButton.mousePressed(() => snake.total += 5);
Directly increases the snake's total tail length property by 5 segments as a cheat/debug tool
teleportButton.mousePressed(() => { snake.x = int(teleportXInput.value()) * scl; snake.y = int(teleportYInput.value()) * scl; });
Reads grid coordinates from two input boxes, multiplies by scl to convert to pixel coordinates, and moves the snake there instantly

createMenuUI()

This is leftover code from an earlier version of the game that used real HTML <div> menus instead of canvas-drawn ones. It's never even called anymore - see the improvements section for why it should be removed.

function createMenuUI() {
  // --- Main Menu ---
  // (all DOM-based menu creation code below has been commented out
  // because menus are now drawn directly on the canvas instead)
}
Line-by-line explanation (1 lines)
function createMenuUI() {
This function is now essentially empty - every line inside it in the original file is commented out

positionMenuUI()

Vestigial code from the DOM-menu era of this project. It's not called anywhere in the current sketch.

function positionMenuUI() {
  // (all positioning code below has been commented out
  // because DOM-based menus were replaced with canvas drawing)
}
Line-by-line explanation (1 lines)
function positionMenuUI() {
An empty leftover function - its body is entirely commented-out code that used to position the old DOM menus

updateOutfitsList()

This function used to rebuild an HTML list of outfit cards using getContext('2d') mini-canvases. Now that drawOutfitsMenu() draws everything directly on the main canvas, this function is dead code still being called from windowResized().

function updateOutfitsList() {
  // (all list-rendering code below has been commented out
  // because the outfits menu is now drawn directly on the canvas
  // inside drawOutfitsMenu() instead)
}
Line-by-line explanation (1 lines)
function updateOutfitsList() {
Despite being called from windowResized(), this function's body is entirely commented out, so calling it currently does nothing

Snake constructor()

The constructor runs once when `new Snake()` is called, setting up all the starting properties an instance needs before update() and show() can use them.

constructor() {
    this.x = 0;
    this.y = 0;
    this.xspeed = 1;
    this.yspeed = 0;
    this.tail = [];
    this.total = 0;
  }
Line-by-line explanation (4 lines)
this.x = 0; this.y = 0;
Starts the snake's head at the top-left corner of the canvas grid (pixel position 0,0)
this.xspeed = 1; this.yspeed = 0;
Sets the initial direction to move rightward one grid cell per update
this.tail = [];
An empty array that will store p5.Vector positions of every past location the head has occupied - this becomes the visible body
this.total = 0;
Tracks how many tail segments the snake should have; grows every time food is eaten

Snake.setDir()

A small setter method called from keyPressed() whenever an arrow key changes the snake's movement direction.

setDir(x, y) {
    this.xspeed = x;
    this.yspeed = y;
  }
Line-by-line explanation (1 lines)
this.xspeed = x; this.yspeed = y;
Overwrites the snake's current direction with new x/y speed values, typically -1, 0, or 1

Snake.eat()

Because this is a grid-based game, exact equality checks work reliably for collision detection - a technique that's simpler than distance-based collision used in free-moving games.

eat(pos) {
    if (this.x === pos.x && this.y === pos.y) {
      this.total++;
      return true;
    }
    return false;
  }
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Head-Food Overlap Check if (this.x === pos.x && this.y === pos.y) {

Detects if the snake's head is at exactly the same grid cell as the food

if (this.x === pos.x && this.y === pos.y) {
Since movement is grid-based (always multiples of scl), a simple equality check works instead of needing distance math
this.total++;
Increases the target tail length by one, which update() will use to grow the tail array over the next frames
return true;
Tells drawGameplay() that food was eaten so it can add score and relocate the food

Snake.update()

This is the classic 'unshift + pop' trick for animating a snake's body: each frame you add a new head position to the front and trim the end, creating the illusion of a moving trail even though it's really just an array of past positions.

🔬 This is how the tail follows the head without growing forever. What happens visually if you remove the pop() call entirely, letting the tail array grow every single frame regardless of `total`?

    this.tail.unshift(createVector(this.x, this.y));
    if (this.tail.length > this.total) {
      this.tail.pop();
    }
update() {
    this.tail.unshift(createVector(this.x, this.y));
    if (this.tail.length > this.total) {
      this.tail.pop();
    }
    this.x += this.xspeed * scl;
    this.y += this.yspeed * scl;
  }
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Trim Excess Tail if (this.tail.length > this.total) {

Removes the oldest tail segment once the tail array grows longer than the snake's current total length

this.tail.unshift(createVector(this.x, this.y));
Adds the head's current position to the very front of the tail array before moving, so the body follows where the head just was
if (this.tail.length > this.total) { this.tail.pop(); }
Removes the last (oldest) tail segment if the array has grown longer than the snake's intended length, keeping the tail a fixed size until food is eaten
this.x += this.xspeed * scl; this.y += this.yspeed * scl;
Moves the head forward by exactly one grid cell (scl pixels) in the current direction

Snake.show()

show() is called every frame after update() to render the snake's current state - separating movement logic (update) from rendering logic (show) is good practice in any animated sketch.

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

    fill(bodyColor);
    for (let i = 0; i < this.tail.length; i++) {
      ellipse(this.tail[i].x + scl / 2, this.tail[i].y + scl / 2, scl);
    }

    fill(headColor);
    ellipse(this.x + scl / 2, this.y + scl / 2, scl * 1.2);
  }
Line-by-line explanation (3 lines)

🔧 Subcomponents:

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

Draws one ellipse for every stored tail position, forming the visible snake body

let bodyColor = outfits[currentOutfitIndex].bodyColor;
Looks up the currently equipped outfit's body color from the global outfits array
ellipse(this.tail[i].x + scl / 2, this.tail[i].y + scl / 2, scl);
Draws each tail segment centered within its grid cell (adding half of scl centers the circle inside the square cell) at diameter scl
ellipse(this.x + scl / 2, this.y + scl / 2, scl * 1.2);
Draws the head slightly bigger than the body segments (scl * 1.2) so it's visually distinguishable

Snake.checkDeath()

checkDeath() combines two separate collision checks - wall boundaries and self-collision - into one method, both of which decrement lives, tying directly into the extra-life system in drawGameplay().

🔬 This loop is what makes the snake die if it bites its own tail. What happens if you comment out this whole loop - can the snake now pass through itself freely?

    for (let i = 0; i < this.tail.length; i++) {
      let pos = this.tail[i];
      if (this.x === pos.x && this.y === pos.y) {
        lives--;
        return true;
      }
    }
checkDeath() {
    if (this.x < 0 || this.x >= width || this.y < 0 || this.y >= height) {
      lives--;
      return true;
    }
    for (let i = 0; i < this.tail.length; i++) {
      let pos = this.tail[i];
      if (this.x === pos.x && this.y === pos.y) {
        lives--;
        return true;
      }
    }
    return false;
  }
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Wall Boundary Check if (this.x < 0 || this.x >= width || this.y < 0 || this.y >= height) {

Detects if the snake's head has moved outside the canvas edges

for-loop Self-Collision Check for (let i = 0; i < this.tail.length; i++) {

Checks if the head's position matches any tail segment's position, meaning the snake bit itself

if (this.x < 0 || this.x >= width || this.y < 0 || this.y >= height) {
Checks all four canvas edges at once using logical OR - true if the head has gone off any side
lives--;
Deducts one life immediately whenever a death condition is detected, whether from a wall or self-collision
for (let i = 0; i < this.tail.length; i++) { let pos = this.tail[i]; if (this.x === pos.x && this.y === pos.y) {
Loops through every stored tail segment position and compares it to the head's current position

Food constructor()

A minimal constructor - the Food class relies on pickLocation() being called right after creation to give it a real position.

constructor() {
    this.pos = createVector();
  }
Line-by-line explanation (1 lines)
this.pos = createVector();
Creates an empty p5.Vector (defaulting to 0,0,0) to store the food's grid position, which pickLocation() will fill in properly

Food.pickLocation()

This is the key technique for grid-based games: always work in whole grid units (cols/rows) and multiply by the cell size (scl) at the end, so objects never end up slightly misaligned with each other.

pickLocation() {
    let cols = floor(width / scl);
    let rows = floor(height / scl);
    this.pos = createVector(floor(random(cols)) * scl, floor(random(rows)) * scl);
  }
Line-by-line explanation (3 lines)
let cols = floor(width / scl);
Calculates how many grid columns fit across the canvas width
let rows = floor(height / scl);
Calculates how many grid rows fit down the canvas height
this.pos = createVector(floor(random(cols)) * scl, floor(random(rows)) * scl);
Picks a random column and row index, then multiplies each by scl to convert grid coordinates into pixel coordinates that align perfectly with the snake's movement grid

Food.show()

A simple show() method that mirrors Snake.show() - separating state (pos) from rendering (rect) keeps the class easy to reason about.

show() {
    fill(255, 0, 100);
    rect(this.pos.x, this.pos.y, scl, scl);
  }
Line-by-line explanation (2 lines)
fill(255, 0, 100);
Sets the food's color to a bright pink-red so it stands out against the snake and background
rect(this.pos.x, this.pos.y, scl, scl);
Draws the food as a square exactly one grid cell in size at its stored position

📦 Key Variables

snake object

Holds the current Snake instance, which tracks position, direction, tail, and length

let snake;
food object

Holds the current Food instance, which tracks the food's grid position

let food;
scl number

The pixel size of one grid cell - used everywhere movement, drawing, and collision need to align to the same grid

let scl = 20;
score number

Tracks the player's current score, increased by 10 each time food is eaten

let score = 0;
gameOver boolean

Flags whether the game has permanently ended (used alongside gameState)

let gameOver = false;
lives number

Number of extra chances left before a true Game Over; decremented on every collision

let lives = 1;
adminPanelToggle object

Reference to the gear-icon DOM button that shows/hides the admin panel

let adminPanelToggle;
adminPanelContainer object

Reference to the DOM container div that holds all admin panel content

let adminPanelContainer;
adminPanelContent object

Reference to the inner div where all admin panel sliders/buttons/inputs are appended

let adminPanelContent;
adminPanelWidth number

Fixed pixel width reserved for the admin panel, used when resizing the canvas

const adminPanelWidth = 250;
gameCanvas object

Reference to the p5.Canvas element so it can be resized/repositioned outside setup()

let gameCanvas;
adminPanelExpanded boolean

Tracks whether the admin debug panel is currently open, controlling canvas size and DOM updates

let adminPanelExpanded = false;
frameCountForSpeedDisplay number

A frame counter used to throttle how often the admin panel's fps readout updates

let frameCountForSpeedDisplay = 0;
speedDisplayUpdateInterval number

How many frames to wait between fps display updates, to avoid unnecessary DOM writes every frame

const speedDisplayUpdateInterval = 30;
GAME_STATES object

A constant lookup object naming all valid game states, used to avoid typos when comparing gameState strings

const GAME_STATES = { MENU: 'MENU', ... };
gameState string

The single source of truth for which screen is currently active; drives the switch statement in draw()

let gameState = GAME_STATES.MENU;
outfits array

An array of outfit objects, each with a name, colors, unlocked flag, and score requirement to unlock

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

The array index into outfits[] of the skin currently equipped by the player

let currentOutfitIndex = 0;
outfitsMenuPage number

Tracks which page of the paginated outfits list is currently being shown

let outfitsMenuPage = 0;

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

BUG setup() - `currentOutfitIndex = outfits.findIndex(o => o.unlocked) || 0;`

Array.prototype.findIndex() returns -1 when nothing matches, and in JavaScript `-1 || 0` evaluates to -1 (because -1 is truthy), not 0 as the code seems to intend. If somehow no outfit were unlocked, currentOutfitIndex would become -1, causing outfits[-1] to be undefined and crashing later code that reads .bodyColor/.headColor.

💡 Store the result first and explicitly check for -1: `let idx = outfits.findIndex(o => o.unlocked); currentOutfitIndex = idx !== -1 ? idx : 0;`

STYLE createMenuUI(), positionMenuUI(), updateOutfitsList()

These three functions are leftover from an earlier version of the game that used real HTML DOM menus. Their entire bodies are now commented out, yet updateOutfitsList() is still called from windowResized(), and createMenuUI()/positionMenuUI() aren't called at all.

💡 Delete these three functions entirely along with their call sites, or clearly mark them as deprecated, to make the file easier for future readers to navigate.

PERFORMANCE drawGameplay() - admin panel speed display

Even with the throttling interval, select('#speed-display') re-queries the DOM every time the interval hits, which is more expensive than necessary since the element reference never changes.

💡 Cache the result of select('#speed-display') once in createAdminPanel() (similar to how adminPanelToggle is cached) and reuse that reference instead of calling select() repeatedly inside draw().

FEATURE keyPressed() / overall input handling

The game only supports keyboard arrow keys, which makes it unplayable on touch devices even though the canvas and admin panel are otherwise responsive to window size.

💡 Add on-screen swipe detection (using touchStarted/touchEnded) or simple directional buttons drawn on the canvas so mobile and tablet players can control the snake.

🔄 Code Flow

Code flow showing loadunlockedoutfits, saveunlockedoutfits, setup, draw, drawmenu, drawoutfitsmenu, drawgameplay, drawgameover, keypressed, mousepressed, handlemenuclicks, handleoutfitsmenuclicks, handlegameoverclicks, restartgame, checkoutfitunlocks, toggleadminpanel, windowresized, createadminpanel, createmenuui, positionmenuui, updateoutfitslist, snakeconstructor, setdir, eat, update, show, checkdeath, foodconstructor, picklocation, foodshow

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

graph TD start[Start] --> setup[setup] setup --> loadunlockedoutfits[loadUnlockedOutfits] loadunlockedoutfits --> load-forloop[load-forloop] load-forloop --> setup-unlock-check[setup-unlock-check] setup --> draw[draw loop] draw --> draw-switch[draw-switch] draw-switch --> drawmenu[drawMenu] draw-switch --> drawoutfitsmenu[drawOutfitsMenu] draw-switch --> drawgameplay[drawGameplay] draw-switch --> drawgameover[drawGameOver] drawmenu --> handlemenuclicks[handleMenuClicks] handlemenuclicks --> play-hitbox[play-hitbox] drawoutfitsmenu --> handleoutfitsmenuclicks[handleOutfitsMenuClicks] handleoutfitsmenuclicks --> outfits-forloop[outfits-forloop] outfits-forloop --> outfits-unlocked-check[outfits-unlocked-check] outfits-forloop --> outfits-pagination[outfits-pagination] drawgameplay --> death-check[death-check] death-check --> checkdeath[checkDeath] checkdeath --> eat-check[eat-check] eat-check --> eat[eat] eat --> checkoutfitunlocks[checkoutFitUnlocks] drawgameplay --> update[update] update --> tail-trim[tail-trim] update --> self-collision-loop[self-collision-loop] update --> show[show] drawgameover --> handlegameoverclicks[handleGameOverClicks] drawgameover --> restartgame[restartGame] keypressed --> setdir[setDir] mousepressed --> draw 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 handlemenuclicks href "#fn-handlemenuclicks" click handleoutfitsmenuclicks href "#fn-handleoutfitsmenuclicks" click death-check href "#sub-death-check" click checkdeath href "#fn-checkdeath" click eat-check href "#sub-eat-check" click eat href "#fn-eat" click checkoutfitunlocks href "#fn-checkoutfitunlocks" click update href "#fn-update" click tail-trim href "#sub-tail-trim" click self-collision-loop href "#sub-self-collision-loop" click show href "#fn-show" click handlegameoverclicks href "#fn-handlegameoverclicks" click restartgame href "#fn-restartgame" click setdir href "#fn-setdir"

❓ Frequently Asked Questions

What visual experience does 'The Best Snake Game' sketch offer?

This sketch visually creates a vibrant and engaging snake game with colorful snake outfits and food items on a grid-based canvas.

How can players interact with 'The Best Snake Game'?

Users can control the snake's movement using keyboard inputs, navigate through the game menus, and unlock different outfits by achieving scores.

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

The sketch demonstrates state management for game progression, an outfit unlock system based on scores, and the use of global variables to manage game elements.

Preview

The best snake game - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of The best snake game - Code flow showing loadunlockedoutfits, saveunlockedoutfits, setup, draw, drawmenu, drawoutfitsmenu, drawgameplay, drawgameover, keypressed, mousepressed, handlemenuclicks, handleoutfitsmenuclicks, handlegameoverclicks, restartgame, checkoutfitunlocks, toggleadminpanel, windowresized, createadminpanel, createmenuui, positionmenuui, updateoutfitslist, snakeconstructor, setdir, eat, update, show, checkdeath, foodconstructor, picklocation, foodshow
Code Flow Diagram