Week 10 — Output Devices
Group Assignment
- Measure the power consumption of an output device.
- Document your work on the group page and reflect on your individual page.
- Group assignment reference: this group work was documented by my classmate Lin. See Lin's Output Devices group assignment page.
Individual Assignment
- Add an output device to your custom microcontroller board and program it.
Learning Outcomes
- Demonstrate workflows used for controlling output devices with a custom MCU board.
Overview
For this week, I tested three output devices on my board:
- Servo motor
- Buzzer
- I2C LCD display
Video Links
- Video 1 - Servo basic movement
- Video 2 - Servo advanced motion
- Video 3 - Buzzer sound tests
- Video 4 - I2C LCD game demo
- Video 5 - Space Invaders game demo
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.
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.
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.
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.
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.
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.
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 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.
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
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.
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);
}
Reflection
- I practiced controlling different output devices from one custom board.
- I improved confidence with timing-based behavior and output patterns.
- I2C wiring and pin mapping became much clearer after this week.
- Using short iterative tests made debugging much faster.
References
Video References
- Video 1 - Servo basic movement
- Video 2 - Servo advanced motion
- Video 3 - Buzzer sound tests
- Video 4 - I2C LCD game demo
- Video 5 - Space Invaders game demo
Arduino Code References (.ino)
servo_basic_sweep.inoservo_smooth_motion.inobuzzer_button_interaction.inoi2c_lcd_check.inoi2c_lcd_mini_game.inospace_invaders_v4.ino