D-pad pong on an Arduino Uno
A TFT, a console pad and a piezo on an Uno: paddle game, arrow keys or clicks, A to serve. 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 30 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.
What you'll learn
- Driving an ILI9341 TFT from an Uno
- A paddle that has a speed instead of a position
- Repainting only what changed to stay fast
- Bouncing a ball and steering it by where it hits
- Reading a console pad and playing tones on a piezo
- Difficulty: Advanced
- Time: About 30 minutes
- Board: Arduino Uno R3
- Parts: 5
How it works
The Uno draws a paddle game on a 240x320 TFT, in a window in the middle of the screen. The ball bounces around the window. Hold LEFT or RIGHT on the console pad and the paddle moves five pixels a frame. Let go and it stops.
Where the ball lands on the paddle decides which way it leaves. The piezo on pin 6 beeps on every bounce, and a lower tone sounds when you miss. You get three lives, and each catch scores a point.
After the last life, the screen says GAME OVER. Press A to start again. The sketch only repaints the ball and the strip of paddle that moved, because the Uno has to draw each pixel by hand.
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 × Game console buttonsIn Handheld Console 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 |
| Game console buttons pin DOWN | Arduino Uno R3 pin 8 |
| Game console buttons pin UP | Arduino Uno R3 pin 7 |
| Piezo speaker pin 1 | Arduino Uno R3 pin 6 |
| Game console buttons pin LEFT | Arduino Uno R3 pin 5 |
| Game console buttons pin RIGHT | Arduino Uno R3 pin 4 |
| Game console buttons pin A | Arduino Uno R3 pin 3 |
| Game console buttons pin B | 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 |
| TFT display, 240x320 pin GND | Arduino Uno R3 pin GND |
| Game console buttons pin GND | Arduino Uno R3 pin GND |
| Piezo speaker pin 2 | Arduino Uno R3 pin GND |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// D-pad pong: the paddle game on an ILI9341, played with a console pad.
//
// Panel: CS -> pin 10, DC -> pin 9, SCK -> pin 13, MOSI -> pin 11
// RST -> 5 V, LED -> 5 V, VCC -> 5 V, GND -> GND
// Pad: UP -> 7, DOWN -> 8, LEFT -> 5, RIGHT -> 4, A -> 3, B -> 2, GND -> GND
// Piezo: one leg -> pin 6, the other -> GND
//
// Hold LEFT and RIGHT to move the paddle. A serves, and starts a new game when
// you have run out of lives. On screen the pad's buttons are clickable and the
// arrow keys, Z and X work when the pointer is on the canvas.
//
// # A pad is a direction, not a position
//
// This is the whole difference from the thumb-stick version. A pot is an
// absolute position, so the paddle can be put wherever the stick is. Six
// switches say only left, right or neither, so the paddle has a *speed*: held,
// it moves five pixels a frame, and let go it stops. Five pixels at thirty
// frames a second crosses the playfield in about a second, which is fast enough
// to catch a ball and slow enough to aim.
//
// # 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 bit of every pixel is a pin change made by hand, about fifty
// thousand pixels a second. The whole panel is 76 800 of them, a second and a
// half to clear once. So the game lives in a window in the middle and the rest
// stays the black the controller powers up as. A frame only ever repaints the
// ball and the strip of paddle that moved, which is under two thousand pixels
// and is what makes it playable.
#include "mokxi_ili9341.h"
#include "mokxi_gamepad.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);
// up down left right A B
Gamepad pad(7, 8, 5, 4, 3, 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;
/** Pixels the paddle moves in a frame while a direction is held. */
const int16_t PADDLE_SPEED = 5;
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);
}
// Kept out of line on purpose. mokxiTone is an inline loop, and three copies of
// it inside a loop() this size push an AVR conditional branch past the 64-word
// reach of R_AVR_7_PCREL and the link fails. One call does the same job.
static void beep(uint16_t hz, uint16_t ms) __attribute__((noinline));
static void beep(uint16_t hz, uint16_t ms) {
mokxiTone(PIEZO_PIN, hz, ms);
}
// Also out of line, for flash rather than branch reach: Ili9341::text is
// inline, and a copy of its loop at each of the four labels is more than the
// Uno's 8 KB example budget has room for. The same goes for drawScore(),
// drawLives() and serve() below, each called from startGame() and loop().
static void label(int16_t x, int16_t y, const char *s, uint16_t colour, uint16_t bg,
uint8_t scale) __attribute__((noinline));
static void label(int16_t x, int16_t y, const char *s, uint16_t colour, uint16_t bg,
uint8_t scale) {
tft.text(x, y, s, colour, bg, scale);
}
// 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);
label(FX, FY - 26, "D-PAD", ILI9341_WHITE, ILI9341_BLACK, 3);
label(FX, FY + FH + 10, "SCORE", ILI9341_WHITE, ILI9341_BLACK, 2);
label(FX + 110, FY + FH + 10, "LIVES", ILI9341_WHITE, ILI9341_BLACK, 2);
}
static __attribute__((noinline)) void drawScore() {
tft.number(FX + 64, FY + FH + 10, score, ILI9341_YELLOW, ILI9341_BLACK, 2, 3);
shownScore = score;
}
static __attribute__((noinline)) void drawLives() {
tft.number(FX + 174, FY + FH + 10, lives, ILI9341_ORANGE, ILI9341_BLACK, 2, 1);
}
static __attribute__((noinline)) 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) {
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: D-pad pong");
pad.begin();
mokxiNoTone(PIEZO_PIN);
tft.begin(0);
drawFrame();
startGame();
}
void loop() {
pad.update();
if (!playing) {
if (pad.pressed(PAD_A)) {
tft.fillRect(FX + 8, FY + FH / 2 - 16, FW - 16, 32, BG);
startGame();
}
delay(FRAME_MS);
return;
}
// A direction, not a position: held left or right moves the paddle at a
// fixed speed and nothing moves it back.
int8_t dir = pad.dx();
if (dir != 0) {
px = clampInt((int16_t)(px + (int16_t)dir * PADDLE_SPEED), 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.
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 = 1;
}
if (bdx > 4) bdx = 4;
if (bdx < -4) bdx = -4;
score++;
beep(NOTE_C6, 30);
bounced = false;
}
}
tft.fillRect(bx, by, BALL, BALL, BG);
if (ny > FY + FH - BALL) {
// Missed.
beep(NOTE_C4, 160);
lives--;
drawLives();
if (lives == 0u) {
playing = false;
label(FX + 10, FY + FH / 2 - 10, "GAME OVER", ILI9341_RED, BG, 2);
Serial.println("game over, press A");
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) {
beep(NOTE_G5, 20);
}
delay(FRAME_MS);
}
Code walkthrough
The key functions of the sketch, and what each one does.
clampInt sketch.ino
Keeps a number between a low and a high limit. The paddle uses it so it stops at the left and right edges of the playfield.
static int16_t clampInt(int16_t v, int16_t lo, int16_t hi) {
return v < lo ? lo : (v > hi ? hi : v);
}startGame sketch.ino
Resets the score to zero and lives to LIVES. It clears the playfield on every game after the first, then draws the paddle in the middle and calls serve to launch the ball.
static void startGame() {
score = 0;
shownScore = -1;
lives = LIVES;
playing = true;
if (started) {
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();
}drawFrame sketch.ino
Draws the parts that never change: the playfield, its cyan border and the D-PAD, SCORE and LIVES labels. It runs once, in setup.
// 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);
label(FX, FY - 26, "D-PAD", ILI9341_WHITE, ILI9341_BLACK, 3);
label(FX, FY + FH + 10, "SCORE", ILI9341_WHITE, ILI9341_BLACK, 2);
label(FX + 110, FY + FH + 10, "LIVES", ILI9341_WHITE, ILI9341_BLACK, 2);
}loop sketch.ino
Each frame, about 33 ms, it reads the pad and moves the paddle. It then moves the ball, bounces it off the walls and the paddle, and checks for a miss. A missed ball costs a life.
void loop() {
pad.update();
if (!playing) {
if (pad.pressed(PAD_A)) {
tft.fillRect(FX + 8, FY + FH / 2 - 16, FW - 16, 32, BG);
startGame();
}
delay(FRAME_MS);
return;
}
// A direction, not a position: held left or right moves the paddle at a
// fixed speed and nothing moves it back.
int8_t dir = pad.dx();
if (dir != 0) {
px = clampInt((int16_t)(px + (int16_t)dir * PADDLE_SPEED), 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.
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);
// ... 76 more lines in the editorTroubleshooting
The usual mistakes with this circuit, and the fix for each.
- The screen stays blank or shows garbage. This project
Check the TFT wiring: SCK on 13, MOSI on 11, CS on 10, DC on 9, and RST, LED and VCC all on 5 V. - The paddle moves the wrong way. This project
LEFT should go to pin 5 and RIGHT to pin 4. Swap them if they are reversed. - After GAME OVER nothing happens. This project
Press the A button, wired to pin 3. It starts a new game. - 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. - Buttons read the wrong way around, or float. Game console buttons
These are switches to ground. Set each pin to INPUT_PULLUP and treat LOW as pressed. Without the pull-up the pin floats. - One press registers several times. Game console buttons
Mechanical buttons bounce for a few milliseconds. Wait about 5 to 20 ms before reading again, or use a debounce library. - 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.