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:

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!