Week 10 - Output Devices
Group Assignment:
• Measure the power consumption of an output device
Individual Assignment:
• Add an output device to a microcontroller board you've designed
and program it to do something
Group Assignment
For this week's group assignment, we measured the power consumption of two output devices — an LED and a servo motor.
How Power Consumption Works
Power consumption is how much energy a device uses over time, measured in watts (W) or milliwatts (mW). To calculate it you need the voltage (V) and current (I):
P = V × I
To measure current, the multimeter must be connected in series with the circuit.
LED Results
We built a simple series circuit with a blue LED, 220Ω resistor, and a 4.7V battery pack.
Result: The ammeter read 0.005 A (5 mA), giving a power consumption of 18 mW.
Servo Motor Results
We connected an SG90 servo to the XIAO ESP32-C3 for PWM control and measured current in two conditions:
| Condition | Current | Power |
|---|---|---|
| Without load | 40 mA | 188 mW |
| With load | 725 mA | 3.41 W |
Under load, the servo drew 18× more current than at idle — something you always need to account for when choosing a power supply.
Conclusion
The LED was simple and predictable at just 18 mW. The servo was far more demanding, spiking to 3.41 W under load. The key lesson — always design your power supply for the worst case, not just idle current.
For more details, check out the full group assignment page here.
Individual Assignment
This week I got to test a bunch of output devices using my XIAO ESP32 DEV Board — the same board I milled and soldered last week. The goal was simple: get each device connected, write the code, and make it actually do something you can see or hear. I used Claude AI to help me write all the code again, and honestly this week was even more fun than input devices because you can actually see the results.
I tested the following output devices this week:
Servo Motor

A servo motor is a type of motor that can rotate to a specific angle and hold it there. Unlike a regular DC motor that just spins continuously, a servo takes a signal and moves to a precise position — anywhere from 0° to 180°. Super useful for robotics, grippers, and anything that needs controlled movement.
Wiring
| Servo Motor | XIAO ESP32-C3 |
|---|---|
| Red (VCC) | 5V |
| Brown/Black (GND) | GND |
| Yellow/Orange (Signal) | D2 (GPIO4) |
⚠️ Note: Use the 5V pin, not 3.3V. Servos need 5V to run properly. For small servos like the SG90 the XIAO's 5V USB pin is enough.

Library
I installed ESP32Servo by Kevin Harrington from the Library Manager. The standard Arduino Servo.h doesn't work on the ESP32-C3 — you need this specific one.

Code
I generated this code using Claude AI with the following prompt:
"Write an Arduino program for a servo motor. I will give you the pins I used — Signal is connected to D2 (GPIO4) on the XIAO ESP32-C3. Factcheck the code before you answer me."
It sweeps the servo from 0° to 180° and back on repeat — the classic servo test.
#include <ESP32Servo.h>
Servo myServo;
const int SERVO_PIN = 4; // D2 = GPIO4 on XIAO ESP32-C3
void setup() {
Serial.begin(115200);
ESP32PWM::allocateTimer(0);
ESP32PWM::allocateTimer(1);
ESP32PWM::allocateTimer(2);
ESP32PWM::allocateTimer(3);
myServo.setPeriodHertz(50);
myServo.attach(SERVO_PIN, 500, 2400);
Serial.println("Servo Ready!");
}
void loop() {
for (int angle = 0; angle <= 180; angle++) {
myServo.write(angle);
Serial.print("Angle: ");
Serial.println(angle);
delay(15);
}
for (int angle = 180; angle >= 0; angle--) {
myServo.write(angle);
Serial.print("Angle: ");
Serial.println(angle);
delay(15);
}
}
Result
It worked on the first try!! The servo swept smoothly from 0° to 180° and back continuously. Really satisfying to watch honestly.
OLED Display — 0.91" 128x32

The OLED display is a tiny monochrome screen that uses I2C — the same protocol as the MPU6050 from last week. It has no backlight since each pixel emits its own light, which makes the contrast really sharp and clean. The module version I have is 128x32 pixels so it's very small and narrow, but it's enough to display text and simple graphics.
Wiring
| OLED Module | XIAO ESP32-C3 |
|---|---|
| VCC | 3.3V |
| GND | GND |
| SDA | D4 (GPIO6) |
| SCL | D5 (GPIO7) |

Library
I installed Adafruit SSD1306 and Adafruit GFX Library from the Library Manager.

Code
I generated this code using Claude AI with the following prompt:
"Write an Arduino program for a 0.91 inch 128x32 OLED display using I2C. SDA is connected to D4 (GPIO6) and SCL to D5 (GPIO7) on the XIAO ESP32-C3. Factcheck the code before you answer me."
Two things that are critical for this specific display: - Screen height must be set to 32 not 64 — wrong value gives a blank screen - I2C address is 0x3C for the 128x32 version
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 32 // must be 32 for this display
#define OLED_RESET -1
#define SCREEN_ADDRESS 0x3C // correct address for 128x32
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
void setup() {
Serial.begin(115200);
Wire.begin(6, 7); // SDA=GPIO6, SCL=GPIO7
if (!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
Serial.println("SSD1306 not found! Check wiring.");
while (1);
}
display.clearDisplay();
display.setTextSize(2);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println("Hello!");
display.setTextSize(1);
display.setCursor(0, 20);
display.println("XIAO ESP32-C3");
display.display();
Serial.println("OLED Ready!");
}
void loop() {}
OLED Animations
After getting the basic display working I also loaded a more complex animation sketch that cycles through 4 animations:
I generated this using Claude AI with the following prompt:
"Write an Arduino program for the OLED 128x32 display with animations — Iron Man helmet pixel art with a scan line effect, water ripple effect, rotating 3D wireframe cube, and a simplified solar system with 3 orbiting planets. Factcheck the code before you answer me."
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <math.h>
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 32
#define OLED_RESET -1
#define SCREEN_ADDRESS 0x3C
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
// Iron Man helmet bitmap 40x32
static const unsigned char PROGMEM ironmanBitmap[] = {
0x00,0x3F,0xFC,0x00,0x00, 0x00,0xFF,0xFF,0x00,0x00,
0x01,0xFF,0xFF,0x80,0x00, 0x03,0xFF,0xFF,0xC0,0x00,
0x07,0xFF,0xFF,0xE0,0x00, 0x0F,0xFF,0xFF,0xF0,0x00,
0x1F,0xFF,0xFF,0xF8,0x00, 0x1F,0xFF,0xFF,0xF8,0x00,
0x3F,0xC0,0x03,0xFC,0x00, 0x3F,0x00,0x00,0xFC,0x00,
0x7F,0x00,0x00,0xFE,0x00, 0x7E,0x00,0x00,0x7E,0x00,
0x7C,0xFF,0xFF,0x3E,0x00, 0x7C,0xFF,0xFF,0x3E,0x00,
0x7C,0xFF,0xFF,0x3E,0x00, 0x7C,0xFF,0xFF,0x3E,0x00,
0x7F,0xFF,0xFF,0xFE,0x00, 0x7F,0xFF,0xFF,0xFE,0x00,
0x3F,0xFF,0xFF,0xFC,0x00, 0x3F,0xE7,0xE7,0xFC,0x00,
0x1F,0xC3,0xC3,0xF8,0x00, 0x1F,0x81,0x81,0xF8,0x00,
0x0F,0xFF,0xFF,0xF0,0x00, 0x0F,0xFF,0xFF,0xF0,0x00,
0x07,0xFF,0xFF,0xE0,0x00, 0x07,0xFF,0xFF,0xE0,0x00,
0x03,0xFF,0xFF,0xC0,0x00, 0x01,0xFF,0xFF,0x80,0x00,
0x00,0xFF,0xFF,0x00,0x00, 0x00,0x7F,0xFE,0x00,0x00,
0x00,0x1F,0xF8,0x00,0x00, 0x00,0x03,0xC0,0x00,0x00,
};
void showIronMan() {
for (int f = 0; f < 60; f++) {
display.clearDisplay();
display.drawBitmap(44, 0, ironmanBitmap, 40, 32, SSD1306_WHITE);
int scanY = (f * 2) % 34;
display.drawFastHLine(44, scanY, 40, SSD1306_BLACK);
display.display();
delay(40);
}
}
void showWaterRipple() {
int cx = 64, cy = 16;
for (int frame = 0; frame < 60; frame++) {
display.clearDisplay();
for (int ring = 0; ring < 4; ring++) {
int r = ((frame * 2) + ring * 12) % 48;
if (r > 0 && r < 40) display.drawCircle(cx, cy, r, SSD1306_WHITE);
}
if (frame % 15 == 0) display.fillCircle(cx, cy, 2, SSD1306_WHITE);
display.display();
delay(35);
}
}
float cubeVerts[8][3] = {
{-1,-1,-1},{1,-1,-1},{1,1,-1},{-1,1,-1},
{-1,-1,1},{1,-1,1},{1,1,1},{-1,1,1}
};
int cubeEdges[12][2] = {
{0,1},{1,2},{2,3},{3,0},
{4,5},{5,6},{6,7},{7,4},
{0,4},{1,5},{2,6},{3,7}
};
void showRotatingCube() {
float angleX = 0, angleY = 0;
for (int frame = 0; frame < 80; frame++) {
display.clearDisplay();
angleX += 0.05; angleY += 0.07;
int projected[8][2];
for (int i = 0; i < 8; i++) {
float x=cubeVerts[i][0], y=cubeVerts[i][1], z=cubeVerts[i][2];
float y1=y*cos(angleX)-z*sin(angleX), z1=y*sin(angleX)+z*cos(angleX);
float x2=x*cos(angleY)+z1*sin(angleY), z2=-x*sin(angleY)+z1*cos(angleY);
float scale=20.0/(3.5+z2);
projected[i][0]=(int)(64+x2*scale);
projected[i][1]=(int)(16+y1*scale);
}
for (int e = 0; e < 12; e++)
display.drawLine(projected[cubeEdges[e][0]][0], projected[cubeEdges[e][0]][1],
projected[cubeEdges[e][1]][0], projected[cubeEdges[e][1]][1],
SSD1306_WHITE);
display.display();
delay(30);
}
}
void showSolarSystem() {
for (int frame = 0; frame < 120; frame++) {
display.clearDisplay();
float t = frame * 0.08;
int sunX = 20, sunY = 16;
display.fillCircle(sunX, sunY, 5, SSD1306_WHITE);
float a1 = t * 2.5;
display.fillCircle(sunX+(int)(14*cos(a1)), sunY+(int)(7*sin(a1)), 1, SSD1306_WHITE);
float a2 = t * 1.2;
int p2x=sunX+(int)(28*cos(a2)), p2y=sunY+(int)(11*sin(a2));
display.fillCircle(p2x, p2y, 2, SSD1306_WHITE);
float a3 = t * 0.7;
int p3x=sunX+(int)(48*cos(a3)), p3y=sunY+(int)(13*sin(a3));
if(p3x>0&&p3x<128&&p3y>0&&p3y<32)
display.fillCircle(p3x, p3y, 2, SSD1306_WHITE);
display.display();
delay(40);
}
}
void setup() {
Serial.begin(115200);
Wire.begin(6, 7);
if (!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
Serial.println("SSD1306 not found!");
while (1);
}
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(20, 12);
display.println("Output Devices!");
display.display();
delay(2000);
}
void loop() {
showIronMan(); delay(500);
showWaterRipple(); delay(500);
showRotatingCube(); delay(500);
showSolarSystem(); delay(500);
}
Result
All four animations worked! The rotating 3D cube was my favourite — it actually looks really impressive on such a tiny screen. The Iron Man helmet was very bad but I didn't expect any good at 128x32 resolution.
TFT Display — 1.44" 128x128

The TFT is a full color display — 128x128 pixels with 16-bit color. It uses SPI which is a faster communication protocol than I2C. This is a big step up from the OLED — instead of just white pixels on black, you get the full color palette. The driver chip on this one is the ST7735.
Wiring
| TFT Module | XIAO ESP32-C3 |
|---|---|
| VCC | 3.3V |
| GND | GND |
| SCL (SCK) | GPIO8 (D8) |
| SDA (MOSI) | GPIO10 (D10) |
| CS | GPIO2 (D0) |
| DC (A0) | GPIO3 (D1) |
| RST | GPIO4 (D2) |
| BLK (LED) | 3.3V |
⚠️ Note: BLK must be connected to 3.3V — without it the backlight stays off and the screen looks black even if it's working.

Library
I installed Adafruit ST7735 and ST7789 Library and Adafruit GFX Library from the Library Manager.

❌ Failure 1 — D0 pin naming error
The first version of the code used D0, D1, D2 style pin names but my Arduino board package didn't support that naming convention and threw this error:
Fix: Switched to raw GPIO numbers (2, 3, 4, 8, 10) instead of the D prefix names.
❌ Failure 2 — Wrong init type caused noise
After fixing the pin names, the display showed a strip of colorful noise on the left side of the screen. I tried adjusting column and row offsets using _colstart and _rowstart but the library had those variables marked as protected — so I couldn't access them directly.

I then tried creating a subclass to access the protected variables:
class ST7735_Fixed : public Adafruit_ST7735 {
public:
ST7735_Fixed(int8_t cs, int8_t dc, int8_t mosi, int8_t sclk, int8_t rst)
: Adafruit_ST7735(cs, dc, mosi, sclk, rst) {}
void setOffsets(uint8_t col, uint8_t row) {
_colstart = col;
_rowstart = row;
}
};
This compiled but the subclass constructor was crashing the board before it even got to setup() — the Serial Monitor printed the header then stopped completely.
Fix: After a lot of trial and error I discovered the real issue was the initialization type. Switching from INITR_144GREENTAB to INITR_BLACKTAB fixed everything — no noise, full screen, perfect colors.

Code — Hello World
I generated this code using Claude AI with the following prompt:
"Write an Arduino program for a 1.44 inch 128x128 TFT display using the ST7735 driver. The pins I used — CS is GPIO2, DC is GPIO3, RST is GPIO4, MOSI is GPIO10, SCK is GPIO8 on the XIAO ESP32-C3. Factcheck the code before you answer me."
#include <Adafruit_GFX.h>
#include <Adafruit_ST7735.h>
#include <SPI.h>
#define TFT_CS 2
#define TFT_DC 3
#define TFT_RST 4
#define TFT_MOSI 10
#define TFT_SCLK 8
Adafruit_ST7735 tft = Adafruit_ST7735(TFT_CS, TFT_DC, TFT_MOSI, TFT_SCLK, TFT_RST);
void setup() {
Serial.begin(115200);
pinMode(TFT_RST, OUTPUT);
digitalWrite(TFT_RST, LOW); delay(200);
digitalWrite(TFT_RST, HIGH); delay(200);
tft.initR(INITR_BLACKTAB); // BLACKTAB fixes the noise issue
tft.setRotation(3);
tft.fillScreen(ST77XX_BLACK);
tft.setTextSize(2);
tft.setTextColor(ST77XX_RED);
tft.setCursor(10, 20);
tft.println("Hello!");
tft.setTextColor(ST77XX_GREEN);
tft.setCursor(10, 50);
tft.println("TFT");
tft.setTextColor(ST77XX_BLUE);
tft.setCursor(10, 80);
tft.println("128x128");
Serial.println("TFT Ready!");
}
void loop() {}
Code — Night Drive Animation
After getting the basic display working I built a night drive animation — a late night countryside scene with parallax scrolling mountains, a blue car fixed in the center, and a crescent moon. This was the most complex thing I coded this whole week.
I generated this using Claude AI with the following prompt:
"Write an Arduino program for a TFT 128x128 ST7735 display — a night drive animation with parallax scrolling. A blue car centered on screen, mountains scrolling at different speeds for depth, pine trees, road with dashed center line, crescent moon, twinkling stars, and drifting clouds. Use zone-based partial updates so the screen doesn't flash. Factcheck the code before you answer me."
❌ Failure 3 — Full screen redraw caused flickering
The first version of the animation redrawed the entire 128x128 screen every frame. This caused a very visible black flash between frames — the whole screen would go black and then redraw, which looked terrible.
Fix: Split the screen into zones and only redraw the parts that actually changed each frame. The sky, moon, and mountains get redrawn every frame (since they scroll), but the car is drawn once at startup and never touched again. The road base is also drawn once — only the dashed center line updates each frame.
Zone 1 (y=0..54) — sky + stars + moon + clouds + mountains + trees → redrawn every frame
Zone 2 (y=55..71) — road base + dashes → only dashes update
Zone 3 (y=51..70) — car → drawn once at startup, never redrawn
This is the final working animation code:
#include <Adafruit_GFX.h>
#include <Adafruit_ST7735.h>
#include <SPI.h>
#include <math.h>
#define TFT_CS 2
#define TFT_DC 3
#define TFT_RST 4
#define TFT_MOSI 10
#define TFT_SCLK 8
Adafruit_ST7735 tft = Adafruit_ST7735(TFT_CS, TFT_DC, TFT_MOSI, TFT_SCLK, TFT_RST);
uint16_t C(uint8_t r, uint8_t g, uint8_t b){ return tft.color565(r,g,b); }
float skyOff=0,mtnFOff=0,mtnMOff=0,mtnNOff=0,treeOff=0,roadOff=0;
int frame=0;
uint16_t skyPal[45];
uint16_t roadPal[17];
void precompute(){
for(int y=0;y<45;y++){
float t=(float)y/45.0f;
skyPal[y]=C(5+t*14,5+t*14,18+t*38);
}
for(int y=0;y<17;y++){
float t=(float)y/17.0f;
uint8_t v=35+t*15;
roadPal[y]=C(v,v,v+5);
}
}
struct Star{uint8_t x,y;bool b;};
Star stars[38];
void initStars(){
randomSeed(123);
for(int i=0;i<38;i++){
stars[i].x=random(0,256);
stars[i].y=random(1,28);
stars[i].b=random(0,2);
}
}
void hln(int x,int y,int w,uint16_t c){if(w>0)tft.drawFastHLine(x,y,w,c);}
void vln(int x,int y,int h,uint16_t c){if(h>0)tft.drawFastVLine(x,y,h,c);}
void r(int x,int y,int w,int h,uint16_t c){if(w>0&&h>0)tft.fillRect(x,y,w,h,c);}
void p(int x,int y,uint16_t c){if(x>=0&&x<128&&y>=0&&y<72)tft.drawPixel(x,y,c);}
void mtnShape(int px,int py,int w,uint16_t body,uint16_t snow,int snowH){
for(int dx=-w;dx<=w;dx++){
int sx=px+dx;
if(sx<0||sx>=128) continue;
int top=py+abs(dx);
if(top>=72) continue;
if(snowH>0&&abs(dx)<=snowH&&top<py+snowH+3){
int sBot=min(py+snowH+3,72);
vln(sx,top,sBot-top,snow);
if(sBot<72) vln(sx,sBot,72-sBot,body);
} else {
vln(sx,top,72-top,body);
}
}
}
void drawZoneSky(){
for(int y=0;y<45;y++) hln(0,y,128,skyPal[y]);
for(int i=0;i<38;i++){
int x=((int)(stars[i].x-skyOff*0.15f+512))%128;
if(x<0)x+=128;
bool lit=stars[i].b;
if((frame+i*7)%25==0) lit=!lit;
p(x,stars[i].y, lit?C(255,255,200):C(74,85,112));
}
int mx=88,my=9;
r(mx,my,8,8,C(216,208,168));
hln(mx,my,8,C(232,224,184));
r(mx+4,my,5,8,C(13,13,40));
r(mx+3,my,2,2,C(13,13,40));
r(mx+3,my+6,2,2,C(13,13,40));
int cpos[][2]={{0,8},{70,12},{140,7},{200,13}};
for(auto& c2:cpos){
int cx=((int)(c2[0]-skyOff*0.4f+600))%260-20;
if(cx<-22||cx>130) continue;
int cy=c2[1];
r(cx,cy+2,14,3,C(30,30,58));
r(cx+1,cy+1,12,2,C(30,30,58));
r(cx+3,cy,8,2,C(30,30,58));
hln(cx+2,cy,8,C(42,42,74));
}
int pf[][3]={{15,25,16},{50,22,19},{90,24,15},{125,23,17},{160,25,14},{-20,24,16}};
for(auto& pt:pf){
int x=((int)(pt[0]-mtnFOff+512))%200-20;
mtnShape(x,pt[1],pt[2],C(28,28,62),C(144,144,176),5);
}
int pm[][3]={{20,32,18},{60,29,20},{105,31,16},{148,30,18},{-15,32,15}};
for(auto& pt:pm){
int x=((int)(pt[0]-mtnMOff+512))%200-20;
mtnShape(x,pt[1],pt[2],C(16,30,20),C(120,120,144),4);
}
int pn[][2]={{5,38},{40,36},{80,37},{118,35},{158,38},{-20,37}};
for(auto& pt:pn){
int x=((int)(pt[0]-mtnNOff+512))%190-20;
mtnShape(x,pt[1],14,C(8,15,10),C(8,15,10),0);
}
hln(0,48,128,C(10,32,12));
r(0,49,128,5,C(8,24,8));
hln(0,54,128,C(6,14,6));
int pos1[]={0,30,60,90,120,150,180};
int pos2[]={15,45,75,105,135,165};
for(int pt:pos1){
int tx=((int)(pt-treeOff+600))%180-8;
if(tx<-10||tx>130) continue;
r(tx+2,48,2,5,C(16,10,6));
r(tx,45,7,4,C(6,18,8));
r(tx+1,41,5,5,C(6,18,8));
r(tx+2,38,3,4,C(6,18,8));
hln(tx+1,45,5,C(10,28,12));
hln(tx+2,41,3,C(10,28,12));
}
for(int pt:pos2){
int tx=((int)(pt-treeOff+600))%180-8;
if(tx<-10||tx>130) continue;
r(tx+2,49,2,5,C(16,10,6));
r(tx,46,7,4,C(6,18,8));
r(tx+1,42,5,5,C(6,18,8));
r(tx+2,39,3,4,C(6,18,8));
hln(tx+1,46,5,C(10,28,12));
hln(tx+2,42,3,C(10,28,12));
}
}
void drawZoneRoad(){
for(int y=0;y<17;y++) hln(0,55+y,128,roadPal[y]);
hln(0,55,128,C(176,168,128));
hln(0,56,128,C(80,78,64));
hln(0,71,128,C(48,48,52));
int dashLen=10,gap=8,total=dashLen+gap;
int off2=(int)roadOff%total;
hln(0,62,128,roadPal[7]);
hln(0,63,128,roadPal[7]);
for(int x=-total+off2;x<128;x+=total){
int x1=max(0,x),x2=min(128,x+dashLen);
if(x2>x1){hln(x1,62,x2-x1,C(200,176,96));hln(x1,63,x2-x1,C(200,176,96));}
}
}
void drawCar(){
int cx=36,cy=51;
r(cx-20,cy+4,20,3,C(30,28,18));
r(cx+1,cy+17,38,2,C(15,15,18));
r(cx,cy+4,42,10,C(16,16,176));
r(cx+6,cy,26,6,C(12,12,152));
hln(cx+8,cy,22,C(32,32,204));
r(cx+8,cy+1,10,5,C(122,176,200));
vln(cx+8,cy+1,5,C(90,136,158));
vln(cx+17,cy+1,5,C(90,136,158));
r(cx+20,cy+1,9,5,C(104,152,176));
vln(cx+28,cy+1,5,C(90,136,158));
r(cx+19,cy,2,5,C(10,10,120));
r(cx,cy+5,4,4,C(255,248,176));
r(cx,cy+9,4,3,C(224,200,64));
vln(cx+4,cy+5,7,C(192,192,200));
p(cx-2,cy+7,C(255,240,160));
p(cx-4,cy+7,C(224,216,136));
p(cx-6,cy+8,C(192,184,96));
r(cx+38,cy+5,4,4,C(16,16,204));
r(cx+38,cy+9,4,3,C(8,8,136));
vln(cx+37,cy+5,7,C(8,8,136));
r(cx-1,cy+12,5,2,C(144,144,152));
r(cx+38,cy+12,5,2,C(144,144,152));
vln(cx+20,cy+4,10,C(10,10,144));
hln(cx+10,cy+9,5,C(192,192,200));
hln(cx+24,cy+9,5,C(192,192,200));
r(cx+4,cy+13,10,6,C(24,24,24));
hln(cx+5,cy+12,8,C(40,40,40));
hln(cx+5,cy+19,8,C(40,40,40));
vln(cx+4,cy+13,6,C(40,40,40));
vln(cx+13,cy+13,6,C(40,40,40));
r(cx+7,cy+14,4,4,C(144,144,152));
vln(cx+9,cy+13,6,C(120,120,128));
hln(cx+5,cy+16,8,C(120,120,128));
r(cx+28,cy+13,10,6,C(24,24,24));
hln(cx+29,cy+12,8,C(40,40,40));
hln(cx+29,cy+19,8,C(40,40,40));
vln(cx+28,cy+13,6,C(40,40,40));
vln(cx+37,cy+13,6,C(40,40,40));
r(cx+31,cy+14,4,4,C(144,144,152));
vln(cx+33,cy+13,6,C(120,120,128));
hln(cx+29,cy+16,8,C(120,120,128));
p(cx+43,cy+12,C(48,48,52));
p(cx+45,cy+11,C(36,36,40));
}
void setup(){
Serial.begin(115200);
initStars();
precompute();
pinMode(TFT_RST,OUTPUT);
digitalWrite(TFT_RST,LOW); delay(200);
digitalWrite(TFT_RST,HIGH);delay(200);
tft.initR(INITR_BLACKTAB);
tft.setRotation(3);
tft.fillScreen(C(5,5,20));
drawZoneRoad();
drawCar();
Serial.println("Night Drive Ready!");
}
void loop(){
drawZoneSky();
drawZoneRoad();
drawCar();
skyOff += 0.18f;
mtnFOff += 0.40f;
mtnMOff += 0.85f;
mtnNOff += 1.50f;
treeOff += 2.80f;
roadOff += 5.50f;
frame++;
delay(20);
}
Parallax scrolling explained
The reason the animation feels like real movement is because of parallax — different layers move at different speeds. Things far away move slowly, things close move fast. This tricks your brain into seeing depth.
| Layer | Speed | Effect |
|---|---|---|
| Sky + Stars | 0.18 px/frame | Feels infinitely far away |
| Far mountains | 0.40 px/frame | Far range barely moves |
| Mid mountains | 0.85 px/frame | Noticeably scrolling |
| Near mountains | 1.50 px/frame | Closer, faster |
| Trees | 2.80 px/frame | Whooshing past |
| Road dashes | 5.50 px/frame | Rushing under the car |
| Car | Fixed | The subject — everything moves around it |
Result
The animation looks really smooth with no flickering at all. The parallax effect gives a real sense of depth and speed — the mountains in the distance barely move while the trees rush past and the road dashes fly under the car. Honestly one of my favourite things I've made in Fab Academy so far.
Reflection
This week was genuinely one of the most fun weeks I've had in Fab Academy. Output devices are so much more satisfying than input devices because you can actually see and interact with the result. Getting the TFT night drive animation working was the highlight — it took a lot of debugging and multiple failed attempts but the final result was worth it.
The main things I learned:
- Always check the init type for TFT displays —
INITR_BLACKTABvsINITR_144GREENTABmakes a huge difference - Full screen redraws every frame cause flickering — partial zone updates are the fix
- Parallax scrolling is a simple but powerful trick for making 2D animations feel 3D
- Documenting failures is just as important as documenting successes — every fix came from understanding what went wrong first
No new design files were created this week — the XIAO ESP32-C3 dev board used is the same board milled and soldered in Week 9. All output devices were interfaced externally using jumper wires.