Pong console on an Arduino Uno
An Uno, a 240x320 TFT, a thumb stick and a piezo: a paddle game in the window an Uno can repaint. The whole build, an Arduino Uno and 3 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.
Teaching? Assign it to your class in one click.
Advanced About 45 minutes to build and run. Runs in your browser. Free, and no account needed.
The parts retail for about $43. On Mokxi they're free.
Step by step, with the wiring and the common mistakes: Arduino ILI9341 TFT Display: Wiring and Code
What you'll learn
- Drawing to a TFT over SPI
- Repainting only what changed to save time
- Mapping a joystick axis to a paddle position
- Bouncing a ball and aiming it with the hit position
- Making game sounds with a piezo
- Difficulty: Advanced
- Time: About 45 minutes
- Board: Arduino Uno R3
- Parts: 5
How it works
The thumb stick's X axis on A0 sets the paddle position directly. Press the stick (pin 2) to serve again after a game ends. You start with 3 lives, and the score counts how many times you send the ball back.
The Uno has no hardware SPI in Mokxi, so each pixel is sent by flipping pins by hand. That is slow, about fifty thousand pixels a second. So the game lives in a 160x200 window in the middle of the 240x320 screen.
Each frame only repaints the ball and the strip of paddle that moved. That is under two thousand pixels, which keeps it playable at about 30 frames a second. A piezo on pin 6 beeps on bounces, catches and misses.
Parts list
5 parts, plus the jumper wires. They retail for about $43; on Mokxi they're free.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Full-size breadboard
- 1 × TFT display, 240x320In Handheld Console Kit.
- 1 × Thumb stickIn Claw Machine Kit, Spaceship Panel Kit.
- 1 × Piezo speakerIn Starter Kit, Claw Machine Kit, Vending Machine Kit.
Wiring table
Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.
| Part and pin | Connects to |
|---|---|
| TFT display, 240x320 pin SCK | Arduino Uno R3 pin 13 |
| TFT display, 240x320 pin MOSI | Arduino Uno R3 pin 11 |
| TFT display, 240x320 pin CS | Arduino Uno R3 pin 10 |
| TFT display, 240x320 pin DC | Arduino Uno R3 pin 9 |
| Piezo speaker pin 1 | Arduino Uno R3 pin 6 |
| Thumb stick pin SW | Arduino Uno R3 pin 2 |
| TFT display, 240x320 pin VCC | Arduino Uno R3 pin 5V |
| TFT display, 240x320 pin RST | Arduino Uno R3 pin 5V |
| TFT display, 240x320 pin LED | Arduino Uno R3 pin 5V |
| Thumb stick pin VCC | Arduino Uno R3 pin 5V |
| TFT display, 240x320 pin GND | Arduino Uno R3 pin GND |
| Thumb stick pin GND | Arduino Uno R3 pin GND |
| Piezo speaker pin 2 | Arduino Uno R3 pin GND |
| Thumb stick pin VRX | Arduino Uno R3 pin A0 |
| Thumb stick pin VRY | Arduino Uno R3 pin A1 |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Pong: a 2.4 inch ILI9341, a thumb stick and a piezo, on an Arduino Uno.
//
// Wiring, panel to board:
// CS -> pin 10 SCK -> pin 13
// DC -> pin 9 MOSI -> pin 11
// RST -> 5 V LED -> 5 V VCC -> 5 V, GND -> GND
// Thumb stick:
// VRX -> A0 VRY -> A1 SW -> pin 2, VCC -> 5 V, GND -> GND
// Piezo:
// one leg -> pin 6, the other -> GND
//
// Push the stick left and right to move the paddle. Press it to serve. Three
// lives; the score is how many times you sent the ball back.
//
// # Why the playfield is a window and not the screen
//
// There is no SPI peripheral on an ATmega328P in Mokxi (docs/uno.md section 3),
// so every one of the sixteen bits of a pixel is a pin change made by hand.
// Even writing the port directly (which mokxi_spi.h does, rather than going
// through digitalWrite), that is about nineteen cycles a bit, so this board
// manages some fifty thousand pixels a second. The whole 240x320 panel is
// 76 800 of them: a second and a half to clear it once, and nothing left over.
//
// So the game lives in a 160x200 window in the middle of the panel and the rest
// is left at the black the controller powers up as; painting it would cost
// more than the game does. Clearing the window costs about six hundred
// milliseconds at the start and nothing afterwards, because a frame only ever
// repaints the ball and the strip of paddle that moved. That is under two
// thousand pixels a frame, which is what makes it playable.
//
// The ESP32-C3 version of this example gets a 180x220 window at forty frames a
// second off the same driver, which is the difference between the two boards
// written down as an area and a frame rate.
#include "mokxi_ili9341.h"
#include "mokxi_joystick.h"
#include "mokxi_tone.h"
const uint8_t TFT_CS = 10;
const uint8_t TFT_DC = 9;
const uint8_t TFT_SCK = 13;
const uint8_t TFT_MOSI = 11;
const uint8_t PIEZO_PIN = 6;
SoftSpi spi(TFT_SCK, TFT_MOSI, TFT_CS);
Ili9341 tft(spi, TFT_DC);
Joystick stick(A0, A1, 2);
// The playfield: a window in the middle of the panel, in panel coordinates.
const int16_t FX = 40;
const int16_t FY = 70;
const int16_t FW = 160;
const int16_t FH = 200;
const uint16_t BG = 0x0841; // a very dark blue, so "cleared" is visible
const uint16_t EDGE = ILI9341_CYAN;
const uint16_t BALL_COLOUR = ILI9341_YELLOW;
const uint16_t PADDLE_COLOUR = ILI9341_GREEN;
const int16_t BALL = 8;
const int16_t PADDLE_W = 36;
const int16_t PADDLE_H = 8;
const int16_t PADDLE_Y = FY + FH - 16;
const int FRAME_MS = 33; // 30 frames a second
const uint8_t LIVES = 3;
int16_t bx, by, bdx, bdy;
int16_t px, oldPx;
uint8_t lives;
int16_t score, shownScore;
bool playing;
bool started;
static int16_t clampInt(int16_t v, int16_t lo, int16_t hi) {
return v < lo ? lo : (v > hi ? hi : v);
}
// The furniture: a border, the labels, and a cleared playfield. Drawn once.
static void drawFrame() {
tft.fillRect(FX, FY, FW, FH, BG);
tft.rect(FX - 2, FY - 2, FW + 4, FH + 4, EDGE, 2);
tft.text(FX, FY - 26, "PONG", ILI9341_WHITE, ILI9341_BLACK, 3);
// One row, not two: the panel is 320 tall and the module's own silkscreen
// takes the last few rows of it.
tft.text(FX, FY + FH + 10, "SCORE", ILI9341_WHITE, ILI9341_BLACK, 2);
tft.text(FX + 110, FY + FH + 10, "LIVES", ILI9341_WHITE, ILI9341_BLACK, 2);
}
static void drawScore() {
tft.number(FX + 64, FY + FH + 10, score, ILI9341_YELLOW, ILI9341_BLACK, 2, 3);
shownScore = score;
}
static void drawLives() {
tft.number(FX + 174, FY + FH + 10, lives, ILI9341_ORANGE, ILI9341_BLACK, 2, 1);
}
static void serve() {
bx = FX + FW / 2 - BALL / 2;
by = FY + 24;
// Alternate sides rather than pull in a random number generator: there is
// 32 KB of flash and this example is already most of an eighth of it.
bdx = (int16_t)-bdx;
if (bdx == 0) {
bdx = 3;
}
bdy = 3;
tft.fillRect(bx, by, BALL, BALL, BALL_COLOUR);
}
static void startGame() {
score = 0;
shownScore = -1;
lives = LIVES;
playing = true;
if (started) {
// Only a restart needs the playfield wiped; the first one was just painted.
tft.fillRect(FX, FY, FW, FH, BG);
}
started = true;
drawScore();
drawLives();
px = FX + (FW - PADDLE_W) / 2;
oldPx = px;
tft.fillRect(px, PADDLE_Y, PADDLE_W, PADDLE_H, PADDLE_COLOUR);
serve();
}
void setup() {
Serial.begin(115200);
Serial.println("Mokxi Uno: pong on an ILI9341");
stick.begin();
mokxiNoTone(PIEZO_PIN);
tft.begin(0);
drawFrame();
startGame();
}
void loop() {
if (!playing) {
if (stick.clicked()) {
tft.fillRect(FX + 8, FY + FH / 2 - 16, FW - 16, 32, BG);
startGame();
}
delay(FRAME_MS);
return;
}
// The paddle follows the stick outright rather than drifting with it: the
// pot is an absolute position and so is the paddle. A shift rather than
// map(), because 1023 is near enough 1024 and a 32-bit divide is 150 bytes
// of flash this sketch would rather spend on the game.
px = (int16_t)(FX + (int16_t)(((int32_t)stick.x() * (FW - PADDLE_W)) >> 10));
px = clampInt(px, FX, (int16_t)(FX + FW - PADDLE_W));
if (px != oldPx) {
// Only the strip that stopped being paddle is erased; the rest is redrawn
// over itself, which is far less than repainting the whole thing. A jump
// wider than the paddle (which a stick slammed to one end is) leaves no
// overlap at all, so the old rectangle goes as a whole.
int16_t gap = (int16_t)(px > oldPx ? px - oldPx : oldPx - px);
int16_t ex = oldPx;
if (gap >= PADDLE_W) {
gap = PADDLE_W;
} else if (px < oldPx) {
ex = (int16_t)(px + PADDLE_W);
}
tft.fillRect(ex, PADDLE_Y, gap, PADDLE_H, BG);
tft.fillRect(px, PADDLE_Y, PADDLE_W, PADDLE_H, PADDLE_COLOUR);
oldPx = px;
}
int16_t nx = (int16_t)(bx + bdx);
int16_t ny = (int16_t)(by + bdy);
bool bounced = false;
if (nx < FX) {
nx = FX;
bdx = (int16_t)-bdx;
bounced = true;
} else if (nx > FX + FW - BALL) {
nx = (int16_t)(FX + FW - BALL);
bdx = (int16_t)-bdx;
bounced = true;
}
if (ny < FY) {
ny = FY;
bdy = (int16_t)-bdy;
bounced = true;
}
// The paddle: caught if the ball's foot reaches its top while they overlap.
if (bdy > 0 && ny + BALL >= PADDLE_Y && by + BALL < PADDLE_Y + PADDLE_H) {
if (nx + BALL > px && nx < px + PADDLE_W) {
ny = (int16_t)(PADDLE_Y - BALL);
bdy = (int16_t)-bdy;
// Where it hit decides which way it leaves, which is the whole game.
int16_t offset = (int16_t)((nx + BALL / 2) - (px + PADDLE_W / 2));
bdx = (int16_t)(offset / 6);
if (bdx == 0) {
bdx = (bdx >= 0) ? 1 : -1;
}
if (bdx > 4) bdx = 4;
if (bdx < -4) bdx = -4;
score++;
mokxiTone(PIEZO_PIN, NOTE_C6, 30);
bounced = false;
}
}
tft.fillRect(bx, by, BALL, BALL, BG);
if (ny > FY + FH - BALL) {
// Missed.
mokxiTone(PIEZO_PIN, NOTE_C4, 160);
lives--;
drawLives();
if (lives == 0u) {
playing = false;
tft.text(FX + 10, FY + FH / 2 - 10, "GAME OVER", ILI9341_RED, BG, 2);
Serial.println("game over, press the stick to play again");
delay(FRAME_MS);
return;
}
serve();
delay(300);
return;
}
bx = nx;
by = ny;
tft.fillRect(bx, by, BALL, BALL, BALL_COLOUR);
if (score != shownScore) {
drawScore();
}
if (bounced) {
mokxiTone(PIEZO_PIN, NOTE_G5, 20);
}
delay(FRAME_MS);
}
Code walkthrough
The key functions of the sketch, and what each one does.
drawFrame sketch.ino
Draws the furniture once: it fills the playfield window with the dark blue BG, adds a cyan border, and writes PONG, SCORE and LIVES. Clearing the window takes about 600 ms, so this is never done again during play.
// The furniture: a border, the labels, and a cleared playfield. Drawn once.
static void drawFrame() {
tft.fillRect(FX, FY, FW, FH, BG);
tft.rect(FX - 2, FY - 2, FW + 4, FH + 4, EDGE, 2);
tft.text(FX, FY - 26, "PONG", ILI9341_WHITE, ILI9341_BLACK, 3);
// One row, not two: the panel is 320 tall and the module's own silkscreen
// takes the last few rows of it.
tft.text(FX, FY + FH + 10, "SCORE", ILI9341_WHITE, ILI9341_BLACK, 2);
tft.text(FX + 110, FY + FH + 10, "LIVES", ILI9341_WHITE, ILI9341_BLACK, 2);
}startGame sketch.ino
Resets the score and sets lives to LIVES (3). On a restart it wipes the playfield, then draws the score, the lives and the paddle in the middle, and serves the ball with serve.
static void startGame() {
score = 0;
shownScore = -1;
lives = LIVES;
playing = true;
if (started) {
// Only a restart needs the playfield wiped; the first one was just painted.
tft.fillRect(FX, FY, FW, FH, BG);
}
started = true;
drawScore();
drawLives();
px = FX + (FW - PADDLE_W) / 2;
oldPx = px;
tft.fillRect(px, PADDLE_Y, PADDLE_W, PADDLE_H, PADDLE_COLOUR);
serve();
}loop sketch.ino
Each pass moves the paddle with the stick, erasing only the strip it left. Then it moves the ball, bounces it off the walls and top, and checks the paddle. Where the ball hits the paddle decides its sideways speed. A miss costs a life. Each frame ends with delay(FRAME_MS).
void loop() {
if (!playing) {
if (stick.clicked()) {
tft.fillRect(FX + 8, FY + FH / 2 - 16, FW - 16, 32, BG);
startGame();
}
delay(FRAME_MS);
return;
}
// The paddle follows the stick outright rather than drifting with it: the
// pot is an absolute position and so is the paddle. A shift rather than
// map(), because 1023 is near enough 1024 and a 32-bit divide is 150 bytes
// of flash this sketch would rather spend on the game.
px = (int16_t)(FX + (int16_t)(((int32_t)stick.x() * (FW - PADDLE_W)) >> 10));
px = clampInt(px, FX, (int16_t)(FX + FW - PADDLE_W));
if (px != oldPx) {
// Only the strip that stopped being paddle is erased; the rest is redrawn
// over itself, which is far less than repainting the whole thing. A jump
// wider than the paddle (which a stick slammed to one end is) leaves no
// overlap at all, so the old rectangle goes as a whole.
int16_t gap = (int16_t)(px > oldPx ? px - oldPx : oldPx - px);
int16_t ex = oldPx;
if (gap >= PADDLE_W) {
gap = PADDLE_W;
} else if (px < oldPx) {
ex = (int16_t)(px + PADDLE_W);
}
// ... 75 more lines in the editorTroubleshooting
The usual mistakes with this circuit, and the fix for each.
- The screen stays white or blank. This project
Check CS on pin 10, DC on pin 9, SCK on pin 13 and MOSI on pin 11. Also check that RST and LED go to 5 V. - The paddle moves the wrong way. This project
The stick's VRX wire must go to A0. Check its VCC and GND too, since the position comes straight from the reading. - Pressing the stick does not restart the game. This project
A new game only starts after GAME OVER. The stick's SW pin must go to pin 2. - The screen stays white or black. TFT display
Check the SPI wiring: CS, DC, RST, MOSI, SCK. Power the module at 3.3 V logic, or use level shifting on 5 V boards. Call begin() and set the rotation. - Slow drawing or garbled colors. TFT display
Use hardware SPI pins and avoid redrawing the whole screen. Fill only changed areas. Confirm the library matches ILI9341 and the color order. - The axis reads about half scale at rest instead of exactly zero, and the values drift a little, so the cursor or motor creeps. Thumb stick
Center is about half of the ADC range (about 512 on a 10 bit Arduino). Read it at startup and subtract that, then add a dead zone of a few counts. - The push button reads random values or always looks pressed. The switch pin is a plain contact to ground and has no pull-up on the stick. Thumb stick
Set the switch pin to INPUT_PULLUP and treat LOW as pressed. Debounce it in code. Power the stick from the same voltage as the ADC reference. - A passive piezo stays silent when you just set the pin HIGH. Piezo speaker
A passive piezo needs a changing signal. Use tone(pin, frequency) or a PWM wave. Around 2 to 4 kHz is loudest. A buzzer with a built in oscillator makes sound from plain DC instead.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.