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.
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// 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.
- 1 × Arduino Uno R3
- 3 × Arcade button
- 1 × Piezo speaker
- 1 × OLED display, 128x64
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.