Before smartphones, millions of children carried a tiny digital pet on their keyring: a Tamagotchi. It got hungry, bored and sleepy, and you had to look after it. In this capstone you build your own. Pip lives on the 1602 LCD with its own pixel-art faces. Four needs slowly go down over time: food, fun, energy and cleanliness. With the joystick you choose what to do: feed Pip, play a quick catch game, put it to sleep or clean up. When one of the needs runs low, Pip looks sad and chirps for attention. Pip's state is saved in the UNO's memory, so it is still there when you plug in the next day.
What you'll learn
- How to design custom characters (small pixel pictures) for the LCD
- How to model a pet with numbers that change over time
- How a menu works with a joystick: move, then press to choose
- How to save a game in EEPROM so it survives a power cut
What you need
- UNO R3 board and USB cable (UNO R3 Starter Kit)
- 1602 LCD display, 10 kΩ potentiometer and 1 resistor, 220 Ω (UNO R3 Starter Kit)
- Joystick module (UNO R3 Starter Kit)
- Passive buzzer (UNO R3 Starter Kit)
- Breadboard and jumper wires (UNO R3 Starter Kit)
Wiring

| From | To |
|---|---|
| LCD VSS, RW and K | GND |
| LCD VDD | 5V |
| LCD V0 | Middle pin (W) of the potentiometer |
| LCD RS | UNO pin 12 |
| LCD E | UNO pin 11 |
| LCD D4, D5, D6, D7 | UNO pins 5, 4, 3, 2 |
| LCD D0 to D3 | Not connected |
| LCD A (backlight +) | 220 Ω resistor, then 5V |
| Potentiometer outer pins | 5V and GND |
| Joystick GND, +5V | GND, 5V |
| Joystick VRx | UNO A0 |
| Joystick SW | UNO pin 6 |
| Passive buzzer + | UNO pin 8 |
| Passive buzzer − | GND |
The sketch
LiquidCrystal and EEPROM come with the Arduino IDE. Set SPEED to 10 while you test, so Pip's needs change ten times faster; set it back to 1 to keep Pip as a real pet.
#include <LiquidCrystal.h>
#include <EEPROM.h>
LiquidCrystal lcd(12, 11, 5, 4, 3, 2); // RS, E, D4, D5, D6, D7
const int JOY_X = A0, JOY_BUTTON = 6, BUZZER_PIN = 8;
const float SPEED = 1; // 10 = time runs ten times faster (for testing)
const char NAME[] = "Pip";
// Pixel art: 8 rows of 5 pixels each (1 = dark pixel)
byte HAPPY[8] = {0b00000, 0b01010, 0b01010, 0b00000, 0b10001, 0b01110, 0b00000, 0b00000};
byte SAD[8] = {0b00000, 0b01010, 0b01010, 0b00000, 0b01110, 0b10001, 0b00000, 0b00000};
byte BLINK[8] = {0b00000, 0b00000, 0b01010, 0b00000, 0b10001, 0b01110, 0b00000, 0b00000};
byte SLEEP[8] = {0b00000, 0b00000, 0b11011, 0b00000, 0b00000, 0b01110, 0b00000, 0b00000};
byte HEART[8] = {0b00000, 0b01010, 0b11111, 0b11111, 0b01110, 0b00100, 0b00000, 0b00000};
byte FOOD[8] = {0b00110, 0b01111, 0b01111, 0b00110, 0b00100, 0b00100, 0b01110, 0b00000};
byte STAR[8] = {0b00100, 0b00100, 0b11111, 0b01110, 0b01010, 0b10001, 0b00000, 0b00000};
enum { C_HAPPY, C_SAD, C_BLINK, C_SLEEP, C_HEART, C_FOOD, C_STAR };
struct Pet {
byte marker; // 0x50 = a saved pet
float food, fun, energy, clean; // 0 to 100
bool asleep;
unsigned long ageMinutes;
};
Pet pet;
const char* ACTIONS[] = {"Feed", "Play", "Sleep", "Clean"};
int menu = 0;
unsigned long lastTick = 0, lastSave = 0, lastCall = 0, minuteTimer = 0;
bool lastButton = HIGH, joyCentred = true;
void newPet() {
pet = {0x50, 80, 80, 80, 80, false, 0};
}
void save() {
EEPROM.put(0, pet);
lastSave = millis();
}
void chirp(int a, int b) {
tone(BUZZER_PIN, a, 70);
delay(90);
tone(BUZZER_PIN, b, 90);
}
float lowestNeed(int& which) {
float v[4] = {pet.food, pet.fun, pet.energy, pet.clean};
which = 0;
for (int i = 1; i < 4; i++) if (v[i] < v[which]) which = i;
return v[which];
}
void bar(float value) { // 6-cell bar for 0 to 100
int cells = (value + 8) / 17;
for (int i = 0; i < 6; i++) lcd.write(i < cells ? (uint8_t)255 : (uint8_t)'-');
}
void draw() {
int need;
float low = lowestNeed(need);
bool blink = !pet.asleep && (millis() / 150) % 27 == 0;
lcd.setCursor(0, 0);
lcd.write((uint8_t)(pet.asleep ? C_SLEEP : low < 25 ? C_SAD : blink ? C_BLINK : C_HAPPY));
lcd.print(' ');
lcd.print(NAME);
lcd.print(' ');
const char* mood = pet.asleep ? "zzz... " : low >= 25 ? "is happy "
: need == 0 ? "is hungry " : need == 1 ? "is bored " : need == 2 ? "is sleepy " : "is dirty ";
lcd.print(mood);
lcd.setCursor(0, 1);
lcd.print('<');
lcd.print(ACTIONS[menu]);
lcd.print('>');
for (int i = strlen(ACTIONS[menu]); i < 6; i++) lcd.print(' ');
float values[4] = {pet.food, pet.fun, pet.energy, pet.clean};
lcd.write((uint8_t)(menu == 0 ? C_FOOD : menu == 1 ? C_STAR : menu == 2 ? C_SLEEP : C_HEART));
bar(values[menu]);
}
void playCatch() { // 5 rounds: push towards the star quickly
int caught = 0;
for (int round = 0; round < 5; round++) {
lcd.clear();
int side = random(2); // 0 = left, 1 = right
lcd.setCursor(side ? 15 : 0, 0);
lcd.write((uint8_t)C_STAR);
lcd.setCursor(6, 1);
lcd.print("Catch!");
unsigned long start = millis();
int got = -1;
while (millis() - start < 1200 && got < 0) {
int x = analogRead(JOY_X);
if (x < 200) got = 0;
if (x > 820) got = 1;
}
lcd.clear();
if (got == side) {
caught++;
lcd.print("Got it!");
chirp(988, 1319);
} else {
lcd.print("Missed!");
tone(BUZZER_PIN, 220, 200);
}
delay(500);
while (abs(analogRead(JOY_X) - 512) > 150) delay(10); // wait for the stick to centre
}
pet.fun = min(100.0f, pet.fun + 8 * caught + 5);
pet.energy = max(0.0f, pet.energy - 10);
lcd.clear();
lcd.print("Caught ");
lcd.print(caught);
lcd.print(" of 5!");
delay(1200);
lcd.clear();
}
void doAction() {
if (pet.asleep && menu != 2) { // asleep: only "Sleep" (wake up) works
tone(BUZZER_PIN, 200, 100);
return;
}
if (menu == 0) {
pet.food = min(100.0f, pet.food + 25);
pet.clean = max(0.0f, pet.clean - 5);
chirp(660, 880);
} else if (menu == 1) {
if (pet.energy < 10) { tone(BUZZER_PIN, 200, 150); return; }
playCatch();
} else if (menu == 2) {
pet.asleep = !pet.asleep;
chirp(pet.asleep ? 523 : 392, pet.asleep ? 392 : 523);
lcd.clear();
} else {
pet.clean = 100;
chirp(1047, 1319);
}
save();
}
void tick(float minutes) { // needs change with time
if (pet.asleep) {
pet.energy = min(100.0f, pet.energy + 6 * minutes);
pet.food = max(0.0f, pet.food - 0.5 * minutes);
if (pet.energy >= 100) pet.asleep = false; // wakes up rested
} else {
pet.food = max(0.0f, pet.food - 1.5 * minutes);
pet.fun = max(0.0f, pet.fun - 2 * minutes);
pet.energy = max(0.0f, pet.energy - 1 * minutes);
pet.clean = max(0.0f, pet.clean - 0.8 * minutes);
}
}
void setup() {
pinMode(JOY_BUTTON, INPUT_PULLUP);
lcd.begin(16, 2);
lcd.createChar(C_HAPPY, HAPPY);
lcd.createChar(C_SAD, SAD);
lcd.createChar(C_BLINK, BLINK);
lcd.createChar(C_SLEEP, SLEEP);
lcd.createChar(C_HEART, HEART);
lcd.createChar(C_FOOD, FOOD);
lcd.createChar(C_STAR, STAR);
randomSeed(analogRead(A3));
EEPROM.get(0, pet);
if (pet.marker != 0x50) newPet(); // first start: a new pet hatches
// Hold the joystick button while plugging in to start with a new pet
if (digitalRead(JOY_BUTTON) == LOW) newPet();
lcd.print("Hello, I'm ");
lcd.print(NAME);
lcd.print("!");
chirp(784, 1047);
delay(1200);
lcd.clear();
lastTick = millis();
}
void loop() {
unsigned long now = millis();
float minutes = (now - lastTick) / 60000.0 * SPEED;
lastTick = now;
tick(minutes);
minuteTimer += (unsigned long)(minutes * 1000);
if (minuteTimer >= 1000) { pet.ageMinutes += minuteTimer / 1000; minuteTimer %= 1000; }
// Joystick left/right moves through the menu (once per push)
int x = analogRead(JOY_X);
if (joyCentred && x < 200) { menu = (menu + 3) % 4; joyCentred = false; tone(BUZZER_PIN, 1500, 10); }
if (joyCentred && x > 820) { menu = (menu + 1) % 4; joyCentred = false; tone(BUZZER_PIN, 1500, 10); }
if (x > 350 && x < 670) joyCentred = true;
bool button = digitalRead(JOY_BUTTON);
if (lastButton == HIGH && button == LOW) doAction();
lastButton = button;
int need;
if (!pet.asleep && lowestNeed(need) < 25 && now - lastCall > 30000) { // call for attention
lastCall = now;
chirp(1319, 988);
}
if (now - lastSave > 120000) save(); // save every 2 minutes
draw();
delay(30);
}
Try it
- Upload the sketch. Pip says hello and appears with a happy face and its name.
- Push the joystick left or right to go through the actions: Feed, Play, Sleep and Clean. The bar on the right shows the need that belongs to the action.
- Choose Play and press the joystick: a star appears on the left or the right. Push the stick towards it within 1.2 seconds. Five rounds; every catch makes Pip happier, but playing makes it tired.
- Choose Sleep to put Pip to bed. It wakes up by itself when it is rested, or when you choose Sleep again.
- Set
SPEEDto 10, upload again and wait a few minutes: Pip gets hungry, then sad, and chirps every 30 seconds until you help. Unplug and plug in again: Pip remembers everything. Hold the joystick button while plugging in for a brand-new pet.
How it works
Each character on the LCD is a grid of 5 × 8 dots. With createChar() you can design up to eight of your own: every row of the picture is one byte, and each 1 is a dark dot. Pip's faces, the heart, the food and the star are drawn this way, and the sketch shows them with lcd.write().
Pip is really just a struct full of numbers: four needs from 0 to 100, whether it is asleep and how old it is. tick() lowers the needs a little every time loop() runs, depending on how much time has passed. While Pip sleeps, energy goes up instead. Because everything is based on time, not on loop passes, Pip lives at the same speed however fast the UNO runs, and SPEED can speed up the clock for testing. The lowest need decides Pip's mood and what it says.
The whole struct is written to the EEPROM with EEPROM.put() every two minutes and after every action, and read back with EEPROM.get() at start-up. The marker byte 0x50 shows that a real pet was saved: a brand-new UNO has random EEPROM contents, and without the marker Pip could start with nonsense values. Saving only every two minutes protects the EEPROM, which can be written about 100,000 times per byte.
Try this next
- Give Pip a growth stage: after 3 days (4320 minutes) it changes to a bigger face.
- Add a "Medicine" action: if a need stays at 0 for an hour, Pip gets sick and only medicine helps.
- Let Pip sleep automatically at night: add a clock module or count hours from when you plugged it in.
- Design your own pet faces. Draw the 5 × 8 grid on paper first, then turn every row into a binary number.
Troubleshooting
- The LCD lights up but shows nothing, or only black boxes: turn the potentiometer slowly from one end to the other until the text appears.
- The menu jumps two steps at a time: let the joystick spring back to the middle between pushes; the sketch only moves once per push.
- Pip forgets everything after a restart: the pet is saved every 2 minutes. If you unplug sooner, the last changes are lost. Do an action (like Clean) to save at once.
- Strange values after upload: hold the joystick button while plugging in to hatch a new pet.