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