Raise a virtual pet on the LCD

Pip lives on your LCD screen and needs you: feed it, play a catch game with the joystick, put it to bed and clean up after it. If you forget, it gets sad and calls you. It even remembers everything...
  • Level:Advanced
  • Duration: 2 h
  • Ages 10+
  • Platform: Arduino

Tools for this lesson

Helpful for this build. Open a tool to see tips and more projects that use it.

Raise a virtual pet on the LCD – EEMENTOR STEM™ lesson

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

Breadboard wiring diagram: Raise a virtual pet on the LCD
Breadboard view. Check each connection against the wiring table below.
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

  1. Upload the sketch. Pip says hello and appears with a happy face and its name.
  2. 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.
  3. 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.
  4. Choose Sleep to put Pip to bed. It wakes up by itself when it is rested, or when you choose Sleep again.
  5. Set SPEED to 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.

Build it yourself

Everything used in this lesson comes in one kit, with parts tested by our engineers.

UNO R3 Starter Kit | Arduino IDE Compatible | Free Tutorials $56.00

Shop the kit