Final Project: Arcade Snake Game

Blind User Testing

First things first:

Concept

I initially planned on extending the functionality of my midterm project through the final project by giving it physical controls using the arduino. However, in the end, I decided to make a game inspired by all the amazing games that I saw in the midterms and final proposals (call it FOMO, I guess 😉 ). I settled with a game we all know and love, the Snake game that I’m sure most of us remember playing on our parents’ Nokias and BlackBerries. You eat fruits, you avoid hitting the boundaries and you avoid eating yourself – pretty simple, right? I settled for a simple aesthetic, making it pixel-y for the whole nostalgia package. Instead of using buttons on our parents’ phones, I’ve made it an arcade game where the snake is controlled by a joystick!

Basically, the game begins by pressing the joystick. For every fruit eaten, the player’s score increments by 1. After every 5 fruits eaten, if the player is still alive, the player levels up and the speed increases until you get to 30 scores after which it remains at the highest speed. After eating 20 fruits, your habitat (background) changes. There’s fun sounds and Minecraft music playing in the background, so the player will never get bored! The game is competitive too; it keeps track of the highest score for all turns!

Visuals

Implementation

I basically coded the game in p5.js initially, with an intention to control the snake with push buttons. After struggling with the implementation and orientation on the small breadboard, I thought about using the big LED buttons in a box, but when I saw Khaleeqa’s project using a joystick, I thought to myself: We can do that??? (thank you for the idea, Khaleeqa. I pray you’re able to do the Tolerance readings and response on time!). So now the snake is controlled by a joystick, adding to the nostalgic arcade feel. The design is extremely user friendly; you couldn’t get stuck or confused if you tried.

Arduino Code and Description of Communication

Basically, the joystick uses two potentiometers on the x and y axes, and I sent these two values to my p5.js sketch to control the direction of the snake’s movement. Pressing the joystick gives a specific value on the x potentiometer (1023) which I use to restart the game (thank you again Professor Aya for lending me a joystick, real lifesaver here).

Arduino Schematic

Arduino Code

int xValue = 0 ;
int yValue = 0 ; 

void setup()	
{	
    Serial.begin(9600) ;

}	

void loop()	
{	
    xValue = analogRead(A2);	
    yValue = analogRead(A1);		
    Serial.print(xValue,DEC);
    Serial.print(",");
    Serial.println(yValue,DEC);

    delay(100);	
}

P5 Code and Sketch

The P5 code is extensive in functionality, but I’ve tried to make the code as short as possible to make up for the 800 line monster I created for my midterm. I’ve basically used 4 classes in the implementation for each element of the snake, the snake as a whole, fruits, and the gameplay. The snake class is a list of snake elements, so it inherits the list class. The various classes, methods and logic are explained in the sketch comments, so instead of describing it again, here is the code:

let RESOLUTION = 500; // Setting canvas resolution
let ROWS = 20; // Number of rows in the grid
let COLS = 20; // Number of columns in the grid
let WIDTH = RESOLUTION / ROWS; // Width of each grid cell
let HEIGHT = RESOLUTION / COLS; // Height of each grid cell
let end_flag = false; // Flag to indicate the end of the game
let start_flag = false;

let bg_flag = 0;
let xVal = 0;
let yVal = 0;
let high_score = 0;

let head_up,
  head_left,
  apple,
  banana,
  game_background1,
  gamebackground2,
  game_font,
  bite_sound,
  level_up,
  game_over,
  background_music; // Loading images for background, snake head, apple, and banana; fonts; sounds

class SnakeElement {
  constructor(x, y, element_num) {
    // Snake element constructor
    this.x = x; // X-coordinate of the element
    this.y = y; // Y-coordinate of the element
    this.element_num = element_num; // Identifier for the type of element
  }

  display() {
    if (this.element_num === 1) {
      // Displaying the head facing up
      noFill();
      stroke(250);
      strokeWeight(2);
      ellipse(this.x + WIDTH / 2, this.y + HEIGHT / 2, WIDTH + 2);
      image(head_up, this.x, this.y, WIDTH, HEIGHT);
    } else if (this.element_num === 2) {
      // Displaying the head facing down (flipped vertically)
      noFill();
      stroke(250);
      strokeWeight(2);
      ellipse(this.x + WIDTH / 2, this.y + HEIGHT / 2, WIDTH + 2);
      push(); // Save the current drawing state
      scale(1, -1); // Flip vertically
      image(head_up, this.x, -this.y - HEIGHT, WIDTH, HEIGHT);
      pop(); // Restore the original drawing state
    } else if (this.element_num === 3) {
      // Displaying the head facing left
      noFill();
      stroke(250);
      strokeWeight(2);
      ellipse(this.x + WIDTH / 2, this.y + HEIGHT / 2, WIDTH + 2);
      image(head_left, this.x, this.y, WIDTH, HEIGHT);
    } else if (this.element_num === 4) {
      // Displaying the head facing right (flipped horizontally)
      noFill();
      stroke(250);
      strokeWeight(2);
      ellipse(this.x + WIDTH / 2, this.y + HEIGHT / 2, WIDTH + 2);
      push(); // Save the current drawing state
      scale(-1, 1); // Flip horizontally
      image(head_left, -this.x - WIDTH, this.y, WIDTH, HEIGHT);
      pop(); // Restore the original drawing state
    } else {
      // Displaying a circle for the body elements
      stroke(250);
      strokeWeight(2);
      if (this.element_num === 5) {
        fill(120, 220, 20); // Green circle
      } else if (this.element_num === 6) {
        fill(200, 48, 32); // Red circle
      } else if (this.element_num === 7) {
        fill(251, 240, 76); // Yellow circle
      }
      ellipse(this.x + WIDTH / 2, this.y + HEIGHT / 2, WIDTH);
    }
  }
}

class Snake extends Array {
  constructor() {
    super();
    // Initializing the snake with head and initial body elements
    this.push(new SnakeElement(RESOLUTION / 2, RESOLUTION / 2, 1));
    this.push(new SnakeElement(RESOLUTION / 2 - WIDTH, RESOLUTION / 2, 5));
    this.push(new SnakeElement(RESOLUTION / 2 - WIDTH * 2, RESOLUTION / 2, 5));
    this.full_flag = false; // Flag to check if the snake has filled the grid
  }

  display() {
    // Displaying all snake elements
    for (let element of this) {
      element.display();
    }
  }

  move(current_dir) {
    // Controlling the movement of the snake
    let head_x = this[0].x;
    let head_y = this[0].y;

    // Updating head position based on the current direction
    if (current_dir === "UP") {
      this[0].element_num = 1; // Updating element_num for head facing up
      this[0].y -= WIDTH; // Moving up
    } else if (current_dir === "DOWN") {
      this[0].element_num = 2; // Updating element_num for head facing down
      this[0].y += WIDTH; // Moving down
    } else if (current_dir === "LEFT") {
      this[0].element_num = 3; // Updating element_num for head facing left
      this[0].x -= WIDTH; // Moving left
    } else if (current_dir === "RIGHT") {
      this[0].element_num = 4; // Updating element_num for head facing right
      this[0].x += WIDTH; // Moving right
    }

    // Moving the body elements
    for (let i = 1; i < this.length; i++) {
      let temp_x = this[i].x;
      let temp_y = this[i].y;
      this[i].x = head_x;
      this[i].y = head_y;
      head_x = temp_x;
      head_y = temp_y;
    }
  }

  collide_self() {
    // Checking if the snake collides with itself
    for (let i = 1; i < this.length; i++) {
      if (this[0].x === this[i].x && this[0].y === this[i].y) {
        end_flag = true; // Collision occurred, end the game
        game_over.play();
      }
    }
  }

  collide_walls() {
    // Checking if the snake collides with the canvas borders
    if (
      this[0].x >= RESOLUTION ||
      this[0].x < 0 ||
      this[0].y < 0 ||
      this[0].y >= RESOLUTION
    ) {
      end_flag = true; // Snake has left the canvas, end the game
      game_over.play();
    }
  }

  board_full() {
    // Checking if the snake has filled the entire grid
    if (this.length === ROWS * COLS) {
      end_flag = true; // Board is full, end the game
      this.full_flag = true; // Player wins
    }
  }
}

class Fruit {
  constructor() {
    // Generating a random position for the fruit
    this.x = Math.floor(Math.random() * ROWS) * WIDTH;
    this.y = Math.floor(Math.random() * COLS) * HEIGHT;
    this.fruit_num = Math.floor(Math.random() * 2); // Randomly choosing apple or banana
  }

  display() {
    // Displaying the fruit based on its type
    if (this.fruit_num === 0) {
      image(apple, this.x, this.y, WIDTH, HEIGHT);
    } else {
      image(banana, this.x, this.y, WIDTH, HEIGHT);
    }
  }
}

class Game {
  constructor() {
    // Initializing the game with snake, fruit, and default direction
    this.snake = new Snake();
    this.fruit = new Fruit();
    this.current_dir = "RIGHT";
    this.score = 0; // Player's score
    this.frames = 12;
    this.eat_count = 0;
  }

  display() {
    // Displaying the snake, checking for fruit collision, and displaying the score
    this.snake.display();
    let n = 0;
    while (n < this.snake.length) {
      // Checking if the fruit is at the same position as any snake element
      if (
        this.fruit.x === this.snake[n].x &&
        this.fruit.y === this.snake[n].y
      ) {
        this.fruit = new Fruit(); // Create a new fruit
      }
      n++;
    }
    this.fruit.display();
    textSize(10);
    fill(0);
    text("Score: " + this.score, RESOLUTION - 100, 30);
  }

  move() {
    // Moving the snake, checking for collisions
    this.snake.move(this.current_dir);
    this.snake.collide_self();
    this.snake.collide_walls();
  }

  eat() {
    // Checking if the snake eats the fruit
    if (this.snake[0].x === this.fruit.x && this.snake[0].y === this.fruit.y) {
      // Adding a new element to the snake based on the current direction
      if (this.current_dir === "DOWN") {
        this.snake.push(
          new SnakeElement(
            this.snake[this.snake.length - 1].x,
            this.snake[this.snake.length - 1].y - HEIGHT,
            6 + this.fruit.fruit_num
          )
        );
      }
      if (this.current_dir === "UP") {
        this.snake.push(
          new SnakeElement(
            this.snake[this.snake.length - 1].x,
            this.snake[this.snake.length - 1].y + HEIGHT,
            6 + this.fruit.fruit_num
          )
        );
      }
      if (this.current_dir === "LEFT") {
        this.snake.push(
          new SnakeElement(
            this.snake[this.snake.length - 1].x + WIDTH,
            this.snake[this.snake.length - 1].y,
            6 + this.fruit.fruit_num
          )
        );
      }
      if (this.current_dir === "RIGHT") {
        this.snake.push(
          new SnakeElement(
            this.snake[this.snake.length - 1].x - WIDTH,
            this.snake[this.snake.length - 1].y,
            6 + this.fruit.fruit_num
          )
        );
      }
      this.fruit = new Fruit(); // Create a new fruit
      this.score += 1; // Increase the score
      if (this.score > 20) {
        bg_flag = 1;
      }
      frames -= 1;
      this.eat_count += 1;

      if (this.eat_count % 5 === 0 && this.eat_count <= 30) {
        this.frames -= 1;
        level_up.play();
      }
      bite_sound.play();
    }
  }

  rungame() {
    // Main method to be called in draw()
    if (frameCount % this.frames === 0) {
      if (!end_flag) {
        // If the game hasn't ended
        if (bg_flag === 0) {
          image(game_background1, 0, 0, RESOLUTION, RESOLUTION);
        } else if (bg_flag === 1) {
          image(game_background2, 0, 0, RESOLUTION, RESOLUTION);
        }
        this.display();
        this.move();
        this.eat();
      } else if (!this.snake.full_flag) {
        // If the game has ended and the board is not full
        if (bg_flag === 0) {
          image(game_background1, 0, 0, RESOLUTION, RESOLUTION);
        } else if (bg_flag === 1) {
          image(game_background2, 0, 0, RESOLUTION, RESOLUTION);
        }
        textSize(30);
        text("Game Over :(", RESOLUTION / 2 - 175, RESOLUTION / 2 - 15);
        textSize(18);
        text(
          "Final Score: " + this.score,
          RESOLUTION / 2 - 120,
          RESOLUTION / 2 + 15
        );
        textSize(10);
        text(
          "Click anywhere to restart :D",
          RESOLUTION / 2 - 130,
          RESOLUTION - 40
        );
        if (this.score > high_score) {
          high_score = this.score;
        }
        push();
        textSize(18);
        fill(255);
        stroke(0);
        text(
          "High Score: " + high_score,
          RESOLUTION / 2 - 110,
          RESOLUTION / 2 + 40
        );
        pop();
      } else {
        // If the game has ended and the board is full
        if (bg_flag === 0) {
          image(game_background1, 0, 0, RESOLUTION, RESOLUTION);
        } else if (bg_flag === 1) {
          image(game_background2, 0, 0, RESOLUTION, RESOLUTION);
        }
        textSize(25);
        text("You Win :D", RESOLUTION / 2 - 140, RESOLUTION / 2);
        textSize(7.5);
        text(
          "Click anywhere to restart :D",
          RESOLUTION / 2 - 100,
          RESOLUTION - 50
        );
      }
    }
  }
}

let game;

function preload() {
  // Loading images before setup()
  head_up = loadImage("images/head_up.png");
  head_left = loadImage("images/head_left.png");
  apple = loadImage("images/apple.png");
  banana = loadImage("images/banana.png");
  game_background1 = loadImage("images/snake_game_background1.png");
  game_background2 = loadImage("images/snake_game_background2.png");
  game_font = loadFont("fonts/snake_game_font.ttf");
  bite_sound = loadSound("sounds/bite.m4a");
  level_up = loadSound("sounds/levelup.mp3");
  game_over = loadSound("sounds/gameover.mp3");
  background_music = loadSound("sounds/backgroundmusic.m4a");
}

function setup() {
  // Setup function for creating the canvas and initializing the game
  createCanvas(RESOLUTION, RESOLUTION);
  game = new Game();
  textFont(game_font);
}

function draw() {
  // Draw function for running the game
  if (xVal >= 1000) {
    start_flag = true;
  }
  if (!background_music.isPlaying()) {
    background_music.play();
  }
  if (start_flag === true) {
    game.rungame();
  } else {
    image(game_background1, 0, 0, RESOLUTION, RESOLUTION);
    push();
    textSize(50);
    text("SNAKE", RESOLUTION / 2 - 120, RESOLUTION / 2 + 20);
    textSize(15);
    strokeWeight(1.5);
    text(
      "the most original game ever",
      RESOLUTION / 2 - 200,
      RESOLUTION / 2 + 40
    );
    pop();
  }

  if (!serialActive) {
    stroke(255);
    strokeWeight(2);
    text("Press Space Bar to select Serial Port", 20, 30);
  } else if (start_flag === true) {
    text("Connected", 20, 30);
  } else {
    text("Connected. Press the joystick to \nstart playing!", 20, 30);
  }

  // changing the direction of the snake using values from arduino
  if (xVal < 300) {
    // joystick moved left
    if (game.current_dir !== "RIGHT") {
      game.current_dir = "LEFT";
    }
  } else if (xVal > 700 && xVal < 1000) {
    // joystick moved right
    if (game.current_dir !== "LEFT") {
      game.current_dir = "RIGHT";
    }
  } else if (yVal < 300) {
    // joystick moved down
    if (game.current_dir !== "UP") {
      game.current_dir = "DOWN";
    }
  } else if (yVal > 700) {
    if (game.current_dir !== "DOWN") {
      // joystick moved up
      game.current_dir = "UP";
    }
  } else if (xVal >= 1000) {
    // restart game when joystick pressed
    if (end_flag === true) {
      end_flag = false;
      game = new Game();
      bg_flag = 0;
    }
  }
}

function mousePressed() {
  // Restart the game on mouse click
  if (end_flag === true) {
    start_flag = true;
    end_flag = false;
    game = new Game();
    bg_flag = 0;
  }
}

function keyPressed() {
  // Change the direction of movement on arrow key press
  if (keyCode === LEFT_ARROW) {
    if (game.current_dir !== "RIGHT") {
      game.current_dir = "LEFT";
    }
  } else if (keyCode === RIGHT_ARROW) {
    if (game.current_dir !== "LEFT") {
      game.current_dir = "RIGHT";
    }
  } else if (keyCode === UP_ARROW) {
    if (game.current_dir !== "DOWN") {
      game.current_dir = "UP";
    }
  } else if (keyCode === DOWN_ARROW) {
    if (game.current_dir !== "UP") {
      game.current_dir = "DOWN";
    }
  }

  if (key == " ") {
    // important to have in order to start the serial connection!!
    setUpSerial();
  }
}

// This function will be called by the web-serial library
// with each new *line* of data. The serial library reads
// the data until the newline and then gives it to us through
// this callback function
function readSerial(data) {
  ////////////////////////////////////
  //READ FROM ARDUINO HERE
  ////////////////////////////////////
  if (data != null) {
    // make sure there is actually a message
    // split the message
    let fromArduino = split(trim(data), ",");
    // if the right length, then proceed
    if (fromArduino.length == 2) {
      // only store values here
      // do everything with those values in the main draw loop
      xVal = int(fromArduino[0]);
      yVal = int(fromArduino[1]);
    }
  }
}

 

And a link to the sketch:

https://editor.p5js.org/haroonshafi/full/M1HJeVoiy

Aspects That I’m (IM) Proud Of

Actually getting the Snake game to work was a big part of this project. There’s so many conditions to cater to, so much stuff to consider. You don’t think that while you’re on the playing end but boy when you’re on the making end – whew. I’m proud that after messing up a bajillion times, particularly with the serial communication part, that I’ve finally got this to work with the help of the professor.  (I’m also proud of getting a high score of 34, so I wanna see someone beat that lol).

Areas for Future Improvement

There is plenty of room for improvement. Had I had more time, I would have incorporated some lights for indicating when a fruit is eaten or when the snake collides so there would be communication from p5 to arduino as well. I also wanted to use a potentiometer to control the speed of the snake through the framerate, because that would be fun (read: chaotic).

 

Leave a Reply