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.
AdvancedRuns in your browser. Free, and no account needed.
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);
}
Parts list
4 parts, plus the jumper wires. Every one is in the editor's parts bin.
How it is wired
7 connections, pin by pin, read from the circuit itself. Each line is one set of pins joined together, by a jumper wire or a breadboard strip.
- Arduino Uno R3 pin 2; Thumb stick pin SW
- Arduino Uno R3 pin 5V; OLED display, 128x64 pin VCC; Thumb stick pin VCC
- Ground: Arduino Uno R3 pin GND; OLED display, 128x64 pin GND; Thumb stick pin GND
- Arduino Uno R3 pin A0; Thumb stick pin VRY
- Arduino Uno R3 pin A1; Thumb stick pin VRX
- Arduino Uno R3 pin A4; OLED display, 128x64 pin SDA
- Arduino Uno R3 pin A5; OLED display, 128x64 pin SCL
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.