Hummingbird Tamagotchi

Firmware, bring-up & sprite art — the software half of the build

Board Waveshare RP2350-LCD-1.28 Toolchain PlatformIO + arduino-pico Display Arduino_GFX Art 120×120 @ 2×

Companion to the hardware & wiring guide · code lives in firmware/

Contents

What this guide is

The software half of the Hummingbird Tamagotchi. Its companion, the hardware & wiring guide, covers the bill of materials, component validation, soldering and the 3D-printed case. This one covers everything from an unopened board to a working pet: bring-up, toolchain, the driver decision, the code, and the sprite pipeline.

Read this before you pick up the soldering iron The instinct on a hardware project is to build the hardware first. On this board that ordering is backwards, and it costs you the one unit you own.

The display, the IMU and USB are all wired internally — the board is fully functional out of the box. Soldering only adds three buttons and a buzzer. And firmware cannot brick an RP2350: the BOOTSEL ROM always recovers it (hold BOOT, plug in USB, drag a UF2), no matter how badly a build misbehaves.

So the only genuinely irreversible risks in this whole project are the soldering iron, reversed battery polarity, and LiPo abuse. Take unlimited risk in firmware; save the iron for last. You can have a fully working pet on screen, driven by tilt gestures, before anything is soldered at all.

The order of operations

0
Board truth. Power it on, prove the panel and IMU with someone else's firmware, and measure. No code, no soldering. Highest-leverage step in the project.
1
Toolchain & driver ladder. Install PlatformIO, flash the self-test, decide which display library actually works on RP2350, lock the SPI clock.
2
Game simulation. Already done — the pet logic is written, pure, and covered by 39 host tests that run on your laptop with no hardware attached.
3
Time, power & persistence. Deep sleep, the always-on timer, save/restore. Do this on the Pico 2 W — same RP2350 silicon, and it keeps your only display board free.
4
Render engine & sprite art. The asset pipeline is built and tested; the blitter and the drawing are what remain.
5
Integration. Screens, input, audio, the full loop — still on USB, still unsoldered, driven by IMU gestures.
6
Power tuning against the Phase 0 measurements.
7
Assembly. The wiring guide takes over here. The only irreversible step.
8
Enclosure. Print, fit, iterate.

Phases 1, 3 and 4 are genuinely parallel, and two of them don't need the LCD board at all.

Phase 0 — Board truth

Every estimate downstream is a guess until this is done. It takes an evening and needs no code of your own.

Status on this build Steps 1, 2 and 4 are done and passed: CircuitPython 10.2.1 drove the panel and read the IMU (0x6B, WHO_AM_I=0x05, QMI8658), BOOTSEL recovery has been used many times over, and — importantly — this board has no always-on power LED, which removes the single largest threat to the sleep budget.

Steps 3 and 5 are only partly done, measured with a multimeter in series with the battery rather than an inline USB meter. Active draw is ~300mA with the display lit; the sleep states remain unmeasured, so every battery-life figure here is modelled, not measured. Charging measured a safe 0.5C but was taken on a nearly-full cell, where the reading cannot distinguish a sane charger from an aggressive one. Details in Sources & verification notes.

A cheaper substitute for step 3 if you lack instruments: this board exposes BAT_ADC (GP29, behind a /2 divider, confirmed working), so firmware can log battery voltage over time and measure real runtime directly. That takes days of wall-clock but no equipment, and it is strictly better evidence than a current reading — runtime is what you actually care about; current draw is only a proxy for it.
Doing this first is what makes later bugs cheap Proving the panel with someone else's known-good driver before writing your own is the highest-leverage half hour in the project. When this build's own driver later produced a blank screen, the CircuitPython result meant the hardware was never a suspect — the search space collapsed to our code immediately. Skip this step and every subsequent bug becomes a two-variable problem.

1Prove the hardware with CircuitPython

  1. Download Adafruit's CircuitPython .uf2 for the Waveshare RP2350-LCD-1.28 — there is an official board definition for this exact board.
  2. Hold BOOT, plug in USB-C, release. A drive called RP2350 appears.
  3. Drag the .uf2 onto it. The board reboots as CIRCUITPY.
  4. Open the serial REPL and draw something to the panel; read the IMU.

This proves the panel, the SPI wiring, the IMU, the USB path and your flashing workflow before any of your own code exists. From here on, a garbled display is definitively a software problem — that's worth a lot when you're debugging your own driver later.

2Deliberately break it, then recover it

Flash something wrong on purpose, then recover with BOOTSEL. Do it now, while nothing is at stake, so you know from experience that firmware can't kill this board. It changes how boldly you're willing to experiment for the rest of the project.

In practice you get this for free the first time you flash your own firmware over CircuitPython — a real binary is a better test than a synthetic broken one, and BOOTSEL is a hardware ROM feature that no firmware of yours can disable.

The flash_nuke.uf2 everyone recommends is RP2040-only The widely-linked flash_nuke.uf2 carries UF2 family ID 0xe48bff56 (RP2040). RP2350 is 0xe48bff59, so the bootloader simply rejects it — harmless, but useless, and a bad thing to discover mid-panic. Note also that re-flashing CircuitPython does not erase the filesystem, so a hostile boot.py that disables the USB drive will survive a reinstall. If you want to rehearse that specific lockout, get picotool erase working first.

3Measure the current draw

With an inline USB power meter, record the draw in each state: idle, backlight at 100%, backlight off, panel asleep.

Why this is the most valuable half-hour in the project: published RP2350 sleep figures span a 16× range (~150µA to ~2.5mA depending on the board), and the sleep floor is roughly two-thirds of the entire battery budget. Whether this pet runs for 3 days or 6 weeks is decided by a number you can only get by measuring.

4Hunt for an always-on power LED

Waveshare boards often have one. At 1–3mA it would single-handedly dominate your entire sleep budget — more than every firmware optimisation in this project combined. If you find one, lifting its tiny series resistor with a fine tip is a two-minute job and the single highest-leverage physical change available.

5Check the charge current — safety

The onboard ETA6096 charger supports up to 2.5A. Into a 500mAh cell that would be 5C, and a genuine fire risk. Waveshare almost certainly fitted a sane program resistor, but that value is not documented anywhere I could verify.

Measure charge current inline, or at absolute minimum keep a hand on the cell through the first full charge and stop if it gets warm. This is the one electrical unknown on this board that can actually hurt you.

A plain multimeter in series with the battery's positive lead is enough — you don't need a USB meter. Because there's no power-path (the ETA6096 has no SYS pin), that one setup reads net current into the cell with USB connected, which is exactly what matters thermally, and system draw with USB unplugged. Use a JST extension so you never have bare cell leads. Start on the 10A jack, since the mA jack is typically fused at 200–400mA and 2.5A would destroy it.

A false zero here is more dangerous than no measurement On a multimeter the jack and the dial must match. With the probe in 10A and the dial on a mA range, the meter happily passes current through the 10A shunt while measuring the mA input — and displays a completely honest zero. On this gate that would read as "the charger is safe" while up to 2.5A flowed into the cell.

Sanity-check the rig before you trust it: with USB unplugged, the board should be running off the battery through the meter. If lifting a probe doesn't kill the board, the meter isn't in the circuit and every reading you've taken is meaningless.
Measure it on a FLAT cell, or you learn nothing The 2.5A hazard lives in the constant-current phase, which only happens when the battery is depleted. On a nearly-full cell the charger sits in constant-voltage top-off, where current is set by the cell's remaining headroom and its internal resistance — not by RISET. A charger programmed for 2.5A and one programmed for 0.5A read the same there.

This build measured a comfortable 0.5C at 4.03V resting and still cannot claim the gate is closed. If you take this reading on a full battery, you have confirmed that today's top-up is safe and nothing more.

Deliverable: a filled-in power table. Write it into the component doc — everything in Phase 6 is tuned against it.

Phase 1 — Toolchain

Install PlatformIO (the VS Code extension, or pip install platformio for the CLI). Then, from the firmware/ folder:

pio run -e pico2_lcd      # build for the Waveshare board
pio run -e pico2_lcd -t upload
pio device monitor        # 115200 baud

pio test -e native        # the game logic tests — no hardware needed
Compile the HAL before you believe a word of it This project's display HAL was written carefully against documentation, reviewed, described in this guide — and had never once been compiled. It called writeAddrWindow() and writePixels() on an Arduino_GFX*, but both are declared on Arduino_TFT, the subclass the concrete driver actually derives from. Two of six functions in the file did not exist.

A hardware-free module with a passing test suite gives a powerful illusion of progress: 39 green tests said nothing whatsoever about the file that talks to the panel. Build every environment, including the ones you can't yet run, from the very first day.

The configuration that matters, already set in platformio.ini:

platform = https://github.com/maxgerhardt/platform-raspberrypi.git
board_build.core = earlephilhower
board = rpipico2
board_build.mcu = rp2350
Why rpipico2 for a Waveshare board The Waveshare RP2350-LCD-1.28 isn't a registered PlatformIO board. The Pico 2 is the correct base — same RP2350, same 4MB flash — and every pin difference is confined to a single file, include/hb_config.h. Nothing else in the codebase contains a pin number.

Phase 1 — The display driver ladder

Don't use TFT_eSPI, despite what most tutorials say It's the default recommendation everywhere for this display, and this project originally committed to it too. That commitment was wrong. Issue #3537 — open since November 2024 with no maintainer response — reports RP2350 compiling cleanly and then displaying garbage on exactly this driver chip. It would have been discovered during bring-up, which is the most expensive place to find it.

Because the question isn't fully settled until real hardware confirms it, the risk is contained rather than assumed: every driver sits behind a six-function interface in src/hal/hal_display.h. Swapping rungs is a one-file change, and nothing above that line knows or cares which library is underneath.

Work down the ladder until one passes. Rung 5 is the guaranteed floor.
RungDriverNotes
1Waveshare Pico-SDK demoNot what you ship — but harvest its GC9A01A init sequence. Rung 5 needs it.
2Arduino_GFXConfirmed on hardware — this is the one. Passed every selftest gate on first contact with a real panel; the ladder was never descended. Active, explicit RP2350 support, ships Waveshare round-display entries.
3Adafruit_GC9A01AGotcha: call SPI1.setTX()/setSCK()/setCS() before tft.begin(), or you get a blank screen with otherwise-correct code. Retry once with that fixed before declaring it dead.
4TFT_eSPIOnly if 2 and 3 both fail. See above.
5Hand-rolled, ~200 linesDMA over spi_write_blocking. GC9A01A is simple and well documented — no library can block this project.

Running the self-test

Flash, open the monitor at 115200, and use the menu:

w   sweep SPI clocks, report the highest that passes
s   full self-test (interactive — you confirm what you see)
r   regression run (timing only, no prompts)
The 1-pixel checkerboard is the real gate — not the colour fills Solid fills will pass even with broken bit ordering, the wrong SPI mode, or marginal timing. They only prove the wiring is alive. A 1px checkerboard fails visibly and instantly on all three. If it shows banding, shimmer or moiré instead of even grey, the driver is not working, no matter how good the colour fills looked.
A round panel hides bugs in the first row or two On first hardware contact this driver produced a completely blank screen. The cause was that blitRGB565() called endWrite(), closing the SPI transaction after every row — so of the 240 rows in each frame, only the first one ever reached the panel and the other 239 were written with CS deasserted and silently discarded.

On a rectangular display that's a one-minute diagnosis: you see an obvious stripe along the top. On a round 240×240 panel, row 0 sits where the circle is only a handful of pixels wide — so the single row that did render was effectively invisible, and a working driver with one misplaced call looked identical to a dead one.

The tell came from the diagonal test, which draws one row per setWindow() and therefore has balanced transactions. It rendered perfectly on the same broken binary. If some test patterns work and others show nothing, suspect your transaction pairing before you suspect the wiring — and make sure your test patterns have structure through the middle of the panel, not just at the edges.
Restore the backlight after every re-init A sensible init() blanks the backlight first, so the operator never sees init garbage. But the clock sweep calls init() once per step — so unless the sweep turns the light back on, the panel renders each test correctly behind a dark screen and every clock reads as a failure. This cost a false FAIL at 4MHz, a frequency that could not plausibly fail, which is what gave the game away. When a result is physically implausible, suspect the harness before the hardware.

The GC9A01 datasheet's official maximum write clock is ~15MHz. On this board, every step of the sweep passed the checkerboard all the way up to 75MHz — five times the rated figure. So the sweep found the ceiling of the test, not of the panel. This is exactly why the sweep exists instead of a guess.

Most SPI clocks you can ask for don't exist RP2350's PL022 divides clk_peri (150MHz) by CPSDVSR × (1+SCR), and CPSDVSR must be even. So the only rates available up here are 75 / 37.5 / 25 / 18.75MHz, and the SDK silently rounds your request down to the nearest one. Ask for anything from 38 to 74MHz and you get 37.5.

This is not theoretical. This project ran on HB_LCD_SPI_HZ = 40MHz for its entire design phase; the bus was at 37.5MHz the whole time. Requesting 40MHz measured 34,431µs per frame and requesting 37.5MHz measured 34,423µs — 0.02% apart, because they are the same clock. A sweep step at 62.5MHz likewise re-tests 37.5 and tells you nothing new.

Have activeSpiHz() return spi_get_baudrate(), not the value you asked for. Reporting the request makes your selftest print a number that was never real — precisely the kind of figure that later gets copied into a power calculation as if it had been measured.

Measured full-frame push on this board (240×240 RGB565, 115,200 bytes):

Only two of these are reachable. Pick one.
ClockFull frameCeiling
37.5MHz34,423µs29 fps
75MHz19,534µs51 fps

Note that doubling the clock only bought 1.76×, not 2× — there's a fixed per-frame overhead of roughly 4–5ms that no clock increase touches. It comes from RPIPICOSPI_MAX_PIXELS_AT_ONCE being 32, which chops every 240-pixel row into 8 separate writes. If you ever need more than this, raise that before reaching for a faster clock.

This build settled on 37.5MHz. 29fps is far more than a virtual pet needs, and it keeps margin on a part already being driven well past its rated spec. 75MHz is proven to work on one unit at room temperature and is a one-line change if power measurements later show that push time matters.

The code

firmware/
├── platformio.ini 3 environments: board, Pico 2 W twin, host
├── include/hb_config.h EVERY pin and tunable. The single source of truth.
├── src/
│ ├── main.cpp bring-up harness (serial menu)
│ ├── hal/ the ONLY place with pin numbers or vendor includes
│ │ └── hal_display.h ← the swappable driver boundary
│ ├── game/ PURE. No Arduino, no millis(), no pins.
│ │ ├── pet_state.h the save struct + all balance constants
│ │ └── pet_sim.cpp advance(state, dt, activity, events)
│ ├── render/ sprite format + 120×120 framebuffer
│ └── diag/selftest.cpp the driver-ladder gate
├── tools/gfx_pack.py art → flashable C header
└── test/test_pet_sim/ 39 host tests

Three boundaries carry the whole design

  1. hal_display.h — six functions. Contains the entire RP2350 display-library risk in one file.
  2. src/game/ is pure — no hardware dependency at all, which is why 15 days of pet life fast-forwards in under a millisecond on your laptop, and why the balance was tuned without flashing anything. The host tests fail loudly if an Arduino include ever sneaks in.
  3. core/scheduler is the only module that decides power state — screens merely hint. Keep it that way, or power policy leaks into a dozen files and stops being auditable. (Not yet written.)
Pinout is fully confirmed — and the buttons and buzzer are on different headers Waveshare's official pinout diagram (2026-08-02) confirms every GPIO-to-function binding independently of the Adafruit CircuitPython source we'd been using, with no discrepancies, and adds the physical positions:

Buttons GP13/14/15 → H1 pins 11/13/15, ground on H1 pin 20. Buzzer GP16 → H2 pin 2, ground on H2 pin 19 or 20. That's two harnesses to opposite headers — fine electrically, since each header carries its own GND, but worth routing deliberately inside a Φ32.4mm case.

Also newly documented and easy to miss: SWD debug on H1 pins 17/19, and RUN/BOOT broken out on H2 pins 13/15 so reset and bootloader entry work even with the onboard buttons enclosed.
Two things that will bite if you forget them Buttons must be INPUT_PULLUP, switch-to-GND, active low. RP2350 erratum E9 causes leakage on Bank 0 GPIO that makes internal pull-downs unreliable — and GP13/14/15 are all Bank 0. Never INPUT_PULLDOWN on those pins.

Stats are stored in milli-units. STAT_MAX is 1,000,000, not 1000 — use statPercent() for anything user-facing. This isn't cosmetic: at a 1-minute tick a ~100/hr decay is only ~1.67 display units, and truncating that every call loses ~20% of the signal in one consistent direction, so a pet advanced in small steps would starve far faster than one advanced in a single large step. That would silently break the whole time model. A regression test pins it.

The pet, in one function

advance(state, dt_ms, wake_cause, activity, in_events, out_events)

Pure, allocation-free, and safe to call with a very large dt_ms — that's how a long sleep catches up in a single step. The full design lives in GAME-DESIGN.md; the two rules worth knowing here:

Sprite art

Full art direction in SPRITES.md. The single decision that drives everything:

Author at 120×120, render at 2× A 240×240 panel at 0.135mm pitch is too fine to read as pixel art — draw at native resolution and you get a small smooth illustration, not a pixelated pet. So make the pixels deliberately bigger than the panel's: draw everything on a 120×120 canvas and upscale 2× with nearest-neighbour. Integer scaling only; never interpolate.

It isn't only aesthetic. The backbuffer becomes 28.8KB instead of 115.2KB, compositing touches a quarter as many pixels (directly attacking the battery risk), and there's a quarter as much art to hand-draw. The 2× expansion happens during the push to the panel, so SPI traffic is unchanged.

At 120×120 the bird is 40–56px tall — 80–112px on screen — which is plenty of room for an eye, a defined beak, a gorget and tail feathers. Colour is 4bpp indexed, 16 colours per asset (not a shared global palette — iridescence needs a dense saturated ramp all to itself).

Two techniques that make it read as a hummingbird

The pipeline

Aseprite is the right tool (indexed mode, animation tags, stable JSON export). LibreSprite, Pixelorama and Piskel are free and work fine — the packer accepts a plain PNG strip too, so no tool is mandatory.

# Aseprite spritesheet + JSON, exported "by tag"
python3 tools/gfx_pack.py --sheet art/chick.png --json art/chick.json --name chick

# or a plain PNG strip from any tool
python3 tools/gfx_pack.py --strip art/idle.png --frame-width 34 --name chick_idle

# prove the codec before trusting it with real art
python3 tools/gfx_pack.py --self-test

Output is src/assets/assets_generated.h — per-asset RGB565 palettes and per-row RLE frames, stored as const in XIP-mapped flash, so sprites cost zero SRAM. Measured compression on flat-shaded art is about 2.4–3.3×.

Measured on hardware — the zero-SRAM claim holds A 34×34 four-frame chick idle packs to 955 bytes (2.4×, 0.04% of the art budget) and animates at 3.1ms per 68×68 frame at 37.5MHz. Of that, ~1.97ms is unavoidable wire time, so decoding and the 2× expansion together cost only ~1.15ms — and at 100ms per frame the CPU is idle ~97% of the time while animating, which is what makes an animated pet affordable on a battery.

Adding the asset moved flash from 87,372 to 89,316 bytes and moved RAM by exactly zero bytes. That's the decode-straight-from-flash design confirmed by measurement rather than asserted.
Compile the generated header too gfx_pack.py's codec self-test passes entirely in Python — it proves the encoding and never once compiles the C it emits. Ours emitted HbFrame and HbAnim unqualified while both live in namespace render, so the generated header had never been compiled by anything and failed the moment real firmware included it.

This was the fourth bug in this project found by simply building something for the first time. Every single one was in an artefact that had been written, reviewed and documented but never fed to a compiler, and none was caught by a passing test suite. A codec self-test is not a build.
The packer refuses to quantise for you Exceeding 16 colours is a hard error with a non-zero exit code, not a silent re-quantisation. Silent quantisation is exactly how sprite art mysteriously degrades between one export and the next, and you never find out which export did it.

Budget check: roughly 200–260 frames for the entire game, against ~2.7MB of flash available for art. That's under 200KB — art is nowhere near the constraint, so spend detail freely.

What to draw first

Not the adult. Draw 4 frames of the chick idling, pack it, and get it on the panel. Then one background. Those two steps prove the entire pipeline end to end — after a hand-drawn frame appears correctly on the display, everything that follows is content rather than engineering.

Use throwaway art to prove the pipeline before you draw anything real This build ran the gate with a procedurally generated placeholder chick rather than waiting for hand-drawn art, and that ordering was worth it: it surfaced the generated-header bug above while the only thing at stake was a script. Proving PNG → packer → flash → panel with art you don't care about means the first real frame you draw is a content problem, never a toolchain one.

Two things the placeholder taught about the subject, which apply to the real art too. A hummingbird is recognised by its bill first — the first attempt had a stubby one and read unmistakably as a frog; lengthening it to a straight needle running past the canvas midline fixed the silhouette on its own. And the chick has no gorget. A red throat patch on the lower face reads as an open mouth, and juvenile hummingbirds genuinely haven't grown one — gorget_flash belongs to the adult morphs.
Once a battery is attached, BOOTSEL stops working the way you expect BOOTSEL is sampled at power-on. With a cell connected the board never loses power when you unplug USB, so "hold BOOT, replug USB" silently stops entering the bootloader — the chip simply never resets. Disconnect the battery before flashing. Nothing about this is obvious from the symptom, which is just a board that keeps running your old firmware.

Handing off to assembly

When firmware has confirmed the pin assignment and you've run the battery-life numbers you care about, the wiring guide takes over: three buttons on GP13/14/15, a buzzer on GP16, and the slide switch spliced into the battery's positive lead.

One thing the slide switch is not It isn't a normal power switch — it's the torpor control. The RP2350 has no real-time clock and no battery-backed timer, so cutting power doesn't just pause the pet, it erases its sense of time entirely. Rather than fight that, the game leans in: flipping that switch puts the bird into torpor (real hummingbirds drop their metabolism ~95% overnight to survive without feeding), time stops, nothing decays, and it wakes exactly as you left it.

Everyday "off" is the other thing — press and hold Button 3, which sleeps the chip with the clock still running. Label them differently on the case, and make them look different in the firmware. It should be a choice the owner makes, not a trap they fall into.

Troubleshooting

SymptomLikely cause
Nothing on the panel, code runs fineSPI pins not remapped before begin(). Classic on arduino-pico — see rung 3.
Colour fills fine, checkerboard is bandedSPI clock too high, or wrong SPI mode. Run the sweep (w) and drop a step.
Colours inverted or channel-swappedIPS flag or byte order. GC9A01A panels vary — try toggling the IPS argument.
Diagonal line bendsStride or window-address bug in setWindow.
Buttons read randomlyINPUT_PULLDOWN on Bank 0. See erratum E9 above — use pull-ups.
Board seems dead after a bad flashIt isn't. Hold BOOT, plug in USB, drag a UF2.
Driver misbehaving and printf isn't enoughUse SWD. SWCLK/SWDIO are on H1 pins 17/19 — real breakpoints and single-stepping, not just serial prints. Easy to forget this board exposes them.
Pet starves overnightCheck the sleep path is actually being used — decay in Soft Sleep is 40% of awake.
pio run can't find the platformThe maxgerhardt fork is required for RP2350; the official PlatformIO platform doesn't cover it.

Sources & verification notes