DEV Community

Cover image for Coding an Arduino snake game using a TFT LCD screen
Luis Felipe LΓ³pez
Luis Felipe LΓ³pez

Posted on • Originally published at kozmicluis.pro

Coding an Arduino snake game using a TFT LCD screen

I want show you how I implemented the classic snake game in Arduino using a 2.8 inch TFT screen and a random joystick that came inside my ELEGOO Arduino Mega learning kit that I got from Amazon many years ago for an university project.

The final product - πŸ”— Github Repo

video

In the short video above you can see what it looks like once it's finished. It's just a screen that displays 3 things: the background, the snake's head and its body, and a randomly spawned blue apple.

On the right, connected to 2 analog pins, a 5V pin, and a GND pin, is the joystick I decided to use to move the snake around in 4 directions (up, down, left, and right); yes I know you can't see it but it's there, trust!

Table of Contents


Assumptions and requirements

If it wasn't obvious enough, you'll need:

  1. An Arduino board with enough pins to connect the display and the joystick. You can get them anywhere (Amazon, ebay, your local electronics store) from like 15-20 dollars to 40-50 USD.
  2. A TFT LCD screen, preferably one that comes as a shield you can just mount on top of the board so you don't have to whip out your breadboard and a bunch of cables. Popular brands are Adafruit, and ELEGOO; both come with a CD that contains libraries and examples (which you can also download online).
  3. A joystick component, or if you can't find one, 4 buttons (one for each direction), which are really easy to set up and code. And if neither are an option for you, you could research how to use the screen's touch feature to create your own virtual joypad.

I am also making some assumptions about what you already know:

  1. I assume already how to plug the board to your PC and that you have an IDE installed. You most likely have the official Arduino IDE, or maybe prefer using PlatformIO (I'm using CLion with the PlatformIO plugin) with VSCode.
  2. I also assume you are not new to the C++ language, but it's not completely necessary to know a big deal about it, because I'm also kind of a beginner myself. If you are good at it and you find stuff in my code that rubs you the wrong way, hit me up (@kozmicluis on X/Twitter).
  3. I will not go into full detail about every possible implementation of the game, mechanic, or historical fact because I'm writing an article soon. Once I finish it I'll replace this paragraph with a link to it.

Testing the TFT LCD screen

Here's a picture of how I have the TFT shield connected:

shield

To connect the shield to the board just line up the corresponding pins. You can read on the back that on one row you have 5V, GND, and some analog pins; while on the other side's row you have a few digital pins, some of which are used for the SD card reader and the touch screen (which won't be used for this project).

If you do prefer using a breadboard (or if your screen can't be used as a shield), refer to the screen's manual or maybe this wiring tutorial. Additionally, you need to make the libraries available in your project! The ones we need are the GFX library, and TFT-LCD library which contains all the "pin magic", a bunch of examples, and a class that we can instantiate to interact with the underlying rendering functions.

The ELEGOO screen came with a CD containing all the PDFs and library zips I needed to get started, but you can also do a quick google search for Github repositories that might have them for download (such as this repository with the CD's contents). Once I dragged the library folders into my project's dedicated folder (for PlatformIO projects, it's the /lib subdirectory), I created the following files: /include/Snake.h, /include/constants.h, and /src/Snake.cpp, which will be explained later.

Here's how my project structure looks like:

.
β”œβ”€β”€ include
β”‚   β”œβ”€β”€ constants.h
β”‚   └── Snake.h
β”œβ”€β”€ lib
β”‚   β”œβ”€β”€ Elegoo_GFX
β”‚   β”‚   └── <library files>
β”‚   └── Elegoo_TFTLCD
β”‚       └── <library files>
β”œβ”€β”€ platformio.ini
β”œβ”€β”€ src
β”‚   β”œβ”€β”€ main.cpp
β”‚   └── Snake.cpp
└── test
    └── <test files>
Enter fullscreen mode Exit fullscreen mode

Writing some code, finally!

Plug the board with the screen already connected, and upload your project once you modify your /src/main.cpp script to make an instance of the TFT class, and calling a bunch of initialization functions.

#include <Arduino.h>
#include <Elegoo_TFTLCD.h>

Elegoo_TFTLCD tft(A3, A2, A1, A0, A4);

void setup() {
  Serial.begin(9600);
  tft.reset();
  tft.begin(0x9341);
  tft.setRotation(3); // Landscape
  tft.fillScreen(0x0000);
  tft.fillRect(50, 50, 100, 100, 0xFFFF);
}

void loop() {
}
Enter fullscreen mode Exit fullscreen mode

The first thing to do is create an instance of the library class. If you are using a different screen, the library folder and the class might have a different name and implementation; for example, it could be Adafruit_TFTLCD instead. Most 2.8 screens out there use the IL9341 driver, that's why the graphics example file in the library folder that came with my CD had the tft.begin(0x9341); line hardcoded somewhere, but it could be different in your example file, and to be honest it took me a long time to make my screen work with a different library until I just decided to just copy the example file, learn from it and then delete everything but the initialization calls (tft.reset(), tft.begin()).

Once you run this code, you should see a black background with a white square (100x100 pixels in size) at the position (x = 20, y = 20). Bear in mind these screens have a resolution of 320x240 pixels. If all you see is a white background, you are probably using the wrong library/driver, connected the pins in the wrong holes, or didn't pass the right pin numbers to the library constructor.

Lastly, the number 3 I provided as argument to the orientation setter corresponds to landscape mode (left-to-right), but you can try others from 0 to 2 (0 and 2 being portrait).

A header file just for constants

It is good practise to have all the constants in a file that can be accessed from anywhere in the project, and what better way to have them in a header file inside our /include directory! Also, it is advised to avoid #define as much as possible and use instead static constants. Start by creating a file called constants.h in the includes folder:

#ifndef CONSTANTS_H
#define CONSTANTS_H

/* ILI9341 | a-Si TFT LCD Single Chip Driver
 * 240RGBx320 Resolution and 262K color
 * https://cdn-shop.adafruit.com/datasheets/ILI9341.pdf */
constexpr uint16_t TFT_DRIVER_ID = 0x9341;

constexpr uint8_t BOARD_HEIGHT = 24;
constexpr uint8_t BOARD_WIDTH = 32;
constexpr uint8_t BLOCK_SIZE = 10;
constexpr uint8_t INTERVAL = 100;

constexpr uint16_t BLACK = 0x0000;
constexpr uint16_t BLUE = 0x001F;
constexpr uint16_t WHITE = 0xFFFF;

constexpr uint8_t JOYSTICK_PIN_X = 15;
constexpr uint8_t JOYSTICK_PIN_Y = 14;
constexpr uint16_t JOYSTICK_THRESHOLD = 300;

#endif // CONSTANTS_H
Enter fullscreen mode Exit fullscreen mode

We already know what the driver ID constant is for, so, please replace the hardcoded value in main.cpp with the constant and let your IDE automatically insert a #include "constants.h" line at the very top. The other constants will eventually make sense but for now all you need to know is that our snake will have a block size of 10x10 pixels, therefore, our relative board width and height is 32x24 blocks.

Planning the Snake class

For this particular implementation I decided to create a class that defined the behaviour of a snake entity that consists of 3 main components: the head, its body (not including the head), and the tail (the last element of the body); without forgetting the random position of the apple to eat next. This snake will also have a direction that will be changed every tick (at a given interval) by the joystick, and this direction dictates how the head moves inside the board (up, right, down, or left).

Let's start by creating the include/Snake.h file:

#ifndef SNAKE_H
#define SNAKE_H

#include "../../../.platformio/packages/toolchain-atmelavr/avr/include/stdint.h"
#include "constants.h"

enum Direction : uint8_t { NONE = 0, UP, RIGHT, DOWN, LEFT };

struct Vec2 {
  int8_t x;
  int8_t y;
  void operator+=(const Direction dir) {
    if (dir == RIGHT) x += 1;
    else if (dir == LEFT) x -= 1;
    else if (dir == UP) y -= 1;
    else if (dir == DOWN) y += 1;
  }
};

class Snake {
public:
  Vec2 apple_;
  Vec2 head_;

  explicit Snake() : dir_(), head_(), tail_(), apple_() {}
  void changeDir(Direction dir);
  void move();
  void reset();

private:
  Direction body_[BOARD_HEIGHT][BOARD_WIDTH]{};
  Direction dir_;
  Vec2 tail_;

  void spawnApple();
  bool wallCollision() const;
  bool selfCollision() const;
};

#endif // SNAKE_H
Enter fullscreen mode Exit fullscreen mode

I will try to explain part by part, starting with the first include (yes, that extra long line above #include "constants.h"). The reason it exists is because I'm using PlatformIO and stdint.h, the file where uint8_t, uint16_t (and their unsigned version) are defined, resides at that location (CLion auto inserted it). However, and it might be different if you're using the Arduino IDE.

What's with uint_8 and its buddies? Well, as you may know, Arduino boards (and relatives) have limited memory, thus it's wise to use 8 bit integers for values no greater than 127 (signed 8 bit integers, or bytes), or 255 (unsigned bytes). It's worth noting that the Arduino library has a byte type but I prefer using uint8_t and int8_t for cross-compatibility.

Now, the Direction enum defines 4 possible directions a snake can turn to, and NONE which has a value of 0 and is used as a null value for the body part 2d array called body_. If a cell in the body_ grid has a direction value of NONE, it means there is no snake body part in there, and I use these values in a onditional that checks for head-to-body self-collision. It can also be used to improve the spawnApple() method so that it doesn't spawn apples if the corresponding grid coordinate has a snake body part in it (something I chose not to do because I was lazy for simplicity sake).

Next is the struct Vec2 definition, which is a very idiomatic "C-like" way of representing a 2D coordinate. Both x and y members are of type int8_t because neither will be greater than 127 or less than -127, but if my board size were bigger than 127x127, then I'd have to define them as int16_t or just int if they are 16 bits and not bigger. Inside the struct I chose to overload the += operator so that I can add a direction to a point like this:

Vec2 position = { .x = 5, .y = 3 };
position += DOWN; // [position]'s [y] member was mutated
printf("Position(x = %i, y = %i)\n", position.x, position.y);
// Position(x = 5, y = 4)
Enter fullscreen mode Exit fullscreen mode

Last, we have the class definition with, initially, 3 public methods, and 3 private methods, all 6 very self-explanatory:

Method/Member Visibility Purpose
Snake() Public Default constructor that initializes all members.
changeDir() Public Sets the direction of the snake while preventing it from turning 180Β° (would cause self-collision).
move() Public Where most of the mechanics' code goes, it's responsible for moving the head, updating the body grid, the tail, and checking for collisions.
spawnApple() Private Finds a random position inside the grid for the first or next apple. It is called upon game reset and when the snake eats the previous apple.
wallCollision() Private Returns true if the updated position of the snake's head is out of bounds, meaning, it collided against a wall.
selfCollision() Private The second losing condition; returns true if the updated position of the snake's head is occupied by a body part (including the tail).
reset() Public Resets the members to their initial values. It's called at the start of the program and whenever a losing condition (collision) is met.
body_ Private A statically allocated 2D array of 24 rows and 32 columns. Represents all the cells in the board grid and whether or not (direction is NONE or greater than) a snake body part (represented as a direction) exists in those x, y positions. It's basically a lookup table that makes collision detection and tail-updating as time-efficient as it gets at the cost of some memory. In our case, 32x24 bytes (around 37.55% of the 2KB available SRAM in the Arduino UNO).
dir_ Private The current direction of the snake's head. Initial Value: RIGHT.
head_ Public The current position of the snake's head (not included in the body grid), possibily the most relevant member as it's used a lot by the game's mechanics. Initial Value: Half the width of the board (for x), and half the height (for y).
tail_ Private The current position of the snake's tail (included in the body grid), it's updated every time the snake moves unless an apple is eaten. Initial Value: The same as the head, at least until the snake eats an apple.
apple_ Public The position of the current apple, it changes at random whenever it's time to spawn a new apple. Initial value: It's chosen at runtime after a call to reset(), and therefore, to spawnApple().

Minimal implementation of the Snake class

We're very close to uploading the first iteration of our snake game, one that displays the head moving from its starting position all the way to the right wall (I chose RIGHT as my starting direction, but you choose yours :>), then colliding and resetting again and again.

For this, we need to have a minimum working implementation of 4 very important methods in the Snake class: move(), spawnApple(), wallCollision(), and reset(). First let's implement reset(), which will be called both inside Arduino's setup() function, and inside move() whenever it encounters a losing condition (collision, in our case).

Here are the first lines of code of our src/Snake.cpp file:

#include "Snake.h"
#include <Arduino.h>

void Snake::reset() {
  for (auto &row : body_)
    for (auto &cell : row)
      cell = NONE;

  dir_ = RIGHT;
  head_.x = BOARD_WIDTH / 2;
  head_.y = BOARD_HEIGHT / 2;
  tail_.x = head_.x;
  tail_.y = head_.y;
  spawnApple();
}
Enter fullscreen mode Exit fullscreen mode

This method will fill all the grid cells with NONE, symbolizing the non-existance of body parts (the head isn't included). Then, it sets the initial values I wrote about in the previous table, and finally, a call to spawnApple() is made so that we have an apple at the start.

Now let's implement the minimum working move() method right below reset():

// ... src/Snake.cpp

void Snake::move() {
  head_ += dir_;
  if (wallCollision()) {
    reset();
  }
}
Enter fullscreen mode Exit fullscreen mode

The move() method will be about 4 times bigger at the end but for now this code is all we need to see the snake's head move automatically and then reset its position when it collides with the right wall. Finally, let's implement the remaining 2 methods that won't change much in later steps:

// ... src/Snake.cpp

void Snake::spawnApple() {
  apple_.x = static_cast<int8_t>(random(BOARD_WIDTH));
  apple_.y = static_cast<int8_t>(random(BOARD_HEIGHT));
}

bool Snake::wallCollision() const {
  return head_.x >= BOARD_WIDTH ||
         head_.x < 0 ||
         head_.y >= BOARD_HEIGHT ||
         head_.y < 0;
}
Enter fullscreen mode Exit fullscreen mode

For now that's all there is to the class implementation. A few things to note are, first, we need to cast the return type of random() to a int8_t which is something your IDE will most likely suggest. Additionally, if you're wondering why there's a const after the method name in wallCollision(), it's because I was taught to mark methods that don't mutate the state of a class/struct as const; I think it all helps to prvent you from shooting yourself in the foot with accidental mutations, and it may help the compiler make optimizations.

Uploading the first iteration

First, we need to update the src/main.cpp sketch script to make sure the head is moved every few milliseconds (as defined by the INTERVAL constant) and to initialize the seed of the random number generator.

#include <Arduino.h>
#include <Elegoo_TFTLCD.h>
#include <Snake.h>
#include <constants.h>

Elegoo_TFTLCD tft(A3, A2, A1, A0, A4);
Snake snake;

void drawBlock(int8_t x, int8_t y, uint16_t color) {
  tft.fillRect(x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE, color);
}

void setup() {
  Serial.begin(9600);
  randomSeed(analogRead(A6));

  tft.reset();
  tft.begin(TFT_DRIVER_ID);
  tft.setRotation(3); // Landscape
  tft.fillScreen(BLACK);

  snake.reset();
}

void loop() {
  snake.move();
  drawBlock(snake.head.x, snake.head.y, WHITE);
  drawBlock(snake.apple.x, snake.apple.y, BLUE);
  delay(INTERVAL);
}
Enter fullscreen mode Exit fullscreen mode

In the global scope we instantiate the Snake class, then inside the setup() function we add a seed initializer randomSeed(analogRead(A6)) that uses a random unconnected analog pin's value (as the documentation suggests), or the current time. The goal to make the seed different every time the board's code is processed at startup.

I also defined a helper function called drawBlock() that prevents me from writing the BLOCK_SIZE constant every time I want to render a block. Finally, at the end of the setup() function is a call to reset() so that the initial values of the snake's state are properly set.

Inside the loop, we first move the snake's head and then wait for a few milliseconds before moving it again. I chose adelay of 100 milliseconds because it just feels right and not so fast, while also not being painfully slow. Could I have implemented the interval in terms of the difference in time between the last tick and the current timestamp? Yes, but I didn't want to overcomplicate things, plus I can just tell you at the end of the tutorial how you'd achieve such thing... I learned it while playing around with the LΓΆve2D Lua library.

It's time to upload the sketch! It should now display a white square going from the center of the screen to the right in a loop, and a blue square (the apple) appearing in random positions every time the snake collides with the wall.

Leaving a trail...

You may have noticed already that I kinda lied to you! Sure the white square (snake's head) moved to the right, and blue squares (apples) appeared at random, but also, the previous apple wasn't removed from the screen, nor was the space occupied by the previous head. Why? Because naturally, we need to manually refresh all the entities, including the background to give the illusion of motion, otherwise it'd just look like everything is leaving a trail or is placed on top of existing things.

There is just one problem... your (and my) TFT LCD screen probably takes a painful half a second to paint the entire screen pixel by pixel, and making a call to tft.fillScreen(BLACK) every time loop() is called requires our INTERVAL to be more than 600ms for repaints to be viable. But we want our game to run as fast as 30 FPS or as slow as 5FPS, not almost every second; so how do we achieve that?

Easy! For starters, every time we need to remove the tail when a snake moves without eating an apple, we draw a black square (or the same color as your background of choice) on top of the old tail. We also need to do the same with apples, we draw a black square on top of the old apple every time a new one has to spawn, but only if it's not the first time an apple spawns, so we'd have to let the code know somehow whether it's the first time the apple spawns or not. When is it the first time an apple spawns? When we call reset(), and every time after that (calls to move()), isn't.

Changes we need to make

First of all, I want to make the snake as unaware of the outside world as possible. I don't want to mix Snake logic with rendering function (shouldn't have to change the code if I decide to use SDL2, or Raylib, or the console) calls, or how intervals are calculated, or how we determine the direction of the snake (shouldn't know if I use a joystick, or the keyboard, or buttons, or if it's done programatically).

In order to achieve that while also accounting for the fact that I can't just naively repaint the background and entities, I decided to implement event handlers to let the outside world subscribe to 3 events: reset, successful move (failure causes reset to be triggered instead), and appleSpawn. This way we can subscribe to those events inside the setup() function and write rendering logic inside their corresponding handlers.

Note: This would not be necessary if the repaint speed wasn't so slow, I could just add getters for body_, head_, and tail_ and have a draw() function access them, running inside loop() every X times per second.

Let's begin changing the code, starting with a modified version
of include/Snake.h:

// ...

class Snake {
public:
  explicit Snake() : dir_(), head_(), tail_(), apple_() {}
  void changeDir(Direction dir);
  void onReset(void (*handler)(const Vec2 &));
  void onMove(void (*handler)(const Vec2 &, const Vec2 *));
  void onAppleSpawn(void (*handler)(const Vec2 &, const Vec2 *));
  void move();
  void reset();

private:
  Direction body_[BOARD_HEIGHT][BOARD_WIDTH]{};
  Direction dir_;
  Vec2 head_;
  Vec2 tail_;
  Vec2 apple_;
  void (*resetHandler_)(const Vec2 &) = nullptr;
  void (*moveHandler_)(const Vec2 &, const Vec2 *) = nullptr;
  void (*appleSpawnHandler_)(const Vec2 &, const Vec2 *) = nullptr;

  void spawnApple(bool firstTime);
  bool wallCollision() const;
  bool selfCollision() const;
};

// ...
Enter fullscreen mode Exit fullscreen mode

The most obvious addition here is 3 new private methods and members with nullptr assigned to them. If you have never worked with function pointers before, this retType (*ptrName)(type params) syntax might seem incredibly confusing and alien. Basically, I'm defining these handlers as pointers to void functions that accept 1 or 2 parameters of type const Vec2 & (read-only reference to a coordinate) or const Vec2 * (read-only pointer to a coordinate). Why is the 2nd parameter of appleSpawnHandler and moveHandler_ a pointer and not a reference? Because they correspond to the old apple position and *old tail position*s respectively, which can be nullptr: in the case of appleSpawnHandler_, it will be null if there is no old apple position to paint black (it's the first time an apple's been spawned); and for moveHandler_, it's null when we don't want to remove old tail (because the snake just ate an apple and has to grow).

The other change I made was make head_, apple_ and tail_ private instead of public (no getters needed) because they are already being passed as arguments to the handlers. If you are observant enough, the fact that I added an underscore to their names from the very beginning was a spoiler for this change.

Last change is minimal but telling, I added a parameter to spawnApple() called bool firstTime, which will let the function know whether it should give appleSpawnHandler_ the address to the old apple position, or pass nullptr to it.

Note: I recognise that I could also just send the apple's current position regardless, and have the handler paint the block black first, then paint the apple again. This is a way to avoid having this extra param, and I only realized AFTER I finished the project :D. Anyway, here's the updated implementation inside src/Snake.cpp:

// ...

void Snake::move() {
  Vec2 oldTail = tail_;
  bool ateApple = false;

  // ...
  tail_.x = head_.x;
  tail_.y = head_.y;

  if (moveHandler_ != nullptr)
    moveHandler_(head_, ateApple ? nullptr : &oldTail);
}

void Snake::reset() {
  // ...
  if (resetHandler_ != nullptr) resetHandler_(head_);
  spawnApple(true);
}

void Snake::spawnApple(const bool firstTime) {
  const Vec2 oldApple = apple_;
  // ...
  if (appleSpawnHandler_ != nullptr)
    appleSpawnHandler_(apple_, firstTime ? nullptr : &oldApple);
}

// ... wallCollision()

void Snake::onReset(void (*handler)(const Vec2 &)) { resetHandler_ = handler; }

void Snake::onMove(void (*handler)(const Vec2 &, const Vec2 *)) {
  moveHandler_ = handler;
}

void Snake::onAppleSpawn(void (*handler)(const Vec2 &, const Vec2 *)) {
  appleSpawnHandler_ = handler;
}
Enter fullscreen mode Exit fullscreen mode

Remember, this isn't the final version, and some things WILL change on the next iteration but for now this is all we need to finally visualize what I promised would happen; without leaving trails or superposing stuff, of course!

Uploading iteration #2

It's time to update the main sketch script and test that things now behave as I said they would a few sections ago. Here's the updated code for src/main.cpp:

// ...

void setup() {
  // ...

  snake.onReset([](const Vec2 &head) {
    tft.fillScreen(BLACK);
    drawBlock(head.x, head.y, WHITE);
  });
  snake.onMove([](const Vec2 &head, const Vec2 *oldTail) {
    if (oldTail != nullptr) drawBlock(oldTail->x, oldTail->y, BLACK);
    drawBlock(head.x, head.y, WHITE);
  });
  snake.onAppleSpawn([](const Vec2 &newApple, const Vec2 *oldApple) {
    if (oldApple != nullptr) drawBlock(oldApple->x, oldApple->y, BLACK);
    drawBlock(newApple.x, newApple.y, BLUE);
  });

  // ...
}

// ... loop()
Enter fullscreen mode Exit fullscreen mode

Don't forget to remove the line that says tft.fillScreen(BLACK); after tft.setRotation(3); since it's gonna be done inside a handler instead.

Now, upload the sketch and observe the result. Looks pretty simple and pointless right? Who'd want to stare at a white square trapped inside a loop, unable to even eat the unsuspecting apple that dares to be on its path. Well, in the next section we're going to finish the move() method, add the joystick to the mix, and play around with the final result!

Finishing the game's mechanics

Our snake can move, but it has no way of eating and growing yet, it doesn't check for self-collision (because there is no point yet), nor it properly updates its tail position after it moves; and on top of everything, it can't even change direction! Let's start implementing these things one by one, starting with changing direction:

// ... Snake.cpp

void Snake::changeDir(const Direction dir) {
  const bool isOpposite =
      (dir_ == UP && dir == DOWN) || (dir_ == DOWN && dir == UP) ||
      (dir_ == RIGHT && dir == LEFT) || (dir_ == LEFT && dir == RIGHT);

  if (!isOpposite) dir_ = dir;
}

// ...
Enter fullscreen mode Exit fullscreen mode

We had already defined the method, we just hadn't implemented it yet! All there is to it is accept a direction as parameter and check for a 180Β° direction change before assigning it to the corresponding member. Do I think this is the safest way of doing it? No... there are many bugs that can stem from this but given the simple nature of my project, I chose not to address the issue; I did however in a different implementation where I had 2 direction variables: the actual direction that only changes on every tick, and a tentative direction that is changed every frame (through event polling) or obtained from event handlers.

Next up, the super complex algorithm to check for self-collision:

// ... src/Snake.cpp

bool Snake::selfCollision() const {
  return body_[head_.y][head_.x] != NONE;
}

//  ...
Enter fullscreen mode Exit fullscreen mode

Yep, thanks to the way the snake's body is implemented (no double ended queue, array, linked list, etc), I don't need to have a loop. All I need to do is ask the 2D array if the coordinate (x, y) (where x and y are the members of the head's position) has a snake body part (any direction that isn't NONE).

Have you wondered yet why I'm storing directions inside body_ and not just booleans? It's because I need them in order to find the next position of the tail once the snake moves! For example, if body_[tail_.y][tail_.x] has an UP value, it means that the next tail will be { .x = tail_.x, .y = tail_.y - 1 }. And of course, after updating the tail's position I need to set that cell to NONE. Having all that in mind, here's the updated move() code:

// ... src/Snake.cpp

void Snake::move() {
  Vec2 oldTail = tail_;
  bool ateApple = false;

  body_[head_.y][head_.x] = dir_;
  head_ += dir_;
  if (wallCollision()) {
    reset();
    return;
  }

  if (head_.x == apple_.x && head_.y == apple_.y) {
    spawnApple(false);
    ateApple = true;
  } else {
    const Direction tailDir = body_[tail_.y][tail_.x];
    body_[tail_.y][tail_.x] = NONE;
    tail_ += tailDir;
  }
  if (selfCollision()) {
    reset();
    return;
  }

  if (moveHandler_ != nullptr)
    moveHandler_(head_, ateApple ? nullptr : &oldTail);
}

// ...
Enter fullscreen mode Exit fullscreen mode

I show you the entire method's code instead of commenting out the unchanged lines because I didn't want any confusion as to what was added and what was removed. At the very start of the method, we need to save the old tail (copied by value into a local variable called oldTail) and initialize a ateApple boolean thattells the moveHandler_ whether it should paint the background color on top of the old tail's white block or not.

After that, it saves the current head's direction inside body_, which will eventually allow the method to update the tail's position, Then the head moves to the corresponding direction and if it collides against a wall, the state is reset.

Next is the interesting part: growing! If the head's position coincides with the apple's position, spawn a new apple (passing false to tell appleSpawnHandler_ not to paint anything black); and if not, it didn't grow and thus the tail needs to be removed and updated the way I mentioned earlier. Finally, it checks for self collision, resetting in case. And then, sends the appropriate info to the successful move handler.

NOTE: You may have noticed I've done null checks for all 3 handlers. This is because it's not guaranteed that the client will provide such handlers, and we don't want to enable null pointer exceptions out here, am I right? An alternative is to initialize the handlers with default ones that do absolutely nothing (or anything you want, really). Or maybe you are into the observer pattern, or pub-sub, who knows...

What about spawning apples on top of the snake?

Funny you should ask! I really don't mind, but if you do care, you can do two things:

  1. Keep getting random Vec2s until it doesn't coincide with a body part (or the head). This can be done with a while loop, but the downside is that the longer the snake grows, the less available spots to spawn, meaning more potential for very long loops.
  2. Keep an array filled with all available positions (those not hosting the head or the body), and every time an apple needs to spawn, pick one at a random. You do need to keep this array updated by adding the deleted tail and removing the updated head every time it moves. And maybe it's better to use a linked list for this.

Connecting the joystick

Now that we finally have a way to change the snake's direction, and now that the direction change is actually relevant to the movement method, we need to connect the joystick to the Arduino and write some minimal code.

joystick

A joystick is nothing more than 2 potentiometers in one, with a button thrown in there just because sometimes you need extra buttons. Fun fact, for the longest time I didn't know you could press a joystick, let alone that those presses were actually mapped to an action in some games.

What is a potentiometer you say? In layman's terms, imagine a pull lever that when it's all the way down, has a value of 0, and 1024 when it's all the way up; that's kinda what a potentiometer is. Now imagine you have two of them, one maps to the horizontal axis x, and the other one, to the vertical axis y. Now imagine you put them into one metal box and have a plastic knob that when rotated around, can push and pull both at the same time a given amount.

potentiometers

Now you kinda see what I'm getting at! These potentiometers expose a pin that you can read from in your sketch's code (through analogRead()) when they're connected to any analog pin. The button pin is digital (HIGH when pressed, LOW when not pressed) but we're not going to use it for this project. Lastly, you need to connect its ground (GND) and 5 volt (5V) pins. In my case, my Arduino mega has 2 extra 5V and GND pins on the vertical section to the very right, and some free analog pins (A9 to A15). You can see how I connected mine in the picture above, or you can use a breadboard.

Note: If you use the shield on an Arduino UNO, you might not have any analog pin left. If you decide to use a breadboard, you can connect the control pins to digital pins instead of the analog ones that the shield connects to (A0 to A4). The user manual tells you that the RST pin can also be connected to the board's reset pin.

Uploading iteration #3 (final)

I'm again going to show you the entire code but for src/main.cpp to avoid any confusion:

#include <Arduino.h>
#include <Elegoo_TFTLCD.h>
#include <Snake.h>
#include <constants.h>

Elegoo_TFTLCD tft(A3, A2, A1, A0, A4);
Snake snake;

uint16_t neutralX, neutralY;
uint16_t joystickX, joystickY;

void drawBlock(int8_t x, int8_t y, uint16_t color) {
  tft.fillRect(x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE, color);
}

void setup() {
  Serial.begin(9600);
  randomSeed(analogRead(A6));
  tft.reset();
  tft.begin(TFT_DRIVER_ID);
  tft.setRotation(3); // Landscape
  neutralX = analogRead(JOYSTICK_PIN_X);
  neutralY = analogRead(JOYSTICK_PIN_Y);

  snake.onReset([](const Vec2 &head) {
    tft.fillScreen(BLACK);
    drawBlock(head.x, head.y, WHITE);
  });
  snake.onMove([](const Vec2 &head, const Vec2 *oldTail) {
    if (oldTail != nullptr) drawBlock(oldTail->x, oldTail->y, BLACK);
    drawBlock(head.x, head.y, WHITE);
  });
  snake.onAppleSpawn([](const Vec2 &newApple, const Vec2 *oldApple) {
    if (oldApple != nullptr) drawBlock(oldApple->x, oldApple->y, BLACK);
    drawBlock(newApple.x, newApple.y, BLUE);
  });

  snake.reset();
}

void loop() {
  joystickX = analogRead(JOYSTICK_PIN_X);
  joystickY = analogRead(JOYSTICK_PIN_Y);

  if (joystickX > neutralX + JOYSTICK_THRESHOLD) snake.changeDir(RIGHT);
  else if (joystickX < neutralX - JOYSTICK_THRESHOLD) snake.changeDir(LEFT);
  else if (joystickY > neutralY + JOYSTICK_THRESHOLD) snake.changeDir(DOWN);
  else if (joystickY < neutralY - JOYSTICK_THRESHOLD) snake.changeDir(UP);

  snake.move();
  delay(INTERVAL);
}
Enter fullscreen mode Exit fullscreen mode

You can immediately take notice of what I added:

  1. 4 uint16_t variables that hold the x and y analog values from the joystick. The neutral ones are only written to once (inside setup()) and they serve one purpose: avoid making assumptions about the initial resting value of both potentiometers. When I monitored both values, I got 509 and 516, which are completely different from 512 (half of 1024, AKA, the true center).
  2. Inside loop() I added some lines of code to read from the joystick on every frame and write to the joystickX and joystickY variables. And the simplest way I could come up with to determine the direction of the knob, was to just check if the horizontal or vertical values were above the neutral center plus a moderate threshold to require at least around 800-1024, or 0-200 in order to consider a direction change.

Like I've said before, this code is really not optimal but I tested it and it works, you just need to keep the joystick pointed to where you want the snake to go or the direction changes might not even happen (especially the bigger the interval between frames is).

Final remarks

I left some things unimplemeted such as a Game Over screen, score display, a better apple spawning algorithm, and a better way to determine the direction of the joystick's knob. However, I will leave them to you as homework while I also work on it through the Github repository, and if I'm pleased with the new result I may come back and re-write the things that need to be rewritten (even if it's the entire tutorial).

I also left the whole screen wiring up in the air because this wasn't a tutorial about how to connect the screen, but rather how to code Snake, and make calls to tft.fillScreen() and tft.fillRect(). If you want to get more acquainted with your TFT screen's capabilities, explore and experiment with the example files.

Did I even mention that I connected the joystick analog pins to A15 (for VRx) and A14 (for VRy)? You can literally connect them to any free analog pins so it doesn't matter which ones I picked. Anyway, there are a lot of things that I could've done or explained better, but I'm always open for improving and taking suggestions.

Top comments (0)