Week 10 — Output Devices

Group Assignment

Individual Assignment

Learning Outcomes


Overview

For this week, I tested three output devices on my board:

  1. Servo motor
  2. Buzzer
  3. I2C LCD display

Video Links


1. Servo Motor

I installed the ESP32Servo library in Arduino IDE and tested the servo on pin D10. First I verified simple left-right movement, then moved to smoother and more expressive motion.

Servo wiring and test setup on board
Servo setup connected to XIAO ESP32-C3 board.

Step 1 was confirming that the hardware and library were working with a very simple motion test. After that, I moved to a second test to explore better movement quality and behavior design.

AI Request (Prompt)

I connected the servo motor to XIAO ESP32-C3, and it is connected to pin D10.
Give me some code on how to control a servo motor with some interesting motions.

Code 1 - Basic sweep

#include <ESP32Servo.h>

Servo myServo;

void setup() {
  // Standard servo signal frequency
  myServo.setPeriodHertz(50);
  // Attach the servo signal wire to pin D10 and set pulse range
  myServo.attach(D10, 500, 2400);
}

void loop() {
  // Move from 0 to 180 degrees, 2 degrees at a time
  for (int pos = 0; pos <= 180; pos += 2) {
    // Send the current angle to the servo
    myServo.write(pos);
    // Small delay makes the motion visible and not too fast
    delay(15);
  }

  // Move back from 180 to 0 degrees
  for (int pos = 180; pos >= 0; pos -= 2) {
    // Send the current angle while counting backwards
    myServo.write(pos);
    delay(15);
  }
}

Code explanation

In this first code I attach the servo to D10 and move it from 0 to 180 degrees. The servo position changes by 2 degrees each time, and delay(15) makes each small movement a little slower so I can see the sweep. After it reaches 180, the second loop does the same thing backwards and returns the servo to 0.

Result 1

Servo motor moved left and right through the full 180 degree range.

Video 1 - Basic servo sweep.

After confirming the basic sweep worked, I wanted to make some more interesting motion for the servo motor. I asked AI to create a different type of movement based on the previous program, with smoother transitions and more expressive behavior.

AI Request (Prompt)

Based on the previous servo program, create a new version with more interesting motion.
Use the same board and pin (XIAO ESP32-C3, D10), and add smooth transitions,
speed variation, and a short wiggle-style movement.

Code 2 - Smooth and varied motion

// Load the servo library for ESP32 boards
#include <ESP32Servo.h>

// Create one servo object
Servo myServo;

void smoothMove(int startPos, int endPos, int stepDelay) {
  // This function moves the servo smoothly between two angles
  // startPos is the angle where movement begins
  // endPos is the angle where movement finishes
  // stepDelay changes the speed: bigger number = slower movement
  if (startPos < endPos) {
    // Count upward if the target angle is larger
    for (int pos = startPos; pos <= endPos; pos++) {
      // Send each angle one by one to the servo
      myServo.write(pos);
      // stepDelay controls the speed of this movement
      delay(stepDelay);
    }
  } else {
    // Count downward if the target angle is smaller
    for (int pos = startPos; pos >= endPos; pos--) {
      // Send each angle one by one while moving backwards
      myServo.write(pos);
      // Same speed control for the backward movement
      delay(stepDelay);
    }
  }
}

void setup() {
  // Set normal servo frequency
  myServo.setPeriodHertz(50);
  // Same servo pin as the first test
  myServo.attach(D10, 500, 2400);
  // Start from the middle position
  myServo.write(90);
  delay(1000);
}

void loop() {
  // Slow move to one side
  smoothMove(90, 20, 12);  // 90 -> 20, slower because delay is 12
  delay(300);

  // Faster move across to the other side
  smoothMove(20, 160, 6);  // 20 -> 160, faster because delay is 6
  delay(300);

  // Return back to the center
  smoothMove(160, 90, 10);  // 160 -> 90, medium speed
  delay(500);

  // Short wiggle movement around the center
  myServo.write(70); delay(120);   // small move left
  myServo.write(110); delay(120);  // small move right
  myServo.write(80); delay(120);   // left again
  myServo.write(100); delay(120);  // right again

  // Finish by returning to the center
  myServo.write(90);
  delay(1000);
}

Code explanation

In this version I understood that the main new part is the smoothMove() function. Instead of writing every movement again, I can give it a start angle, an end angle, and a delay value. A bigger delay makes the servo move slower, and a smaller delay makes it move faster. I changed the motion pattern by moving from 90 to 20, then faster to 160, then back to the center, and finally I added a small wiggle using quick angle changes around 90.

Result 2

Servo movement became smoother, faster in some segments, and included short "wiggle" actions.

Video 2 - Advanced servo motion sequence.

2. Buzzer

I connected a buzzer to D10 and tested two interactions using a button: single-click for a short blaster sound and double-click to start a melody.

In this step, I wanted to practice output behavior based on user input patterns. The goal was to build two clear interactions: short press for sound effect and multi-click behavior for melody.

ChatGPT Request

Write Arduino code for a buzzer connected to pin D10 and a button connected to pin D7 on a XIAO ESP32-C3.
When I press the button once, it should play a short sci-fi blaster shot sound.
When I double-click the button, it should start playing a Star Wars-style melody on the buzzer.
By default, there should be no sound.

Code - Button + buzzer interaction

const int buzzerPin = D10;  // buzzer signal pin
const int buttonPin = D7;   // button input pin

// ---------- Notes ----------
// These numbers are sound frequencies for musical notes.
#define NOTE_C4 262
#define NOTE_D4 294
#define NOTE_E4 330
#define NOTE_F4 349
#define NOTE_FS4 370
#define NOTE_G4 392
#define NOTE_GS4 415
#define NOTE_A4 440
#define NOTE_B4 494
#define NOTE_C5 523
#define NOTE_D5 587
#define NOTE_E5 659
#define NOTE_F5 698
#define NOTE_FS5 740
#define NOTE_G5 784
#define NOTE_GS5 831
#define NOTE_A5 880

// ---------- Melody ----------
// This array stores the melody notes.
int melody[] = {
  NOTE_A4, NOTE_A4, NOTE_A4,
  NOTE_F4, NOTE_C5,
  NOTE_A4, NOTE_F4, NOTE_C5, NOTE_A4,

  NOTE_E5, NOTE_E5, NOTE_E5,
  NOTE_F5, NOTE_C5,
  NOTE_GS4, NOTE_F4, NOTE_C5, NOTE_A4,

  NOTE_A5, NOTE_A4, NOTE_A4,
  NOTE_A5, NOTE_GS5, NOTE_G5, NOTE_FS5, NOTE_F5,
  NOTE_FS5
};

// This array stores how long each note plays.
int noteDurations[] = {
  500, 500, 500,
  350, 150,
  500, 350, 150, 650,

  500, 500, 500,
  350, 150,
  500, 350, 150, 650,

  500, 350, 150,
  500, 250, 250, 125, 125, 250,
  250
};

// ---------- Button handling ----------
bool lastButtonReading = HIGH;
unsigned long lastDebounceTime = 0;
const unsigned long debounceDelay = 30;

int clickCount = 0;                         // counts one click or two clicks
unsigned long firstClickTime = 0;           // time when first click happened
const unsigned long doubleClickGap = 350;   // time window for double click

// ---------- Music state ----------
bool musicPlaying = false;          // true while melody is playing
int currentNote = 0;                // current note position in the melody
unsigned long noteStartTime = 0;    // when the current note started
unsigned long noteTotalTime = 0;    // how long the current note should play
bool noteActive = false;            // true while one note is active

void setup() {
  // Buzzer is an output because the board sends sound signals to it.
  pinMode(buzzerPin, OUTPUT);
  // Button uses INPUT_PULLUP, so pressed means LOW.
  pinMode(buttonPin, INPUT_PULLUP);
}

// Aggressive sci-fi shoot sound
void playShootSound() {
  // Stop any sound before starting the effect.
  noTone(buzzerPin);

  for (int burst = 0; burst < 3; burst++) {
    // Sweep frequency downward to make a blaster-like sound.
    for (int freq = 2200; freq >= 700; freq -= 120) {
      tone(buzzerPin, freq);
      delay(10);
    }
    noTone(buzzerPin);
    delay(15);
  }

  noTone(buzzerPin);
}

// Start melody from beginning
void startMusic() {
  // Reset melody variables before playing.
  musicPlaying = true;
  currentNote = 0;
  noteActive = false;
}

// Stop melody
void stopMusic() {
  // Stop playback and silence the buzzer.
  musicPlaying = false;
  currentNote = 0;
  noteActive = false;
  noTone(buzzerPin);
}

// Non-blocking melody player
void updateMusic() {
  // If music is off, leave this function immediately.
  if (!musicPlaying) return;

  // Count how many notes are in the melody array.
  int totalNotes = sizeof(melody) / sizeof(melody[0]);

  if (currentNote >= totalNotes) {
    // Stop when all notes have finished.
    stopMusic();
    return;
  }

  if (!noteActive) {
    int duration = noteDurations[currentNote];
    // Play the current note and save the start time.
    tone(buzzerPin, melody[currentNote]);
    noteStartTime = millis();
    noteTotalTime = duration;
    noteActive = true;
    return;
  }

  unsigned long elapsed = millis() - noteStartTime;

  if (elapsed >= noteTotalTime) {
    // Stop the sound but keep a short gap before the next note.
    noTone(buzzerPin);
  }

  if (elapsed >= (unsigned long)(noteTotalTime * 1.3)) {
    // Move to the next note after note time plus a pause.
    currentNote++;
    noteActive = false;
  }
}

// Detect click and single/double click logic
void updateButton() {
  // Read the button state.
  bool reading = digitalRead(buttonPin);

  if (reading != lastButtonReading) {
    // Button changed, so reset debounce timer.
    lastDebounceTime = millis();
    lastButtonReading = reading;
  }

  if ((millis() - lastDebounceTime) > debounceDelay) {
    // After debounce delay, accept the reading as stable.
    static bool lastStableState = HIGH;

    if (reading != lastStableState) {
      lastStableState = reading;

      if (lastStableState == LOW) {  // button pressed
        clickCount++;

        if (clickCount == 1) {
          // Save time of first click and wait to see if second click comes.
          firstClickTime = millis();
        } else if (clickCount == 2) {
          // Double click detected
          clickCount = 0;
          noTone(buzzerPin);
          startMusic();
        }
      }
    }
  }

  // If only one click happened and time expired -> single click action
  if (clickCount == 1 && (millis() - firstClickTime > doubleClickGap)) {
    clickCount = 0;
    if (!musicPlaying) {
      playShootSound();
    }
  }
}

void loop() {
  // Check button events.
  updateButton();
  // Keep melody running while the main loop continues.
  updateMusic();
}

Code explanation

This code is longer, but I understand it as a few small blocks. First I set D10 as the buzzer output and D7 as the button input. The note list is just frequencies for the melody, and the duration list controls how long each note plays. playShootSound() makes a short sound by changing frequency quickly. updateButton() checks if I pressed once or twice, and updateMusic() keeps the melody playing without stopping the whole program. In the main loop(), I only keep checking the button and updating the music.

Result 3

In this buzzer code, the board first waits and reads the button on D7. Because the button can give small noisy changes, the code uses debounce timing before accepting the press. If there is only one click inside the time window, it calls playShootSound(), so the buzzer on D10 makes a short blaster sound by sweeping the frequency down. If there are two clicks close together, the code starts the melody instead. The melody is handled by updateMusic(), which uses millis() timing, so the program can keep checking the button while the sound is playing. This helped me understand that the buzzer is not only "on/off"; the behavior comes from timing, frequency changes, and button logic working together.

Video 3 - Buzzer output test.

3. I2C LCD Display

For the I2C screen, I used four connections: VCC, GND, SDA, and SCL. On my board, SDA = D4 and SCL = D5.

I split this into two parts: first, verify basic I2C communication and LCD output; second, reuse the working setup to test a simple interactive mini-game with a button.

XIAO ESP32 pin map reference for I2C pins
Pin map reference used to identify I2C pins.
I2C display wiring photo 1
Wiring photo of LCD and board.
I2C display wiring photo 2
Close-up of LCD wiring.

I2C (Inter-Integrated Circuit) is a communication protocol that allows a microcontroller to exchange data with other devices using only two signal lines: SDA and SCL. This is why an I2C display usually has four pins in total: VCC, GND, SDA, and SCL. VCC provides power to the display, and GND provides the ground connection. The two other pins are used for communication. SDA stands for Serial Data, and it carries the actual information being sent between the microcontroller and the display. SCL stands for Serial Clock, and it provides the timing signal that tells the devices when to read or send each bit of data.

The communication works in a synchronized way. The SDA line carries the bits of information, while the SCL line provides the clock pulses. Each time the clock changes, the receiving device knows when to read the next bit from the data line. In this way, the display receives the information one bit at a time, reconstructs the bytes, and then processes them according to whether they are commands or display data.

I2C connection diagram snippet
Quick wiring diagram for SDA/SCL mapping.

AI Request (Prompt)

My I2C display is connected with VCC and GND.
SCL is connected to pin D5 and SDA is connected to pin D4.
Make a simple program for the screen just to check that it works.

Code - I2C LCD check program

// Wire library is used for I2C communication
#include <Wire.h>
// Library for controlling the 16x2 I2C LCD
#include <LiquidCrystal_I2C.h>

// LCD address is 0x20, with 16 columns and 2 rows
LiquidCrystal_I2C lcd(0x20, 16, 2);

void setup() {
  // Start I2C using my board pins: SDA = D4, SCL = D5
  Wire.begin(D4, D5);
  // Initialize the LCD module
  lcd.init();
  // Turn on the LCD backlight
  lcd.backlight();

  // Set cursor to first column, first row
  lcd.setCursor(0, 0);
  lcd.print("Hello Yaro!");

  // Set cursor to first column, second row
  lcd.setCursor(0, 1);
  lcd.print("LCD works");
}


void loop() {
  // Nothing repeats in this test; the message stays on the screen
}

Code explanation

This first LCD code is only a communication check. I start the I2C connection with Wire.begin(D4, D5) because my board uses D4 for SDA and D5 for SCL. Then I initialize the LCD, turn on the backlight, choose the row and column with setCursor(), and print two short messages. The loop() is empty because this test only needs to show that the LCD can receive data and display text.

LCD game development photo
LCD game development and test setup.

LCD screen is working, and now I can write and experiment with different code.

LCD Mini-Game (Button on D7)

I also tested a simple obstacle-jump style game idea on the LCD using button input from D7.

AI Request (Prompt)

Make a simple interaction game for a 16x2 I2C LCD and a button on D7.
Maybe something like a simple jump-over-obstacle game.
Use XIAO ESP32-C3 with SDA on D4 and SCL on D5.

Code - I2C LCD Button jump game

// I2C communication library
#include <Wire.h>
// Library for 16x2 I2C LCD control
#include <LiquidCrystal_I2C.h>

// LCD address, columns, and rows
LiquidCrystal_I2C lcd(0x20, 16, 2);   // change to 0x7C if needed

const int buttonPin = D7;   // button used for jump and restart

// Button debounce variables
bool lastButtonState = HIGH;
unsigned long lastDebounceTime = 0;
const unsigned long debounceDelay = 30;

int playerRow = 1;          // 1 = ground row, 0 = jump row
bool isJumping = false;     // true while player is in the air
unsigned long jumpStartTime = 0;  // time when jump started
const unsigned long jumpDuration = 500;  // how long the jump lasts

int obstacleCol = 15;       // obstacle starts at the right side of the screen
int score = 0;              // score increases when obstacle passes
bool gameOver = false;      // stops the game when collision happens
unsigned long lastStepTime = 0;  // controls when game updates
int gameSpeed = 250;        // smaller = faster

void setup() {
  // Button uses internal pull-up, so pressed means LOW.
  pinMode(buttonPin, INPUT_PULLUP);

  // Start I2C on my board pins.
  Wire.begin(D4, D5);
  // Start LCD and turn on light.
  lcd.init();
  lcd.backlight();

  // Show start screen before game begins.
  showStartScreen();
}

void loop() {
  if (gameOver) {
    // After game over, button restarts the game.
    if (buttonPressed()) {
      resetGame();
    }
    return;
  }

  if (buttonPressed() && !isJumping) {
    // Start jump only if player is not already jumping.
    isJumping = true;
    jumpStartTime = millis();
    playerRow = 0;
  }

  if (isJumping && millis() - jumpStartTime > jumpDuration) {
    // After jump time is finished, return to ground.
    isJumping = false;
    playerRow = 1;
  }

  if (millis() - lastStepTime > gameSpeed) {
    // Update and redraw the game based on timing, not delay().
    lastStepTime = millis();
    updateGame();
    drawGame();
  }
}

bool buttonPressed() {
  // Read the current button state.
  bool reading = digitalRead(buttonPin);

  if (reading != lastButtonState) {
    // Reset debounce timer when button state changes.
    lastDebounceTime = millis();
    lastButtonState = reading;
  }

  if ((millis() - lastDebounceTime) > debounceDelay) {
    // Accept the button only after it stays stable.
    static bool stableState = HIGH;

    if (reading != stableState) {
      stableState = reading;
      if (stableState == LOW) {
        // Return true only when a real press is detected.
        return true;
      }
    }
  }

  return false;
}

void showStartScreen() {
  // Clear screen and show title/start instruction.
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print("Mini Jump Game");
  lcd.setCursor(0, 1);
  lcd.print("Press btn start");

  while (!buttonPressed()) {
    // Wait here until button is pressed.
    delay(10);
  }

  resetGame();
}

void resetGame() {
  // Put all game variables back to starting values.
  playerRow = 1;
  isJumping = false;
  obstacleCol = 15;
  score = 0;
  gameOver = false;
  gameSpeed = 250;
  lcd.clear();
  drawGame();
}

void updateGame() {
  // Move obstacle one column left each update.
  obstacleCol--;

  if (obstacleCol < 0) {
    // If obstacle leaves screen, restart it from the right.
    obstacleCol = 15;
    score++;

    if (gameSpeed > 120) {
      gameSpeed -= 10;   // gradually increase difficulty
    }
  }

  if (obstacleCol == 1 && playerRow == 1) {
    // Collision: obstacle reaches player while player is on ground.
    gameOver = true;
    showGameOver();
  }
}

void drawGame() {
  // Redraw the full screen each frame.
  lcd.clear();

  // player
  lcd.setCursor(1, playerRow);
  lcd.print("P");

  // obstacle
  lcd.setCursor(obstacleCol, 1);
  lcd.print("#");

  // score
  lcd.setCursor(10, 0);
  lcd.print("S:");
  lcd.print(score);
}

void showGameOver() {
  // Show final score, then restart instruction.
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print("Game Over!");
  lcd.setCursor(0, 1);
  lcd.print("Score:");
  lcd.print(score);
  delay(1500);

  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print("Press button");
  lcd.setCursor(0, 1);
  lcd.print("to restart");
}

Code explanation

This LCD game uses the same I2C setup as the first LCD test, but now it also reads a button on D7. The player is shown as P, and the obstacle is shown as #. When I press the button, the code changes the player row from the ground row to the top row for a short jump time, then brings it back down. The obstacle moves from right to left using timing from millis(), and when it passes the player the score increases and the game becomes a little faster. If the obstacle reaches the player while the player is on the ground, the game switches to game over and waits for another button press to restart.

LCD game running on screen
LCD game behavior after deployment.
Video 4 - I2C LCD game demo.

4. Space Invaders on QPad (RP2040 + OLED)

Next, I decided to build a QPad board based on Quentin Bolsee's QPad project. I used QPad Xiao because I have an RP2040 microcontroller.

QPad source: https://gitlab.cba.mit.edu/quentinbolsee/qpad-xiao

QPad milling and board preparation photo 1
Milled board .
QPad milling and board preparation photo 2
Drilled holes by drill press

On Mods project (https://modsproject.org/) I found a mill-2D PCB with G-code output that fits my Lunyee 3018 CNC machine. It was also an experiment to use the new CNC milling machine and different end mills.

With my CNC machine, I do not have tiny bits (or enough experience yet) for drilling all holes with the same setup, so I drilled the holes using a drill press. For components layout, I soldered pin headers so the XIAO RP2040 and display can be connected on top without permanent soldering. I also soldered two resistors. After testing with hello world, the display worked correctly.

QPad board components and pin setup
Header/pin setup for removable modules.
Tetris demo on QPad display
Tetris test from Quentin's repository.

Tetris reference used for initial testing: qpad_tetris (Quentin Bolsee)

Based on the existing code, I created several requests to ChatGPT to build a Space Invaders game. After the first version, I used multiple additional prompts to improve behavior and visuals.

AI Request (Prompt) 1

Make a space invaders game based on the tetris code and device configuration.
Also make an animation preview screen with cool graphics for my small screem.

AI Request (Prompt) 2

The code is working, the platform is moving, but it's not enemies, no space invaders coming,
which I can shoot. Also, you can make platform looks more beautiful,
and space invaders also can have different shapes.

AI Request (Prompt) 3

Okay, good, it's working, but when I finish, it's no next level.
Make at least three levels with different types of invaders.
Also, the welcome screen can be different graphics, maybe some simple animation,
several frames of animation like Space Invaders, and put the text a bit higher
and maybe some characters and platform fighting.

AI Request (Prompt) 4

Okay good, it's a few adjustments:
- I want you to add some space and whether the invaders' text can be in the center
  because the last word is out of the screen.
- You can add text by Yaro underneath. Maybe this text by Yaro can be on the left side;
  then on the right side it can be a 3D animation of the space invader monster face.
- Also don't forget to make the space invaders monster and the platform interesting shapes.
- When the game is over, when next level, it can be the message "Level complete. Press B for the next level"
  and put the invaders a little bit higher because they are too low and a little bit more dense
  with each other in the vertical direction.
- Also when the invaders reach the bottom or hit the platform, it's game over.
  In place press B to restart.

AI Request (Prompt) 5

It's one strange thing when I kill invaders from the bottom line,
but it's still some invaders on the top line. I still have game over
when probably bottom line is without any invaders, which is a bottom check.
Please to be sure that only invaders which are not eliminated can reach the bottom
and cause game over.

Code - Space Invaders V4


// SPACE INVADERS V4
// XIAO RP2040 + SSD1306 OLED + touch pads
// Updated with centered title, intro art, denser/higher invaders,
// level complete screen, game over rules, and improved sprite shapes.
#include <Arduino.h>
#include <U8g2lib.h>
#include <Wire.h>

// Create the OLED display object using hardware I2C.
U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE);

// Touch/button pins on the QPad.
static const uint8_t PIN_LEFT = D1;
static const uint8_t PIN_RIGHT = D7;
static const uint8_t PIN_A = D9;
static const uint8_t PIN_B = D10;

#define N_TOUCH 4
#define THRESHOLD 6

// Store all touch pins in one array so they can be checked in a loop.
int touch_pins[N_TOUCH] = { PIN_LEFT, PIN_RIGHT, PIN_A, PIN_B };
bool nowT[N_TOUCH] = {false, false, false, false};
bool pastT[N_TOUCH] = {false, false, false, false};

void update_touch() {
  // Touch sensing works by discharging the pin, then measuring how long it stays low.
  for (int i = 0; i < N_TOUCH; i++) {
    int p = touch_pins[i];
    pinMode(p, OUTPUT);
    digitalWrite(p, LOW);
    delayMicroseconds(20);
    pinMode(p, INPUT_PULLUP);
    int t = 0;
    while (!digitalRead(p) && t < 200) t++;
    pastT[i] = nowT[i];
    nowT[i] = (t > THRESHOLD);
  }
}

// pressed() is true only once when a touch starts.
bool pressed(int i) { return nowT[i] && !pastT[i]; }
// held() stays true while the touch pad is still touched.
bool held(int i) { return nowT[i]; }
enum { T_LEFT = 0, T_RIGHT = 1, T_A = 2, T_B = 3 };
// ================= GAME CONSTANTS =================
// Enemy grid: 5 columns and 3 rows.
#define EC 5
#define ER 3
#define MAX_BULLETS 4

const int PLAYER_Y = 58;
const int PLAYER_W = 12;
const int ENEMY_W = 10;
const int ENEMY_H = 8;
const int ENEMY_GAP_X = 5;
const int ENEMY_GAP_Y = 4;   // denser vertically
const int ENEMY_START_X = 18;
const int ENEMY_START_Y = 10; // higher
// ================= GAME STATE =================
// Player position and level.
int px = 58;
int level = 1;

// Enemy group position and direction.
int ex = ENEMY_START_X;
int ey = ENEMY_START_Y;
int dir = 1;

// Enemy movement timing and animation.
int enemyStepDelay = 22;
int enemyFrame = 0;
unsigned long lastEnemyMove = 0;

// True means this enemy is still alive.
bool enemy[ER][EC];

// Screen states.
bool intro = true;
bool gameOver = false;
bool levelComplete = false;
int completedLevel = 1;
unsigned long introTick = 0;

// Bullet data: x/y position and active state.
struct Bullet {
  int x;
  int y;
  bool a;
};
Bullet bullets[MAX_BULLETS];
// ================= HELPERS =================
void clearBullets() {
  // Turn off all bullet slots.
  for (int i = 0; i < MAX_BULLETS; i++) bullets[i].a = false;
}
void initEnemies() {
  // Reset all enemies to alive.
  for (int r = 0; r < ER; r++) {
    for (int c = 0; c < EC; c++) {
      enemy[r][c] = true;
    }
  }
  ex = ENEMY_START_X;
  ey = ENEMY_START_Y;
  dir = 1;
  clearBullets();
}
bool enemiesAlive() {
  // Check if at least one enemy is still alive.
  for (int r = 0; r < ER; r++) {
    for (int c = 0; c < EC; c++) {
      if (enemy[r][c]) return true;
    }
  }
  return false;
}
void startGame() {
  // Restart from level 1.
  level = 1;
  px = 58;
  gameOver = false;
  levelComplete = false;
  initEnemies();
}
void startNextLevel() {
  // Go to the next level, then loop back after level 3.
  level++;
  if (level > 3) level = 1;
  levelComplete = false;
  px = 58;
  initEnemies();
}
// ================= PLAYER =================
void drawPlayer() {
  // Draw the player as a small spaceship made from pixels and boxes.
  u8g2.drawPixel(px + 5, PLAYER_Y - 3);
  u8g2.drawBox(px + 4, PLAYER_Y - 2, 3, 1);
  u8g2.drawBox(px + 3, PLAYER_Y - 1, 5, 1);
  u8g2.drawBox(px + 2, PLAYER_Y, 7, 1);
  u8g2.drawBox(px + 1, PLAYER_Y + 1, 9, 1);
  u8g2.drawBox(px, PLAYER_Y + 2, 12, 1);
  u8g2.drawPixel(px + 1, PLAYER_Y + 3);
  u8g2.drawPixel(px + 10, PLAYER_Y + 3);
}
// ================= BULLETS =================
void fire() {
  // Find an empty bullet slot and activate it.
  for (int i = 0; i < MAX_BULLETS; i++) {
    if (!bullets[i].a) {
      bullets[i].x = px + PLAYER_W / 2;
      bullets[i].y = PLAYER_Y - 5;
      bullets[i].a = true;
      break;
    }
  }
}
void updateBullets() {
  // Move active bullets upward and disable them when they leave the screen.
  for (int i = 0; i < MAX_BULLETS; i++) {
    if (bullets[i].a) {
      bullets[i].y -= 3;
      if (bullets[i].y < 0) bullets[i].a = false;
    }
  }
}
void drawBullets() {
  // Draw every active bullet as a short vertical line.
  for (int i = 0; i < MAX_BULLETS; i++) {
    if (bullets[i].a) u8g2.drawVLine(bullets[i].x, bullets[i].y, 3);
  }
}
// ================= ENEMIES =================
void drawEnemyType1(int x, int y, bool phase) {
  // Enemy type 1 changes leg shape using phase.
  u8g2.drawBox(x + 2, y, 6, 1);
  u8g2.drawBox(x + 1, y + 1, 8, 1);
  u8g2.drawBox(x, y + 2, 10, 1);
  u8g2.drawPixel(x + 2, y + 3);
  u8g2.drawPixel(x + 7, y + 3);
  if (phase) {
    u8g2.drawLine(x + 1, y + 4, x + 3, y + 6);
    u8g2.drawLine(x + 8, y + 4, x + 6, y + 6);
  } else {
    u8g2.drawLine(x + 2, y + 4, x + 1, y + 6);
    u8g2.drawLine(x + 7, y + 4, x + 8, y + 6);
  }
}
void drawEnemyType2(int x, int y, bool phase) {
  // Enemy type 2 is a framed alien shape.
  u8g2.drawFrame(x + 1, y + 1, 8, 4);
  u8g2.drawPixel(x, y + 2);
  u8g2.drawPixel(x + 9, y + 2);
  u8g2.drawPixel(x + 3, y + 2);
  u8g2.drawPixel(x + 6, y + 2);
  if (phase) {
    u8g2.drawLine(x + 2, y + 5, x + 1, y + 6);
    u8g2.drawLine(x + 7, y + 5, x + 8, y + 6);
  } else {
    u8g2.drawLine(x + 3, y + 5, x + 3, y + 6);
    u8g2.drawLine(x + 6, y + 5, x + 6, y + 6);
  }
}
void drawEnemyType3(int x, int y, bool phase) {
  // Enemy type 3 is used for variation on the later level.
  u8g2.drawPixel(x + 4, y);
  u8g2.drawBox(x + 2, y + 1, 5, 1);
  u8g2.drawBox(x + 1, y + 2, 7, 1);
  u8g2.drawBox(x, y + 3, 9, 1);
  u8g2.drawPixel(x + 2, y + 4);
  u8g2.drawPixel(x + 6, y + 4);
  if (phase) {
    u8g2.drawLine(x + 2, y + 5, x + 1, y + 6);
    u8g2.drawLine(x + 6, y + 5, x + 7, y + 6);
  } else {
    u8g2.drawLine(x + 3, y + 5, x + 3, y + 6);
    u8g2.drawLine(x + 5, y + 5, x + 5, y + 6);
  }
}
void drawEnemy(int x, int y, int rowType) {
  // Alternate animation phase over time.
  bool phase = ((millis() / 220) % 2) == 0;
  if (level == 1) {
    drawEnemyType1(x, y, phase);
  } else if (level == 2) {
    drawEnemyType2(x, y, phase);
  } else {
    // mix on level 3
    if (rowType == 0) drawEnemyType1(x, y, phase);
    else if (rowType == 1) drawEnemyType2(x, y, phase);
    else drawEnemyType3(x, y, phase);
  }
}
void drawEnemies() {
  // Draw only enemies that are still alive.
  for (int r = 0; r < ER; r++) {
    for (int c = 0; c < EC; c++) {
      if (enemy[r][c]) {
        int x = ex + c * (ENEMY_W + ENEMY_GAP_X);
        int y = ey + r * (ENEMY_H + ENEMY_GAP_Y);
        drawEnemy(x, y, r);
      }
    }
  }
}
void updateEnemies() {
  // Move enemies only after the movement delay has passed.
  if (millis() - lastEnemyMove < (unsigned long)enemyStepDelay) return;
  lastEnemyMove = millis();
  enemyFrame++;
  int speed = level;
  ex += dir * speed;
  // Find bounds using only alive invaders
  int leftMost = 999;
  int rightMost = -999;
  int bottomMost = -999;
  for (int r = 0; r < ER; r++) {
    for (int c = 0; c < EC; c++) {
      if (!enemy[r][c]) continue;
      int x = ex + c * (ENEMY_W + ENEMY_GAP_X);
      int y = ey + r * (ENEMY_H + ENEMY_GAP_Y);
      if (x < leftMost) leftMost = x;
      if (x + ENEMY_W > rightMost) rightMost = x + ENEMY_W;
      if (y + ENEMY_H > bottomMost) bottomMost = y + ENEMY_H;
    }
  }
  if (leftMost == 999) return; // no alive invaders
  if (leftMost < 2 || rightMost > 126) {
    // If the group hits the screen edge, reverse direction and step downward.
    dir = -dir;
    ex += dir * speed;
    ey += 3;
    // recompute bottom after stepping down
    bottomMost = -999;
    for (int r = 0; r < ER; r++) {
      for (int c = 0; c < EC; c++) {
        if (!enemy[r][c]) continue;
        int y = ey + r * (ENEMY_H + ENEMY_GAP_Y);
        if (y + ENEMY_H > bottomMost) bottomMost = y + ENEMY_H;
      }
    }
  }
  // Game over only if a STILL-ALIVE invader reaches the base height
  if (bottomMost >= PLAYER_Y - 2) {
    gameOver = true;
  }
  // Game over only if a STILL-ALIVE invader touches the player area
  for (int r = 0; r < ER; r++) {
    for (int c = 0; c < EC; c++) {
      if (!enemy[r][c]) continue;
      int x = ex + c * (ENEMY_W + ENEMY_GAP_X);
      int y = ey + r * (ENEMY_H + ENEMY_GAP_Y);
      bool hitPlayerZone = !(x + ENEMY_W < px || x > px + PLAYER_W ||
y + ENEMY_H < PLAYER_Y - 4 || y > PLAYER_Y + 3);
      if (hitPlayerZone) {
        gameOver = true;
      }
    }
  }
}
// ================= COLLISION =================
void checkHit() {
  // Compare every active bullet with every living enemy.
  for (int i = 0; i < MAX_BULLETS; i++) {
    if (!bullets[i].a) continue;
    for (int r = 0; r < ER; r++) {
      for (int c = 0; c < EC; c++) {
        if (!enemy[r][c]) continue;
        int x = ex + c * (ENEMY_W + ENEMY_GAP_X);
        int y = ey + r * (ENEMY_H + ENEMY_GAP_Y);
        if (bullets[i].x > x && bullets[i].x < x + ENEMY_W &&
bullets[i].y > y && bullets[i].y < y + ENEMY_H) {
          // If bullet is inside enemy area, remove both.
          enemy[r][c] = false;
          bullets[i].a = false;
        }
      }
    }
  }
}
// ================= INTRO =================
void drawMonsterHead3D(int x, int y, int frame) {
  // pseudo-3D face on right side
  u8g2.drawRFrame(x, y, 28, 20, 3);
  u8g2.drawRFrame(x + 2, y + 2, 24, 16, 2);
  u8g2.drawDisc(x + 8, y + 8, 2, U8G2_DRAW_ALL);
  u8g2.drawDisc(x + 20, y + 8, 2, U8G2_DRAW_ALL);
  u8g2.drawLine(x + 6, y + 14, x + 22, y + 14);
  u8g2.drawLine(x + 3, y + 3, x - 2, y - 2);
  u8g2.drawLine(x + 25, y + 3, x + 30, y - 2);
  if (frame % 20 < 10) {
    u8g2.drawLine(x + 10, y + 15, x + 14, y + 17);
    u8g2.drawLine(x + 18, y + 15, x + 14, y + 17);
  } else {
    u8g2.drawLine(x + 9, y + 16, x + 19, y + 16);
  }
}
void drawIntro() {
  u8g2.clearBuffer();
  int frame = (millis() / 90) % 40;
  // left side text
  u8g2.setFont(u8g2_font_7x13B_tr);
  u8g2.drawStr(8, 14, "SPACE");
  u8g2.drawStr(8, 28, "INVADERS");
  u8g2.setFont(u8g2_font_5x8_tf);
  u8g2.drawStr(8, 39, "by Yaro");
  // right side monster face
  drawMonsterHead3D(88, 8, frame);
  // small battle scene lower area
  int invX = 16 + (frame % 18);
  drawEnemyType3(invX, 46, frame % 2 == 0);
  drawEnemyType2(invX + 18, 46, frame % 2 != 0);
  drawPlayer();
  if (frame % 16 < 8) {
    u8g2.drawVLine(px + 6, 44, 8);
  }
  u8g2.drawHLine(0, 54, 128);
  u8g2.setFont(u8g2_font_6x12_tr);
  u8g2.drawStr(28, 63, "PRESS B");
  u8g2.sendBuffer();
}
// ================= SCREENS =================
void drawHUD() {
  u8g2.setFont(u8g2_font_5x8_tf);
  u8g2.setCursor(0, 8);
  u8g2.print("LVL:");
  u8g2.print(level);
}
void drawLevelComplete() {
  u8g2.clearBuffer();
  u8g2.setFont(u8g2_font_6x12_tr);
  u8g2.drawStr(14, 20, "LEVEL COMPLETE");
  u8g2.setFont(u8g2_font_5x8_tf);
  u8g2.drawStr(22, 34, "Press B for the");
  u8g2.drawStr(32, 44, "next level");
  u8g2.drawStr(48, 56, "L=");
  u8g2.print(completedLevel);
  u8g2.sendBuffer();
}
void drawGameOver() {
  u8g2.clearBuffer();
  u8g2.setFont(u8g2_font_7x13B_tr);
  u8g2.drawStr(22, 22, "GAME OVER");
  u8g2.setFont(u8g2_font_5x8_tf);
  u8g2.drawStr(18, 38, "Invaders reached");
  u8g2.drawStr(35, 46, "the base");
  u8g2.drawStr(18, 58, "Press B to restart");
  u8g2.sendBuffer();
}
void render() {
  u8g2.clearBuffer();
  drawHUD();
  drawEnemies();
  drawBullets();
  drawPlayer();
  u8g2.sendBuffer();
}
// ================= SETUP =================
void setup() {
  Serial.begin(115200);
  Wire.begin();
  u8g2.begin();
  startGame();
}
// ================= LOOP =================
void loop() {
  update_touch();
  if (intro) {
    drawIntro();
    if (pressed(T_B)) {
      intro = false;
      startGame();
    }
    delay(30);
    return;
  }
  if (gameOver) {
    drawGameOver();
    if (pressed(T_B)) {
      startGame();
    }
    delay(30);
    return;
  }
  if (levelComplete) {
    drawLevelComplete();
    if (pressed(T_B)) {
      startNextLevel();
    }
    delay(30);
    return;
  }
  if (held(T_LEFT)) px -= 2;
  if (held(T_RIGHT)) px += 2;
  if (px < 0) px = 0;
  if (px > 116) px = 116;
  if (pressed(T_A)) fire();
  updateBullets();
  updateEnemies();
  checkHit();
  if (!enemiesAlive()) {
    completedLevel = level;
    levelComplete = true;
  }
  render();
  delay(30);
}
Space Invaders running on QPad display
Space Invaders running on QPad display.
Video 5 - Space Invaders game demo.

Reflection

References

Video References

Arduino Code References (.ino)