Arduino Racing Game

Guinness once released an advert with the slogan of “good things come to those who wait”. Much like a fine pint of Irish stout, you can’t rush a project.

This project first started in July/August 2022 as a follow on from the Arduino 2 Player Shooter Game. Unfortunately, like most projects: it got to about 80% completion before being shelved in favour of another more shiny project.

However, seven months later, the dust was cleaned off the TFT display and the final 20% was finished.

Game Play

The aim of the game is simple: complete 5 laps of a circuit as fast as possible.

The unofficial title of the game is ‘space race’ as the physics behind the game is based on a frictionless world where the only commands are rotation and acceleration!

Hardware

The physical set up is fairly simple: an Arduino Nano, 1.3inch TFT display and a 5 key analogue keypad.

pinout

TFT – GND => ground
TFT – VCC => 5V (check on the screen specifics – some are only good for 3.3V)
TFT – SCL => D13 (SPI clock, NOT I2C clock)
TFT – SDA => D11 (SPI MOSI, NOT I2C data)
TFT – RES => D8
TFT – DC => D7

Controller VCC => 5V
Controller GND => ground
Controller output => A0

Software

Writing janky code is one thing thing. Having to come back after seven months to edit janky code is another thing. So the following is not pretty. In my defence, I was told that if something looks stupid but works then it’s not stupid.

Before starting, you’ll need to run a sketch to determine the output of the analogue controller buttons as there is some variation dependant on the resistors used.

Libraries, Definitions & Constructors

For this sketch, I’ve used the Adafruit GFX and Adafruit ST7789_Fast library to control the display, supported by the SPI library.

In addition to this, the EEPROM library is used for the long term storage of high scores.

#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Arduino_ST7789_Fast.h>
#include <EEPROM.h>

#define SCR_WD   240     // OLED display width, in pixels
#define SCR_HT   240      // OLED display height, in pixels
#define TFT_DC  7
#define TFT_RST 8

Arduino_ST7789 tft = Arduino_ST7789(TFT_DC, TFT_RST);

Global variables and constants

This is janky code. So expect many global variables. For what it’s worth, I think most of them are used in more than one function.

const int keypadP1 = A0;

int xPos = 119;
int yPos = 30;
int xVel = 0;
int yVel = 0;
int rot = 6;

int propX = 0;
int propY = 0;

int laps = 0;

long timer = 0;
long prev_timer = 0;
int refreshPeriod = 50;

long timeOffset = 0;
long timeOffset1 = 0;
long lapTimer = 0;
long fastLapTimer = 0;
long fastestLap = 600000;

long checkpointTime = 0;

long recordTime;
long recordLap;

long lapMins;
long lapSecs;
long lapMilSecs;

long mins;
long secs;
long milsecs;

setup & Countdown

The setup function is simple: it initialises the display and calls the countdown function.

void setup() {

  tft.begin();
  tft.fillScreen(BLACK);      //equivalent to clear screen
  tft.setTextColor(WHITE);

  countDown();
  tft.fillScreen(BLACK);

}
void countDown () {

  tft.drawRect(60, 60, 120, 120, WHITE);

  tft.drawLine(120, 0, 120, 60, WHITE);

  for (int i = 3; i > 0; i--) {
    tft.setCursor(100, 95);
    tft.setTextSize(7);
    tft.fillRect(65, 65, 100, 100, BLACK);
    tft.print(i);
    delay(1000);
  }
  tft.setTextSize(1);

  timeOffset = millis();
  timeOffset1 = millis();

}

Main Loop

The loop function polls the millis() timer until it reaches the refresh threshold (50 milliseconds / 20Hz). This also helps to debounce the input.

Upon entering the if ( ) statement, the game code will be executed which will include displaying the game time and status, reading the controller value and acting on it while also checking other conditions.

void loop() {

  timer = millis();

  if (timer > prev_timer + refreshPeriod) {

    drawBackground();

    displayTime();

    prev_timer = timer;

    int input = (analogRead(keypadP1) / 100);

    switch (input) {
      case 0:   //left
        (rot > 0) ? rot-- : rot = 7 ;
        break;

      case 4:   //right
        (rot < 7) ? rot++ : rot = 0;
        break;

      case 7:   //trust
        fireThrusters(rot);
        break;

      default:
        break;
    }

    tft.fillRect(xPos - 4, yPos - 4, 9, 9,  BLACK); //clear screen

    moveCraft();
    drawCraft(rot);
    lapCount();

  }

}

Physics Engine

To call this code a ‘physics engine’ is a bit of an overstatement, but the following three functions do control the motion of the ‘craft’.

The fireThusters( ) function takes the rotation of the craft and runs it through a switch:case statement to apply an x & y velocity vector. Note that the top speed is limited to 5 pixels per frame.

void fireThrusters(int rotation) {

  switch (rotation) {

    case 0:
      (yVel < -5) ? yVel : yVel--;
      break;

    case 1:
      (yVel < -5) ? yVel : yVel--;
      (xVel > 5) ? xVel : xVel++;
      break;

    case 2:
      (xVel > 5) ? xVel : xVel++;
      break;

    case 3:
      (xVel > 5) ? xVel : xVel++;
      (yVel > 5) ? yVel : yVel++;
      break;

    case 4:
      (yVel > 5) ? yVel : yVel++;
      break;

    case 5:
      (yVel > 5) ? yVel : yVel++;
      (xVel < -5) ? xVel : xVel--;
      break;

    case 6:
      (xVel < -5) ? xVel : xVel--;
      break;

    case 7:
      (xVel < -5) ? xVel : xVel--;
      (yVel < -5) ? yVel : yVel--;
      break;

    default:
      break;

  }
}

The moveCraft( ) applies the velocity vector to the current co-ordinates and deals with any edge cases. In this instance, the game was designed to have ‘sticky’ walls that reduce the velocity to zero.

void moveCraft () {

  propX = xPos + xVel ;

  propY = yPos + yVel ;

  //start-end reverse protection

  if (propY <= 55) {
    if (propX >= 120 && xPos < 120) {
      xVel = 0;
      xPos = 119;
      return;
    } else {
      xPos = propX;
    }
  }

  //central island check

  if (propX >= 55 && propX <= 185) {  //within central zone
    if (propY >= 55 && propY <= 185) {
      yVel = 0;
      xVel = 0;//crash in top or bottom
      return;
    }
  } else {
    xPos = propX;
    yPos = propY;
  }

  //boundary check

  if (propX < 4) {  //gone off left boundary
    xPos = 4;
    xVel = 0;
    yVel = 0;
    return;
  } else {
    xPos = propX;
  }

  if (propX > 235) { //gone off right boundary
    xPos = 235;
    xVel = 0;
    yVel = 0;
    return;
  } else {
    xPos = propX;
  }

  if (propY < 4) { //gone off top boundary
    yPos = 4;
    xVel = 0;
    yVel = 0;
    return;
  } else {
    yPos = propY;
  }

  if (propY > 235) { //gone off right boundary
    yPos = 235;
    xVel = 0;
    yVel = 0;
    return;
  } else {
    yPos = propY;
  }

}

The final part of the ‘physics engine’ is to display the craft. This function, takes the rotation and co-ordinates of the craft and plots it’s position on the display.

void drawCraft (int rotation) {

  switch (rotation) {
    case 0:
      tft.fillTriangle(xPos, yPos - 4, xPos + 3, yPos + 4, xPos - 3, yPos + 4, RED); //up
      break;
    case 1:
      tft.fillTriangle(xPos + 4, yPos - 4, xPos, yPos + 4, xPos - 4, yPos, RED); //up-right
      break;
    case 2:
      tft.fillTriangle(xPos + 4, yPos, xPos - 4, yPos + 3, xPos - 4, yPos - 3, RED); //right
      break;
    case 3:
      tft.fillTriangle(xPos + 4, yPos + 4, xPos - 4, yPos, xPos, yPos - 4, RED); //down-right
      break;
    case 4:
      tft.fillTriangle(xPos, yPos + 4, xPos - 3, yPos - 4, xPos + 3, yPos - 4, RED); //down
      break;
    case 5:
      tft.fillTriangle(xPos - 4, yPos + 4, xPos, yPos - 4, xPos + 4, yPos, RED); //down-left
      break;
    case 6:
      tft.fillTriangle(xPos - 4, yPos, xPos + 4, yPos - 3, xPos + 4, yPos + 3, RED); //left
      break;
    case 7:
      tft.fillTriangle(xPos - 4, yPos - 4, xPos + 4, yPos, xPos, yPos + 4, RED); //up-left
      break;
    default:
      break;
  }

}

Game graphics

Again, this term is used loosely to describe the functions involved with displaying the background and time.

void drawBackground () {

  tft.drawRect(60, 60, 120, 120, WHITE);

  tft.drawLine(120, 0, 120, 60, WHITE);

  tft.setCursor(65, 62);
  tft.setTextSize(2);
  tft.print("Game Time:");

  tft.setCursor(65, 100);
  tft.print("Lap Count:");

  tft.setCursor(65, 138);
  tft.println("Best Lap:");

}

void displayTime () {

  //race timer
  lapTimer = millis() - timeOffset;

  mins = (lapTimer / 60000);
  long minEQ = (60000 * mins);

  secs = ((lapTimer - minEQ) / 1000);
  long secEQ = (secs * 1000);

  milsecs = (lapTimer - (minEQ + secEQ));

  tft.fillRect(65, 82, 110, 19, BLACK);
  tft.setCursor(65, 82);
  tft.print(mins);
  tft.print(":");
  tft.print(secs);
  tft.print(".");
  tft.print(milsecs);

}

Yes, this is ugly. Yes, this could be achieved using the % operator. Yes, the variables have horrible names.

Gameplay code

This obtuse bit of code controls the lap counter and what happens when a lap is completed.

void lapCount () {

  propX = xPos + xVel ;

  propY = yPos + yVel ;

  if (yPos < 55) {
    if (propX < 120 && xPos >= 120) {
      laps++;

      checkpointTime = millis();

      fastLapTimer = checkpointTime - timeOffset1;
      timeOffset1 = checkpointTime;

      if (fastLapTimer < fastestLap) {
        fastestLap = fastLapTimer ; 

        //best lap
        lapMins = (fastestLap / 60000);
        long lapMinEQ = (60000 * lapMins);

        lapSecs = ((fastestLap - lapMinEQ) / 1000);
        long lapSecEQ = (lapSecs * 1000);

        lapMilSecs = (fastestLap - (lapMinEQ + lapSecEQ));

        tft.fillRect(65, 155, 110, 19, BLACK);
        tft.setCursor(65, 158);
        tft.print(lapMins);
        tft.print(":");
        tft.print(lapSecs);
        tft.print(".");
        tft.print(lapMilSecs);
      }
    }
  }

  if (laps == 6) {    //set as 6 for 5 complete laps

    tft.drawLine(120, 0, 120, 59, BLACK);
    tft.setCursor(75, 15);
    tft.setTextSize(4);
    tft.print("GAME");
    tft.setCursor(75, 195);
    tft.print("OVER");

    delay (10000);

    recordCheck();
  }

  tft.setCursor(65, 120);
  tft.fillRect(65, 120, 110, 19, BLACK);
  tft.print(laps) ; tft.print("/5") ;

}

High scores

This is the section of code that reads/writes to the EEPROM*, checks if a new high score has been set and acts accordingly.

void recordCheck() {

  tft.fillScreen(BLACK);

  //store lap data at address 4 and total time data at address 8
  EEPROM.get(4, recordTime);
  EEPROM.get(8, recordLap);

  long lapDiff = recordLap - fastestLap;
  long lapDiffMins = (lapDiff / 60000);
  long lapDiffMinsEQ = (60000 * lapDiffMins);
  long lapDiffSecs = ((lapDiff - lapDiffMinsEQ) / 1000);
  long lapDiffSecEQ = (lapDiffSecs * 1000);
  long lapDiffMilsecs = (lapDiff - (lapDiffMinsEQ + lapDiffSecEQ));

  long gameDiff = recordTime - lapTimer;
  long gameDiffMins = (gameDiff / 60000);
  long gameDiffMinsEQ = (60000 * gameDiffMins);
  long gameDiffSecs = ((gameDiff - gameDiffMinsEQ) / 1000);
  long gameDiffSecEQ = (gameDiffSecs * 1000);
  long gameDiffMilsecs = (gameDiff - (gameDiffMinsEQ + gameDiffSecEQ));

  long recordMins = (recordTime / 60000);
  long recordMinEQ = (60000 * recordMins);
  long recordSecs = ((recordTime - recordMinEQ) / 1000);
  long recordSecEQ = (recordSecs * 1000);
  long recordMilsecs = (recordTime - (recordMinEQ + recordSecEQ));

  long recordLapMins = (recordLap / 60000);
  long recordLapMinEQ = (60000 * recordLapMins);
  long recordLapSecs = ((recordLap - recordLapMinEQ) / 1000);
  long recordLapSecEQ = (recordLapSecs * 1000);
  long recordLapMilsecs = (recordLap - (recordLapMinEQ + recordLapSecEQ));

  bool gameTimeFlag = false;
  bool lapTimeFlag = false;

  if (lapTimer < recordTime) {
    recordTime = lapTimer;
    EEPROM.put(4, lapTimer);
    gameTimeFlag = true;
  }

  if (fastestLap < recordLap) {
    recordLap = fastestLap;
    EEPROM.put(8, fastestLap);
    lapTimeFlag = true;
  }

  tft.setTextSize(3);
  tft.setCursor(12, 10);
  tft.print("LEADER BOARD");

  tft.drawLine(80, 40, 240, 40, WHITE);

  tft.setTextSize(2);

  tft.setCursor(87, 45);
  tft.print("Total");
  tft.setCursor(92, 65);
  tft.print("Time");

  tft.setCursor(175, 45);
  tft.print("Fast");
  tft.setCursor(180, 65);
  tft.print("Lap");

  tft.drawLine(80, 40, 80, 240, WHITE); //vertical lines
  tft.drawLine(160, 40, 160, 240, WHITE);

  tft.drawLine(0, 90, 240, 90, WHITE); //horizontal lines
  tft.drawLine(0, 140, 240, 140, WHITE);
  tft.drawLine(0, 190, 240, 190, WHITE);

  tft.setCursor(15, 95);
  tft.print("Your");
  tft.setCursor(10, 115);
  tft.print("Score");

  tft.setCursor(5, 145);
  tft.print("Record");
  tft.setCursor(10, 165);
  tft.print("Times");

  tft.setCursor(10, 205);
  tft.print("+ / -");

  tft.setTextSize(1);

  //Data - game time
  tft.setCursor(100, 115);
  tft.print(mins);
  tft.print(":");
  tft.print(secs);
  tft.print(".");
  tft.print(milsecs);

  //Data - lap time
  tft.setCursor(180, 115);
  tft.print(lapMins);
  tft.print(":");
  tft.print(lapSecs);
  tft.print(".");
  tft.print(lapMilSecs);

  //data - record time
  tft.setCursor(100, 155);
  tft.print(recordMins);
  tft.print(":");
  tft.print(recordSecs);
  tft.print(".");
  tft.print(recordMilsecs);

  //data - record lap
  tft.setCursor(180, 155);
  tft.print(recordLapMins);
  tft.print(":");
  tft.print(recordLapSecs);
  tft.print(".");
  tft.print(recordLapMilsecs);

  //data +/- game difference
  if (gameTimeFlag) {
    tft.setTextColor(GREEN);
  } else {
    tft.setTextColor(RED);
  }
  tft.setCursor(100, 205);
  tft.print(abs(gameDiffMins));
  tft.print(":");
  tft.print(abs(gameDiffSecs));
  tft.print(".");
  tft.print(abs(gameDiffMilsecs));

  //data +/- lap difference
  if (lapTimeFlag) {
    tft.setTextColor(GREEN);
  } else {
    tft.setTextColor(RED);
  }
  tft.setCursor(180, 205);
  tft.print(abs(lapDiffMins));
  tft.print(":");
  tft.print(abs(lapDiffSecs));
  tft.print(".");
  tft.print(abs(lapDiffMilsecs));

  while (analogRead(keypadP1) > 1000);

  playAgain();

}

Play again?

Once is simply not enough. Therefore this function allows the player to play again while displaying the most basic of ‘screen savers’. In effect, it waits for a key to be pressed and then resets the variables back to zero and restarts the program.

void playAgain() {

  tft.fillScreen(BLACK);
  tft.setTextColor(WHITE);

  yPos = 200;
  xPos = 0;

  do {
    //Play again text
    tft.setCursor(0, 70);
    tft.setTextSize(3);
    tft.print("Press any key");
    tft.setCursor(90, 110);
    tft.print("to");
    tft.setCursor(20, 150);
    tft.print("play again");


    //scrolling player
    tft.fillRect(xPos - 4, yPos - 4, 9, 9,  BLACK);
    (xPos > 240) ? xPos = 0 : xPos++;
    tft.fillTriangle(xPos - 4, yPos - 4, xPos + 4, yPos, xPos, yPos + 4, RED);
    delay(50);

  }  while (analogRead(keypadP1) > 1000);

  xPos = 119;
  yPos = 30;
  xVel = 0;
  yVel = 0;
  rot = 6;
  propX = 0;
  propY = 0;
  laps = 0;
  timer = 0;
  prev_timer = 0;
  timeOffset = 0;
  lapTimer = 0;
  fastLapTimer = 0;
  fastestLap = 600000;

  setup();

}

Additional Code

If you upload the above and play the game then you’ll likely find that the high scores won’t work.

This seems to be due to the way the EEPROM works. I’m not sure on the exact mechanics, but the way around it is to ‘prime’ the EEPROM with some pre-formatted data.

Upload this sketch before uploading the main game and it will set the fastest lap time to 1 min and the fasted game to 10 mins.

#include <EEPROM.h>

void setup() {

  Serial.begin(9600);
  Serial.println("EEPROM RESET");

  long recordLap = 60000;
  long recordTime = 600000;

  EEPROM.put(8, recordLap);
  EEPROM.put(4, recordTime);

  delay(1000);

}

void loop() {
  
  long readLap;
  long readTime;

  EEPROM.get(4, readLap);
  EEPROM.get(8, readTime);

  Serial.println(readLap);
  Serial.println(readTime);

  Serial.println("EEPROM RESET COMPLETE");

  while(1);

}

This code can also be used to reset the high scores.


Bonus Content

As part of this project, a 3D design was made to house the controller.


Last updated: 02/04/2023