Built-in project · Arduino Mega 2560 R3

Breakout game on an OLED, on an Arduino Mega

Breakout game on an OLED with a knob for the paddle: 32 bricks, three lives, a faster ball for every wall you clear, and sound on a piezo. The whole build, an Arduino Mega and 4 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 Runs in your browser. Free, and no account needed.

The parts retail for about $54. On Mokxi they're free.

Breakout game on an OLED · Arduino Mega 2560 R3live0.000 s 0.00x
Press Run, watch the demo, then press the button and turn the knob
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 Mega 2560 R3
// Breakout on a 128x64 OLED, with a knob for the paddle, on an Arduino Mega.
//
//   OLED     SDA -> 20 (SDA), SCL -> 21 (SCL), VCC -> 5 V, GND -> GND (0x3C),
//            mounted upside down, pins at the bottom: setRotation(2) turns
//            the picture round to match
//   knob     the ends to 5 V and GND, the wiper to A0: moves the paddle
//   button   pin 2 and GND (INPUT_PULLUP): starts a game
//   piezo    pin 8 and GND
//
// Knock out all 32 bricks with the ball. Each brick is 10 points. Miss the
// ball and you lose one of three lives. Clear the wall and a new one comes,
// with a faster ball.
//
// Before a game, the computer plays: the paddle follows the ball by itself.
// Press the button to take over with the knob.
//
// # How the ball moves
//
// The ball's position and speed are kept in sixteenths of a pixel, in whole
// numbers. That lets it move at speeds like 1.5 pixels a frame without using
// slow floating point. Each frame the sketch works out where the ball would
// go next. If that spot is past a wall, inside a brick or on the paddle, the
// ball bounces: its speed in that direction changes sign. Where the ball
// hits the paddle sets its angle, so you can aim: the ends send it off
// sideways, the middle straight up.
//
// # Drawing only what changes
//
// The OLED library sends only the parts of the screen that changed since the
// last display(). So instead of clearing the screen and drawing everything
// each frame, the sketch rubs out the ball and paddle where they were and
// draws them where they are. That is a few bytes on the I2C bus, not 1024.

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

const uint8_t KNOB = A0;
const uint8_t BUTTON = 2;
const uint8_t PIEZO = 8;

const int W = 128;
const int H = 64;
const int TOP = 10;  // the top wall; the score is above it

const int ROWS = 4;
const int COLS = 8;
const int BRICK_W = 15;
const int BRICK_H = 4;
const int BRICK_Y = 14;  // the top row of bricks

const int PADDLE_W = 20;
const int PADDLE_Y = 60;
const int BALL = 2;  // the ball is 2x2 pixels

const unsigned long FRAME_MS = 20;

Adafruit_SSD1306 display(W, H, &Wire, -1);

bool brick[ROWS][COLS];
int bricksLeft = 0;

// Ball position and speed, in sixteenths of a pixel.
long bx, by;
int vx, vy;
int speed = 24;  // 1.5 pixels a frame to start

int paddle = 54;  // its left edge
int lives = 3;
unsigned int score = 0;
bool demo = true;
bool lastButton = HIGH;

// What is drawn now, so it can be rubbed out.
int shownBallX = -10, shownBallY = -10, shownPaddle = -100;
long shownScore = -1;

void drawBrick(int r, int c, bool on) {
  display.fillRect(c * (BRICK_W + 1) + 1, BRICK_Y + r * (BRICK_H + 2), BRICK_W, BRICK_H,
                   on ? SSD1306_WHITE : SSD1306_BLACK);
}

void newWall() {
  for (int r = 0; r < ROWS; r++) {
    for (int c = 0; c < COLS; c++) {
      brick[r][c] = true;
      drawBrick(r, c, true);
    }
  }
  bricksLeft = ROWS * COLS;
}

void serve() {
  bx = (long)(paddle + PADDLE_W / 2) * 16;
  by = (long)(PADDLE_Y - 8) * 16;
  vx = random(2) ? speed / 2 : -speed / 2;
  vy = -speed;
}

void drawStatus() {
  long now = (long)score * 10 + lives;
  if (now == shownScore) return;
  shownScore = now;
  display.fillRect(0, 0, W, 8, SSD1306_BLACK);
  display.setCursor(0, 0);
  if (demo) {
    display.print("DEMO  press start");
  } else {
    display.print("SCORE ");
    display.print(score);
    display.setCursor(84, 0);
    display.print("LIVES ");
    display.print(lives);
  }
}

void newGame(bool asDemo) {
  demo = asDemo;
  score = 0;
  lives = 3;
  speed = 24;
  display.clearDisplay();
  display.drawFastHLine(0, TOP - 1, W, SSD1306_WHITE);
  shownBallX = -10;
  shownPaddle = -100;
  shownScore = -1;
  newWall();
  serve();
  drawStatus();
  display.display();
  Serial.println(demo ? "demo: press the button to play" : "game on: turn the knob to move");
}

// Does a ball with its corner at (x, y) touch this brick?
bool hitsBrick(int x, int y, int r, int c) {
  int left = c * (BRICK_W + 1) + 1;
  int top = BRICK_Y + r * (BRICK_H + 2);
  return x + BALL > left && x < left + BRICK_W && y + BALL > top && y < top + BRICK_H;
}

void step() {
  long nx = bx + vx;
  long ny = by + vy;
  int px = nx / 16;
  int py = ny / 16;

  // the side walls and the top
  if (px < 0 || px > W - BALL) {
    vx = -vx;
    nx = bx + vx;
    tone(PIEZO, 600, 10);
  }
  if (py < TOP) {
    vy = -vy;
    ny = by + vy;
    tone(PIEZO, 600, 10);
  }
  px = nx / 16;
  py = ny / 16;

  // the bricks: the first one the ball would land in breaks
  for (int r = 0; r < ROWS; r++) {
    for (int c = 0; c < COLS; c++) {
      if (!brick[r][c] || !hitsBrick(px, py, r, c)) continue;
      brick[r][c] = false;
      bricksLeft--;
      drawBrick(r, c, false);
      score += 10;
      // Came in from the side if it was not already level with the brick.
      if (hitsBrick(bx / 16, py, r, c)) {
        vy = -vy;
      } else {
        vx = -vx;
      }
      tone(PIEZO, 1200 - r * 150, 20);
      if (!demo) {
        Serial.print("brick! score ");
        Serial.println(score);
      }
      r = ROWS;  // one brick a frame
      break;
    }
  }

  // the paddle
  if (vy > 0 && py + BALL >= PADDLE_Y && by / 16 + BALL <= PADDLE_Y &&
      px + BALL > paddle && px < paddle + PADDLE_W) {
    // -8 at the left end to +8 at the right: the angle it goes off at
    int where = (px + BALL / 2 - paddle) * 16 / PADDLE_W - 8;
    vx = where * speed / 10;
    vy = -speed;
    ny = (long)(PADDLE_Y - BALL) * 16;
    tone(PIEZO, 400, 15);
  }

  bx = bx + vx;
  by = ny;
  if (bx < 0) bx = 0;
  if (bx > (long)(W - BALL) * 16) bx = (long)(W - BALL) * 16;
}

void loseBall() {
  lives--;
  tone(PIEZO, 120, 300);
  Serial.print("ball lost, ");
  Serial.print(lives);
  Serial.println(lives == 1 ? " life left" : " lives left");
  if (lives == 0) {
    Serial.print("game over: score ");
    Serial.println(score);
    display.fillRect(28, 30, 72, 12, SSD1306_BLACK);
    display.setCursor(37, 32);
    display.print("GAME OVER");
    display.display();
    delay(2500);
    newGame(true);
    return;
  }
  delay(600);
  serve();
}

void setup() {
  Serial.begin(115200);
  pinMode(BUTTON, INPUT_PULLUP);
  pinMode(PIEZO, OUTPUT);
  if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    Serial.println("No OLED at 0x3C: check SDA and SCL");
    while (true) {
    }
  }
  // The OLED is mounted upside down, with its pins at the bottom next to the
  // Mega's header, so turn the picture round to read the right way up.
  display.setRotation(2);
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);
  // Nothing is wired to A1, so it reads noise: a different serve each time.
  randomSeed(analogRead(A1));
  newGame(true);
}

void loop() {
  unsigned long started = millis();

  bool button = digitalRead(BUTTON);
  if (button == LOW && lastButton == HIGH && demo) newGame(false);
  lastButton = button;

  if (demo) {
    // The computer keeps the paddle under the ball.
    paddle = constrain((int)(bx / 16) - PADDLE_W / 2 + 3, 0, W - PADDLE_W);
  } else {
    paddle = map(analogRead(KNOB), 0, 1023, 0, W - PADDLE_W);
  }

  step();
  if (by / 16 > H) {
    display.fillRect(shownBallX, shownBallY, BALL, BALL, SSD1306_BLACK);
    shownBallX = -10;
    display.display();
    loseBall();
    return;
  }
  if (bricksLeft == 0) {
    speed += 6;
    Serial.println("wall cleared: faster ball");
    newWall();
    serve();
  }

  // rub out and redraw only what moved
  int x = bx / 16;
  int y = by / 16;
  if (x != shownBallX || y != shownBallY) {
    if (shownBallX >= 0) display.fillRect(shownBallX, shownBallY, BALL, BALL, SSD1306_BLACK);
    display.fillRect(x, y, BALL, BALL, SSD1306_WHITE);
    shownBallX = x;
    shownBallY = y;
  }
  if (paddle != shownPaddle) {
    display.fillRect(0, PADDLE_Y, W, 3, SSD1306_BLACK);
    display.fillRect(paddle, PADDLE_Y, PADDLE_W, 3, SSD1306_WHITE);
    shownPaddle = paddle;
  }
  drawStatus();
  display.display();

  while (millis() - started < FRAME_MS) {
  }
}

Parts list

6 parts, plus the jumper wires. They retail for about $54; on Mokxi they're free.

How it is wired

11 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 Mega 2560 R3 pin 8; Piezo speaker pin 1
  • Arduino Mega 2560 R3 pin 2; Arcade button pin 2
  • Arduino Mega 2560 R3 pin 20; OLED display, 128x64 pin SDA
  • Arduino Mega 2560 R3 pin 21; OLED display, 128x64 pin SCL
  • Arduino Mega 2560 R3 pin 5V; Potentiometer pin 3
  • Arduino Mega 2560 R3 pin A0; Potentiometer pin 2
  • Ground: OLED display, 128x64 pin GND
  • 5 V: OLED display, 128x64 pin VCC
  • Ground: Arcade button pin 1
  • Ground: Piezo speaker pin 2
  • Ground: Potentiometer pin 1

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.