Tamagotchi
Your physical virtual-pet on an ESP32
Created on 3rd June 2026
•
Tamagotchi
Your physical virtual-pet on an ESP32
The problem Tamagotchi solves
Gochi is a real, physical Tamagotchi — a virtual pet that actually lives on a tiny ESP32-C3 SuperMini wired to a 0.96" SSD1306 OLED, a piezo buzzer, and buttons. Unlike the 90s keychain toys with pre-baked sprite frames, every one of Gochi's 10+ expressions is drawn fully procedurally from primitives (rounded rects, arcs, lines, circles, triangles) — no bitmaps anywhere. Each face has its own idle animation: a happy bob, a sad sliding tear, sleepy floating "Z"s with a breathing chest, an excited fast bounce, an angry tremble, pulsing love hearts with blush, and dead X-eyes.
The pet runs autonomously. It boots into Free Mode, wandering a shared mood (content / playful / grumpy / sleepy / affectionate), picking mood-appropriate expressions every ~10-17s and drifting its mood over minutes. The buzzer plays a short jingle matched to each face, kept in sync no matter what triggered the change.
You can also drive it like a desktop companion. A cross-platform gochi CLI mirrors a serial line protocol (show face, set mood, get state, ping...) and even streams live images — point it at any PNG/JPG and it Floyd–Steinberg-dithers the frame down to 128×64 and renders it on the OLED. A background daemon owns the single connection, auto-detects the device, and exposes a REST API so AI agents (like this one!) can reflect task outcomes on the pet's face. The whole thing works cable-free over Bluetooth LE once paired over USB.
Challenges I ran into
Cable-free without a desktop Bluetooth stack to lean on. The C3 is BLE-only (no Classic/SPP), so wireless control is a Nordic UART Service mirroring the serial protocol. The hard bug: NimBLE "just-works" bonding silently connects but never bonds (getNumBonds() stays 0, encrypted characteristics unusable) unless you call BOTH setSecurityInitKey/RespKey(ENC|ID) and NimBLEDevice::startSecurity(connHandle) in onConnect. Pairing is USB-gated for safety — BLE only advertises after a USB command opens a window — and bonds auto-reconnect on boot.
Truncated frames over USB. Streaming a base64 image frame kept failing with mbedtls_base64_decode returning -44 (INVALID_CHARACTER). Root cause: HWCDC's default 256-byte RX FIFO overruns mid-payload. Raising Serial.setRxBufferSize(2048) and the line cap to 1536 fixed it cleanly.
Frame-rate bottleneck. Bit-banged I2C capped the OLED at ~15-25 fps. Switching to the C3's hardware I2C peripheral at 400 kHz (routed to GPIO5/6 via the GPIO matrix) unblocked smooth animation.
A wedged radio, not a stale bond. After re-pairing, "no BLE connection" turned out not to be a macOS bond issue but the daemon's long-lived noble stack getting wedged — the connect-failure path called scan() while still busy, so it no-op'd forever. Fixed with a coalesced scheduleScan() and a force stop→start; gochi kill (fresh CoreBluetooth) became the reliable recovery.
No IDE. Everything is built, flashed, and linted from the terminal with arduino-cli, a Makefile, and a portable clangd config — reproducible on any machine.
