Built-in project · Arduino Uno R3

Space Invaders on an OLED, on an Arduino Uno

Space Invaders on an OLED with an Arduino: invaders that march faster as you shoot them, three buttons to move and fire, bombs, waves and a high score. The whole build, an Arduino Uno and 5 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 $36. On Mokxi they're free.

Space Invaders on an OLED · Arduino Uno R3live0.000 s 0.00x
Press Run, watch the demo, then press the red fire button to play
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
// Space Invaders on a 128x64 OLED with three arcade buttons and a piezo, on an
// Arduino Uno.
//
//   OLED      SDA -> A4, SCL -> A5, VCC -> 5 V, GND -> GND (address 0x3C)
//   buttons   left -> pin 4, right -> pin 3, fire -> pin 2, each to GND
//   piezo     A1 and GND
//
// Eighteen invaders march across the screen and down, faster as there are
// fewer of them. Move your ship with left and right and press fire to shoot;
// one shot is on its way at a time. The bottom row is worth 10 points, the
// middle 20 and the top 30. The invaders drop bombs: three hits and the game
// is over, and so it is if they march all the way down to you. Clear the
// screen and a new wave starts one step lower. The best score is kept in the
// EEPROM.
//
// Before a game the computer plays. Press fire to take over.
//
// # Many things moving at different speeds
//
// The ship moves every frame, a shot moves 3 pixels a frame, bombs 1, and the
// invaders take one step every few hundred milliseconds. Each keeps its own
// clock: loop() runs one frame every 30 ms and asks each thing whether it is
// time for it to move. The invaders' step time comes from how many are left,
// which is what makes the last few so quick.
//
// # Which invaders are alive
//
// The wave is a grid of 3 rows by 6 columns, and each row is one byte in
// `alive`: bit 0 is the left column, bit 5 the right one. Shooting an
// invader clears its bit. That is 3 bytes for the whole wave, on a board
// whose screen buffer already uses half its memory.

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

const uint8_t LEFT = 4;
const uint8_t RIGHT = 3;
const uint8_t FIRE = 2;
const uint8_t PIEZO = A1;
const int BEST_ADDRESS = 0;  // two bytes

const int W = 128;
const int H = 64;
const int TOP = 9;  // the score bar is above this line

const int ROWS = 3;
const int COLS = 6;
const int ALIEN_W = 8;
const int ALIEN_H = 6;
const int STEP_X = 14;  // from one invader to the next across
const int STEP_Y = 9;   // and down

const int SHIP_Y = 58;
const int SHIP_W = 9;
const int BOMBS = 2;
const unsigned long FRAME_MS = 30;

// The invaders, two frames each so they wiggle as they march.
const uint8_t ALIEN[2][6] PROGMEM = {
    {0x3C, 0x7E, 0xDB, 0xFF, 0x24, 0x42},
    {0x3C, 0x7E, 0xDB, 0xFF, 0x5A, 0x81},
};
const uint8_t SHIP[4][2] PROGMEM = {
    {0x08, 0x00},
    {0x1C, 0x00},
    {0x7F, 0x00},
    {0xFF, 0x80},
};

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

uint8_t alive[ROWS];
int gridX, gridY;      // the top left of the wave
int8_t march = 2;      // pixels a step, + is right
uint8_t wiggle = 0;
unsigned long stepAt = 0;
int shipX;
int shotX, shotY = -1;  // -1 is no shot
int bombX[BOMBS], bombY[BOMBS];
unsigned long bombAt = 0;
int lives;
unsigned int score;
unsigned int best;
uint8_t wave;
bool demo = true;
bool lastFire = HIGH;

uint8_t aliveCount() {
  uint8_t n = 0;
  for (int r = 0; r < ROWS; r++) {
    for (int c = 0; c < COLS; c++) n += (alive[r] >> c) & 1;
  }
  return n;
}

void drawBar() {
  display.fillRect(0, 0, W, TOP - 1, SSD1306_BLACK);
  display.setCursor(0, 0);
  if (demo) {
    display.print("DEMO  press fire");
    return;
  }
  display.print("SCORE ");
  display.print(score);
  display.setCursor(86, 0);
  display.print("LIVES ");
  display.print(lives);
}

void drawWave(uint16_t color) {
  for (int r = 0; r < ROWS; r++) {
    for (int c = 0; c < COLS; c++) {
      if (!((alive[r] >> c) & 1)) continue;
      int x = gridX + c * STEP_X;
      int y = gridY + r * STEP_Y;
      if (color == SSD1306_BLACK) {
        display.fillRect(x, y, ALIEN_W, ALIEN_H, SSD1306_BLACK);
      } else {
        display.drawBitmap(x, y, ALIEN[wiggle], ALIEN_W, ALIEN_H, SSD1306_WHITE);
      }
    }
  }
}

void drawShip(uint16_t color) {
  display.fillRect(0, SHIP_Y, W, 4, SSD1306_BLACK);
  if (color == SSD1306_WHITE) display.drawBitmap(shipX, SHIP_Y, &SHIP[0][0], SHIP_W, 4, SSD1306_WHITE);
}

void newWave() {
  for (int r = 0; r < ROWS; r++) alive[r] = (1 << COLS) - 1;
  gridX = 10;
  gridY = TOP + 3 + min(wave, (uint8_t)3) * 4;
  march = 2;
  shotY = -1;
  for (int i = 0; i < BOMBS; i++) bombY[i] = -1;
  display.fillRect(0, TOP, W, SHIP_Y - TOP, SSD1306_BLACK);
  drawWave(SSD1306_WHITE);
}

void newGame(bool asDemo) {
  demo = asDemo;
  score = 0;
  lives = 3;
  wave = 0;
  shipX = (W - SHIP_W) / 2;
  display.clearDisplay();
  display.drawFastHLine(0, TOP - 1, W, SSD1306_WHITE);
  newWave();
  drawShip(SSD1306_WHITE);
  drawBar();
  display.display();
  Serial.println(demo ? "demo: press fire to play" : "game on: left, right and fire");
}

void gameOver() {
  tone(PIEZO, 110, 600);
  if (!demo) {
    Serial.print("game over: score ");
    Serial.println(score);
    if (score > best) {
      best = score;
      EEPROM.put(BEST_ADDRESS, best);
      Serial.print("new best: ");
      Serial.println(best);
    }
  }
  display.fillRect(34, 28, 60, 12, SSD1306_BLACK);
  display.setCursor(37, 30);
  display.print("GAME OVER");
  display.display();
  delay(2500);
  newGame(true);
}

// The invaders take a step, or drop a row and turn round at the edge.
// Returns false if they have reached the ship.
bool stepWave() {
  // the leftmost and rightmost columns still standing
  uint8_t cols = alive[0] | alive[1] | alive[2];
  int first = 0, last = COLS - 1;
  while (!((cols >> first) & 1)) first++;
  while (!((cols >> last) & 1)) last--;
  int left = gridX + first * STEP_X;
  int right = gridX + last * STEP_X + ALIEN_W;

  drawWave(SSD1306_BLACK);
  if ((march > 0 && right + march > W) || (march < 0 && left + march < 0)) {
    march = -march;
    gridY += 4;
  } else {
    gridX += march;
  }
  wiggle ^= 1;
  drawWave(SSD1306_WHITE);
  tone(PIEZO, wiggle ? 90 : 70, 40);

  int lowest = 0;
  for (int r = 0; r < ROWS; r++) {
    if (alive[r]) lowest = r;
  }
  return gridY + lowest * STEP_Y + ALIEN_H < SHIP_Y;
}

// Does the shot at (x, y) hit an invader? If so, it is gone.
bool shotHits(int x, int y) {
  for (int r = 0; r < ROWS; r++) {
    for (int c = 0; c < COLS; c++) {
      if (!((alive[r] >> c) & 1)) continue;
      int ax = gridX + c * STEP_X;
      int ay = gridY + r * STEP_Y;
      if (x < ax || x >= ax + ALIEN_W || y < ay || y >= ay + ALIEN_H) continue;
      alive[r] &= ~(1 << c);
      display.fillRect(ax, ay, ALIEN_W, ALIEN_H, SSD1306_BLACK);
      score += (ROWS - r) * 10;
      tone(PIEZO, 1500, 30);
      if (!demo) {
        Serial.print("hit! score ");
        Serial.println(score);
        drawBar();
      }
      return true;
    }
  }
  return false;
}

void dropBomb() {
  for (int i = 0; i < BOMBS; i++) {
    if (bombY[i] >= 0) continue;
    // Half the time from the column nearest the ship, half at random.
    int c = random(2) ? (shipX + SHIP_W / 2 - gridX) / STEP_X : random(COLS);
    c = constrain(c, 0, COLS - 1);
    for (int r = ROWS - 1; r >= 0; r--) {
      if (!((alive[r] >> c) & 1)) continue;
      bombX[i] = gridX + c * STEP_X + ALIEN_W / 2;
      bombY[i] = gridY + r * STEP_Y + ALIEN_H;
      return;
    }
    return;  // nobody in that column this time
  }
}

void shipHit() {
  lives--;
  tone(PIEZO, 200, 300);
  if (!demo) {
    Serial.print("ship hit, ");
    Serial.print(lives);
    Serial.println(lives == 1 ? " life left" : " lives left");
    drawBar();
  }
  // a flash of the ship, then carry on
  drawShip(SSD1306_BLACK);
  display.display();
  delay(400);
  drawShip(SSD1306_WHITE);
}

void setup() {
  Serial.begin(115200);
  pinMode(LEFT, INPUT_PULLUP);
  pinMode(RIGHT, INPUT_PULLUP);
  pinMode(FIRE, INPUT_PULLUP);
  pinMode(PIEZO, OUTPUT);
  if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    Serial.println("No OLED at 0x3C: check SDA and SCL");
    while (true) {
    }
  }
  Wire.setClock(400000);
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);
  // A blank EEPROM reads 0xFFFF: no best score yet.
  EEPROM.get(BEST_ADDRESS, best);
  if (best == 0xFFFF) best = 0;
  // Nothing is wired to A0, so it reads noise: different bombs each time.
  randomSeed(analogRead(A0));
  newGame(true);
}

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

  // the controls, or the computer's idea of them
  bool left, right, fire;
  bool firePin = digitalRead(FIRE) == LOW;
  if (demo) {
    if (firePin && lastFire == HIGH) {
      lastFire = LOW;
      newGame(false);
      return;
    }
    // Aim under the nearest standing column, and fire when under it.
    int target = shipX;
    int bestGap = 999;
    for (int c = 0; c < COLS; c++) {
      if (!((alive[0] | alive[1] | alive[2]) >> c & 1)) continue;
      int x = gridX + c * STEP_X + ALIEN_W / 2 - SHIP_W / 2;
      if (abs(x - shipX) < bestGap) {
        bestGap = abs(x - shipX);
        target = x;
      }
    }
    left = target < shipX - 1;
    right = target > shipX + 1;
    fire = bestGap <= 2;
  } else {
    left = digitalRead(LEFT) == LOW;
    right = digitalRead(RIGHT) == LOW;
    fire = firePin;
  }
  lastFire = firePin ? LOW : HIGH;

  // the ship
  int was = shipX;
  if (left) shipX = max(shipX - 2, 0);
  if (right) shipX = min(shipX + 2, W - SHIP_W);
  if (shipX != was) drawShip(SSD1306_WHITE);

  // the shot
  if (fire && shotY < 0) {
    shotX = shipX + SHIP_W / 2;
    shotY = SHIP_Y - 3;
    tone(PIEZO, 1200, 15);
  }
  if (shotY >= 0) {
    display.drawFastVLine(shotX, shotY, 3, SSD1306_BLACK);
    shotY -= 3;
    if (shotY < TOP || shotHits(shotX, shotY) || shotHits(shotX, shotY + 2)) {
      shotY = -1;
    } else {
      display.drawFastVLine(shotX, shotY, 3, SSD1306_WHITE);
    }
  }

  // the bombs
  if (now - bombAt > 900) {
    bombAt = now;
    dropBomb();
  }
  for (int i = 0; i < BOMBS; i++) {
    if (bombY[i] < 0) continue;
    display.drawFastVLine(bombX[i], bombY[i], 3, SSD1306_BLACK);
    bombY[i] += 1;
    if (bombY[i] + 3 >= SHIP_Y && bombX[i] >= shipX && bombX[i] < shipX + SHIP_W) {
      bombY[i] = -1;
      shipHit();
      if (lives == 0) {
        gameOver();
        return;
      }
    } else if (bombY[i] + 3 >= H) {
      bombY[i] = -1;
    } else {
      display.drawFastVLine(bombX[i], bombY[i], 3, SSD1306_WHITE);
    }
  }

  // the invaders
  uint8_t standing = aliveCount();
  if (standing == 0) {
    wave++;
    if (!demo) Serial.println("wave cleared: here comes the next");
    newWave();
  } else if (now - stepAt > 60UL + 25UL * standing) {
    stepAt = now;
    if (!stepWave()) {
      if (!demo) Serial.println("the invaders landed");
      lives = 0;
      gameOver();
      return;
    }
  }

  display.display();
  while (millis() - now < FRAME_MS) {
  }
}

Parts list

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

How it is wired

12 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 4; Arcade button (1) pin 2
  • Arduino Uno R3 pin 3; Arcade button (2) pin 2
  • Arduino Uno R3 pin 2; Arcade button (3) pin 2
  • Arduino Uno R3 pin 5V; OLED display, 128x64 pin VCC
  • Ground: Arduino Uno R3 pin GND; OLED display, 128x64 pin GND
  • Ground: Arduino Uno R3 pin GND; Piezo speaker pin 2
  • Arduino Uno R3 pin A1; Piezo speaker pin 1
  • Arduino Uno R3 pin A4; OLED display, 128x64 pin SDA
  • Arduino Uno R3 pin A5; OLED display, 128x64 pin SCL
  • Ground: Arcade button (1) pin 1
  • Ground: Arcade button (2) pin 1
  • Ground: Arcade button (3) 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.