OLED pong on an Arduino Uno
Pong on a 128x64 OLED with a thumb stick for the paddle: where the ball strikes sets the angle it leaves at. The whole build, an Arduino Uno and 2 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 20 minutes to build and run. Runs in your browser. Free, and no account needed.
The parts retail for about $38. On Mokxi they're free.
What you'll learn
- Reading a thumb stick as a position with analogRead
- Redrawing only what moved on a small OLED
- Fixed-point math: using sixteenths of a pixel
- Changing a bounce angle by where the ball lands
- Why memory limits shape how a sketch is written
- Difficulty: Advanced
- Time: About 20 minutes
- Board: Arduino Uno R3
- Parts: 4
How it works
Push the thumb stick up or down and your paddle follows it. The sketch reads the stick's Y axis on A0 and maps the 0 to 1023 reading straight onto the rows the paddle can sit on. The board plays the right paddle. First to nine wins.
The ball moves in sixteenths of a pixel so it can travel at shallow angles. Where it hits a paddle sets the angle it leaves at: the ends throw it steeply, the middle sends it flatter.
After each point the game waits. Press the stick button on pin 2 to serve. Score is drawn as small squares, nine per side, instead of digits, to save memory on the Uno.
Parts list
4 parts, plus the jumper wires. They retail for about $38; on Mokxi they're free.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Full-size breadboard
- 1 × OLED display, 128x64In Fight Stick Kit, Pocket Pet Kit, Slot Machine Kit.
- 1 × Thumb stickIn Claw Machine Kit, Spaceship Panel Kit.
Kits that can build it
This starter kit has every part in the circuit. Each kit's page lists what else it can build.
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 |
|---|---|
| Thumb stick pin SW | Arduino Uno R3 pin 2 |
| OLED display, 128x64 pin VCC | Arduino Uno R3 pin 5V |
| Thumb stick pin VCC | Arduino Uno R3 pin 5V |
| OLED display, 128x64 pin GND | Arduino Uno R3 pin GND |
| Thumb stick pin GND | Arduino Uno R3 pin GND |
| Thumb stick pin VRY | Arduino Uno R3 pin A0 |
| Thumb stick pin VRX | Arduino Uno R3 pin A1 |
| OLED display, 128x64 pin SDA | Arduino Uno R3 pin A4 |
| OLED display, 128x64 pin SCL | Arduino Uno R3 pin A5 |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// OLED pong: a game of pong on a 128x64 SSD1306, played with a thumb stick.
//
// Wiring, board to modules:
// OLED SCL -> A5 SDA -> A4 VCC -> 5 V GND -> GND
// stick VRY -> A0 SW -> pin 2 VCC -> 5 V GND -> GND
//
// Push the stick up and down and the left paddle follows it outright, because
// a potentiometer is an absolute position and so is a paddle: there is no
// acceleration to fight and no key to hold. The board plays the right paddle.
// First to nine. Press the stick to serve again.
//
// # This one is the Uno's, and there is an ESP32-C3 one too
//
// A thumb stick is two potentiometers, so reading one needs an ADC, and when
// this sketch was written the ESP32-C3 model had none. It has one now
// (docs/esp32c3.md section 3.5), and firmware/examples/oledpong is this same
// game on that board. The two differ in exactly two places: an axis there is
// 0..4095 rather than 0..1023, and the score there is drawn in the 5x7 font,
// which this board cannot afford; see drawScore() below.
//
// # Painting only what moved
//
// A 128x64 monochrome frame is 1024 bytes, and this board's software I2C
// manages about 32 kbit/s, so sending all of it would take a third of a second.
// mokxi_ssd1306.h sends only the eight-column blocks of each page that changed
// since the last display(), and this sketch helps it: each frame rubs out the
// ball and the two paddles where they were, draws them where they are now, and
// touches nothing else. The court, the net and the score are drawn once.
//
// That is a few dozen bytes a frame instead of a kilobyte, which is the
// difference between a game and a slide show.
//
// # The ball
//
// Sixteenths of a pixel, so the ball can move at a shallow angle without the
// integer arithmetic snapping it to 45 degrees. Where it hits the paddle sets
// the angle it leaves at (the ends throw it steeply, the middle straight),
// which is the one rule that makes pong a game of placement rather than of
// reflexes.
#include "mokxi_i2c.h"
#include "mokxi_ssd1306.h"
const uint8_t SCL_PIN = A5;
const uint8_t SDA_PIN = A4;
const uint8_t VRY_PIN = A0;
const uint8_t SW_PIN = 2;
SoftI2c bus(SCL_PIN, SDA_PIN, 400);
Ssd1306 oled(bus, SSD1306_ADDRESS);
// The stick is read straight rather than through mokxi_joystick.h: this sketch
// wants one absolute axis and a button, not a centred, dead-zoned pair, and an
// ATmega328P with a 1 KB frame buffer in it has no room for the difference.
// The court: the whole panel below the score line.
const int16_t TOP = 10;
const int16_t BOTTOM = 63;
const int16_t LEFT = 0;
const int16_t RIGHT = 127;
const int16_t PADDLE_H = 14;
const int16_t PADDLE_W = 2;
const int16_t BALL = 3;
const int16_t PLAYER_X = 2;
const int16_t COMPUTER_X = RIGHT - 1 - PADDLE_W;
/**
* Sixteenths of a pixel: the ball's own units. A power of two on purpose:
* an ATmega328P has no divide instruction, so `/ 16` would be a call into
* `__divmodhi4` twice a frame and `>> SUB_BITS` is one instruction.
*/
const int16_t SUB = 16;
const uint8_t SUB_BITS = 4;
const uint8_t TARGET = 9;
/** The column the net runs down. */
const int16_t NET_X = 63;
int16_t playerY = TOP + 8;
int16_t computerY = TOP + 8;
int16_t drawnPlayerY = -1;
int16_t drawnComputerY = -1;
int16_t ballX = 64 * SUB;
int16_t ballY = 32 * SUB;
int16_t ballDX = 3 * SUB / 2;
int16_t ballDY = SUB / 2;
int16_t drawnBallX = -1;
int16_t drawnBallY = -1;
uint8_t playerScore = 0;
uint8_t computerScore = 0;
bool waiting = true;
/** How far a paddle may sit, so it never leaves the court. */
static int16_t clampPaddle(int16_t y) {
if (y < TOP + 1) {
return (int16_t)(TOP + 1);
}
if (y > BOTTOM - PADDLE_H) {
return (int16_t)(BOTTOM - PADDLE_H);
}
return y;
}
static void drawCourt() {
oled.clear();
// The net, and the line under the score.
oled.fillRect(NET_X, (int16_t)(TOP + 2), 1, (int16_t)(BOTTOM - TOP - 1), true);
oled.fillRect(LEFT, TOP, (int16_t)(RIGHT - LEFT + 1), 1, true);
}
/**
* The score as pips rather than digits: one filled square a point, the
* player's growing rightwards from the left edge and the board's leftwards
* from the right.
*
* A 5x7 font and the routine that walks it are eleven hundred bytes of an
* ATmega328P's eight-kilobyte budget, and a kilobyte of that budget is already
* the frame buffer. Nine squares say the same thing in twenty.
*/
static void drawScore() {
oled.fillRect(0, 0, 128, 9, false);
for (uint8_t i = 0; i < TARGET; i++) {
int16_t x = (int16_t)(2 + i * 6);
oled.rect(x, 1, 5, 6, true);
if (i < playerScore) {
oled.fillRect((int16_t)(x + 1), 2, 3, 4, true);
}
int16_t rx = (int16_t)(RIGHT - 6 - i * 6);
oled.rect(rx, 1, 5, 6, true);
if (i < computerScore) {
oled.fillRect((int16_t)(rx + 1), 2, 3, 4, true);
}
}
// A cue that the board is waiting for a serve: the net's top pixel blinks
// off, which costs nothing and is the only thing on the panel that moves.
oled.fillRect(NET_X, (int16_t)(TOP + 2), 1, 3, !waiting);
}
/** Rub out the moving things where they were, and draw them where they are. */
static void paint() {
if (drawnPlayerY >= 0 && drawnPlayerY != playerY) {
oled.fillRect(PLAYER_X, drawnPlayerY, PADDLE_W, PADDLE_H, false);
}
if (drawnComputerY >= 0 && drawnComputerY != computerY) {
oled.fillRect(COMPUTER_X, drawnComputerY, PADDLE_W, PADDLE_H, false);
}
int16_t bx = (int16_t)(ballX >> SUB_BITS);
int16_t by = (int16_t)(ballY >> SUB_BITS);
if (drawnBallX >= 0 && (drawnBallX != bx || drawnBallY != by)) {
oled.fillRect(drawnBallX, drawnBallY, BALL, BALL, false);
// The net runs down the middle of the court, so rubbing the ball out there
// takes a piece of it with it. Put that piece back.
if (drawnBallX <= NET_X && drawnBallX + BALL > NET_X) {
oled.fillRect(NET_X, drawnBallY, 1, BALL, true);
}
}
oled.fillRect(PLAYER_X, playerY, PADDLE_W, PADDLE_H, true);
oled.fillRect(COMPUTER_X, computerY, PADDLE_W, PADDLE_H, true);
oled.fillRect(bx, by, BALL, BALL, true);
drawnPlayerY = playerY;
drawnComputerY = computerY;
drawnBallX = bx;
drawnBallY = by;
oled.display();
}
/** Put the ball back in the middle, heading at `dir`. */
static void serve(int16_t dir) {
ballX = 64 * SUB;
ballY = (int16_t)(((TOP + BOTTOM) / 2) * SUB);
ballDX = (int16_t)(dir * 3 * SUB / 2);
ballDY = (int16_t)(((playerScore + computerScore) & 1) == 0 ? SUB / 2 : -SUB / 2);
drawnBallX = -1;
drawnBallY = -1;
}
/**
* The angle the ball leaves a paddle at: where along the paddle it struck,
* as a fraction either side of the middle, scaled to a sensible slope.
*/
static int16_t bounceAngle(int16_t by, int16_t paddleY) {
int16_t centre = (int16_t)(paddleY + PADDLE_H / 2);
int16_t offset = (int16_t)(by + BALL / 2 - centre);
// The paddle is fourteen tall, so the offset runs about +/- 7; doubling it
// spans the slope the ball is allowed, without a divide.
int16_t dy = (int16_t)(offset * 2);
if (dy > SUB + SUB / 2) {
dy = (int16_t)(SUB + SUB / 2);
}
if (dy < -(SUB + SUB / 2)) {
dy = (int16_t)(-(SUB + SUB / 2));
}
return dy;
}
void setup() {
Serial.begin(115200);
Serial.println("Mokxi Uno: pong on a 128x64 OLED, played with a thumb stick");
pinMode(SW_PIN, INPUT_PULLUP);
if (!oled.begin()) {
Serial.println("no OLED acknowledged 0x3c");
return;
}
drawCourt();
drawScore();
paint();
}
void loop() {
// The stick: Y counts down the screen, so pushing it up moves the paddle up.
// 0..1023 onto the 39 rows the paddle may start on. `* 5 >> 7` is that to
// within a row and stays in sixteen bits, where a divide by 1023 would be a
// call into the 32-bit division routine every single frame.
int16_t axis = (int16_t)analogRead(VRY_PIN);
playerY = clampPaddle((int16_t)(TOP + 1 + ((axis * 5) >> 7)));
if (waiting) {
paint();
if (digitalRead(SW_PIN) == LOW) {
waiting = false;
if (playerScore >= TARGET || computerScore >= TARGET) {
playerScore = 0;
computerScore = 0;
drawCourt();
}
serve(playerScore > computerScore ? -1 : 1);
drawScore();
// Let go of the switch before the next round listens for it.
while (digitalRead(SW_PIN) == LOW) {
delay(8);
}
}
delay(24);
return;
}
// The board's paddle: it chases the ball, but not at full speed, so a ball
// placed at the far end of its travel gets past it.
int16_t want = (int16_t)((ballY >> SUB_BITS) - PADDLE_H / 2);
if (want > computerY + 1) {
computerY = (int16_t)(computerY + 2);
} else if (want < computerY - 1) {
computerY = (int16_t)(computerY - 2);
}
computerY = clampPaddle(computerY);
ballX = (int16_t)(ballX + ballDX);
ballY = (int16_t)(ballY + ballDY);
// The top and bottom walls.
if (ballY < (TOP + 1) * SUB) {
ballY = (int16_t)((TOP + 1) * SUB);
ballDY = (int16_t)(-ballDY);
}
if (ballY > (BOTTOM - BALL) * SUB) {
ballY = (int16_t)((BOTTOM - BALL) * SUB);
ballDY = (int16_t)(-ballDY);
}
int16_t bx = (int16_t)(ballX >> SUB_BITS);
int16_t by = (int16_t)(ballY >> SUB_BITS);
// The player's paddle.
if (ballDX < 0 && bx <= PLAYER_X + PADDLE_W && bx + BALL >= PLAYER_X) {
if (by + BALL > playerY && by < playerY + PADDLE_H) {
ballX = (int16_t)((PLAYER_X + PADDLE_W) * SUB);
ballDX = (int16_t)(-ballDX);
ballDY = bounceAngle(by, playerY);
}
}
// The board's.
if (ballDX > 0 && bx + BALL >= COMPUTER_X && bx <= COMPUTER_X + PADDLE_W) {
if (by + BALL > computerY && by < computerY + PADDLE_H) {
ballX = (int16_t)((COMPUTER_X - BALL) * SUB);
ballDX = (int16_t)(-ballDX);
ballDY = bounceAngle(by, computerY);
}
}
// Off the end: a point, and back to waiting.
if (bx < LEFT) {
computerScore++;
waiting = true;
} else if (bx + BALL > RIGHT) {
playerScore++;
waiting = true;
}
if (waiting) {
Serial.println(bx < LEFT ? "point to the board" : "point to you");
serve(0);
ballDX = 0;
ballDY = 0;
drawScore();
}
paint();
delay(24);
}
Code walkthrough
The key functions of the sketch, and what each one does.
paint sketch.ino
Rubs out the paddles and ball where they were, draws them where they are now, and calls oled.display(). Nothing else is touched. The library only sends the blocks that changed, so a frame costs a few dozen bytes instead of a full kilobyte.
/** Rub out the moving things where they were, and draw them where they are. */
static void paint() {
if (drawnPlayerY >= 0 && drawnPlayerY != playerY) {
oled.fillRect(PLAYER_X, drawnPlayerY, PADDLE_W, PADDLE_H, false);
}
if (drawnComputerY >= 0 && drawnComputerY != computerY) {
oled.fillRect(COMPUTER_X, drawnComputerY, PADDLE_W, PADDLE_H, false);
}
int16_t bx = (int16_t)(ballX >> SUB_BITS);
int16_t by = (int16_t)(ballY >> SUB_BITS);
if (drawnBallX >= 0 && (drawnBallX != bx || drawnBallY != by)) {
oled.fillRect(drawnBallX, drawnBallY, BALL, BALL, false);
// The net runs down the middle of the court, so rubbing the ball out there
// takes a piece of it with it. Put that piece back.
if (drawnBallX <= NET_X && drawnBallX + BALL > NET_X) {
oled.fillRect(NET_X, drawnBallY, 1, BALL, true);
}
}
oled.fillRect(PLAYER_X, playerY, PADDLE_W, PADDLE_H, true);
oled.fillRect(COMPUTER_X, computerY, PADDLE_W, PADDLE_H, true);
oled.fillRect(bx, by, BALL, BALL, true);
drawnPlayerY = playerY;
drawnComputerY = computerY;
drawnBallX = bx;
drawnBallY = by;
oled.display();
}bounceAngle sketch.ino
Finds how far above or below the paddle's center the ball hit, doubles that to get a vertical speed, and limits it to a safe range. A center hit goes nearly straight, an edge hit goes steep. It uses no divide, which the Uno handles slowly.
/**
* The angle the ball leaves a paddle at: where along the paddle it struck,
* as a fraction either side of the middle, scaled to a sensible slope.
*/
static int16_t bounceAngle(int16_t by, int16_t paddleY) {
int16_t centre = (int16_t)(paddleY + PADDLE_H / 2);
int16_t offset = (int16_t)(by + BALL / 2 - centre);
// The paddle is fourteen tall, so the offset runs about +/- 7; doubling it
// spans the slope the ball is allowed, without a divide.
int16_t dy = (int16_t)(offset * 2);
if (dy > SUB + SUB / 2) {
dy = (int16_t)(SUB + SUB / 2);
}
if (dy < -(SUB + SUB / 2)) {
dy = (int16_t)(-(SUB + SUB / 2));
}
return dy;
}serve sketch.ino
Puts the ball back in the middle and sets its direction from dir. The vertical speed alternates up and down depending on whether the total score is even or odd, so serves are not always the same.
/** Put the ball back in the middle, heading at `dir`. */
static void serve(int16_t dir) {
ballX = 64 * SUB;
ballY = (int16_t)(((TOP + BOTTOM) / 2) * SUB);
ballDX = (int16_t)(dir * 3 * SUB / 2);
ballDY = (int16_t)(((playerScore + computerScore) & 1) == 0 ? SUB / 2 : -SUB / 2);
drawnBallX = -1;
drawnBallY = -1;
}loop sketch.ino
Each frame reads the stick and sets your paddle. While waiting, it only repaints and checks the button. Otherwise the board's paddle chases the ball at 2 pixels a frame, the ball moves, walls and paddles bounce it, and a ball past either edge scores a point.
void loop() {
// The stick: Y counts down the screen, so pushing it up moves the paddle up.
// 0..1023 onto the 39 rows the paddle may start on. `* 5 >> 7` is that to
// within a row and stays in sixteen bits, where a divide by 1023 would be a
// call into the 32-bit division routine every single frame.
int16_t axis = (int16_t)analogRead(VRY_PIN);
playerY = clampPaddle((int16_t)(TOP + 1 + ((axis * 5) >> 7)));
if (waiting) {
paint();
if (digitalRead(SW_PIN) == LOW) {
waiting = false;
if (playerScore >= TARGET || computerScore >= TARGET) {
playerScore = 0;
computerScore = 0;
drawCourt();
}
serve(playerScore > computerScore ? -1 : 1);
drawScore();
// Let go of the switch before the next round listens for it.
while (digitalRead(SW_PIN) == LOW) {
delay(8);
}
}
delay(24);
return;
}
// ... 62 more lines in the editorTroubleshooting
The usual mistakes with this circuit, and the fix for each.
- The paddle moves the wrong way This project
Check the stick's VRY pin goes to A0. Using VRX on A1 would read the other axis. - The screen stays blank This project
Check SDA on A4, SCL on A5, and power on 5V and GND. Serial prints a message if the OLED does not answer. - The OLED stays blank. OLED display
Check SDA and SCL are not swapped, and power it with 3.3 V or 5 V as its board allows. Most use address 0x3C, some 0x3D. Call display.begin() with the matching address. - Text never shows up even though the code runs. OLED display
Drawing only writes to a buffer. Call display.display() after drawing to push it out. Also set a text color with setTextColor(WHITE) and clear the buffer between frames. - 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. - Upload fails or nothing runs after wiring pins 0 and 1 to a module. Arduino Uno R3
Pins 0 and 1 are the USB serial port. Move the module to other pins, or unplug it while uploading. Also check the right board is selected. - Pins misbehave or the board resets when you power motors or servos from it. Arduino Uno R3
Each I/O pin gives about 20 mA, 40 mA max. Power motors and servos from their own supply and join its ground to the Uno's GND.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.