OLED Animation Maker OLED Maker

Arduino OLED game: build a one-button runner on an SSD1306

Arduino OLED game on an SSD1306 screen: a small runner jumping over blocks

This guide shows you how to build an Arduino OLED game with just two parts: a 0.96 inch SSD1306 screen and one push button. You press the button to jump over blocks that scroll toward you. The game gets faster as you go, beeps when you jump, and remembers your best score after you unplug it. The same sketch runs on an Arduino Uno, an ESP32 or a Raspberry Pi Pico.

Tiny keychain game consoles built on the Pico 2 W and small ESP32 boards are a popular maker build this fall. This project is the core of one of those, kept simple enough to finish in an evening.

Quick answer. Wire the SSD1306 to your board's I2C pins and a button between one pin and GND. Install Adafruit SSD1306 and Adafruit GFX, then upload the sketch below. It runs a game loop about 30 times a second with millis(), moves the player with simple gravity, checks for hits with rectangles and saves the high score in EEPROM.

What you need for this Arduino OLED game

PartNotesRough price (US)
BoardArduino Uno or Nano, any ESP32 dev board, or a Raspberry Pi Pico / Pico 2$5 to $28
OLED0.96 inch SSD1306, 128x64, I2C (4 pins)$4 to $8
Push buttonAny 6 mm or 12 mm tactile buttonUnder $1
Passive buzzerOptional, for jump and crash soundsUnder $1
Breadboard and jumper wiresFor testing before you solder$5

A passive buzzer is one that needs a changing signal to make a sound. That is what tone() sends, so you can play different notes. An active buzzer only makes one fixed beep. If you have never used this screen before, the Arduino OLED display tutorial for the SSD1306 covers the basics in more detail.

Wiring the OLED and the button

I2C is the two-wire bus the screen talks on. SDA carries the data and SCL carries the clock. Each board has default pins for it, and the sketch uses those defaults so you do not need to set them in code.

ConnectionArduino Uno / NanoESP32 dev boardRaspberry Pi Pico
OLED VCC5V3V33V3(OUT), pin 36
OLED GNDGNDGNDGND
OLED SDAA4GPIO 21GP4
OLED SCLA5GPIO 22GP5
Button (other leg to GND)D2GPIO 13GP15
Buzzer + (other leg to GND)D8GPIO 25GP14

The button needs no resistor. The sketch turns on the board's internal pull-up resistor, which holds the pin HIGH. Pressing the button connects the pin to GND, so it reads LOW. Use two legs on opposite sides of a four-leg tactile button, or test with a multimeter first. Two legs on the same side are often joined inside, and the button then reads as always pressed.

Most SSD1306 modules sold on Amazon US have a voltage regulator and work from 3.3 V or 5 V. Check your module's listing if you are not sure.

Install the libraries

  1. Open the Arduino IDE and go to Sketch → Include Library → Manage Libraries.
  2. Search for Adafruit SSD1306 and click Install.
  3. When it asks to install dependencies, click Install All. This adds the Adafruit GFX Library, which has the drawing functions.
  4. For ESP32, add the Espressif esp32 core in Boards Manager. For the Pico, add Earle Philhower's Raspberry Pi Pico/RP2040 core.

EEPROM and Wire come with every board core, so there is nothing else to install. The official Adafruit SSD1306 library on GitHub lists every function if you want to go further.

The complete SSD1306 game sketch

Copy the whole sketch into a new file. The lines you may need to change are marked with comments near the top: the OLED address and the pin numbers. The #if lines pick the right pins for your board automatically when you compile.

// One-button OLED runner game for SSD1306 128x64 (I2C)
// Boards: Arduino Uno/Nano, ESP32, Raspberry Pi Pico (arduino-pico core)
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <EEPROM.h>

#define SCREEN_W 128
#define SCREEN_H 64
#define OLED_ADDR 0x3C          // change to 0x3D if your module uses it

// ---- Pins: change these to match your wiring ----
#if defined(ESP32)
const uint8_t BUTTON_PIN = 13;  // ESP32-C3 SuperMini: use 3
const uint8_t BUZZER_PIN = 25;  // ESP32-C3 SuperMini: use 4
#elif defined(ARDUINO_ARCH_RP2040)
const uint8_t BUTTON_PIN = 15;  // GP15
const uint8_t BUZZER_PIN = 14;  // GP14
#else
const uint8_t BUTTON_PIN = 2;   // Uno / Nano D2
const uint8_t BUZZER_PIN = 8;   // Uno / Nano D8
#endif
const bool USE_BUZZER = true;   // set to false if you have no buzzer

Adafruit_SSD1306 display(SCREEN_W, SCREEN_H, &Wire, -1);

// 8x8 player sprite, 1 = lit pixel (Adafruit drawBitmap format)
const unsigned char PROGMEM playerBmp[] = {
  0b00111100,
  0b01111110,
  0b11011011,
  0b11111111,
  0b01111110,
  0b00111100,
  0b01100110,
  0b11000011
};

// ---- Game tuning ----
const int GROUND_Y = 54;          // y of the ground line
const int PLAYER_X = 16;
const int PLAYER_SIZE = 8;
const float GRAVITY = 0.6;        // bigger = falls faster
const float JUMP_SPEED = -6.0;    // more negative = jumps higher
const float START_SPEED = 2.0;    // pixels per frame
const float MAX_SPEED = 5.0;
const unsigned long FRAME_MS = 33; // about 30 frames per second
const uint8_t MAX_OBS = 3;

enum State { TITLE, PLAYING, GAME_OVER };
State state = TITLE;

float playerY, velY;
bool onGround;
float obsX[MAX_OBS];
uint8_t obsW[MAX_OBS], obsH[MAX_OBS];
float speed;
uint32_t frames, score, best;
unsigned long lastFrame = 0, gameOverAt = 0;

// ---- Button with debounce ----
bool lastReading = HIGH, stableState = HIGH, pressLatched = false;
unsigned long lastChange = 0;

void pollButton() {
  bool reading = digitalRead(BUTTON_PIN);
  if (reading != lastReading) {
    lastChange = millis();
    lastReading = reading;
  }
  if (millis() - lastChange > 15 && reading != stableState) {
    stableState = reading;
    if (stableState == LOW) pressLatched = true;   // pressed (wired to GND)
  }
}

bool takePress() {
  bool p = pressLatched;
  pressLatched = false;
  return p;
}

void beep(unsigned int freq, unsigned long ms) {
  if (USE_BUZZER) tone(BUZZER_PIN, freq, ms);
}

// ---- High score in EEPROM (byte 0 = marker, bytes 1-4 = score) ----
void loadBest() {
#if defined(ESP32) || defined(ESP8266) || defined(ARDUINO_ARCH_RP2040)
  EEPROM.begin(16);
#endif
  if (EEPROM.read(0) == 0x42) EEPROM.get(1, best);
  else best = 0;
}

void saveBest() {
  EEPROM.write(0, 0x42);
  EEPROM.put(1, best);
#if defined(ESP32) || defined(ESP8266) || defined(ARDUINO_ARCH_RP2040)
  EEPROM.commit();
#endif
}

// ---- Obstacles ----
int minGap() {
  return (int)(speed * 20) + 12;  // 20 frames in the air, plus room to land
}

void spawnObstacle(uint8_t i, float x) {
  obsX[i] = x;
  obsW[i] = random(4, 9);         // 4 to 8 px wide
  obsH[i] = random(6, 15);        // 6 to 14 px tall
}

void resetGame() {
  playerY = GROUND_Y - PLAYER_SIZE;
  velY = 0;
  onGround = true;
  speed = START_SPEED;
  frames = 0;
  score = 0;
  float x = SCREEN_W + 10;
  for (uint8_t i = 0; i < MAX_OBS; i++) {
    spawnObstacle(i, x);
    x += minGap() + random(0, 40);
  }
}

// ---- One frame of game logic ----
void updateGame(bool pressed) {
  if (pressed && onGround) {
    velY = JUMP_SPEED;
    onGround = false;
    beep(880, 25);
  }
  velY += GRAVITY;
  playerY += velY;
  if (playerY >= GROUND_Y - PLAYER_SIZE) {
    playerY = GROUND_Y - PLAYER_SIZE;
    velY = 0;
    onGround = true;
  }

  float farthest = 0;
  for (uint8_t i = 0; i < MAX_OBS; i++) {
    obsX[i] -= speed;
    if (obsX[i] > farthest) farthest = obsX[i];
  }
  for (uint8_t i = 0; i < MAX_OBS; i++) {
    if (obsX[i] + obsW[i] < 0) {
      float x = farthest + minGap() + random(0, 40);
      if (x < SCREEN_W) x = SCREEN_W;
      spawnObstacle(i, x);
      farthest = x;
    }
  }

  // Collision: shrink the player box by 1 px so near misses feel fair
  int px = PLAYER_X + 1, py = (int)playerY + 1, ps = PLAYER_SIZE - 2;
  for (uint8_t i = 0; i < MAX_OBS; i++) {
    int ox = (int)obsX[i], oy = GROUND_Y - obsH[i];
    if (px < ox + obsW[i] && px + ps > ox && py < oy + obsH[i] && py + ps > oy) {
      state = GAME_OVER;
      gameOverAt = millis();
      beep(220, 300);
      if (score > best) {
        best = score;
        saveBest();
      }
      return;
    }
  }

  frames++;
  score = frames / 5;                        // about 6 points per second
  speed = START_SPEED + score * 0.01;        // slowly speeds up
  if (speed > MAX_SPEED) speed = MAX_SPEED;
}

// ---- Drawing ----
void drawGame() {
  display.clearDisplay();
  display.drawFastHLine(0, GROUND_Y, SCREEN_W, SSD1306_WHITE);
  display.drawBitmap(PLAYER_X, (int)playerY, playerBmp, 8, 8, SSD1306_WHITE);
  for (uint8_t i = 0; i < MAX_OBS; i++) {
    display.fillRect((int)obsX[i], GROUND_Y - obsH[i], obsW[i], obsH[i], SSD1306_WHITE);
  }
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.print(score);
  display.setCursor(80, 0);
  display.print(F("HI "));
  display.print(best);
  display.display();
}

void drawCentered(const __FlashStringHelper *text, int y, uint8_t size) {
  display.setTextSize(size);
  int w = strlen_P((const char *)text) * 6 * size;
  display.setCursor((SCREEN_W - w) / 2, y);
  display.print(text);
}

void drawTitle() {
  display.clearDisplay();
  drawCentered(F("OLED RUN"), 8, 2);
  drawCentered(F("Press to start"), 36, 1);
  display.setCursor(34, 50);
  display.print(F("Best: "));
  display.print(best);
  display.display();
}

void drawGameOver() {
  display.clearDisplay();
  drawCentered(F("GAME OVER"), 6, 2);
  display.setTextSize(1);
  display.setCursor(30, 30);
  display.print(F("Score: "));
  display.print(score);
  display.setCursor(30, 40);
  display.print(F("Best:  "));
  display.print(best);
  drawCentered(F("Press to retry"), 54, 1);
  display.display();
}

void setup() {
  Serial.begin(115200);
  pinMode(BUTTON_PIN, INPUT_PULLUP);    // button between pin and GND
  Wire.begin();                         // default I2C pins of your board
  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
    Serial.println(F("SSD1306 not found. Check wiring and address."));
    for (;;) {}
  }
  display.setTextColor(SSD1306_WHITE);
  loadBest();
  drawTitle();
}

void loop() {
  pollButton();                          // check the button every loop
  if (millis() - lastFrame < FRAME_MS) return;
  lastFrame = millis();
  bool pressed = takePress();

  if (state == TITLE) {
    if (pressed) {
      randomSeed(micros());              // press timing makes a good seed
      resetGame();
      state = PLAYING;
    }
  } else if (state == PLAYING) {
    updateGame(pressed);
    if (state == PLAYING) drawGame();
    else drawGameOver();
  } else {                               // GAME_OVER
    if (pressed && millis() - gameOverAt > 600) {
      resetGame();
      state = PLAYING;
    }
  }
}

What you should see: the title "OLED RUN" with "Press to start" and your best score. Press the button and a small character appears on the left above a ground line, with blocks scrolling in from the right. Your score counts up in the top left corner, next to your best score on the right. Hit a block and the game over screen appears, ready for another round.

How the game code works

Almost every small game uses the same pattern. You read input, update the game, then draw one frame. Once you understand these four parts, you can build Pong, Snake or a space shooter the same way.

The game loop and frame timing

loop() runs thousands of times a second, but the game only moves once every 33 ms. That is about 30 frames per second. The check millis() - lastFrame < FRAME_MS skips the rest of the loop until it is time for the next frame. This keeps the speed steady on fast and slow boards alike.

Avoid delay() for this. While a delay runs, the board cannot read the button, so short presses get lost. With millis(), the sketch calls pollButton() on every pass of the loop. A press is stored as a flag and used in the next frame.

On an Uno, sending the full screen over I2C is the slowest step. The library speeds the bus up to 400 kHz while it sends, but it still uses a large part of each frame. That is why the sketch calls display.display() only once per frame. ESP32 and Pico boards finish the same work with time to spare.

Reading the button without double jumps

Mechanical buttons "bounce". The metal contacts flicker on and off for a few milliseconds when you press them. The code only accepts a new state after the pin has stayed the same for 15 ms. It counts a press only on the change from HIGH to LOW. Holding the button down therefore gives one jump, not one jump per frame.

Jump physics in three lines

The player has a position playerY and a vertical speed velY. A jump sets velY to -6, which moves the player up because y counts down from the top of the screen. Every frame, gravity adds 0.6 to the speed, and the speed is added to the position. The player rises, slows, stops and falls back down, just like a real jump.

With these numbers a jump is about 30 pixels high and lasts about 20 frames. The tallest block is 14 pixels, so every block can be cleared. The minGap() function spaces the blocks at least 20 frames of travel apart. This keeps the game fair even at full speed, because you always have room to land before the next jump.

Collision checks with rectangles

Each block and the player are treated as rectangles. Two rectangles overlap when each one starts before the other ends, on both the x and y axes. That is the long if line in updateGame(). The player's box is shrunk by one pixel on each side. Without this, a hit that only touches a corner feels unfair, because the sprite has empty pixels at its corners.

Saving the high score in EEPROM

EEPROM is a small memory that keeps its contents when the power is off. The sketch writes a marker byte at address 0 and the 4-byte best score at addresses 1 to 4. On first boot the marker is missing, so the best score starts at zero instead of a random value.

The Uno has real EEPROM. The ESP32 and Pico copy it into flash memory instead. That is why the sketch calls EEPROM.begin() and EEPROM.commit() on those boards. The Arduino EEPROM library guide covers get() and put() in more detail. The score is only saved when you beat it, so the memory is not worn out by constant writes.

Make the game your own

The player is an 8x8 bitmap in the playerBmp array. Each line is one row of pixels, and each 1 is a lit pixel. You can edit it by hand, or draw a sprite in any paint program and convert it with the free Image to Byte Array converter. Choose Custom size, set 8 by 8 (or 16 by 16), and pick the Adafruit GFX drawBitmap format. If you make the sprite bigger, change PLAYER_SIZE and the two 8s in the drawBitmap() call.

For a two-frame running animation, make two bitmaps and swap them every few frames with (frames / 4) % 2. The guide to converting an image to a byte array for an OLED walks through the conversion step by step. For blocks, clouds or a moon in the background, the guide to drawing OLED shapes with drawCircle and drawRect lists every shape function.

SettingDefaultChange it to
GRAVITY0.60.4 for floaty jumps, 0.8 for snappy ones
JUMP_SPEED-6.0-7.0 to jump higher; keep jumps taller than the tallest block
START_SPEED2.01.5 for young kids, 3.0 for a hard start
MAX_SPEED5.06.0 or more for expert mode
FRAME_MS3325 on ESP32 or Pico for smoother motion

If you change gravity or jump speed, keep minGap() in step. The number 20 in it is the time in the air, which is two times the jump speed divided by gravity.

Turn it into a pocket or keychain game

To carry the game around, move it to a small board and a battery. A good choice is an ESP32-C3 SuperMini or a Pico with a 3.7 V LiPo cell, a USB-C charger module and a slide switch.

  • ESP32-C3 SuperMini: its default I2C pins are GPIO 8 (SDA) and GPIO 9 (SCL). It has no GPIO 13 or 25, so use GPIO 3 for the button and GPIO 4 for the buzzer, as the comments in the sketch say.
  • Power: connect the battery, through the charger module and switch, to VSYS on a Pico or to the 5V pin on an ESP32-C3 SuperMini. The board's own regulator makes 3.3 V, and the OLED runs from its 3V3 pin.
  • Battery life: a game screen is mostly black, which helps, because an OLED only uses power for lit pixels. The guide to OLED power consumption and battery life shows how to estimate run time.
  • Case: print a two-part case in PLA with a window for the screen and a hole above the button. Add a loop for a keyring if you like.

No hardware yet? You can try the sketch in a browser first. The Wokwi SSD1306 simulator guide shows how to add an OLED and a button to a virtual Uno.

Troubleshooting the Arduino OLED game

  • Problem: the screen stays black. Why: wrong wiring or the wrong I2C address. Fix: check SDA and SCL are not swapped, open the Serial Monitor at 115200 baud, and try 0x3D as the address. The SSD1306 blank screen fix guide has an I2C scanner sketch.
  • Problem: the game starts by itself or the player jumps nonstop. Why: the button reads LOW all the time. Fix: move the button so its two legs are on opposite sides, and check it goes to GND, not 5V.
  • Problem: jumps feel late or get missed. Why: a delay() was added somewhere. Fix: remove it, and keep pollButton() as the first line of loop().
  • Problem: the image is shifted by two pixels or shows noise on the right. Why: the screen is a 1.3 inch SH1106, not an SSD1306. Fix: use the Adafruit SH110X library, as explained in the FAQ below.
  • Problem: the Uno freezes or resets when you add more graphics. Why: the screen buffer takes half of the Uno's RAM. Fix: wrap text in F() and keep bitmaps in PROGMEM. The Uno memory limits and PROGMEM guide explains why.
  • Problem: the high score resets after unplugging on ESP32 or Pico. Why: the data was never written to flash. Fix: make sure EEPROM.commit() runs after EEPROM.put().
  • Problem: no sound from the buzzer. Why: it is an active buzzer, or the pin is wrong. Fix: use a passive buzzer, or set USE_BUZZER to false.

Next steps

Once the runner works, try adding a second button for a duck move, or a menu to pick easy, normal and hard modes. The guide to building an Arduino OLED menu with buttons shows how. For a cleaner title screen, design a logo in the OLED Animation Maker and follow the OLED logo splash screen guide to show it at startup. The Adafruit GFX graphics guide lists every drawing and text function you can use in your next game.

Frequently asked questions

Can an Arduino Uno run a game on an OLED display?

Yes. An Uno can run simple games like a runner, Pong or Snake on a 128x64 SSD1306. The screen buffer uses 1,024 of its 2,048 bytes of RAM, so keep text in flash with F() and keep the game state small.

How fast can an SSD1306 OLED refresh for a game?

Over I2C at 400 kHz, sending the full 128x64 screen takes roughly 25 ms, so about 30 frames per second is a realistic target. ESP32 and Pico boards have more spare time for game logic than an Uno.

Why is my OLED game laggy?

Usually the code uses delay() for timing or reads the button only once per frame. Use millis() to time frames, check the button on every pass of loop(), and call display.display() only once per frame.

How do I save a high score on Arduino?

Store it in EEPROM with EEPROM.put() and read it back with EEPROM.get(). On ESP32 and Pico, call EEPROM.begin() first and EEPROM.commit() after writing, because their EEPROM is emulated in flash.

Will this game work on an SH1106 1.3 inch OLED?

Not with the Adafruit SSD1306 library as it is. Swap it for the Adafruit SH110X library, change the display type to Adafruit_SH1106G and use SH110X_WHITE. The game logic stays the same.

Related guides

Free tools: Lithophane Maker for flat, curved, heart, round and cylinder lamp lithophanes, the Shadow Box Lamp Generator for wall shadow lamps, Text to STL for names and keychains, and 3MF to Web to show your prints on a website. See all tools.