- C++ 93.4%
- Python 5%
- C 0.9%
- Shell 0.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Playtesting on hardware produced a Zip from a pet that had merely not been
neglected, which is not what Zip is supposed to mean — and worse, it exposed
that Lumen was very nearly unreachable.
The old rule tested Zip before Lumen on `games >= 6 && weight <= 40`. Games
are the primary source of joy, and joy drains fastest, so any attentive owner
clears six games without trying. The lean, well-raised pet therefore always
fell into Zip, and Lumen — documented as "raised attentively and kept in
trim" — could only be reached between 41 and 58 grams. The flavour text said
trim; the code rewarded heavy. Confirmed by building saves that differ in
nothing but weight: 34g gave Zip, 50g gave Lumen, at identical care.
Three changes:
- Lumen is now tested before the specialists, and additionally requires bond.
It is the ending for having done everything right, so doing everything
right — including playing a great deal — has to be able to reach it.
- Zip keys on games per *game day* rather than an absolute count. An absolute
count cannot mean the same thing at both time bases: three games is
devotion inside a forty-minute emulator life and near-neglect across three
real weeks. Zip is now the pet played with constantly while something else
slipped.
- Every threshold moved to Tuning.h. They were hardcoded inside chooseForm,
which broke this file's own rule that no magic number lives in game logic.
The record screen now shows the rate alongside the count ("GAMES 24 (3/D)"),
since that rate is what decides a form and the player had no way to see it.
Verified across six constructed scenarios covering all four forms, each
landing where designed, including the one that used to misfire:
care 83, 34g, 7 games now gives Lumen rather than Zip. mksave.py grew
--games/--bond/--care-base to make that matrix expressible. All six input
scripts unchanged; ESP32-S3 build clean and flashed.
|
||
| bin | ||
| lib | ||
| src | ||
| test/scripts | ||
| tools | ||
| .gitignore | ||
| CLAUDE.md | ||
| platformio.ini | ||
| README.md | ||
pip
A virtual pet for the bonkgotchi handheld — ESP32-S3, 128x128 one-bit Sharp memory LCD, piezo, RGB LED, five-way joystick, accelerometer, LiPo — written and played entirely against the esp32-emu board emulator.
pip hatches from an egg you have to warm, grows through five life stages, and becomes one of four adults depending on how you raised it. It gets hungry, bored, filthy, sleepy and ill. It poops on the floor. It naps when you turn the light out and sulks when you don't. It will die — of neglect if you leave it, of old age if you don't — and then leaves a gravestone, an epitaph and a care grade, and the next egg is generation two.
bin/pip-emu build
bin/pip-emu run --out runs/tour --script test/scripts/tour.json --duration 56000
bin/pip-emu serve --run-dir runs/tour # browse the frames at 127.0.0.1:8000
bin/pip-emu play --out runs/live # live: D-pad, tilt, shake
Requires an esp32-emu checkout at ~/clones/esp32-emu (override with
ESP32EMU_ROOT), plus cmake and ninja.
Playing it
Seven icons along the bottom, moved between with left/right and opened with select. Up pats pip where it stands; down calls it over. Holding select opens its record from anywhere.
| Feed | the pantry: kibble (free), berries, cake, and treats bought in the shop | fills food, adds weight |
| Play | three minigames | fills joy, costs energy and appetite, earns bits |
| Clean | sweeps the floor | clears poop, fills wash |
| Heal | gives medicine, if you bought any | cures illness ~70% of the time |
| Light | the light switch | pip can only sleep in the dark |
| Shop | treats, medicine, and four things pip can wear forever | spends bits |
| Stats | condition and lifetime record | care grade, bests, lineage |
While pip is still an egg, every button warms it and brings hatching forward — so does shaking the device.
The three minigames
Echo — pip plays a phrase on four pads; each pad has its own note and its own colour on the RGB LED. Repeat it. The phrase grows by one each round. The phrase is derived from the pet's own saved seed, so it is this pip's song, and it is the same song every time you play with it.
Catch — fruit falls, pip catches it in a basket. Steer by tilting the whole device or with left/right, whichever you prefer; both drive the same position. Bombs start appearing once you find the rhythm.
Hop — an endless runner. Jump the spikes, duck under the birds; jumping at a bird flies straight into it, so "hold jump" is not a strategy. A heavy pip jumps measurably lower, which is the one place in the game where overfeeding bites back.
Growing up
| stage | at | |
|---|---|---|
| egg | — | rocks, cracks, hatches |
| baby | on hatching | tiny, one sprout, no arms |
| child | day 1 | |
| teen | day 3 | |
| adult | day 6 | one of four, chosen by how you raised it |
| elder | day 16 | and then, eventually, not |
The adult is a verdict, not a coin flip (Pet::chooseForm, read top to
bottom — first line that fits wins):
- Bramble, if care never reached 45 — spiky and wary; it survived, that is all.
- Momo, if it ended over 55 grams — round, sleepy, generously fed.
- Lumen, if care reached 74 and bond 45 — bright, star-crested, and it glitters on its own.
- Zip, if it played 3+ games a game day and stayed under 42 grams — big ears, never still.
- Momo otherwise — comfortable, unremarkable.
Lumen is tested before the specialists on purpose. It is the ending for having done everything right, so doing everything right — including playing a great deal — has to be able to reach it; Zip is the pet that was played with constantly while something else slipped.
The play rate is per game day rather than an absolute count, because three
games is devotion inside a forty-minute emulator life and near-neglect
across three real weeks. Every threshold lives in Tuning.h, not in the
rule.
The care score is not a tally of button presses: it is the running average of pip's four needs, sampled every game hour for its whole life, then adjusted for lapses (a need actually hitting zero), time spent ill, and the bond built by attention. You cannot cram for it at the end.
How it is built
Everything is drawn, sequenced and simulated on the device. No sprite sheet, no sound files, no floating-point time base leaking between systems.
pip's body is procedural. Six life stages by four adult forms by eight
expressions by five wearables by a dozen actions is far too many
combinations to draw. So PetArt.cpp builds the creature out of ellipses
from a one-row-per-form table: radii, eye spacing, crest, ears, tail, belly.
That is what lets it breathe, squash on landing, lean into a walk, blink on
an uneven schedule, and grow continuously — and it is why adding a form
costs eight numbers rather than a sheet of pixels.
The art that does have a fixed shape is stored as the picture.
Sprites.h holds every icon and prop as rows of characters — # is ink,
+ and - are dither levels, . is transparent. You can read the artwork
in the source and edit it by typing.
Tone on a one-bit panel comes from ordered dithering. A 4x4 Bayer matrix gives four grey levels, used for the floor, the sick pip's pallor, momo's belly, the parallax ridges in Hop and the wash that falls over the room when the light goes out.
A 3x5 font, authored the same way as the sprites, carries the HUD; the built-in 5x7 handles headlines. Without it the status line would not fit.
The simulation has no idea it is being drawn. Pet.cpp advances in
whole simulated minutes and raises events; App.cpp turns those into sound,
light and screen changes. Needs are stored as integers but drained through
float mirrors, so a slow drain does not round away to nothing.
Audio and the LED each own a task. The piezo runs a note queue with an
epoch counter, so a new jingle supersedes whatever was still queued instead
of waiting in line; Audio::play() returns immediately and nothing in the
game loop ever calls delay(). The LED renders two layers at 40Hz — an
ambient layer the simulation owns (a green breath when content, an amber
pulse when something needs doing, a hard red blink when it is urgent, dim
blue asleep) and an overlay layer for whatever just happened — and only
pushes a frame when the quantised colour actually changes.
Frames are only pushed when they differ. Gfx::present() hashes the
2KB framebuffer and skips the SPI burst if nothing moved, which keeps a
still screen off the bus (and out of the emulator's frame log).
Saves are versioned, size-checked and checksummed. A record that fails any of those is discarded and treated as a first boot — a half-written flash page gives you a fresh egg, never a pet with a negative age.
Layout
src/
Main.cpp setup()/loop(): bring-up order, restore or hatch, hand off
Tuning.h every balance number, and the single ms->game-hour constant
Pet.h/.cpp the simulation: needs, growth, illness, economy, death
PetArt.h/.cpp pip's body, drawn from ellipses
Sprites.h icons and props, stored as ASCII art
Gfx.h/.cpp dithering, ellipses, the 3x5 font, panels, meters, bubbles
App.h/.cpp screen stack, input routing, frame loop, event handling
ScreensMain.cpp boot, the room, the record, growing up, dying
ScreensCare.cpp pantry, shop, arcade
Minigames.cpp Echo, Catch, Hop
Audio.h/.cpp piezo note sequencer (own task)
Leds.h/.cpp two-layer LED renderer (own task)
Sensors.h/.cpp accelerometer, battery, USB
Save.h/.cpp NVS persistence
Joystick.* unchanged from bring-up; PinDefinitions.h is fixed by the board
lib/ vendored HarvatekLED and MC36XX drivers
test/scripts/ input scripts, and the readable sources they are built from
tools/ dev aids: contact sheets, script builder, save-file builder
Three background tasks run beside loop(): the joystick poller, the audio
sequencer and the LED renderer. Game state is only ever touched from the
loop() thread; the other two are fed through FreeRTOS queues.
The board, used
Nothing on this board is decorative.
- Display — every screen; ~15fps for the room, 20fps in the minigames.
- Joystick — menus, patting, calling, and both minigames that need reflexes. Releases are routed too, so Catch and Hop can hold a direction.
- Piezo — 22 jingles, from a two-note menu blip to the evolution fanfare and the death dirge, plus per-pad tones in Echo.
- RGB LED — pip's mood at a glance, and the four colours of Echo.
- Accelerometer — tilt slides pip around its room and steers the basket in Catch; a shake wakes it, startles it, or warms the egg.
- Battery / USB — pip shares the cell with the board it lives in. On the
charger it tops itself up and says so; on a nearly flat battery it goes
sluggish.
--vbusand--battery-mvdrive both. - NVS — the pet, and the epitaph of the pet before it.
--nvs-filekeeps a pip alive across runs; kill the run and it is still there.
Running it
# every screen in one pass, from a fresh egg
bin/pip-emu run --out runs/tour --script test/scripts/tour.json --duration 56000
# five clean rounds of Echo (the phrase is fixed by --seed)
bin/pip-emu run --out runs/echo --seed 1 --script test/scripts/echo.json --duration 48000
# the two reflex games
bin/pip-emu run --out runs/catch --script test/scripts/catch.json --duration 40000
bin/pip-emu run --out runs/hop --script test/scripts/hop.json --duration 40000
# a whole life, unattended: hatch, decline, illness, death, memorial
bin/pip-emu run --out runs/life --seed 7 --duration 560000
# keep one pip alive across separate runs
bin/pip-emu run --out runs/day1 --nvs-file runs/pip.nvs --duration 120000
bin/pip-emu run --out runs/day2 --nvs-file runs/pip.nvs --duration 120000
Read a run by opening runs/<dir>/display/frame_*.png, or use the tools:
tools/contact_sheet.py runs/tour --step 20 # tile the frames into one sheet
tools/mkscript.py test/scripts/tour.txt -o test/scripts/tour.json
tools/mksave.py --stage adult --form lumen --wear crown -o runs/lumen.nvs
tools/mksave.py writes a save directly, which is how the eight silhouettes
were checked without waiting twelve minutes for each one. It mirrors the
save record exactly, checksum included — if PetState changes and the
version is not bumped, the firmware rejects its output rather than misreading
it, which is the point of versioning the record.
test/scripts/*.txt are the readable sources for the .json input scripts;
tools/mkscript.py turns press SELECT, hold DOWN 900 and at 24000
into absolute press/release pairs.
Time, and the two builds
kMsPerGameHour in Tuning.h is the only place milliseconds become game
time. Every other number in that file is quoted per game hour, so the whole
balance re-scales coherently from that one value — which is what lets the
same source serve two quite different products:
emulator (bin/pip-emu) |
hardware (pio run) |
|
|---|---|---|
PIP_MS_PER_GAME_HOUR |
5000 (default) | 3600000 (real time) |
| a game day | 2 real minutes | 24 real hours |
| egg to elder | ~40 minutes | ~16 days |
PIP_EGG_HATCH_MINUTES |
150 → ~12s | 4 → 4 minutes |
The emulator keeps the accelerated defaults because a life you cannot watch
inside one run is a life you cannot debug. The hardware build overrides both
in platformio.ini, because a pet living on a desk should age like one.
Both are printed at boot, so no log is ever ambiguous about which pacing it
ran under:
pip: 5000 ms per game hour (accelerated), egg hatches in 150 game min
pip: 3600000 ms per game hour (real time), egg hatches in 4 game min
The egg needs its own knob rather than riding the time base, because it is a one-off wait before a life rather than part of one: left to scale, the 150 game minutes that make a pleasant twelve-second egg under the emulator make a two-and-a-half-hour egg on hardware.
Running on hardware
It is a real firmware checkout, and the ESP32-S3 build is clean:
pio run # 464KB flash, 24KB RAM
pio run -t upload --upload-port /dev/ttyACM1
pio device monitor -p /dev/ttyACM1 -b 115200 # USB CDC is on; you get pip's log
Verified on a Revision 4 board: display, joystick, piezo, accelerometer and
NVS all come up, and the pet survives power cycles in real flash. The
accelerometer's mounting orientation was measured rather than assumed —
rebuild with -DPIP_ACCEL_DEBUG to dump raw axes at 4Hz and repeat the
experiment on a different board revision. See src/Sensors.cpp for the
poses and the readings they produced.