First, install the Arduino IDE.
What is Arduino IDE?
Arduino IDE is used to edit sketches and upload them to boards like the ESP32-C6 over USB.
- Step 1: Install the ESP32 Board Package
- Step 2: Install the Board in Boards Manager
- Step 3: Select the Board and Port
- Step 4: Install Required Libraries
- Step 5: Create the Project
- Step 6: Reading Your Schematic for Firmware
- Step 7: Includes and Hardware Configuration
- Step 8: Define the Pet Model
- Step 9: Define UI States (State Machine)
- Step 10: Setup Function
- Step 11: Game Update Logic
- Step 12: Reading Button Input
- Step 13: Screen Logic
- Step 14: Pet Sprites
- Step 15: Display Function (OLED Rendering)
- Step 16: Main Loop
ESP32 support isn't included by default, so you need to add Espressif's board package URL.
- Open the Arduino IDE.
- Go to File > Preferences (or Arduino IDE > Settings on macOS).
- In the "Additional Boards Manager URLs" field, add the following URL:
https://espressif.github.io/arduino-esp32/package_esp32_index.jsonNote: If you already have URLs here, separate entries with commas.
- Click OK to save the preferences.
- Wait for the board index to download.
Why this step?
This URL provides Arduino with Espressif's board definitions and the tools needed to compile and upload.
- Open the Boards Manager by navigating to Tools > Board > Boards Manager.
- In the search bar, type
esp32. - Locate the entry for "esp32 by Espressif Systems".
- Click the Install button.
Important: Ensure you are using version 3.0.0 or later, which supports the C6.
- Wait for the installation to finish.
What gets installed?
It installs the RISC-V cross-compiler toolchain, ESP-IDF libraries, and upload tools.
- After installation, go to Tools > Board > esp32.
- Select your board model:
XIAO ESP32C6.
Why does the board selection matter?
The selected board controls default pin mappings and build settings (flash size, bootloader, clock speed).
In Sketch > Include Library > Manage Libraries, install:
- Adafruit SSD1306 (by Adafruit)
- Adafruit GFX Library (by Adafruit), which will be prompted as a dependency.
What are these libraries for?
- Adafruit SSD1306: SSD1306 OLED driver (I2C).
- Adafruit GFX: drawing primitives used by the driver.
Create a new Arduino sketch: File > New Sketch, then save it as Tamagotchi.ino.
What is a .ino file?
Arduino treats .ino as C++. The IDE adds Arduino.h and generates main(), so sketches only need to implement setup() and loop().
Before writing any code, open your .kicad_sch and confirm each component's connections to the MCU. Your pin mappings in firmware must match the schematic.
Trace each component to the MCU and record the GPIO number. KiCad shortcut: click a wire and press ` (backtick) to highlight the net.
Example pin mapping from my schematic (yours will differ):
Buttons (active-low: one side to GND, other side to MCU → reads LOW when pressed):
| Button | MCU Pin | GPIO |
|---|---|---|
| SW1 | Pin 1 | GPIO0 |
| SW2 | Pin 2 | GPIO1 |
| SW3 | Pin 3 | GPIO2 |
OLED Display (I²C address is usually 0x3C, check your datasheet):
| OLED Pin | MCU Pin | GPIO |
|---|---|---|
| SDA | Pin 5 | GPIO22 |
| SCL | Pin 6 | GPIO23 |
Buzzer (positive terminal tied 3.3V to GPIO sinks current, so LOW = ON):
| Buzzer Pin | MCU Pin | GPIO |
|---|---|---|
| Signal | Pin 10 | GPIO20 |
How do I know if my buzzer is active or passive?
- Active buzzer: has an internal oscillator. Turn it on/off with a digital signal.
- Passive buzzer: requires a driven waveform. Control pitch with
tone().
Check the datasheet or distributor listing. Active buzzers typically mention an internal oscillator; passive ones don't.
Define the GPIOs at the top of your sketch:
// Buttons (traced from schematic)
#define BTN1 0 // SW1 → MCU Pin 1 → GPIO0
#define BTN2 1 // SW2 → MCU Pin 2 → GPIO1
#define BTN3 2 // SW3 → MCU Pin 3 → GPIO2
// I2C OLED (traced from schematic)
#define SDA_PIN 22 // OLED SDA → MCU Pin 5 → GPIO22
#define SCL_PIN 23 // OLED SCL → MCU Pin 6 → GPIO23
// Buzzer (traced from schematic)
#define BUZZER 20 // Buzzer signal → MCU Pin 10 → GPIO20Use the GPIO numbers from your schematic. If these #defines don't match your wiring, the firmware will compile but the hardware won't behave correctly.
Add the required includes and configure the display and pin constants.
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
#define BTN_LEFT D0
#define BTN_MIDDLE D1
#define BTN_RIGHT D2
#define BUZZER_PIN D3Notes:
#include <Wire.h>includes the I2C library, which handles communication with the OLED over the SDA/SCL pins.#include <Adafruit_GFX.h>and#include <Adafruit_SSD1306.h>bring in the display driver and graphics functions.SCREEN_WIDTH/SCREEN_HEIGHTmatch the 0.96" OLED resolution (128x64 pixels).OLED_RESETis set to-1because our OLED does not have a dedicated reset pin.Adafruit_SSD1306 display(...)creates the display object we'll use to draw everything.BTN_LEFT,BTN_MIDDLE,BTN_RIGHTare the GPIO pins connected to the buttons. Set these to match your schematic.BUZZER_PINis the GPIO connected to the buzzer.
What if my buttons are on different pins?
Check your KiCad schematic to see which GPIO pins your buttons are wired to. Then change D0, D1, D2, and D3 to match. For example, if your left button is on pin D5, change the line to #define BTN_LEFT D5.
Define a struct to store the pet's stats.
struct Pet {
int hunger; // 0 to 100 (0 = starving, 100 = full)
int happiness; // 0 to 100 (0 = miserable, 100 = ecstatic)
int energy; // 0 to 100 (0 = exhausted, 100 = fully rested)
unsigned long age; // total seconds the pet has been alive
};Notes:
struct Pet { ... };defines aPettype with fields for the pet's stats.int hunger;is an integer from 0 to 100. We choseintbecause the values are small whole numbers.unsigned long age;usesunsigned long(0 to 4,294,967,295) because time values frommillis()can get very large and are never negative.
Create one global instance:
Pet pet;Why global?
A global variable lets setup(), loop(), and helpers share the same state.
Step 9: Define UI States (State Machine)
Define named states for the UI using a finite state machine. The program is always in exactly one state.
enum Screen {
SCREEN_MAIN,
SCREEN_FEED,
SCREEN_PLAY,
SCREEN_SLEEP
};
Screen currentScreen = SCREEN_MAIN;Notes:
enum Screen { ... };is anenum(enumeration) that assigns human-readable names to integer constants. Under the hood,SCREEN_MAIN= 0,SCREEN_FEED= 1, etc., but we never need to know that.Screen currentScreen = SCREEN_MAIN;tracks which screen we're on right now. The program starts on the main screen.
Why use a state machine?
Without it, you end up with deeply nested if statements that are hard to read and debug. A state machine makes the logic predictable: "When I'm in state X and event Y happens, go to state Z." This is the same pattern used in real embedded systems, game engines, and protocol parsers.
setup() runs once when the board powers on or resets. Use it to initialize the display, buttons, and starting values.
void setup() {
pinMode(BTN_LEFT, INPUT_PULLUP);
pinMode(BTN_MIDDLE, INPUT_PULLUP);
pinMode(BTN_RIGHT, INPUT_PULLUP);
pinMode(BUZZER_PIN, OUTPUT);
display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println("Tamagotchi Init...");
display.display();
delay(1000);
pet.hunger = 80;
pet.happiness = 80;
pet.energy = 80;
pet.age = 0;
}Notes:
pinMode(BTN_LEFT, INPUT_PULLUP);configures each button pin as an input with the internal pull-up resistor enabled. This means the pin readsHIGHnormally andLOWwhen the button is pressed (active-low).pinMode(BUZZER_PIN, OUTPUT);sets the buzzer pin as an output so we can drive it with a signal.display.begin(SSD1306_SWITCHCAPVCC, 0x3C);initializes the OLED.0x3Cis the I2C address of the display (the most common address for these OLEDs).display.clearDisplay();clears any leftover data in the display buffer.display.setTextSize(1);sets text to the smallest size (6x8 pixels per character).display.setTextColor(SSD1306_WHITE);sets the drawing color to white (lit pixels).display.println(...)writes text to the buffer (not the screen yet).display.display();pushes the buffer to the actual OLED screen. You must call this for anything to appear!- Stats start at 80/100, leaving room for improvement or decay.
What if my OLED doesn't turn on?
The most common issues are:
- Wrong I2C address: Some OLEDs use
0x3Dinstead of0x3C. Try changing the address indisplay.begin(). - Wiring: Double-check that SDA, SCL, VCC (3V3), and GND are connected correctly.
- Library not installed: Make sure you installed both Adafruit SSD1306 and Adafruit GFX.
Stats decay over time using millis() instead of delay(). delay() blocks the entire program; millis() lets input and rendering continue.
unsigned long lastUpdate = 0;
void updatePet() {
if (millis() - lastUpdate > 5000) { // every 5 seconds
pet.hunger--;
pet.happiness--;
pet.energy--;
if (pet.hunger < 0) pet.hunger = 0;
if (pet.happiness < 0) pet.happiness = 0;
if (pet.energy < 0) pet.energy = 0;
pet.age += 5;
lastUpdate = millis();
}
}Notes:
unsigned long lastUpdate = 0;remembers the last time we updated the pet. Starts at 0 (boot time).millis() - lastUpdate > 5000checks if more than 5000ms (5 seconds) have passed.millis()returns the number of milliseconds since the board powered on.pet.hunger--uses the--operator to decrement by 1. Every 5 seconds, the pet gets slightly hungrier, sadder, and more tired.if (pet.hunger < 0) pet.hunger = 0;is clamping: we prevent stats from going below zero. Without this, hunger could hit -50, which makes no sense.lastUpdate = millis();resets the timer so the next update happens 5 seconds from now.
What is non-blocking timing?
This millis() pattern is fundamental to embedded programming. It's how you schedule periodic tasks without blocking the CPU. You'll see this exact pattern in Arduino's BlinkWithoutDelay example.
The buttons are wired active-low. Read them with digitalRead() and debounce to prevent repeated triggers.
unsigned long lastButtonPress = 0;
void checkButtons() {
if (millis() - lastButtonPress < 200) return; // debounce: ignore presses within 200ms
if (digitalRead(BTN_LEFT) == LOW) {
currentScreen = SCREEN_FEED;
tone(BUZZER_PIN, 1000, 50);
lastButtonPress = millis();
}
else if (digitalRead(BTN_MIDDLE) == LOW) {
currentScreen = SCREEN_PLAY;
tone(BUZZER_PIN, 1200, 50);
lastButtonPress = millis();
}
else if (digitalRead(BTN_RIGHT) == LOW) {
currentScreen = SCREEN_SLEEP;
tone(BUZZER_PIN, 800, 50);
lastButtonPress = millis();
}
}Notes:
unsigned long lastButtonPress = 0;tracks the last time any button was pressed, used for debouncing.if (millis() - lastButtonPress < 200) return;is debouncing. Physical buttons "bounce" (make and break contact rapidly) when pressed, which can register as multiple presses. By ignoring any press within 200ms of the last one, we ensure one press = one action.digitalRead(BTN_LEFT) == LOWreads the pin state. Because the buttons are active-low,LOWmeans the button is currently being pressed.currentScreen = SCREEN_FEED;sets the state but doesn't act on it. The actual logic runs in Step 13, keeping input detection and action execution separate.tone(BUZZER_PIN, 1000, 50);plays a short beep (1000Hz for 50ms) as audible feedback.
What is debouncing?
When you press a button, the contacts bounce for a few milliseconds, creating rapid on/off signals. The 200ms cooldown timer filters these out so one press registers once.
Button mapping:
| Button | Action | State Transition |
|---|---|---|
| Left | Feed the pet | → SCREEN_FEED |
| Middle | Play with pet | → SCREEN_PLAY |
| Right | Put pet to sleep | → SCREEN_SLEEP |
Each screen modifies the pet's stats and returns to the main screen. Actions are one-shot: press feed, hunger goes up, done.
void handleScreenLogic() {
switch(currentScreen) {
case SCREEN_FEED:
pet.hunger += 10;
if (pet.hunger > 100) pet.hunger = 100;
currentScreen = SCREEN_MAIN;
break;
case SCREEN_PLAY:
pet.happiness += 10;
pet.energy -= 5;
if (pet.happiness > 100) pet.happiness = 100;
if (pet.energy < 0) pet.energy = 0;
currentScreen = SCREEN_MAIN;
break;
case SCREEN_SLEEP:
pet.energy += 15;
if (pet.energy > 100) pet.energy = 100;
currentScreen = SCREEN_MAIN;
break;
case SCREEN_MAIN:
break; // do nothing, just display stats
}
}Notes:
switch(currentScreen)selects behavior based on the current UI state.pet.hunger += 10;uses the+=operator to add 10 to the current value. Feeding restores 10 hunger points.if (pet.hunger > 100) pet.hunger = 100;is upper clamping: stats can't exceed 100. This is the mirror of the lower clamping inupdatePet().pet.energy -= 5;: playing increases happiness but costs energy.currentScreen = SCREEN_MAIN;returns to main after the action completes. This makes each action a one-shot: the player must actively choose to do something again.- Each
caseends withbreakto prevent fall-through.
Why does SCREEN_MAIN do nothing?
SCREEN_MAIN only renders stats; actions happen in the other states.
Create 1-bit sprites for the pet using draw-to-bit, which exports pixel art as C byte arrays.
You need at least a few expressions so the sprite reflects the pet's stats. Example 16x16 sprites:
// Example: Happy face (all stats above 50)
const unsigned char PROGMEM petHappy[] = {
0b00000000, 0b00000000,
0b00011111, 0b11111000,
0b00100000, 0b00000100,
0b01000000, 0b00000010,
0b01001100, 0b00110010,
0b01001100, 0b00110010,
0b01000000, 0b00000010,
0b01000000, 0b00000010,
0b01000100, 0b00100010,
0b01000011, 0b11000010,
0b01000000, 0b00000010,
0b00100000, 0b00000100,
0b00011111, 0b11111000,
0b00000000, 0b00000000,
0b00000000, 0b00000000,
0b00000000, 0b00000000
};
// Example: Sad face (any stat below 30)
const unsigned char PROGMEM petSad[] = {
0b00000000, 0b00000000,
0b00011111, 0b11111000,
0b00100000, 0b00000100,
0b01000000, 0b00000010,
0b01001100, 0b00110010,
0b01001100, 0b00110010,
0b01000000, 0b00000010,
0b01000000, 0b00000010,
0b01000000, 0b00000010,
0b01000011, 0b11000010,
0b01000100, 0b00100010,
0b00100000, 0b00000100,
0b00011111, 0b11111000,
0b00000000, 0b00000000,
0b00000000, 0b00000000,
0b00000000, 0b00000000
};
// Example: Neutral face (everything else)
const unsigned char PROGMEM petNeutral[] = {
0b00000000, 0b00000000,
0b00011111, 0b11111000,
0b00100000, 0b00000100,
0b01000000, 0b00000010,
0b01001100, 0b00110010,
0b01001100, 0b00110010,
0b01000000, 0b00000010,
0b01000000, 0b00000010,
0b01000111, 0b11100010,
0b01000000, 0b00000010,
0b01000000, 0b00000010,
0b00100000, 0b00000100,
0b00011111, 0b11111000,
0b00000000, 0b00000000,
0b00000000, 0b00000000,
0b00000000, 0b00000000
};
// Example: Sleeping face (closed eyes)
const unsigned char PROGMEM petSleep[] = {
0b00000000, 0b00000000,
0b00011111, 0b11111000,
0b00100000, 0b00000100,
0b01000000, 0b00000010,
0b01001111, 0b01110010,
0b01000000, 0b00000010,
0b01000000, 0b00000010,
0b01000000, 0b00000010,
0b01000011, 0b11000010,
0b01000000, 0b00000010,
0b01000000, 0b00000010,
0b00100000, 0b00000100,
0b00011111, 0b11111000,
0b00000000, 0b00000000,
0b00000000, 0b00000000,
0b00000000, 0b00000000
};Notes:
const unsigned char PROGMEMstores the bitmap in flash instead of RAM. SeePROGMEM.- Each row is two bytes (16 bits = 16 pixels wide). A
1bit lights up that pixel, a0bit leaves it dark. - Four example expressions are defined above. Replace them with your own sprites from draw-to-bit.
Can I make the pet sprite bigger?
Yes. Use a larger bitmap (e.g., 32x32) and update the drawBitmap() dimensions to match. Larger sprites use more flash and screen space.
Draws the sprite and stat bars. Sprite selection is based on current stats.
void render() {
display.clearDisplay();
// Choose the right sprite based on pet stats
const unsigned char* sprite;
if (pet.hunger < 30 || pet.happiness < 30 || pet.energy < 30) {
sprite = petSad;
} else if (pet.hunger > 50 && pet.happiness > 50 && pet.energy > 50) {
sprite = petHappy;
} else {
sprite = petNeutral;
}
// Draw the pet sprite (centered horizontally, near the top)
display.drawBitmap(56, 2, sprite, 16, 16, SSD1306_WHITE);
// Draw stat bars below the pet
display.setTextSize(1);
display.setCursor(0, 24);
display.print("HUN ");
drawBar(24, 24, pet.hunger);
display.setCursor(0, 34);
display.print("HAP ");
drawBar(24, 34, pet.happiness);
display.setCursor(0, 44);
display.print("ENG ");
drawBar(24, 44, pet.energy);
// Button labels at the bottom
display.setCursor(0, 56);
display.println("[Feed] [Play] [Sleep]");
display.display();
}
// Draws a stat bar: empty rectangle with a filled portion based on value (0 to 100)
void drawBar(int x, int y, int value) {
int barWidth = 100;
int barHeight = 6;
int fillWidth = map(value, 0, 100, 0, barWidth);
display.drawRect(x, y, barWidth, barHeight, SSD1306_WHITE); // outline
display.fillRect(x, y, fillWidth, barHeight, SSD1306_WHITE); // filled portion
}Notes:
- Sprite is chosen by
if/else: any stat below 30 = sad, all above 50 = happy, otherwise neutral. display.drawBitmap()draws a 1-bit bitmap.56, 2centers the 16px sprite on the 128px-wide screen.drawBar()draws an outline withdrawRect()and fills it proportionally withfillRect().map()scales the stat value to the bar's pixel width.display.display()pushes the buffer to the OLED.
Why do we clear and redraw every frame?
The SSD1306 uses a framebuffer. Without clearing first, old pixels remain and overlap new content. The clear → draw → display pattern is standard for frame-based rendering.
loop() runs repeatedly after setup() finishes. It reads input, updates state, and redraws the display.
void loop() {
checkButtons(); // 1. Read input
updatePet(); // 2. Update state over time
handleScreenLogic(); // 3. Process actions
render(); // 4. Display results
delay(100); // 5. Short pause
}Loop order:
┌──────────────────────────────────────────────────────┐
│ GAME LOOP │
│ │
│ ┌─────────┐ ┌────────┐ ┌───────┐ ┌────────┐ │
│ │ INPUT │──▶│ UPDATE │──▶│ LOGIC │──▶│ RENDER │ │
│ │ (read │ │ (decay │ │ (feed,│ │ (draw │ │
│ │ buttons) │ │ stats) │ │ play) │ │ OLED) │ │
│ └─────────┘ └────────┘ └───────┘ └────────┘ │
│ ▲ │ │
│ └──────────── delay(100) ──────────────┘ │
└──────────────────────────────────────────────────────┘
- Input: Read buttons, set
currentScreen. - Update: Decay stats based on elapsed time.
- Logic: Execute the selected action (feed/play/sleep), return to main.
- Render: Draw the frame.
- Pause:
delay(100)limits refresh rate while keeping input responsive.
What is a game loop?
This is a standard game loop pattern (input → update → render), common in interactive programs.