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