Built-in project · Arduino Uno R3

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.

OLED pong · Arduino Uno R3live0.000 s 0.00x
Hold the button
The circuit itself, running here on the simulator. Press what can be pressed; click anything else to open it in the editor.

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · Arduino Uno R3
// 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.