Coding an Arduino snake game using a TFT LCD screen
Today I will 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.
Ah yes, my classmates at the time built crazy stuff you may find at your typical
science fair.
But me? I just thought it’d be fun to implement a snake game with my super
limited knowledge of C++ and enjoy myself rather than just stealing code
from follow a tutorial online on how to build yet another line-following
robot.
The final product - 🔗 Github Repo
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 head and body, a randomly spawned apple, and it resets after colliding with a wall or itself. 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
- Testing the TFT LCD screen
- Planning the
Snake
class - Minimal implementation of the Snake class
- Uploading the first iteration
- Leaving a trail…
- Changes we need to make
- Uploading iteration #2
- Finishing the game’s mechanics
- Connecting the joystick
- Uploading iteration #3 (final)
- Final remarks
Assumptions and requirements
If it wasn’t obvious enough, you’ll need:
- 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.
- 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).
- 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 how to do or are familiar with:
- I assume you are familiar with 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.
- 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.
- 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:
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 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>
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() {
}
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
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
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 conditional 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)
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();
}
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();
}
}
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;
}
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);
}
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;
};
// ...
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 positions
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;
}
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()
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;
}
// ...
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;
}
// ...
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);
}
// ...
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 that
tells
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:
- Keep getting random
Vec2
s 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. - 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.
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.
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);
}
You can immediately take notice of what I added:
- 4
uint16_t
variables that hold thex
andy
analog values from the joystick. The neutral ones are only written to once (insidesetup()
) 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). - Inside
loop()
I added some lines of code to read from the joystick on every frame and write to thejoystickX
andjoystickY
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.