<main id='app'></main>
@import url("https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap");

/*    Global styles    */
:root {
  --green: #79be5b;
  --black: #0000;
  --grey: rgb(115, 115, 115);
  --dark-green: #77b85c;
  --light-black: #1e2c0f;
  --light-grey: rgb(208, 208, 208);
}

@font-face {
  font-family: "Press Start 2P";
  src: url("@fonts/PressStart2P-Regular.woff2") format("woff2"),
    url("@fonts/PressStart2P-Regular.woff") format("woff");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
html {
  font-size: 62.5%;
  font-family: "Press Start 2P", cursive;
}
body {
  background: var(--green);
  min-height: 100vh;
  padding: 10rem;
}

main {
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: inherit;
}
section {
  width: 60vw;
  height: 60vw;
  min-width: 320px;
  min-height: 320px;
  max-width: 500px;
  max-height: 500px;
  background: var(--green);
}
button {
  padding: 1rem;
  background: transparent;
  border: 4px double black;
  border-radius: 5px;
  font-family: "Press Start 2P", cursive;
  font-size: 1.2rem;
  text-transform: uppercase;
  cursor: pointer;
}
button:hover {
  transform: scale(1.05);
}

button:active {
  transform: scale(1);
}

.hidden {
  opacity: 0;
}

 /* Menu section */
.menu-section {
  display: flex;
  flex-direction: column;
  justify-content: space-evenly;
  align-items: center;
  padding: 2rem 0;
  border: 5px double var(--light-black);
  border-radius: 10px;
}

/* Menu header */
.menu-header {
  width: 50%;
  margin-bottom: 2rem;
  text-align: center;
}
.menu-header--img {
  width: 20%;
}
.menu-header h1 {
  font-size: 2.5rem;
}
.menu-button--play {
  width: 30%;
  min-width: 85px;
}

/* Choose a level form */
.menu-levelsForm {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: center;
  width: 60%;
  min-height: 145px;
  text-align: center;
}
.menu-levelsForm h2 {
  font-size: 1.4rem;
  line-height: 2.2rem;
}
.menu-levelsForm--options {
  display: flex;
  justify-content: space-between;
  justify-content: center;
  align-items: center;
  flex-wrap: wrap;
  gap: 5px;
  width: 100%;
  margin: 1.5rem 0;
}
.levelsForm-option--label {
  padding: 1rem;
  border-top: 1px solid var(--light-grey);
  border-bottom: 2px solid var(--light-black);
  border-left: 1px solid var(--light-grey);
  border-right: 2px solid var(--light-black);
  border-radius: 3%;
}
.levelsForm-option--input {
  display: none;
}
.levelsForm-option--input:checked + .levelsForm-option--label {
  background-color: var(--dark-green);
  border-top: 1px solid var(--grey);
  border-left: 1px solid var(--grey);
}
.levelsForm-option--title {
  font-size: 1rem;
}

 /* Game section */
.game-section {
  display: flex;
  flex-direction: column;
  position: relative;
}

/* Score */
.game-score {
  position: absolute;
  top: -6rem;
  left: 3rem;
  font-size: 2rem;
}
.game-score::before {
  content: url("https://img.icons8.com/ios-filled/50/minecraft-golden-apple.png");
  position: relative;
  top: 5px;
}

/* Game canvas */
.game-canvas {
  width: 90%;
  margin: 0 auto;
  border: 5px double;
  border-radius: 5px;
  touch-action: none;
  -ms-touch-action: none;
}

/* Game Over Message */
.game-msg {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  position: absolute;
  z-index: 10;
  top: -10%;
  bottom: 0;
  left: 0;
  right: 0;
  width: 88%;
  height: 88%;
  min-height: 200px;
  min-width: 200px;
  margin: auto;
  text-align: center;
  backdrop-filter: blur(10px);
}

.game-msg.hidden {
  z-index: -10;
}
.game-msg--over {
  font-size: 2rem;
}
.game-msg--highScore {
  font-size: 1.3rem;
  margin: 2rem 0;
}
.game-msg-playBtn {
  margin: 1rem 0;
}
.game-msg-backToMenu {
  background: transparent;
  border: none;
  font-size: 0.8rem;
  line-height: 1.6rem;
  text-decoration: underline;
}

/* Game move buttons */
.game-btns {
  display: grid;
  grid-template-columns: repeat(3, 25%);
  grid-template-rows: repeat(2, auto);
  grid-gap: 0.5rem;
  justify-content: center;
  margin-top: 2rem;
}
.game-btns .moveUp {
  grid-column: 2/3;
}
.game-btns .moveDown {
  grid-column: 2/3;
  grid-row: 2/3;
}
.game-btns .moveLeft {
  grid-column: 1/2;
}
.game-btns .moveRight {
  grid-column: 3/4;
}
class App {
  constructor() {
    this.container = document.querySelector("#app");
    this.canvasElement = null;
    this.gameCanvas = null;
    this.scoreElement = null;
    this.highScoreElement = null;
    this.gameMsgElement = null;
  }

  playGame = (event) => {
    event.preventDefault();

    const form = document.querySelector(".menu-levelsForm");
    const formData = new FormData(form);
    const chosenLevel = formData.get("levelOption");

    if (chosenLevel) {
      const gamePage = new GameLayout();

      this.container.replaceChildren(gamePage.render());

      // Getting the canvas and other elements after rendering the game
      this.canvasElement = document.querySelector(".game-canvas");
      this.gameCanvas = this.canvasElement.getContext("2d");
      this.scoreElement = document.querySelector(".game-score");
      this.gameMsgElement = document.querySelector(".game-msg");
      this.highScoreElement = document.querySelector(".highScore-span");

      // Creating the snake and the apple in canvas
      gamePage.start(chosenLevel);
    }
  };

  fillGameCanvas = ({ color, x, y, width, height }) => {
    this.gameCanvas.fillStyle = color;
    this.gameCanvas.fillRect(x, y, width, height);
  };

  drawImgGameCanvas = ({ image, x, y, width, height }) => {
    this.gameCanvas.drawImage(image, x, y, width, height);
  };

  clearGameCanvas = () => {
    this.gameCanvas.clearRect(
      0,
      0,
      this.canvasElement.width,
      this.canvasElement.height
    );
  };

  updateScoreElement = (val) => {
    this.scoreElement.textContent = val;
  };

  updateHighScoreElement = (val) => {
    this.highScoreElement.textContent = val;
  };

  toggleGameOverMsg = () => {
    this.gameMsgElement.classList.toggle("hidden");
  };

  backToMenuPage = () => {
    const menuPage = new Menu();

    this.container.replaceChildren(menuPage.render());
  };
}

class Cell {
  constructor(defaultPosition = { x: 140, y: 140 }) {
    this.position = defaultPosition;
    this.defaultPosition = defaultPosition;
    this.size = {
      width: 20,
      height: 20,
    };
    this.color = "#233311";
  }

  paint() {
    const fill = { ...this.position, ...this.size, color: this.color };
    app.fillGameCanvas(fill);
  }
}

class Apple extends Cell {
  constructor() {
    super({
      // Set default position
      x: 140,
      y: 40,
    });
    this.image = new Image();
    this.image.src = "https://img.icons8.com/ios-filled/50/minecraft-golden-apple.png";
  }

  paint() {
    const draw = { ...this.position, ...this.size, image: this.image };

    app.drawImgGameCanvas(draw);
  }

  setNewPosition() {
    this.position = {
      x: this.getRandomPosition(),
      y: this.getRandomPosition(),
    };
  }

  getRandomPosition() {
    const maxCellsOnCanvas = (app.canvasElement.width - this.size.width) / 10;
    const minCellsOnCanvas = 0;
    let randomPosition = Math.floor(
      Math.random() *
        (maxCellsOnCanvas - minCellsOnCanvas - 0 + minCellsOnCanvas) +
        0
    );

    if (randomPosition % 2 !== 0) {
      // If it's odd make it even
      randomPosition -= 1;
    }

    return randomPosition * 10;
  }

  reset() {
    this.position = {
      x: this.defaultPosition.x,
      y: this.defaultPosition.y,
    };
  }
}

class SnakeCell extends Cell {
  constructor({ id, prevCell }) {
    super({
      x: prevCell?.oldPosition.x || 140,
      y: prevCell?.oldPosition.y || 140,
    });
    this.id = id;
    this.oldPosition = {
      x: this?.position.x || null,
      y: this?.position.y || null,
    };
    this.nextCell = null;
    this.prevCell = prevCell;
  }

  updateCellPosition(velocity) {
    if (this.id === 0) {
      // Snake head
      this.position.x += velocity.x;
      this.position.y += velocity.y;
    } else {
      this.position.y = this.prevCell.oldPosition.y;
      this.position.x = this.prevCell.oldPosition.x;
    }
  }

  setOldPosition() {
    this.oldPosition.y = this.position.y;
    this.oldPosition.x = this.position.x;
  }
}

class Snake {
  constructor({ defaultSize }) {
    this.head = null;
    this.tail = null;
    this.size = 0;
    this.defaultSize = defaultSize;
    this.velocity = { x: 0, y: 0 };
  }

  create() {
    let initialSize = this.defaultSize;

    while (initialSize) {
      this.createCell();
      initialSize--;
    }
  }

  createCell() {
    if (!this.head) {
      this.head = new SnakeCell({ id: this.size, prevCell: null });
      this.tail = this.head;
    } else {
      const newSnakeCell = new SnakeCell({
        id: this.size,
        prevCell: this.tail,
      });

      this.tail.nextCell = newSnakeCell;
      this.tail = newSnakeCell;
    }

    this.tail.paint();
    this.size++;
  }

  move({ event, moveTo }) {
    const moveToValue = this.getMoveToValue({ event, moveTo });

    if (
      !this.velocity.y &&
      (moveToValue === "moveUp" || moveToValue === "moveDown")
    ) {
      const { height: val } = this.head.size;
      const velocity = moveToValue === "moveDown" ? val : -val;

      this.updateVelocity("y", velocity);
    } else if (
      !this.velocity.x &&
      (moveToValue === "moveRight" || moveToValue === "moveLeft")
    ) {
      const { width: val } = this.head.size;
      const velocity = moveToValue === "moveRight" ? val : -val;

      this.updateVelocity("x", velocity);
    }
  }

  getMoveToValue({ event, moveTo }) {
    const validKeys = {
      ArrowUp: "moveUp",
      ArrowDown: "moveDown",
      ArrowRight: "moveRight",
      ArrowLeft: "moveLeft",
    };

    if (!event) {
      return moveTo;
    } else if (event.type === "keydown" && validKeys[event.key]) {
      return validKeys[event.key];
    } else if (event.type === "click") {
      return event.target.className;
    }
  }

  updateVelocity(axis, val) {
    const oppositeAxis = {
      x: "y",
      y: "x",
    };

    this.velocity[axis] += val;
    this.velocity[oppositeAxis[axis]] = 0;
  }

  updatePosition() {
    let snakeCell = this.head;

    while (snakeCell) {
      snakeCell.setOldPosition();
      snakeCell.updateCellPosition(this.velocity);
      snakeCell.paint();

      snakeCell = snakeCell.nextCell;
    }
  }

  hasCollidedWithItself() {
    let snakeHeadPosition = this.head.position;
    let snakeCell = this.head.nextCell;

    while (snakeCell) {
      if (
        snakeHeadPosition.x === snakeCell.position.x &&
        snakeHeadPosition.y === snakeCell.position.y
      ) {
        return true;
      }

      snakeCell = snakeCell.nextCell;
    }

    return false;
  }

  reset() {
    this.size = 0;
    this.head = null;
    this.tail = null;
    this.velocity = {
      x: 0,
      y: 0,
    };
    this.create();
  }
}

class Menu {
  constructor() {
    this.container = document.createElement("section");
    this.container.setAttribute("class", "menu-section");

    this.gameLevels = ["Easy", "Normal", "Hard"];
  }

  render() {
    const menuHeader = this.createMenuHeader();
    const levelsForm = this.createLevelsForm();

    this.container.append(menuHeader, levelsForm);

    return this.container;
  }

  createMenuHeader() {
    const container = document.createElement("div");
    container.setAttribute("class", "menu-header");

    const headerImage = document.createElement("img");
    headerImage.setAttribute("src", 'https://img.icons8.com/ios-filled/50/rattlesnake.png');
    headerImage.setAttribute("alt", "snake");
    headerImage.setAttribute("class", "menu-header--img");

    const title = document.createElement("h1");
    title.textContent = "Snake";

    container.append(headerImage, title);

    return container;
  }

  createLevelsForm() {
    const form = document.createElement("form");
    form.setAttribute("class", "menu-levelsForm");

    const title = document.createElement("h2");
    title.textContent = "Choose a level:";

    const optionsContainer = document.createElement("div");
    optionsContainer.setAttribute("class", "menu-levelsForm--options");

    this.gameLevels.forEach((level) => {
      const label = document.createElement("label");
      label.setAttribute("class", "levelsForm-option--label");
      label.setAttribute("for", level.toLowerCase());

      const option = document.createElement("input");
      option.setAttribute("class", "levelsForm-option--input");
      option.setAttribute("id", level.toLowerCase());
      option.setAttribute("name", "levelOption");
      option.setAttribute("type", "radio");
      option.setAttribute("value", level.toLowerCase());
      option.setAttribute("autocomplete", "off");

      const optionTitle = document.createElement("span");
      optionTitle.setAttribute("class", "levelsForm-option--title");
      optionTitle.textContent = level;

      label.append(optionTitle);
      optionsContainer.append(option, label);
    });

    const playBtn = document.createElement("button");
    playBtn.setAttribute("class", "menu-button--play");
    playBtn.setAttribute("type", "submit");
    playBtn.textContent = "Play";

    playBtn.addEventListener("click", app.playGame);

    form.append(title, optionsContainer, playBtn);

    return form;
  }
}

class Game {
  constructor() {
    this.snake = null;
    this.apple = new Apple();
    this.speed = null;
    this.isGameOver = false;
    this.score = 0;
    this.highScore = localStorage.getItem("highScore") || 0;
    this.interval = null;

    this.moveSnake = this.moveSnake.bind(this);
    this.paintCanvas = this.paintCanvas.bind(this);
    this.playAgain = this.playAgain.bind(this);
  }

  start(chosenLevel) {
    switch (chosenLevel) {
      case "easy":
        this.speed = 200;
        this.snake = new Snake({ defaultSize: 3 });
        break;
      case "normal":
        this.speed = 150;
        this.snake = new Snake({ defaultSize: 5 });
        break;
      case "hard":
        this.speed = 100;
        this.snake = new Snake({ defaultSize: 8 });
        break;
    }

    this.snake.create();
    this.apple.paint();
    this.moveSnake({ moveTo: "moveUp" })();
  }

  moveSnake({ moveTo } = {}) {
    let debounceTimer = null;

    return (event) => {
      if (debounceTimer) {
        clearTimeout(debounceTimer);
      }

      debounceTimer = setTimeout(() => {
        if (!this.isGameOver) {
          this.snake.move({ event, moveTo });

          if (!this.interval) {
            this.paintCanvas();
            this.interval = setInterval(this.paintCanvas, this.speed);
          }
        }
      }, 100);
    };
  }

  paintCanvas() {
    app.clearGameCanvas();
    this.snake.updatePosition();
    this.apple.paint();
    this.checkCollisionWithApple();
    this.checkGameOver();
  }

  checkCollisionWithApple() {
    if (
      this.snake.head.position.x === this.apple.position.x &&
      this.snake.head.position.y === this.apple.position.y
    ) {
      this.updateScore();
      this.snake.createCell(); // The snake grows
      this.apple.setNewPosition();
    }
  }

  checkGameOver() {
    const snakeHead = this.snake.head;
    const snakeHeadPosition = snakeHead.position;
    const borderStart = 0;
    const borderEnd = app.canvasElement.width - snakeHead.size.width;

    // If snake head collides with a border or with itself
    if (
      snakeHeadPosition.x < borderStart ||
      snakeHeadPosition.x > borderEnd ||
      snakeHeadPosition.y < borderStart ||
      snakeHeadPosition.y > borderEnd ||
      this.snake.hasCollidedWithItself()
    ) {
      this.isGameOver = true;
      this.stopInterval();
      this.getHighScore();
      app.toggleGameOverMsg();
    }
  }

  updateScore() {
    this.score++;
    app.updateScoreElement(this.score); // !!
  }

  stopInterval() {
    clearInterval(this.interval);
    this.interval = null;
  }

  getHighScore() {
    if (this.score > this.highScore) {
      this.highScore = this.score;
      localStorage.setItem("highScore", this.highScore);
      app.updateHighScoreElement(this.highScore);
    }
  }

  playAgain() {
    app.clearGameCanvas();
    this.score = 0;
    this.isGameOver = false;
    this.snake.reset();
    this.apple.reset();
    app.updateScoreElement(this.score);
    app.toggleGameOverMsg();
    this.moveSnake({ moveTo: "moveUp" })();
  }
}

class GameLayout extends Game {
  constructor() {
    super();
    this.container = document.createElement("section");
    this.container.setAttribute("class", "game-section");

    /* Move on mobile  */
    this.touchStartX = 0;
    this.touchStartY = 0;
    this.touchEndX = 0;
    this.touchEndY = 0;

    this.moveOnMobileStart = this.moveOnMobileStart.bind(this);
    this.moveOnMobileEnd = this.moveOnMobileEnd.bind(this);
  }

  render() {
    const scoreElement = this.createScoreElement();
    const canvas = this.createCanvas();
    const gameOverMsgElement = this.createGameOverMsg();
    const gameButtons = this.createGameButtons();

    this.container.append(
      scoreElement,
      canvas,
      gameOverMsgElement,
      gameButtons
    );

    document.body.addEventListener("keydown", this.moveSnake());

    return this.container;
  }

  createScoreElement() {
    const scoreElement = document.createElement("span");
    scoreElement.setAttribute("class", "game-score");
    scoreElement.textContent = 0;

    return scoreElement;
  }

  createCanvas() {
    const canvas = document.createElement("canvas");
    canvas.setAttribute("class", "game-canvas");
    canvas.setAttribute("width", "300");
    canvas.setAttribute("height", "300");
    canvas.setAttribute("tabindex", "0");

    canvas.addEventListener("touchstart", this.moveOnMobileStart);
    canvas.addEventListener("touchend", this.moveOnMobileEnd);

    return canvas;
  }

  createGameOverMsg() {
    const gameOverMsgContainer = document.createElement("div");
    gameOverMsgContainer.setAttribute("class", "game-msg hidden");

    const gameOverP = document.createElement("p");
    gameOverP.setAttribute("class", "game-msg--over");
    gameOverP.textContent = "GAME OVER";

    const highScoreP = document.createElement("p");
    highScoreP.setAttribute("class", "game-msg--highScore");
    highScoreP.textContent = "High Score: ";

    const highScoreVal = document.createElement("span");
    highScoreVal.setAttribute("class", "highScore-span");
    highScoreVal.textContent = this.highScore;

    const playAgainBtn = document.createElement("button");
    playAgainBtn.setAttribute("class", "game-msg-playBtn");
    playAgainBtn.textContent = "Play again";

    const backToMenuBtn = document.createElement("button");
    backToMenuBtn.setAttribute("class", "game-msg-backToMenu");
    backToMenuBtn.textContent = "Choose another level";

    playAgainBtn.addEventListener("click", this.playAgain);
    backToMenuBtn.addEventListener("click", app.backToMenuPage);

    highScoreP.append(highScoreVal);
    gameOverMsgContainer.append(
      gameOverP,
      highScoreP,
      playAgainBtn,
      backToMenuBtn
    );

    return gameOverMsgContainer;
  }

  createGameButtons() {
    const buttonsValue = {
      moveUp: "↑",
      moveDown: "↓",
      moveLeft: "←",
      moveRight: "→",
    };
    const container = document.createElement("div");
    container.setAttribute("class", "game-btns");

    for (let key in buttonsValue) {
      const button = document.createElement("button");

      button.setAttribute("class", key);
      button.textContent = buttonsValue[key];
      button.addEventListener("click", this.moveSnake());

      container.append(button);
    }

    return container;
  }

  /* Move on mobile */
  moveOnMobileStart(event) {
    this.touchStartX = event.changedTouches[0].screenX;
    this.touchStartY = event.changedTouches[0].screenY;
  }

  moveOnMobileEnd(event) {
    this.touchEndX = event.changedTouches[0].screenX;
    this.touchEndY = event.changedTouches[0].screenY;

    const x = Math.abs(this.touchStartX - this.touchEndX);
    const y = Math.abs(this.touchStartY - this.touchEndY);

    if (x > y) {
      if (this.touchStartX < this.touchEndX) {
        this.moveSnake({ moveTo: "moveRight" })();
      } else {
        this.moveSnake({ moveTo: "moveLeft" })();
      }
    } else if (y > x) {
      if (this.touchStartY < this.touchEndY) {
        this.moveSnake({ moveTo: "moveDown" })();
      } else {
        this.moveSnake({ moveTo: "moveUp" })();
      }
    }
  }
}

const app = new App();
const menuPage = new Menu();

app.container.append(menuPage.render());
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.