Mirko Pavleski
Published © GPL3+

8-bit Space Shooter on ESP8266 & LED Matrix

A retro-style space shooting game built using an ESP8266 microcontroller and a 32x8 WS2812B LED matrix display.

BeginnerFull instructions provided2 hours310
8-bit Space Shooter on ESP8266 & LED Matrix

Things used in this project

Hardware components

NodeMCU ESP8266 Breakout Board
NodeMCU ESP8266 Breakout Board
×1
8x32 matrix with WS2812B Leds
×1
Pushbutton Switch, Momentary
Pushbutton Switch, Momentary
×3
Buzzer
Buzzer
×1
Resistor 10k ohm
Resistor 10k ohm
×3

Software apps and online services

Arduino IDE
Arduino IDE

Hand tools and fabrication machines

Soldering iron (generic)
Soldering iron (generic)
Solder Wire, Lead Free
Solder Wire, Lead Free

Story

Read more

Schematics

Schematic

...

Code

Code

C/C++
...
/*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;
}

Credits

Mirko Pavleski
190 projects • 1461 followers

Comments