Want to turn a small ESP32 board into a mini arcade game you can actually play? This ESP32 OLED Mini Shooter Game uses a 128x64 OLED display and two push buttons to create a simple shooter experience. The player moves left and right, bullets fire upward, and enemies fall from the top of the screen. It is a small project, but it already feels like a real handheld game once the display starts updating.
This build is a great next step after basic OLED and button tutorials. Instead of only printing text or drawing one shape, the code manages several moving objects at the same time. It tracks the player, bullets, enemies, collisions, and game-over state.
The screen is divided into a simple grid. The 128x64 OLED becomes a 16x8 playfield, where each tile is 8x8 pixels. This makes object movement easier to understand because the player, enemies, and bullets move by grid position instead of raw pixel math.
Why build it?
This project teaches interactive programming on real hardware. The ESP32 reads button input, updates game objects, checks collisions, and draws the next frame on the OLED. That is much more active than a normal sensor display project.
It also teaches timing without blocking the whole game flow. The code uses millis() to control when bullets and enemies update, so they can move at different speeds. This is useful because many embedded projects need timed actions without stopping everything else.
What you'll learn
- ESP32 OLED display control - drawing text, squares, circles, and game objects on an SSD1306 screen.
-
Custom I2C pins - using
Wire.begin(5, 19)so the OLED uses GPIO5 for SDA and GPIO19 for SCL. - Button input handling - reading two push buttons for left and right movement.
- Debounce logic - preventing one press from being counted many times.
- Grid-based game design - turning a 128x64 screen into a simple 16x8 game map.
- Game object arrays - storing multiple bullets and enemies with active/inactive states.
-
Timer-based updates - using
millis()to move bullets and enemies at controlled intervals. - Collision checking - detecting when a bullet and enemy share the same grid position.
- Difficulty scaling - making enemies faster by slowly reducing their update interval.
- Game-over handling - stopping the game loop and showing a final screen.
What you'll need
- ESP32 Dev Board (CH9102, 30-pin micro USB)
- 0.96 inch I2C OLED display (SSD1306, 128x64)
- Tactile push buttons (2 pieces)
- 400-tie-point breadboard
- Dupont jumper wires
- USB cable for programming the ESP32
The buttons use the ESP32's internal pull-up resistors - each button connects from its GPIO pin to GND, so you do not need extra external resistors.
Wiring connections
OLED to ESP32:
- VCC to 3.3V
- GND to GND
- SDA to GPIO 5
- SCL to GPIO 19
Left button: one leg to GPIO 4, the other to GND.
Right button: one leg to GPIO 22, the other to GND.
Library setup
In the Arduino IDE: Sketch → Include Library → Manage Libraries, search for Adafruit SSD1306 and install it. The IDE may also ask for Adafruit GFX Library - install both. The built-in Wire library handles I2C and is already included.
After installing the libraries, select the correct ESP32 board and port, then open the Serial Monitor at 115200 baud to check startup messages.
The sketch
Paste the full sketch below into the Arduino IDE and upload. The game starts immediately on the OLED.
#include <Adafruit_SSD1306.h>
#include <Wire.h>
Adafruit_SSD1306 display(128, 64, &Wire, -1);
class DebounceButton {
public:
unsigned long buttonLastAction;
unsigned long debounceMS;
bool isAcceptingChanges;
int targetPin;
DebounceButton(int pin, unsigned long debounceMS) {
this->targetPin = pin;
this->buttonLastAction = millis();
this->debounceMS = debounceMS;
this->isAcceptingChanges = true;
pinMode(pin, INPUT_PULLUP);
}
bool CheckPress() {
bool btnValue = digitalRead(targetPin);
if (btnValue == LOW &&
(millis() - buttonLastAction) > debounceMS &&
isAcceptingChanges) {
buttonLastAction = millis();
isAcceptingChanges = false;
return true;
}
if (btnValue == HIGH &&
!isAcceptingChanges &&
(millis() - buttonLastAction) > debounceMS) {
isAcceptingChanges = true;
}
return false;
}
};
DebounceButton BTL(4, 200);
DebounceButton BTR(22, 200);
const int MAX_BULLETS = 5;
const int MAX_ENEMIES = 4;
const int GRID_W = 16;
const int GRID_H = 8;
unsigned long BULLET_INTERVAL = 750;
unsigned long ENEMY_INTERVAL = 1500;
class Bullet { public: uint8_t bx, by; bool active; Bullet(){bx=0; by=0; active=false;} };
class Enemy { public: uint8_t ex, ey; bool active; Enemy(){ex=0; ey=0; active=false;} };
Bullet playersBullets[MAX_BULLETS];
Enemy enemies[MAX_ENEMIES];
int px = 4;
int py = 6;
unsigned long lastBulletTick = 0;
unsigned long lastEnemyTick = 0;
bool gameOver = false;
void spawnBullet() {
for (int i = 0; i < MAX_BULLETS; i++) {
if (!playersBullets[i].active) {
playersBullets[i].bx = px;
playersBullets[i].by = py - 1;
playersBullets[i].active = true;
return;
}
}
}
void updateBullets() {
for (int i = 0; i < MAX_BULLETS; i++) {
if (!playersBullets[i].active) continue;
if (playersBullets[i].by == 0) playersBullets[i].active = false;
else playersBullets[i].by--;
}
}
void drawBullets() {
for (int i = 0; i < MAX_BULLETS; i++) {
if (playersBullets[i].active) {
display.fillRect(playersBullets[i].bx*8+2, playersBullets[i].by*8, 4, 6, WHITE);
}
}
}
void spawnEnemy() {
for (int i = 0; i < MAX_ENEMIES; i++) {
if (!enemies[i].active) {
enemies[i].ex = random(0, GRID_W);
enemies[i].ey = 0;
enemies[i].active = true;
return;
}
}
}
void updateEnemies() {
for (int i = 0; i < MAX_ENEMIES; i++) {
if (!enemies[i].active) continue;
enemies[i].ey++;
if (enemies[i].ey >= GRID_H) { gameOver = true; return; }
}
}
void drawEnemies() {
for (int i = 0; i < MAX_ENEMIES; i++) {
if (enemies[i].active) {
display.drawCircle(enemies[i].ex*8+4, enemies[i].ey*8+4, 3, WHITE);
}
}
}
void checkCollisions() {
for (int i = 0; i < MAX_ENEMIES; i++) {
if (!enemies[i].active) continue;
for (int b = 0; b < MAX_BULLETS; b++) {
if (!playersBullets[b].active) continue;
if (playersBullets[b].bx == enemies[i].ex && playersBullets[b].by == enemies[i].ey) {
enemies[i].active = false;
playersBullets[b].active = false;
}
}
}
}
void setup() {
Serial.begin(115200);
Wire.begin(5, 19);
randomSeed(analogRead(0));
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println("ERR LOADING LCD SCREEN");
}
display.clearDisplay();
display.display();
}
void loop() {
if (gameOver) {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(WHITE);
display.setCursor(28, 28);
display.println("GAME OVER");
display.display();
return;
}
unsigned long now = millis();
bool leftButton = BTL.CheckPress();
bool rightButton = BTR.CheckPress();
if (rightButton) px++;
else if (leftButton) px--;
if (px > GRID_W - 1) px = GRID_W - 1;
if (px < 0) px = 0;
if (now - lastBulletTick >= BULLET_INTERVAL) {
lastBulletTick = now;
spawnBullet();
updateBullets();
}
if (now - lastEnemyTick >= ENEMY_INTERVAL) {
lastEnemyTick = now;
spawnEnemy();
updateEnemies();
if (ENEMY_INTERVAL > 300) ENEMY_INTERVAL -= 5;
}
checkCollisions();
display.clearDisplay();
display.fillRect(px*8, py*8, 8, 8, WHITE);
drawBullets();
drawEnemies();
display.display();
}
How it works
The game screen is treated as a grid instead of raw pixels - 16 columns by 8 rows, each cell 8x8 pixels. The player position is stored as px and py, then drawn as an 8x8 filled square.
The buttons control only the player's horizontal movement. Pressing the right button increases px; pressing the left button decreases it. The code limits the value so the player cannot move outside the screen.
The DebounceButton class waits a short time before accepting another change, so a mechanical button bounce does not register as multiple presses.
Bullets and enemies each live in fixed-size arrays. When a slot becomes inactive, it can be reused for the next bullet or enemy. Enemies spawn at random columns on the top row and move downward. If any enemy reaches the bottom of the grid, the game changes to game-over state.
Collisions are checked by comparing grid positions: if a bullet and enemy share the same x and y, both become inactive. The game gets harder over time by reducing ENEMY_INTERVAL, but never below 300 ms - which keeps it playable.
Extensions
- Score counter - increment on each hit, render on the OLED.
- Restart button - one button click after game-over resets everything without the ESP32 reset button.
- Buzzer feedback - short beep for fire, different tone for hit, lower tone for game over.
- 3D-printed case - mount the ESP32, OLED, and buttons in a small enclosure for a portable mini console.
The full tutorial with Fritzing diagram and demo video lives on our learn site: ESP32 OLED Mini Shooter Game.
Originally published on blog.circuit.rocks.
Top comments (0)