Results can be found on the group assignment page of our lab.
This week focused on building an application interface that communicates with physical hardware. I decided to reuse the output board I designed earlier instead of building a new device. The idea was simple. A physical interaction performed on a microcontroller should immediately affect something inside a software application. My final project does not really require a traditional GUI interface, so instead of designing menus or dashboards I decided to build a small interactive game running locally on a computer. I chose Python with Pygame CE and Pyserial. The development process was heavily iterative and AI assisted.
The idea comes from Tamagotchi style virtual pets. A plant grows in the middle of the screen. The plant slowly loses water over time. If nothing happens, the plant dies. Turning a rotary encoder waters the plant and keeps it alive. One input controls the entire system. You use the rotary switch to reset the game.
The project consists of two parts running simultaneously. The ATtiny3226 board reads physical input and sends serial messages. The Python application runs the simulation, visuals, and game logic. Both systems continuously exchange simple characters over UART.
"W", "D" and "R"
Hardware sends W when watering occurs. Software sends D when the plant dies. Hardware sends R when restart is pressed. It's a two way communication loop.
The interaction device is the output board built earlier using an ATtiny3226. A rotary encoder acts as the main controller. The encoder outputs quadrature signals which allow direction detection. Only clockwise rotation is accepted as watering input.
Originally I planned to display plant state on an OLED connected to the board. During implementation the OLED repeatedly failed whenever serial communication was active. After spending too much time debugging I simplified the design and removed the display completely. Instead an onboard LED blinks when the plant dies. This gave physical feedback without introducing extra complexity. The hardware therefore became an input device plus a simple status indicator.
The firmware is structured as small independent handlers running inside loop. The encoder handler detects rotation edges and sends "W" over serial. Edge detection prevents multiple triggers from a single detent. When Python sends "D" the board enters a dead state. Encoder input is ignored and the LED begins blinking using a millis timer instead of delay. The encoder push button becomes a restart control. Pressing it sends "R" back to Python and clears the dead state locally.
The firmware never blocks execution. Every behaviour runs continuously so serial communication always remains responsive. The board is not running the game. It only translates physical actions into events.
I moved toward a locally running application instead of a web interface. Pygame allowed direct rendering, timing control, and easy hardware integration. All visuals were drawn manually using pixel grids. No images or fonts are loaded externally. Everything is generated in code.
The screen contains a centered plant, a water bar, and a survival timer. The program runs at 60 FPS using:
dt = clock.tick(60)
Serial communication runs inside a background thread. Serial reads are blocking operations. If they run in the main loop the entire window freezes while waiting for data. Running serial inside a thread allows the game to continue rendering while hardware messages arrive asynchronously. The thread continuously listens:
def _serial_reader():
global water_event
while True:
line = ser.readline().decode().strip()
if line == "W":
water_event = True
The thread does not modify game logic directly. It only sets flags. The main loop consumes those flags safely every frame.
I used Pygame CE instead of standard Pygame since it is actively maintained. Had to install Pygame CE by inputting:
pip install pygame-ce
Had to install Pyserial by inputting:
pip install pyserial
Windows initially could not find installed scripts. The fix was adding the following to the system PATH environment variable:
C:\Users\KEVIN\AppData\Roaming\Python\Python314\Scripts
After this, pygame installation warnings disappeared.
The plant state is defined by a small set of variables.
water_level = MAX_LEAVES
decay_accum = 0
dead = False
elapsed_ms = 0
Every frame increases decay accumulation.
decay_accum += dt
When decay exceeds a threshold:
if decay_accum >= LEAF_MS:
water_level -= 1
The plant loses water periodically. With LEAF_MS set to 500 ms the plant loses two units per second. Rotating the encoder triggers watering.
water_level += WATER_TICK
Each encoder click adds only a small amount of water so continuous interaction is required. If water reaches zero:
dead = True
Python immediately sends:
ser.write(b"D\n")
The b prefix converts text into raw bytes because serial communication operates on binary data rather than Unicode strings.
Everything on screen is pixel based. The plant is procedurally drawn. Leaves appear or disappear depending on water level. No sprites are stored as images. Each shape is defined as coordinate offsets. When leaves are lost they fall to the ground beside the pot instead of vanishing. Fallen leaves alternate left and right and spread outward. Watering the plant restores leaves and removes fallen ones automatically.
System fonts were avoided entirely. Each character is defined as a small grid describing filled pixels. Text rendering simply draws rectangles wherever a bit exists.
This kept visual style consistent and avoided dependency issues across machines. All UI text including timer and messages uses this renderer.
The water bar represents plant health directly. Its fill level scales with water_level.
BAR_X = W - 180
BAR_Y = 30
BAR_W = 140
BAR_H = 16
def draw_water_bar(surf, level_float):
pygame.draw.rect(surf, WHITE, (BAR_X, BAR_Y, BAR_W, BAR_H), 2)
fill = int((level_float / MAX_LEAVES) * (BAR_W - 4))
if fill > 0:
pygame.draw.rect(surf, WHITE, (BAR_X+2, BAR_Y+2, fill, BAR_H-4))
drop_scale = 3
drop_w = 10 * drop_scale
drop_h = 9 * drop_scale
drop_x = BAR_X - drop_w - 8
drop_y = BAR_Y + (BAR_H - drop_h) // 2
draw_water_droplet(surf, drop_x, drop_y, scale=drop_scale)
A survival timer runs while the plant is alive. The timer freezes at death and becomes the final score.
def draw_game_timer(surf, elapsed_ms):
total_s = elapsed_ms // 1000
mins = total_s // 60
secs = total_s % 60
label = f"{mins:02d}:{secs:02d}"
draw_pixel_text(surf, label, 12, 12, scale=2)
When dead, the screen displays a restart message. Pressing the encoder button sends "R" to Python. Python resets all variables and the plant grows again from full health. Restarting never reloads the program. Only state variables change.
Project: Pixel Plant (Pygame + ATtiny/OLED)
import pygame
import threading
# CONFIG
TEST_MODE = True
SERIAL_PORT = "COM3"
BAUD = 115200
# SERIAL (skipped in TEST_MODE)
water_event = False
ser = None
if not TEST_MODE:
import serial
ser = serial.Serial(SERIAL_PORT, BAUD)
restart_event = False
def _serial_reader():
global water_event, restart_event
while True:
try:
line = ser.readline().decode().strip()
if line == "W":
water_event = True
elif line == "R":
restart_event = True
except Exception:
pass
threading.Thread(target=_serial_reader, daemon=True).start()
else:
restart_event = False
# PYGAME INIT
pygame.init()
W, H = 600, 500
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("Pixel Plant")
clock = pygame.time.Clock()
# PALETTE
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
GREY = (120, 120, 120)
# GAME CONSTANTS
PX = 6
MAX_LEAVES = 8
LEAF_MS = 500
WATER_TICK = 1
# PIXEL-BITMAP FONT (uppercase + punctuation + digits)
_FONT = {
'W': [[1,0,0,0,1],[1,0,0,0,1],[1,0,1,0,1],[1,0,1,0,1],[1,1,0,1,1],[1,1,0,1,1],[0,1,0,1,0]],
'A': [[0,1,1,0,0],[1,0,0,1,0],[1,0,0,1,0],[1,1,1,1,0],[1,0,0,1,0],[1,0,0,1,0],[1,0,0,1,0]],
'T': [[1,1,1,1,1],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0]],
'E': [[1,1,1,1,1],[1,0,0,0,0],[1,0,0,0,0],[1,1,1,1,0],[1,0,0,0,0],[1,0,0,0,0],[1,1,1,1,1]],
'R': [[1,1,1,1,0],[1,0,0,0,1],[1,0,0,0,1],[1,1,1,1,0],[1,0,1,0,0],[1,0,0,1,0],[1,0,0,0,1]],
'H': [[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,1,1,1,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1]],
'P': [[1,1,1,1,0],[1,0,0,0,1],[1,0,0,0,1],[1,1,1,1,0],[1,0,0,0,0],[1,0,0,0,0],[1,0,0,0,0]],
'L': [[1,0,0,0,0],[1,0,0,0,0],[1,0,0,0,0],[1,0,0,0,0],[1,0,0,0,0],[1,0,0,0,0],[1,1,1,1,1]],
'N': [[1,0,0,0,1],[1,1,0,0,1],[1,0,1,0,1],[1,0,0,1,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1]],
'I': [[1,1,1,1,1],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[1,1,1,1,1]],
'G': [[0,1,1,1,0],[1,0,0,0,0],[1,0,0,0,0],[1,0,1,1,1],[1,0,0,0,1],[1,0,0,0,1],[0,1,1,1,0]],
'D': [[1,1,1,0,0],[1,0,0,1,0],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,1,0],[1,1,1,0,0]],
'O': [[0,1,1,1,0],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[0,1,1,1,0]],
'C': [[0,1,1,1,1],[1,0,0,0,0],[1,0,0,0,0],[1,0,0,0,0],[1,0,0,0,0],[1,0,0,0,0],[0,1,1,1,1]],
'F': [[1,1,1,1,1],[1,0,0,0,0],[1,0,0,0,0],[1,1,1,1,0],[1,0,0,0,0],[1,0,0,0,0],[1,0,0,0,0]],
'K': [[1,0,0,0,1],[1,0,0,1,0],[1,0,1,0,0],[1,1,0,0,0],[1,0,1,0,0],[1,0,0,1,0],[1,0,0,0,1]],
'S': [[0,1,1,1,1],[1,0,0,0,0],[1,0,0,0,0],[0,1,1,1,0],[0,0,0,0,1],[0,0,0,0,1],[1,1,1,1,0]],
'M': [[1,0,0,0,1],[1,1,0,1,1],[1,0,1,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1]],
'V': [[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[0,1,0,1,0],[0,1,0,1,0],[0,1,0,1,0],[0,0,1,0,0]],
'!': [[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,0,0,0],[0,0,1,0,0]],
' ': [[0,0,0,0,0],[0,0,0,0,0],[0,0,0,0,0],[0,0,0,0,0],[0,0,0,0,0],[0,0,0,0,0],[0,0,0,0,0]],
'0': [[0,1,1,1,0],[1,0,0,0,1],[1,0,0,1,1],[1,0,1,0,1],[1,1,0,0,1],[1,0,0,0,1],[0,1,1,1,0]],
'1': [[0,0,1,0,0],[0,1,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,1,1,1,0]],
'2': [[0,1,1,1,0],[1,0,0,0,1],[0,0,0,0,1],[0,0,0,1,0],[0,0,1,0,0],[0,1,0,0,0],[1,1,1,1,1]],
'3': [[1,1,1,1,0],[0,0,0,0,1],[0,0,0,0,1],[0,1,1,1,0],[0,0,0,0,1],[0,0,0,0,1],[1,1,1,1,0]],
'4': [[0,0,0,1,0],[0,0,1,1,0],[0,1,0,1,0],[1,0,0,1,0],[1,1,1,1,1],[0,0,0,1,0],[0,0,0,1,0]],
'5': [[1,1,1,1,1],[1,0,0,0,0],[1,0,0,0,0],[1,1,1,1,0],[0,0,0,0,1],[0,0,0,0,1],[1,1,1,1,0]],
'6': [[0,1,1,1,0],[1,0,0,0,0],[1,0,0,0,0],[1,1,1,1,0],[1,0,0,0,1],[1,0,0,0,1],[0,1,1,1,0]],
'7': [[1,1,1,1,1],[0,0,0,0,1],[0,0,0,1,0],[0,0,1,0,0],[0,1,0,0,0],[0,1,0,0,0],[0,1,0,0,0]],
'8': [[0,1,1,1,0],[1,0,0,0,1],[1,0,0,0,1],[0,1,1,1,0],[1,0,0,0,1],[1,0,0,0,1],[0,1,1,1,0]],
'9': [[0,1,1,1,0],[1,0,0,0,1],[1,0,0,0,1],[0,1,1,1,1],[0,0,0,0,1],[0,0,0,0,1],[0,1,1,1,0]],
':': [[0,0,0,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,0,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,0,0,0]],
}
def draw_pixel_text(surf, text, x, y, scale=2, color=WHITE):
cx = x
for ch in text.upper():
bitmap = _FONT.get(ch, _FONT[' '])
for row, bits in enumerate(bitmap):
for col, bit in enumerate(bits):
if bit:
pygame.draw.rect(surf, color, (cx + col*scale, y + row*scale, scale, scale))
cx += (len(bitmap[0]) + 1) * scale
def pixel_text_width(text, scale=2):
total = 0
for ch in text.upper():
bitmap = _FONT.get(ch, _FONT[' '])
total += (len(bitmap[0]) + 1) * scale
return total
# VINE PLANT
_LEAF_RIGHT = [(0,0),(1,0),(2,0),(1,-1),(2,-1),(2,1)]
_LEAF_LEFT = [(0,0),(-1,0),(-2,0),(-1,-1),(-2,-1),(-2,1)]
def _px(surf, x, y, color=WHITE):
pygame.draw.rect(surf, color, (x, y, PX, PX))
def draw_vine_plant(surf, visible_leaves):
cx = W // 2
bot = H - 100
stem_blocks = MAX_LEAVES * 4 + 6
for i in range(stem_blocks):
_px(surf, cx - PX//2, bot - i*PX)
pot_w, pot_h = 8, 5
pot_top = bot
for row in range(pot_h):
w = pot_w - row
ox = (pot_w - w) // 2
for col in range(w):
_px(surf, cx - (pot_w//2)*PX + (ox+col)*PX, pot_top + row*PX)
for i in range(visible_leaves):
ly = bot - (i * 4 + 4) * PX
if i % 2 == 0:
for dx, dy in _LEAF_RIGHT:
_px(surf, cx + PX + dx*PX, ly + dy*PX)
else:
for dx, dy in _LEAF_LEFT:
_px(surf, cx - PX + dx*PX, ly + dy*PX)
_GROUND_LEAF = [
(0, 0), (1, 0), (2, 0),
(1, 1),
]
def _ground_leaf_x_offsets():
offsets = []
for i in range(MAX_LEAVES):
dist = (i // 2 + 1)
side = -1 if i % 2 == 0 else 1
offsets.append((side, dist))
return offsets
_GROUND_OFFSETS = _ground_leaf_x_offsets()
def draw_fallen_leaves(surf, fallen_count):
cx = W // 2
bot = H - 100
pot_half_w = 4 * PX
ground_y = bot + 5 * PX + PX
for i in range(fallen_count):
side, dist = _GROUND_OFFSETS[i]
lx = cx + side * (pot_half_w + dist * 4 * PX)
if side == -1:
lx -= 3 * PX
for dx, dy in _GROUND_LEAF:
_px(surf, lx + dx * PX, ground_y + dy * PX)
def draw_water_droplet(surf, ox, oy, scale=4):
sprite = [
"0000100000",
"0001110000",
"0011111000",
"0111111100",
"0111111100",
"1111111110",
"1111111110",
"0111111100",
"0011111000",
]
for row, line in enumerate(sprite):
for col, ch in enumerate(line):
if ch == '1':
pygame.draw.rect(surf, WHITE, (ox + col*scale, oy + row*scale, scale, scale))
BAR_X = W - 180
BAR_Y = 30
BAR_W = 140
BAR_H = 16
def draw_water_bar(surf, level_float):
pygame.draw.rect(surf, WHITE, (BAR_X, BAR_Y, BAR_W, BAR_H), 2)
fill = int((level_float / MAX_LEAVES) * (BAR_W - 4))
if fill > 0:
pygame.draw.rect(surf, WHITE, (BAR_X+2, BAR_Y+2, fill, BAR_H-4))
drop_scale = 3
drop_w = 10 * drop_scale
drop_h = 9 * drop_scale
drop_x = BAR_X - drop_w - 8
drop_y = BAR_Y + (BAR_H - drop_h) // 2
draw_water_droplet(surf, drop_x, drop_y, scale=drop_scale)
def draw_game_timer(surf, elapsed_ms):
total_s = elapsed_ms // 1000
mins = total_s // 60
secs = total_s % 60
label = f"{mins:02d}:{secs:02d}"
draw_pixel_text(surf, label, 12, 12, scale=2)
water_level = float(MAX_LEAVES)
decay_accum = 0.0
dead = False
elapsed_ms = 0
print("TEST MODE — SPACE to water" if TEST_MODE else "Running with serial")
running = True
while running:
dt = clock.tick(60)
if restart_event:
dead = False
water_level = float(MAX_LEAVES)
decay_accum = 0.0
elapsed_ms = 0
restart_event = False
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if TEST_MODE and event.type == pygame.KEYDOWN:
if event.key == pygame.K_SPACE:
water_event = True
if not dead:
elapsed_ms += dt
decay_accum += dt
if decay_accum >= LEAF_MS:
lost = int(decay_accum // LEAF_MS)
water_level -= lost
decay_accum -= lost * LEAF_MS
if water_event:
water_level = min(MAX_LEAVES, water_level + WATER_TICK)
water_event = False
water_level = max(0.0, water_level)
if water_level <= 0.0:
dead = True
if not TEST_MODE and ser:
ser.write(b"D\n")
screen.fill(BLACK)
if dead:
msg = "PLANT DIED"
msg2 = "GAME OVER"
msg3 = "CLICK TO RESTART"
draw_pixel_text(screen, msg, (W - pixel_text_width(msg, 3))//2, H//2 - 40, scale=3)
draw_pixel_text(screen, msg2, (W - pixel_text_width(msg2, 2))//2, H//2 + 10, scale=2)
draw_pixel_text(screen, msg3, (W - pixel_text_width(msg3, 2))//2, H//2 + 50, scale=2)
draw_game_timer(screen, elapsed_ms)
else:
visible_leaves = int(water_level)
fallen_count = MAX_LEAVES - visible_leaves
draw_vine_plant(screen, visible_leaves)
draw_fallen_leaves(screen, fallen_count)
draw_water_bar(screen, water_level)
label = "WATER THE PLANT!"
draw_pixel_text(screen, label,
(W - pixel_text_width(label, 3))//2,
BAR_Y + BAR_H + 52, scale=3)
draw_game_timer(screen, elapsed_ms)
pygame.display.flip()
pygame.quit()
#include <Wire.h>
// PINS
#define ROTARY_A PIN_PA2
#define ROTARY_B PIN_PA1
#define ROTARY_SW PIN_PB4
#define DEATH_LED PIN_PA4
// STATE
int lastA;
bool dead = false;
bool lastSwitch = HIGH;
unsigned long lastBlink = 0;
bool ledState = false;
void setup() {
Serial.begin(115200);
pinMode(ROTARY_A, INPUT_PULLUP);
pinMode(ROTARY_B, INPUT_PULLUP);
pinMode(ROTARY_SW, INPUT_PULLUP);
pinMode(DEATH_LED, OUTPUT);
digitalWrite(DEATH_LED, LOW);
lastA = digitalRead(ROTARY_A);
}
void loop() {
handleEncoder();
handleGameMessages();
handleDeathBlink();
handleRestartButton();
}
// WATER INPUT
void handleEncoder() {
if(dead) return;
int a = digitalRead(ROTARY_A);
if(a == LOW && lastA == HIGH) {
if(digitalRead(ROTARY_B) == LOW) {
Serial.println("W");
}
}
lastA = a;
}
// SERIAL FROM PYTHON
void handleGameMessages() {
if(Serial.available()) {
String msg = Serial.readStringUntil('\n');
msg.trim();
if(msg == "D") {
dead = true;
}
}
}
// BLINK LED WHEN DEAD
void handleDeathBlink() {
if(!dead) return;
unsigned long now = millis();
if(now - lastBlink > 500) {
lastBlink = now;
ledState = !ledState;
digitalWrite(DEATH_LED, ledState);
}
}
// RESTART BUTTON
void handleRestartButton() {
int sw = digitalRead(ROTARY_SW);
if(sw == LOW && lastSwitch == HIGH) {
if(dead) {
Serial.println("R");
dead = false;
digitalWrite(DEATH_LED, LOW);
ledState = false;
}
}
lastSwitch = sw;
}
A physical rotary encoder controls a virtual plant. Turning the encoder keeps the plant alive. Ignoring it causes decay and death. The computer simulation and microcontroller remain synchronized through simple serial messages.