Class 6
This class discusses the snake game implementation.
Now that all the hardware and firmware is set up, it’s time to get to the software. At this point we have a Cube
class which can enable an LED based on a coordinate. We need to now create software which uses this low level firmware. We will use multiple files to achieve this, as is the goal of object-oriented programming. One class should be dedicated to fulfilling one and only one task. They can communicate, but should know nothing about each other. For the Snake game, we will need a couple of things:
- The snake logic
- A way to handle input
- A sense of direction
We want to decouple these things, as they should perform independently. However they will all work together to pull the game together.
Modifying the Cube Class
I’m going to add two functions to the Cube
class, bufferFromMatrix
and toggleLED
to the Cube
class to make programming the game a little easier, and to add some more functionality. Here is the completed Cube.h
.
/*
* File: Cube.h
* Author: Jeff Longo
* Comments: LED cube controller class
*/
// Header guard, prevents including this file more than once
#ifndef CUBE_H
#define CUBE_H
#include "Arduino.h"
// Begin pin definitions
#define SERIAL_IN 2
#define LATCH_CLK 3
#define INPUT_CLK 4
#define UPDATE_CLK 5
#define RESET 6
#define SET 7
// End pin definitions
class Cube
{
private:
const static byte size = 4;
const static int period = 100;
unsigned short buffer[size];
public:
// Initializes the cube
Cube();
// Adds an LED to the buffer at position (x, y, z) with range (0, size - 1)
void bufferLED(byte x, byte y, byte z);
// Buffer LEDs from a 3D boolean matrix
void bufferFromMatrix(bool m[size][size][size]);
// Switches the state of an LED at position (x, y, z) with range (0, size - 1)
void toggleLED(byte x, byte y, byte z);
// Parses the data from the buffer, displays the frame, then clears the buffer
void display();
// Emptys the contents of the buffer
void clearBuffer();
// Resets the system
void reset();
};
#endif // CUBE_H
And the completed Cube.cpp
#include "Cube.h"
Cube::Cube()
{
// Configure the necessary pins as output
pinMode(SERIAL_IN, OUTPUT);
pinMode(INPUT_CLK, OUTPUT);
pinMode(LATCH_CLK, OUTPUT);
pinMode(UPDATE_CLK, OUTPUT);
pinMode(RESET, OUTPUT);
pinMode(SET, OUTPUT);
}
void Cube::bufferLED(byte x, byte y, byte z)
{
// Ensure the specified LED is within the bounds of the cube
if (x > (size - 1) || y > (size - 1) || z > (size - 1)) return;
// Convert the x,y position to a mapping of the format used for the buffer
/*
* (0, 0) --> 000000000000001 -> 1
* (1, 0) --> 000000000000010 -> 2
* (2, 0) --> 000000000000100 -> 4
* ...
* (3, 3) --> 1000000000000000 -> 32768
*/
unsigned short mapped = pow(2, (x + size*y)) + 0.5;
// Add the LED to the buffer
buffer[z] = buffer[z] | mapped;
}
void Cube::bufferFromMatrix(bool m[size][size][size])
{
for (byte x = 0; x < 4; x++)
{
for (byte y = 0; y < 4; y++)
{
for (byte z = 0; z < 4; z++)
{
if (m[x][y][z])
{
bufferLED(x, y, z);
}
}
}
}
}
void Cube::toggleLED(byte x, byte y, byte z)
{
buffer[z] ^= 1 << (x + size*y);
}
void Cube::clearBuffer()
{
for (byte i = 0; i < size; i++) buffer[i] = 0;
}
void Cube::reset()
{
clearBuffer();
// Remove any active LEDs
display();
// Clear the layer shift register
digitalWrite(RESET, LOW);
delayMicroseconds(period);
digitalWrite(RESET, HIGH);
delayMicroseconds(period);
// Start the layer shift register
digitalWrite(SET, HIGH);
delayMicroseconds(period);
digitalWrite(UPDATE_CLK, HIGH);
delayMicroseconds(period);
digitalWrite(UPDATE_CLK, LOW);
delayMicroseconds(period); // probably not necessary
digitalWrite(SET, LOW);
}
void Cube::display()
{
// Update each layer (z-axis)
for (byte i = 0; i < size; i++)
{
// Update x-y plane
for (byte j = 0; j < size*size; j++)
{
// Retrieve the state of bit j on the current layer
byte curr = (buffer[i] >> (size*size - 1 - j)) & 1;
// Load in the bit
digitalWrite(SERIAL_IN, curr);
// Toggle the input clock to push the bit through
digitalWrite(INPUT_CLK, HIGH);
// Provide time for the input to update
delayMicroseconds(period);
// Toggle the input clock to wait for the next bit
digitalWrite(INPUT_CLK, LOW);
// Provide time to wait for the next bit
delayMicroseconds(period);
}
// Toggle the latch clock to save the output
digitalWrite(LATCH_CLK, HIGH);
// Provide time for the output to latch
delayMicroseconds(period);
// Toggle the latch clock before loading new data
digitalWrite(LATCH_CLK, LOW);
// Provide time before changing the layer
delayMicroseconds(period);
// Toggle the update clock to switch to the next layer
digitalWrite(UPDATE_CLK, HIGH);
// Provide time to switch layers
delayMicroseconds(period);
// Toggle the update clock to work with the selected layer
digitalWrite(UPDATE_CLK, LOW);
}
}
bufferFromMatrix
simply allows you to pass a 3D matrix of booleans (1 or 0) and it will buffer the LEDs which are 1. This will be useful when we need to turn many LEDs on at once.
toggleLED
checks the status of the LED at the given coordinate and uses the XOR operator to switch its status. In other words 1->0 or 0->1. This will be useful for blinking an LED, so we don’t have to keep a variable to hold the current state of an LED.
Direction and Enums
Next we are going to create whats called an enum
to store direction. We could use an int
to store direction (0 = up, 1 = down, ect) but this is not very readable. Instead, we use an enum
. An enum
allows you to define your own data type. For example, you might define an enum called Color and have a set of colors: Red, Blue, Green. Then we can use code such as the following:
enum Color
{
Red,
Blue,
Green
};
Color c;
if (c == Red)
{
// Do something
}
We will do the same to store direction. We create a header file called Direction.h
.
/*
* File: Direction.h
* Author: Jeff Longo
* Comments: Small enum for storing direction
*/
// Header guard, prevents including this file more than once
#ifndef DIRECTION_H
#define DIRECTION_H
enum Direction
{
xpos, // +x
xneg, // -x
ypos, // +y
yneg, // -y
zpos, // +z
zneg // -z
};
#endif // DIRECTION_H
We use the header guard, like normal, and create an enum called Direction
. Any file that includes Direction.h
can use a Direction
type variable. We create a direction for every possible movement in 3D space.
Handling Input
Next we will make a class to handle input. The desired effect is that our Snake
game class creates an instance of the InputHandler
class. The Snake
can query the InputHandler
for the current state of input. The Snake
performs logic based on the current input. This way, the InputHandler
only knows about what inputs the user makes and the Snake
only needs to know what input is given to it by the InputHandler
and performs all the game logic. Here is our InputHandler.h
.
/*
* File: InputHandler.h
* Author: Jeff Longo
* Comments: Handles inputs from the pushbutton switches
*/
// Header guard, prevents including this file more than once
#ifndef INPUT_HANDLER_H
#define INPUT_HANDLER_H
#include "Arduino.h"
#include "Direction.h"
#define START 8
#define UP 9
#define DOWN 10
#define LEFT 11
#define RIGHT 12
class InputHandler
{
public:
// Initializes the input handler
InputHandler();
// Initializes runtime variables
void init();
// Checks for a start button press
bool startPressed();
// Polls for input and handles the snake's direction
void update();
// Updates prevDir for the next frame
void saveDir() { prevDir = dir; }
// Set the current direction
void setDir(Direction dir) { this->dir = dir; }
// Get the current direction
Direction getDir(){ return dir; }
private:
Direction dir, prevDir, lastButtonPressed;
bool released;
unsigned long timeOfUpRelease, timeOfDownRelease;
};
#endif // INPUT_HANDLER_H
We have a lot of functions are variables here. We create the constructor, InputHandler
which will run only when the object is initialized. We also create init
which is necessary because if the game resets, we do not get to run the constructor again, so we need a way to set up again. We create startPressed
to check specifically for the start button being pressed, which the game needs to wait for to start. update
is perhaps the most important function, and it is where we will determine what direction to send to the Snake
game. Lastly, we create three more functions to manipulate direction, since the snake logic will modify direction. These three functions are written inline because they are so simple.
Here is InputHandler.cpp
#include "InputHandler.h"
InputHandler::InputHandler()
{
pinMode(START, INPUT);
pinMode(UP, INPUT);
pinMode(DOWN, INPUT);
pinMode(LEFT, INPUT);
pinMode(RIGHT, INPUT);
}
void InputHandler::init()
{
dir = prevDir = lastButtonPressed = xpos;
timeOfUpRelease = timeOfDownRelease = 0;
released = true;
}
bool InputHandler::startPressed()
{
return digitalRead(START) ? true : false;
}
void InputHandler::update()
{
if (digitalRead(UP))
{
// Detect double up press
if (prevDir != zneg && lastButtonPressed == ypos && (millis() - timeOfUpRelease < 150))
{
dir = zpos;
}
// Prevent direction switch if a double up press just occurred
else if (prevDir != yneg && (dir != zpos || (millis() - timeOfUpRelease > 400)))
{
dir = ypos;
}
lastButtonPressed = ypos;
released = false;
}
else if (digitalRead(DOWN))
{
// Detect double down press
if (prevDir != zpos && lastButtonPressed == yneg && (millis() - timeOfDownRelease < 150))
{
dir = zneg;
}
// Prevent direction switch if a double down press just occurred
else if (prevDir != ypos && (dir != zneg || (millis() - timeOfDownRelease > 400)))
{
dir = yneg;
}
lastButtonPressed = yneg;
released = false;
}
else if (digitalRead(RIGHT))
{
if (prevDir != xneg)
{
dir = xpos;
}
lastButtonPressed = xpos;
released = false;
}
else if (digitalRead(LEFT))
{
if (prevDir != xpos)
{
dir = xneg;
}
lastButtonPressed = xneg;
released = false;
}
// Detect up or down button releases
else if (!released)
{
if (lastButtonPressed == ypos)
{
timeOfUpRelease = millis();
}
else if (lastButtonPressed == yneg)
{
timeOfDownRelease = millis();
}
released = true;
}
}
Similar to the Cube
class, we define the pins used for the switches on the board in the constructor. However this time we are using the pins as inputs instead of outputs.
startPressed
simply performs a read on the Arduino pin corresponding with the start button and returns whether it is pushed or not.
update
is where everything happens. We first create a few if/else if statements. We perform reads on the button pins and check if any of them are pushed. If a button is pushed, we change the Direction
variable to correspond with the direction we should go. For example, pushing the right button should change the direction to moving in the positive x-axis. Pushing left should correspond to moving in the negative x-axis. However, we must ensure that we are not allowed to move in the direction opposite to where we are already moving, or else the player would instantly die. To handle this, we perform a check to see if the current direction is not the opposite direction of the button we pushed before changing the direction. We also record our previous direction, and change the state of a variable which holds whether or not a button is pushed or not to false. This will become clear later.
The UP button and DOWN button are difficult to handle. This is because we use these to determine both a change in the y-axis, or a change in the z-axis, depending on how you push the buttons. My implementation of the game records a double-tap of the UP button as movement in the positive z-axis, and a double-tap of the DOWN button as movement in the negative z-axis.
To differentiate between a regular button push a double press, we do a few things. Take a look at the code for handling an UP press.
if (digitalRead(UP))
{
// Detect double up press
if (prevDir != zneg && lastButtonPressed == ypos && (millis() - timeOfUpRelease < 150))
{
dir = zpos;
}
// Prevent direction switch if a double up press just occurred
else if (prevDir != yneg && (dir != zpos || (millis() - timeOfUpRelease > 400)))
{
dir = ypos;
}
lastButtonPressed = ypos;
released = false;
}
The first if statement
if (prevDir != zneg && lastButtonPressed == ypos && (millis() - timeOfUpRelease < 150))
First checks that you aren’t moving in the opposite direction as the double press, and checks that the last button that was pushed was also up (so that it is a indeed a double press), and also checks the difference in time since you last released the up button and sees if it is less than 150ms. We want to ensure that only double presses that happened fast are recorded as double presses.
The second if statement
else if (prevDir != yneg && (dir != zpos || (millis() - timeOfUpRelease > 400)))
is for a single press. It checks that you aren’t moving in the opposite direction as the single press, then checks that either you didn’t just input a double press up, or if enough time has elapsed to where the last up press was not recent. We do this because when the next game-frame comes and checks for inputs, if you are still holding the up button from the last game frame, it will detect this as a double press, and move you on the z-axis instead of the y-axis.
If no buttons are pressed, we can safely record the time that a button was released.
The Snake Game
Lastly is the implementation of the snake game itself. I will provide most of the code, but I will leave the logic up to you. Here is Snake.h
/*
* File: Snake.h
* Author: Jeff Longo
* Comments: Snake game implementation
*/
// Header guard, prevents including this file more than once
#ifndef SNAKE_H
#define SNAKE_H
#include <ArduinoSTL.h>
#include <deque>
#include "Cube.h"
#include "InputHandler.h"
#include "Direction.h"
class Snake
{
public:
// Initializes the snake
Snake();
// Polls for a start button press then initializes runtime variables
void start();
// Executes one game tick
void update();
// Resets the game state
void reset();
// Checks if the snake is alive
bool isDead() { return dead; }
private:
// Structure to hold a 3D coordinate point
struct Coord { byte x, y, z; };
// Initializes runtime variables
void init();
// Executes game logic
void logic();
// Generates a new food piece
void generateFood();
Cube cube;
bool m[4][4][4];
std::deque<Coord> snake;
Coord food;
InputHandler io;
bool dead;
int frameTime;
};
#endif // SNAKE_H
Taking a look at our functions, we create a start
function, an update
function, a reset
function, and a isDead
function. These are all the public functions needed to run the game. When we’re ready to start, we run start
. The game’s main loop will just continuously run update
until isDead
returns true. When that happens you run reset
and start again. This is what will happen in the loop
function in the .ino
file.
In the private section, we create variables and functions only the Snake
can use. We define a struct
called Coord. A struct
is similar to a class
aside from the fact that you do not put functions in a struct
, only variables. Therefore, we can create an instance of Coord
to create a 3D point. Similar to the InputHandler
, we create an init
function because we can only run the constructor once, and we need a way to reset. logic
is where the game will determine what to do next, while update
manages the framerate and polls for input. generateFood
is a simple function which should place a piece of food in a random location on the map.
For our variables, we create the Cube
object here since the Snake
will be drawing to the cube. We create a 3D array of size 4x4x4 of booleans to determine which LEDs should be active and which LEDs should not be active at any time. This is why we added bufferFromMatrix
to Cube.cpp
. The Snake itself will be stored in a data structure called a deque
(note: this is for my implementation only, there’s no restriction to implement the game the way I did). A deque
is a Double-Ended Queue. It allows you to push or pop (add or remove) from the front of the data structure, or the back of the data structure. It can also be a variable size. Think of it like a variable size array in which you can only add, remove, or access from the first or last element of the array. If you want to use this implementation, you will need to download and include the library ArduinoSTL
(you can do this in the Arduino IDE). More on this when we talk about the game logic.
We create an instance of Coord
to hold the position of the food. We also create an instance of InputHandler
which we will use to poll for inputs. We create a boolean called dead
to store the state of whether the player is alive or dead, and an int frameTime
to fix the framerate of the game.
Here is the unfinished Snake.cpp
. I will discuss the important functions, and what you need to implement yourself.
#include "Snake.h"
Snake::Snake()
{
// Start from a known state
reset();
}
void Snake::start()
{
while (!io.startPressed())
{
}
init();
}
void Snake::update()
{
io.saveDir();
// Repeat the display and input polling for the duration of one frame
unsigned long startTime = millis();
unsigned long elapsed = 0;
int blinkCounter = 0;
while (elapsed < frameTime)
{
// Blink the food LED every 10 updates
if (blinkCounter++ % 10 == 0)
{
cube.toggleLED(food.x, food.y, food.z);
}
cube.display();
io.update();
elapsed = millis() - startTime;
}
// Reset the buffer and prepare data for the next frame
cube.clearBuffer();
logic();
// If the snake dies, pause the display before ending the game
if (dead)
{
cube.bufferFromMatrix(m);
cube.bufferLED(food.x, food.y, food.z);
startTime = millis();
elapsed = 0;
while (elapsed < 1000)
{
cube.display();
elapsed = millis() - startTime;
}
}
}
void Snake::reset()
{
snake.clear();
cube.reset();
// Reset the map matrix
for (byte x = 0; x < 4; x++)
{
for (byte y = 0; y < 4; y++)
{
for (byte z = 0; z < 4; z++)
{
m[x][y][z] = 0;
}
}
}
}
void Snake::init()
{
// Disable any active LEDs
cube.reset();
// Init runtime variables
io.init();
dead = false;
frameTime = 500;
// Init snake location
snake.push_front({0, 0, 0});
m[snake.front().x][snake.front().y][snake.front().z] = 1;
// Spawn a food
generateFood();
// Illuminate the snake and food LEDs
cube.bufferFromMatrix(m);
}
void Snake::logic()
{
/*
* Write this function!
*/
}
void Snake::generateFood()
{
// Find an available slot for the food
/*
* Write this function!
*/
}
The constructor simply resets the systems so that it starts from a known state.
start
runs an infinite loop until the InputHandler
detects a start button press. In the infinite loop, you can display a cool pattern while you are waiting for a start press.
update
is the most important function. We create a loop which runs until the elapsed time since the function update function began is equal to the frametime. This is one game tick. For example, say we want to run the cube at 2 frames per second. 2 frames per second corresponds to half a second per game tick. Thus, we run the loop inside update
until 500ms have elapsed. The loop inside update
will continuously display to the cube poll for input as fast as possible until the frame is over. This way, the updates to the cube and polls for input are not limited to 2 per second. I’ve included code which allows the the food LED to blink using the toggleLED
function.
At the end of the frame, the cube is cleared and the game logic happens. This does not go in the loop because game logic should only happen one time a frame. I have also added code so that if the player dies, it will display where the player died for a brief time before moving to the next frame.
reset
simply clears the matrix of LEDs and the deque for the Snake.
generateFood
you will need to write yourself. You can make use of Arduino’s random
function to generate a random position. Be sure that is within bounds and that it is not spawned on top of the snake!
logic
you will also write yourself and is where the most thought will be required. You will need to determine based on the direction you request from the InputHandler
how the snake moves, if it will live or die, and how to update the snake data structure. Notice that instead of moving every LED forward by one based on direction, you only need to move the head forward by one and delete the tail!
After this is done, all the code you need to run the game in the .ino
file should be the following:
void loop()
{
snake.start();
while (!snake.isDead())
{
snake.update();
}
snake.reset();
}
I hope you are all able to complete this and thanks for being a part of my class! Feel free to message me on Discord, Facebook, or Email if you have any questions or need help!