A virtual pet game for ESP32 with MicroPython, featuring an SSD1306 OLED display and button controls.
The pet tracks 18 stats, split into tiers by how quickly they change:
| Tier | Stats | Change rate |
|---|---|---|
| Rapid | health, fullness, energy, comfort, playfulness, focus | Daily |
| Medium | fulfillment, cleanliness, intelligence, maturity, affection | Weekly |
| Slow | fitness, serenity | Monthly |
| Trait | courage, loyalty, mischievousness, curiosity, sociability | Nearly fixed |
All stats sit on a 0-100 scale. Health is never set directly; it's a weighted average of fitness, fullness, energy, cleanliness, comfort, affection, fulfillment, focus, intelligence, and playfulness, recomputed after every behavior completes.
Each pet gets a unique 64-bit seed at creation. That seed deterministically derives balanced personality offsets (up to +/-10) for the five trait stats, so every pet feels distinct without any individual pet being universally happier or sadder than another.
Stat changes use asymptotic damping: a stat near its ceiling resists further increases, and a stat near the floor resists further decreases. This keeps rewards feeling meaningful throughout the full 0–100 range.
The pet runs one behavior at a time. Behaviors are lazy-loaded and their modules are unloaded from memory after completion, keeping RAM usage low. The full list includes:
sleeping, napping, stretching, kneading, lounging, investigating, observing, chattering, zoomies, vocalizing, self_grooming, being_groomed, hunting, gift_bringing, pacing, sulking, mischief, hiding, training, playing, affection, attention, eating, startled, meandering, go_to
After each behavior finishes, the next one is chosen automatically:
- Each behavior defines a
can_triggercondition (stat thresholds, time of day, location, etc.). - Eligible behaviors are given a random priority draw (lower is better).
- Recently completed behaviors get a priority penalty to prevent loops.
- The best few behaviors are binned together and one is chosen at random from the top bin.
Personality traits feed directly into the selection. A high-mischievousness pet triggers mischief more often; a low-courage pet is more prone to hiding and startled.
High serenity adds a chance to skip the selection entirely and stay idle; a content pet is happy doing nothing.
Coins are the in-game currency. They are earned by:
- Minigames: Zoomies, Snake, Maze, Memory, Hanjie, Breakout, and Tic-tac-toe all award coins on completion, scaled by how well the player did.
- Hunting: each successful hunt awards 1-3 coins at random.
Coins are spent at the store, which is accessible from the main scene menu. The store sells:
| Category | Items | Cost |
|---|---|---|
| Meals | Chicken, Salmon, Tuna, Shrimp, Trout, Herring, Haddock, Cod, Turkey, Kibble, Beef, Lamb, Liver | 4-8c per 5 uses |
| Snacks | Treats, Nuggets, Puree, Milk, Chew Sticks, Fish Bytes, Eggs, Pumpkin, Carrots | 2-4c per 5 uses |
| Toys | String (5c), Feather (8c), Yarn Ball (10c), Laser Pointer (15c) | one-time purchase |
Food is consumed by feeding the pet from the main scene menu and depletes by one use per feeding. Different foods grant different stat bonuses: meals primarily restore fullness and energy, while snacks tend to boost comfort and affection. Toys can be used to trigger the playing behavior.
The pet can roam between five scenes: inside, bedroom, kitchen, outside, and treehouse. Location is tracked in context.last_main_scene.
The pet navigates autonomously using the go_to behavior. At the end of each behavior, there is a small base chance (~8%) of walking to a new room, boosted by relevant needs:
- Hungry: more likely to head to the kitchen
- Tired or uncomfortable: more likely to head to the bedroom
- Bad weather: strongly discourages trips outside or to the treehouse
Each location modifies the stat rewards from behaviors:
| Location | Effect |
|---|---|
| Bedroom | Sleeping grants +30% energy and +25% comfort. Sleep/nap trigger thresholds are raised (the pet falls asleep more readily). |
| Kitchen | Eating grants +20% fullness and energy. |
| Outside / Treehouse | Hunting grants +50% fitness and bonus fulfillment. |
| Outside / Treehouse (bad weather) | Sleeping or lounging in rain, storms, or snow incurs a comfort penalty. |
| Inside / Outside / Treehouse | Lounging grants +30% comfort (vs. a bedroom baseline). |
Weather follows a deterministic Markov chain seeded from the pet's unique seed, so each pet has its own distinct long-term weather trajectory that is reproducible across saves.
Possible states: Clear, Cloudy, Overcast, Windy, Rain, Storm, and Snow (Fall/Winter only, transitioning from Overcast). Each state lasts between 30 and 300 in-game minutes before transitioning.
Weather influences behavior in several ways:
- Scene navigation: rain, storms, and snow reduce the pet's desire to go outside or to the treehouse.
- Outdoor sleep/lounge: bad weather while outside applies a comfort penalty on completion.
- Forecast screen: because the weather is fully deterministic, a 72-hour forecast can be computed ahead of time without any randomness.
Note
Disabled by default. Set WIFI_ENABLED = True in config.py to enable. See the RAM warning below before doing so.
The ESP32's WiFi radio is used to determine whether the pet is at its familiar home location. A scan runs once at boot (while the loading screen is shown) and can be triggered manually from the debug WiFi scene.
Two lists of access points are maintained:
wifi_familiar: up to 16 well-known APs (persisted to flash). An AP here means the pet considers this a home location.wifi_recent: up to 8 candidate APs (persisted). New APs land here first and are promoted to familiar after being seen at least 5 times. Entries that aren't seen decay by 0.25 per scan and are pruned when they reach zero.
context.in_familiar_location is set to True whenever at least one familiar AP is visible. This flag affects multiple behaviors:
| When familiar | When unfamiliar |
|---|---|
| Zoomies and playing are more likely | Investigating, pacing, and startled are more likely |
| Lounging is more likely; grants +1.5 serenity and +15% comfort | Sulking and hiding are more likely |
| Sleeping grants +3 serenity | Sleeping loses 2 serenity and 15% comfort |
| Hiding is much less likely | Hiding is much more likely |
The intent is that a pet left at home is calmer, sleeps better, and plays more freely, while a pet taken somewhere unfamiliar becomes more anxious and restless.
Warning
Enabling wifi will make the device freeze within an hour or two.
On ESP32-C3/C6, all SRAM is shared; there is no separate "WiFi RAM." When the WiFi driver initialises (network.WLAN(...).active(True)), the ESP-IDF stack allocates internal buffers (TX/RX queues, the lwIP network stack, control structures) that are never returned, even after wlan.active(False) and garbage collection. This is because active(False) only stops the radio; it does not call esp_wifi_deinit(), and MicroPython's network API does not expose deinit.
The practical result: enabling WiFi permanently reduces available heap for the rest of the boot session. On devices already running close to the memory limit this is enough to cause allocation failures during scene changes or behavior loads, typically within an hour of boot. With WIFI_ENABLED = False the devices run indefinitely without issue.
- D-pad: Navigate / Move character
- A/B buttons: Action buttons
- Menu buttons: Additional functions
- ESP32-C6 SuperMini OR ESP32-C3 development board
- SSD1306 OLED Display (128x64, I2C)
- 8 Push Buttons for input
mpremoteinstalled (pip install mpremote)
The project supports both ESP32-C6 and ESP32-C3 boards. To configure for your board:
- Open
src/config.py - Set
BOARD_TYPEto either"ESP32-C6"or"ESP32-C3"
# In src/config.py
BOARD_TYPE = "ESP32-C6" # Change to "ESP32-C3" for ESP32-C3 boardChoose the wiring diagram for your board. Each button connects between GPIO pin and GND (internal pull-ups enabled).
Display (I2C):
| Display Pin | ESP32-C6 Pin |
|---|---|
| VCC | 3V3 |
| GND | GND |
| SDA | GPIO4 |
| SCL | GPIO7 |
Buttons:
| Button | GPIO Pin |
|---|---|
| UP | GPIO0 |
| DOWN | GPIO1 |
| LEFT | GPIO2 |
| RIGHT | GPIO3 |
| A | GPIO20 |
| B | GPIO19 |
| MENU1 | GPIO18 |
| MENU2 | GPIO14 |
Display (I2C):
| Display Pin | ESP32-C3 Pin |
|---|---|
| VCC | 3V3 |
| GND | GND |
| SDA | GPIO6 |
| SCL | GPIO7 |
Buttons:
| Button | GPIO Pin |
|---|---|
| UP | GPIO0 |
| DOWN | GPIO1 |
| LEFT | GPIO2 |
| RIGHT | GPIO3 |
| A | GPIO4 |
| B | GPIO5 |
| MENU1 | GPIO10 |
| MENU2 | GPIO11 |
Note: The ESP32-C3 configuration avoids strapping pins (GPIO8, GPIO9) to prevent boot issues.
This project uses custom MicroPython firmware with asset data frozen directly into flash. The sprite/icon data lives in flash rather than RAM, which frees up a significant portion of the ~85KB heap budget. You build the firmware once, flash it, then upload only the game logic.
Install build prerequisites:
brew install cmake ninja dfu-util # macOSClone ESP-IDF and MicroPython into ~/esp/:
mkdir -p ~/esp
# ESP-IDF (required version: v5.5.1)
git clone --recursive https://github.com/espressif/esp-idf.git ~/esp/esp-idf
cd ~/esp/esp-idf && git checkout v5.5.1
git submodule update --init --recursive
./install.sh esp32c6,esp32c3
# MicroPython
git clone https://github.com/micropython/micropython.git ~/esp/micropython
cd ~/esp/micropython
git submodule update --init --recursive
make -C mpy-crossIf you keep ESP-IDF or MicroPython somewhere other than
~/esp/, set theIDF_PATHandMICROPYTHON_DIRenvironment variables before running build scripts.
# Build and flash in one step (auto-detects USB port):
./tools/build_firmware.sh build-flash
# Or specify board and port explicitly:
./tools/build_firmware.sh build-flash esp32c6 /dev/tty.usbmodem1234
./tools/build_firmware.sh build-flash esp32c3This compiles a custom MicroPython binary with all src/assets/ modules frozen in, then flashes bootloader, partition table, and firmware to the device.
Note: Flashing replaces the entire filesystem. Re-run
./upload.shafter flashing to restore game files.
Before uploading, set your board type in src/config.py:
BOARD_TYPE = "ESP32-C6" # or "ESP32-C3"./upload.shThis installs the ssd1306 library, compiles and uploads all game logic. Asset files are not uploaded since they live in the firmware.
For the fastest iteration during development, use the dev.sh script which compiles Python to bytecode and runs via mpremote mount:
./dev.shThis script:
- Compiles all
.pyfiles insrc/to.mpybytecode inbuild/(excludingsrc/assets/) - Mounts the
build/directory on the device - Runs the game
Asset files are skipped because they are frozen into the firmware. MicroPython resolves frozen modules before the filesystem, so uploading them would be redundant.
Note
Requires mpy-cross (pip install mpy-cross) and mpremote (pip install mpremote).
The device must be running the custom firmware (see Installation). Asset imports will fail on stock MicroPython firmware.
Builds custom MicroPython firmware with asset modules frozen in flash, then optionally flashes it:
./tools/build_firmware.sh # build only, ESP32-C6
./tools/build_firmware.sh build-flash # build and flash, ESP32-C6
./tools/build_firmware.sh build esp32c3 # build only, ESP32-C3
./tools/build_firmware.sh flash esp32c6 /dev/tty.usbmodem1234 # flash with explicit portRe-run this whenever you add new sprite data to src/assets/ (after running tools/convert_bytearrays.py to convert any new bytearray literals to bytes literals first).
Verifies that your hardware is working correctly:
./test_hardware.shThis script:
- Resets the device
- Scans I2C to confirm the display is detected
- Enters an interactive button test (press buttons to see them register, Ctrl+C to exit)
Run this first when setting up a new device or debugging hardware issues.
Deploys the project to the ESP32's flash storage:
./upload.sh [port]This script:
- Installs the
ssd1306library viamip - Compiles all
.pyfiles to.mpybytecode (excludingsrc/assets/, which are frozen in firmware) - Cleans existing files from the device (preserves
lib/) - Uploads compiled
.mpyfiles andboot.pyto the device
Use this when you want the pet to run standalone without a laptop connection.
After uploading, the game starts automatically on power-up or reset.
To enter REPL mode instead: Hold A+B buttons while powering on or pressing reset. This skips auto-run so mpremote can connect.
To manually start the game from REPL:
mpremote
>>> import main
>>> main.main()If you see mpremote.transport.TransportError: could not enter raw repl when running ./dev.sh or other mpremote commands, it means boot.py is on the device and auto-running the game, blocking mpremote from connecting.
To fix this:
Either press A + B while ./dev.sh to interrupt the boot sequence.
Or, to remove the boot.py file so that it doesn't activate:
- Run
mpremoteto connect to the device - Press Ctrl+C to interrupt the running game
- Press Ctrl+B to exit raw REPL and enter friendly REPL
- Remove boot.py:
import os os.remove('boot.py')
- Press Ctrl+X to exit mpremote
Now ./dev.sh should work again.
To watch print() output from a running game without sending Ctrl+C or triggering a reset:
macOS:
screen /dev/cu.usbmodem* 115200Linux:
screen /dev/ttyACM0 115200If the glob doesn't match (or you have multiple devices), find the exact port first:
- macOS:
ls /dev/cu.* - Linux:
ls /dev/ttyACM*orls /dev/ttyUSB*
Press Ctrl+A then K to exit screen.
This is useful after a reboot (e.g. from a context save) breaks an mpremote session; the game is still running and its output is still on the serial port.
It's helpful to open an issue prior to making a PR to allow discussion on the changes.
It's also helpful to keep PRs small and targeted.

