A retro-style space shooting game built using an ESP8266 microcontroller and a 32x8 WS2812B LED matrix display.
Arcade games are fast-paced, simple-to-play video games and Goal is usually to get the highest score, not complete a story. In this type of games you’re always doing something (shooting, jumping, etc.), often usingjust a joystick with 1–3 buttons.

From one of my projects I have an 8x32 Led Matrix with WS2812B leds so I decided to make a simple horizontal shooting game on it, somewhat in the style of the popular Space Invaders. On one side, spaceships constantly appear randomly and move towards us, and the goal is to destroy as many enemy ships as possible before they destroy us.
The game is extremely simple to make and consists of only a few components:
- ESP8266 microcontroller board
- 8x32 Led Matrix with WS2812B leds
- 3 Buttons
- and Buzzer

I simplified the device's construction as much as possible, the matrix was first glued to the substrate, and then I placed frosted glass in front of it for a better visual effect with some light diffusion. Then I made a simple two-dimensional joystick that contains three colorful buttons with built-in LEDs, but standard buttons can also be used.

This project is sponsored by PCBWay(https://www.pcbway.com/?from=MirkoP ). They has all the services you need to create your project at the best price, whether is a scool project, or complex professional project. On PCBWay you can share your experiences, or get inspiration for your next project. They also provide completed Surface mount SMT PCB assemblY service at a best price, and ISO9001 quality control.
First, let me explain how the game works. When you turn on the display, a scrolling text appears with the content "Press Fire to Start".

Now if we press the FIRE button, by generating characteristic sound the game starts.
Our weapon is located on the left side of the display and is green. With each press of the up or down buttons, the weapon moves one space. On the other side, on the far right, enemy ships marked in red appear randomly, with a magenta missile on their tip. If we destroy the enemy, the missile remains until the end and we must avoid it so that it does not destroy us.

At the bottom right of the display, as many green dots appear as we have destroyed enemy ships. If an enemy ship hits our weapon, the game ends and the Game Over message appears on the display with appropriate sounds.

If we press the Fire button, a Score appears where for each destroyed enemy ship we get 10 points. By pressing the Fire button again, we return to the beginning and pressing the Fire button again starts a new game.

The destruction of an enemy object is accompanied by a simple animation, and otherwise the entire game is accompanied by appropriate sounds. Honestly, I spent more time introducing these simple sounds than I did for the entire development before, because the sound generation caused more problems in terms of the graphic part of the game. Even at the expense of the sound, some glitches remained in the game.
As for the code, it should be noted that it is not as simple as I initially imagined, but it is highly customizable and at the beginning of the code we can change many parameters, of which the most important for the gameplay are: PLAYER_SPEED, MISSILE_SPEED, ENEMY_SPEED and ENEMY_FIRE_RATE. Regarding the installation of the code, I used Arduino IDE v 1.8.16, and ESP8266 Core v 3.1.2.

And finally a short conclusion. This is a retro-style space shooting game built using an ESP8266 microcontroller and a 32x8 WS2812B LED matrix display, controlled by three push buttons for up/down movement and firing, with a buzzer for sound effects, and programmed using the Arduino IDE with the FastLED library.
/*ESP8266 Horizontal Shooter Game on 8x32 Matrix WS2812b
by mircemk, May 2025
*/
#include <FastLED.h>
// Matrix dimensions
#define MATRIX_WIDTH 32
#define MATRIX_HEIGHT 8
#define NUM_LEDS (MATRIX_WIDTH * MATRIX_HEIGHT)
// Pin definitions
#define LED_PIN D6
#define BTN_UP D2
#define BTN_DOWN D3
#define BTN_FIRE D4
#define BUZZER_PIN D8
// Game parameters
#define PLAYER_COLOR CRGB::Green
#define MISSILE_COLOR CRGB::Yellow
#define ENEMY_COLOR CRGB::Red
#define ENEMY_WEAPON_COLOR CRGB::Magenta
#define ENEMY_MISSILE_COLOR CRGB::Magenta
#define PLAYER_SPEED 1
#define MISSILE_SPEED 2
#define ENEMY_SPEED 0.5f
#define ENEMY_FIRE_RATE 0.02
#define SCROLL_SPEED 80
#define START_TEXT "PRESS FIRE TO START"
#define GAMEOVER_TEXT "GAME OVER"
#define SCORE_TEXT "SCORE - "
// Sound definitions
#define SOUND_SHOOT_FREQ 2000
#define SOUND_SHOOT_DURATION 40
#define SOUND_ENEMY_HIT_FREQ1 800
#define SOUND_ENEMY_HIT_FREQ2 1200
#define SOUND_ENEMY_HIT_DURATION 20
#define SOUND_GAMEOVER_FREQ1 400
#define SOUND_GAMEOVER_FREQ2 300
#define SOUND_GAMEOVER_DURATION 100
#define SOUND_START_FREQ 1000
#define SOUND_START_DURATION 150
#define SOUND_SCORE_FREQ 1500
#define SOUND_SCORE_DURATION 50
#define SQUARE_WAVE_DUTY_CYCLE 50 // Percentage of time the pin is HIGH
// Sound management
unsigned long soundEndTime = 0;
bool soundActive = false;
void playTone(int frequency, int duration) {
unsigned long period = 1000000L / frequency; // Period in microseconds
unsigned long halfPeriod = period / 2;
unsigned long startTime = micros();
while (micros() - startTime < duration * 1000L) {
digitalWrite(BUZZER_PIN, HIGH);
delayMicroseconds(halfPeriod);
digitalWrite(BUZZER_PIN, LOW);
delayMicroseconds(halfPeriod);
}
noTone(BUZZER_PIN); // Ensure the buzzer is off after the tone
}
void updateSound() {
if (soundActive && millis() > soundEndTime) {
noTone(BUZZER_PIN);
soundActive = false;
}
}
void playEnemyDestroyedSound() {
playTone(SOUND_ENEMY_HIT_FREQ1, SOUND_ENEMY_HIT_DURATION);
delay(60);
playTone(SOUND_ENEMY_HIT_FREQ2, SOUND_ENEMY_HIT_DURATION);
}
void playGameOverSound() {
playTone(SOUND_GAMEOVER_FREQ1, SOUND_GAMEOVER_DURATION);
delay(350);
playTone(SOUND_GAMEOVER_FREQ2, SOUND_GAMEOVER_DURATION);
}
// Game state variables
bool upButtonPressed = false;
bool downButtonPressed = false;
unsigned long lastMoveTime = 0;
#define MOVE_COOLDOWN 100
CRGB leds[NUM_LEDS];
enum GameState {
TITLE_SCREEN,
PLAYING,
GAME_OVER,
SCORE_DISPLAY
};
GameState gameState = TITLE_SCREEN;
unsigned long gameOverStartTime = 0;
bool waitingForFireButton = false;
// Game objects
struct Player {
int x = 0;
int y = MATRIX_HEIGHT / 2;
bool moveRequested = false;
} player;
struct Missile {
float xPos;
int x;
int y;
bool active = false;
};
#define MAX_MISSILES 3
Missile playerMissiles[MAX_MISSILES];
Missile enemyMissiles[5];
struct Enemy {
float xPos;
int x;
int y;
bool active = false;
bool hasWeapon = true;
};
#define MAX_ENEMIES 3
Enemy enemies[MAX_ENEMIES];
int score = 0;
int lives = 3;
unsigned long lastEnemySpawn = 0;
#define ENEMY_SPAWN_RATE 1500
bool fireButtonPressed = false;
unsigned long lastFireTime = 0;
#define FIRE_COOLDOWN 300
// Font 5x4
const uint8_t font5x4[44][4] = {
{31,20,20,31}, {31,21,21,10}, {14,17,17,10}, {31,17,17,14},
{31,21,21,17}, {31,20,20,16}, {14,17,21,14}, {31,4,4,31},
{17,31,17,17}, {2,1,1,30}, {31,4,10,17}, {31,1,1,1},
{31,12,12,31}, {31,12,3,31}, {14,17,17,14}, {31,20,20,8},
{14,17,19,14}, {31,20,22,9}, {8,21,21,2}, {16,16,31,16},
{30,1,1,30}, {28,3,3,28}, {31,3,12,31}, {27,4,4,27},
{24,4,3,28}, {19,21,25,17}, {0,0,0,0},
{14,17,17,14}, {0,17,31,1}, {19,21,21,9},
{17,21,21,14}, {28,4,4,31}, {30,21,21,18},
{14,21,21,2}, {16,19,20,24}, {10,21,21,10},
{8,21,21,14}, {0,4,0,0}, {0,0,0,0}
};
int getCharIndex(char c) {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= '0' && c <= '9') return c - '0' + 26; // Correct mapping for digits
if (c == '-') return 36;
if (c == ' ') return 37;
return 37;
}
int XY(int x, int y) {
int flippedX = (MATRIX_WIDTH - 1) - x;
int flippedY = (MATRIX_HEIGHT - 1) - y;
if (flippedX % 2 == 0) {
return (flippedX * MATRIX_HEIGHT) + flippedY;
} else {
return (flippedX * MATRIX_HEIGHT) + (MATRIX_HEIGHT - 1 - flippedY);
}
}
int XY_text(int x, int y) {
x = (MATRIX_WIDTH - 1) - x;
y = (MATRIX_HEIGHT - 1) - y;
if (x % 2 == 0) {
return (x * MATRIX_HEIGHT) + y;
} else {
return (x * MATRIX_HEIGHT) + (MATRIX_HEIGHT - 1 - y);
}
}
void enemyDestructionAnimation(int enemyX, int enemyY) {
// Light up the top and bottom positions around the enemy's red LED
// Adjust the positions if necessary to match your LED layout
leds[XY(enemyX, enemyY - 1)] = CRGB::Orange;
leds[XY(enemyX, enemyY + 1)] = CRGB::Orange;
FastLED.show();
// Keep the animation for 100 milliseconds before clearing
delay(100);
// Clear the animated LEDs (set to black)
leds[XY(enemyX, enemyY - 1)] = CRGB::Black;
leds[XY(enemyX, enemyY + 1)] = CRGB::Black;
FastLED.show();
}
void drawChar(int x, int y, char c, CRGB color) {
int charIndex;
if (c >= '0' && c <= '9') {
charIndex = getCharIndex(c + 1); // Try adding 1 to the character value
} else {
charIndex = getCharIndex(c);
}
for (int col = 0; col < 4; col++) {
if (x + col >= 0 && x + col < MATRIX_WIDTH) {
uint8_t pattern = font5x4[charIndex][col];
for (int row = 0; row < 5; row++) {
if (pattern & (1 << (4 - row))) {
if (y + row >= 0 && y + row < MATRIX_HEIGHT) {
leds[XY_text(x + col, y + row)] = color;
}
}
}
}
}
}
void scrollText(const char* text, CRGB color) {
static int scrollX = MATRIX_WIDTH;
static unsigned long lastScroll = 0;
if (millis() - lastScroll > SCROLL_SPEED) {
FastLED.clear();
int textLen = strlen(text);
for (int i = 0; i < textLen; i++) {
drawChar(scrollX + (i * 5), 1, text[i], color);
}
FastLED.show();
scrollX--;
if (scrollX < -(textLen * 5)) {
scrollX = MATRIX_WIDTH;
}
lastScroll = millis();
}
}
void firePlayerMissile() {
for (int i = 0; i < MAX_MISSILES; i++) {
if (!playerMissiles[i].active) {
playerMissiles[i].xPos = player.x + 1;
playerMissiles[i].x = (int)playerMissiles[i].xPos;
playerMissiles[i].y = player.y;
playerMissiles[i].active = true;
playTone(SOUND_SHOOT_FREQ, SOUND_SHOOT_DURATION);
break;
}
}
}
void handleInput() {
if (digitalRead(BTN_UP) == LOW) {
if (!upButtonPressed && millis() - lastMoveTime > MOVE_COOLDOWN) {
player.y = max(0, player.y - 1);
upButtonPressed = true;
lastMoveTime = millis();
}
} else {
upButtonPressed = false;
}
if (digitalRead(BTN_DOWN) == LOW) {
if (!downButtonPressed && millis() - lastMoveTime > MOVE_COOLDOWN) {
player.y = min(MATRIX_HEIGHT-1, player.y + 1);
downButtonPressed = true;
lastMoveTime = millis();
}
} else {
downButtonPressed = false;
}
if (digitalRead(BTN_FIRE) == LOW) {
if (!fireButtonPressed && millis() - lastFireTime > FIRE_COOLDOWN) {
firePlayerMissile();
fireButtonPressed = true;
lastFireTime = millis();
}
} else {
fireButtonPressed = false;
}
}
void updateGame() {
// Update player missiles
for (int i = 0; i < MAX_MISSILES; i++) {
if (playerMissiles[i].active) {
playerMissiles[i].xPos += MISSILE_SPEED;
playerMissiles[i].x = (int)playerMissiles[i].xPos;
if (playerMissiles[i].x >= MATRIX_WIDTH) {
playerMissiles[i].active = false;
}
}
}
// Spawn enemies
if (millis() - lastEnemySpawn > ENEMY_SPAWN_RATE) {
lastEnemySpawn = millis();
for (int i = 0; i < MAX_ENEMIES; i++) {
if (!enemies[i].active) {
enemies[i].xPos = MATRIX_WIDTH - 1;
enemies[i].x = (int)enemies[i].xPos;
enemies[i].y = random(0, MATRIX_HEIGHT - 1);
enemies[i].active = true;
enemies[i].hasWeapon = true;
break;
}
}
}
// Update enemies
for (int i = 0; i < MAX_ENEMIES; i++) {
if (enemies[i].active) {
enemies[i].xPos -= ENEMY_SPEED;
enemies[i].x = (int)enemies[i].xPos;
// Enemy firing
if (enemies[i].hasWeapon && random(100) < (ENEMY_FIRE_RATE * 100)) {
for (int j = 0; j < 5; j++) {
if (!enemyMissiles[j].active) {
enemyMissiles[j].xPos = enemies[i].xPos - 1;
enemyMissiles[j].x = (int)enemyMissiles[j].xPos;
enemyMissiles[j].y = enemies[i].y;
enemyMissiles[j].active = true;
break;
}
}
}
if (enemies[i].xPos < 0) enemies[i].active = false;
}
}
// Update enemy missiles
for (int i = 0; i < 5; i++) {
if (enemyMissiles[i].active) {
enemyMissiles[i].xPos -= ENEMY_SPEED;
enemyMissiles[i].x = (int)enemyMissiles[i].xPos;
if (enemyMissiles[i].xPos < 0) enemyMissiles[i].active = false;
}
}
checkCollisions();
}
void checkCollisions() {
// Player missiles vs enemies
for (int m = 0; m < MAX_MISSILES; m++) {
if (playerMissiles[m].active) {
for (int e = 0; e < MAX_ENEMIES; e++) {
if (enemies[e].active) {
if ((playerMissiles[m].x == enemies[e].x && playerMissiles[m].y == enemies[e].y) ||
(playerMissiles[m].x == enemies[e].x - 1 && playerMissiles[m].y == enemies[e].y)) {
playerMissiles[m].active = false;
// Save enemy position before deactivating (for animation)
int enemyX = enemies[e].x;
int enemyY = enemies[e].y;
enemies[e].active = false;
score += 10;
playEnemyDestroyedSound();
// Trigger the enemy destruction animation
enemyDestructionAnimation(enemyX, enemyY);
}
}
}
}
}
// Enemy missiles vs player
for (int i = 0; i < 5; i++) {
if (enemyMissiles[i].active && enemyMissiles[i].x == player.x && enemyMissiles[i].y == player.y) {
enemyMissiles[i].active = false;
if (--lives <= 0) gameOver();
}
}
// Enemies vs player
for (int i = 0; i < MAX_ENEMIES; i++) {
if (enemies[i].active && enemies[i].x == player.x && enemies[i].y == player.y) {
enemies[i].active = false;
if (--lives <= 0) gameOver();
}
}
}
void render() {
FastLED.clear();
// Draw player
leds[XY(player.x, player.y)] = PLAYER_COLOR;
// Draw player missiles
for (int i = 0; i < MAX_MISSILES; i++) {
if (playerMissiles[i].active) {
leds[XY(playerMissiles[i].x, playerMissiles[i].y)] = MISSILE_COLOR;
}
}
// Draw enemies
for (int i = 0; i < MAX_ENEMIES; i++) {
if (enemies[i].active) {
leds[XY(enemies[i].x, enemies[i].y)] = ENEMY_COLOR;
if (enemies[i].hasWeapon && enemies[i].x > 0) {
leds[XY(enemies[i].x - 1, enemies[i].y)] = ENEMY_WEAPON_COLOR;
}
}
}
// Draw enemy missiles
for (int i = 0; i < 5; i++) {
if (enemyMissiles[i].active) {
leds[XY(enemyMissiles[i].x, enemyMissiles[i].y)] = ENEMY_MISSILE_COLOR;
}
}
// Draw score LEDs
int scoreLeds = min(score / 10, MATRIX_WIDTH);
for (int i = 0; i < scoreLeds; i++) {
leds[XY(MATRIX_WIDTH - 1 - i, MATRIX_HEIGHT - 1)] = CRGB::Green;
}
}
void gameOver() {
// Flash red 3 times
for (int i = 0; i < 3; i++) {
fill_solid(leds, NUM_LEDS, CRGB::Red);
FastLED.show();
delay(300);
FastLED.clear();
FastLED.show();
delay(300);
}
playGameOverSound(); // play sound after flashes
gameState = GAME_OVER;
}
void loop() {
static unsigned long lastFrame = millis();
if (millis() - lastFrame < 33) {
delay(1);
return;
}
lastFrame = millis();
updateSound(); // keep sound system updated!
switch (gameState) {
case TITLE_SCREEN:
scrollText(START_TEXT, CRGB::Green);
if (digitalRead(BTN_FIRE) == LOW) {
playTone(SOUND_START_FREQ, SOUND_START_DURATION);
gameState = PLAYING;
resetGame();
delay(200);
}
break;
case PLAYING:
handleInput();
updateGame();
render();
FastLED.show();
break;
case GAME_OVER:
if (gameOverStartTime == 0) {
gameOverStartTime = millis();
waitingForFireButton = false;
}
scrollText(GAMEOVER_TEXT, CRGB::Red);
if (!waitingForFireButton && millis() - gameOverStartTime >= 3000) {
waitingForFireButton = true;
}
if (waitingForFireButton && digitalRead(BTN_FIRE) == LOW) {
gameState = SCORE_DISPLAY;
gameOverStartTime = 0;
waitingForFireButton = false;
delay(200);
}
break;
case SCORE_DISPLAY:
static bool scoreInitialized = false;
static unsigned long lastScrollUpdate = 0; // Control scroll update rate
if (!scoreInitialized) {
FastLED.clear();
scoreInitialized = true;
}
if (millis() - lastScrollUpdate > 75) { // Update every 75 milliseconds
char scoreText[30] = "SCORE ";
char scoreValue[6]; // Enough space for a 5-digit score + null terminator
itoa(score, scoreValue, 10); // Convert score to string (base 10)
strcat(scoreText, scoreValue); // Append score value to "SCORE "
FastLED.clear();
scrollText(scoreText, CRGB::Blue);
leds[XY(MATRIX_WIDTH-1, MATRIX_HEIGHT-1)] = CRGB::Black;
FastLED.show();
lastScrollUpdate = millis();
}
if (digitalRead(BTN_FIRE) == LOW) {
scoreInitialized = false;
gameState = TITLE_SCREEN;
delay(200);
}
break;
}
ESP.wdtFeed();
}
void setup() {
Serial.begin(115200);
FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS);
FastLED.setBrightness(30);
pinMode(BTN_UP, INPUT_PULLUP);
pinMode(BTN_DOWN, INPUT_PULLUP);
pinMode(BTN_FIRE, INPUT_PULLUP);
pinMode(BUZZER_PIN, OUTPUT);
resetGame();
}
void resetGame() {
FastLED.clear();
for (int i = 0; i < MAX_MISSILES; i++) playerMissiles[i].active = false;
for (int i = 0; i < 5; i++) enemyMissiles[i].active = false;
for (int i = 0; i < MAX_ENEMIES; i++) enemies[i].active = false;
player.x = 0;
player.y = MATRIX_HEIGHT / 2;
score = 0;
lives = 3;
lastEnemySpawn = millis();
player.moveRequested = false;
gameOverStartTime = 0;
waitingForFireButton = false;
}
