rpg-actor-screen #
A full-screen animated rpg.actor character for the Waveshare ESP32-C6-Touch-AMOLED-2.16, plus a browser-based flasher that provisions it from an atproto handle.
Use the site at https://rpg-actor-screen.byjp.me to flash your device and configure which characters to show.
Controls #
The screen shows your own party by default. Holding BOOT switches to community mode: the characters other rpg-actor-screens on the same network are currently displaying, four at a time.
| Input | Party | Community |
|---|---|---|
| BOOT button | Turn anticlockwise | Previous page |
| KEY button | Turn clockwise | Next page |
| BOOT button (hold ~1s) | Show the community | Back to the party |
| KEY button (hold ~1s) | Cycle the background | Fetch these characters again |
| PWR button (short press) | Stop / start the walk cycle | Same |
| Drag left / right | Carousel between characters | Page through, or carousel one full-screen |
| Tap | — | Fill the screen with that character; tap again to go back |
| Drag up / down | Brightness — the screen acts as a slider, top brightest | Same |
The semantics behind that table:
- Stopping parks the character on the middle standing pose of whichever way it is facing. A long press on PWR still powers the board down.
- Turning rotates through south, west, east, north; 'clockwise' from south goes to east, then north, then west. Sheets that are not four rows have no established facing order.
- A drag commits to one axis before it does anything, so a slightly-sloped horizontal swipe changes character without also dimming the screen.
- A sideways drag completes if it covers half the distance from where it began to the edge it is heading towards — measured against the room it had rather than a fixed distance, so a drag starting mid-screen is not held to the same travel as one starting at the edge. (Drags beginning within a quarter-screen of that edge are ignored, so a stray touch on the very edge while carrying won't trigger any changes)
On the network #
-
Sprite changes arrive over the atproto firehose. The device holds a websocket to a Jetstream instance, filtered to the
actor.rpg.spritecollection and the configured DIDs, so editing a character updates the screen within seconds. (A wide selection of jetstream instances are picked from at random, and rotated between if there are issues.) -
Community mode finds other screens over mDNS, so they must be on the same network, and that network must let clients see each other — guest networks and consumer routers with "AP isolation" block this silently, with nothing to say so.
-
Each device advertises itself as
_rpgactor._udp, carrying adidTXT item naming whoever is currently on screen, so a table of several screens can be told apart:dns-sd -B _rpgactor._udp # macOS: list them dns-sd -L rpg-actor-3f2a _rpgactor._udp # ...and read one's TXT avahi-browse -r _rpgactor._udp # Linux: both at onceIt advertises only — nothing listens on the port in the SRV record. The device also answers to
rpg-actor-xxxx.local.
Layout #
| Path | What |
|---|---|
firmware/ |
ESP-IDF application |
firmware/test/ |
Host tests for the logic that does not need hardware |
site/ |
Static web flasher, published to Wisp at rpg-actor-screen.byjp.me |
sim/ |
The display code compiled to WebAssembly, for the site's live preview |
docs/ |
Board, wire-format and calibration notes |
.tangled/ |
The pipeline that builds both and publishes them |
Building the firmware #
Needs ESP-IDF v5.1 or newer — earlier versions have no ESP32-C6 support and
fail late with unhelpful errors. Developed against v5.5.5; CI builds against
v5.5.2, which is what the nix flake packaging ESP-IDF pins (see
.tangled/workflows/deploy.yaml).
cd firmware
./idf.sh set-target esp32c6
./idf.sh build
./idf.sh -p /dev/cu.usbmodem* flash monitor
idf.sh wraps idf.py. It exists because a virtualenv active in your login
shell will stop ESP-IDF activating its own, and a conda python on PATH will
shadow the one it expects; the wrapper strips both. Set IDF_PATH if your
checkout is not at ~/src/esp/esp-idf-v5.5.5.
Run the host tests before flashing — they are far quicker than a build cycle:
firmware/test/run.sh
The panel's wide-gamut colour correction is already calibrated — the strengths
in firmware/main/pixel_codec_strength.h were measured once and every board of
this model shares the panel. If they ever need re-deriving, the procedure is in
docs/calibration.md.
Trying the flasher locally #
site/serve.sh # or: site/serve.sh 8100
Builds the firmware, merges it into a single image, writes the manifest.json
the page expects, and serves the lot on localhost. In production CI deploys
those two files into the site for the same reason: a static page can only
fetch what its host sends CORS headers for, so the firmware has to be
same-origin rather than hosted anywhere else.
The build happens once, at startup — after a firmware change, restart the script or it keeps serving the old image. The page shows the commit it is about to write for exactly this reason.
Needs a browser with WebSerial: Chrome, Edge, or Firefox 151 and newer. Safari
has none. localhost counts as a secure context, so no HTTPS is required.
Close any serial monitor first, since the browser cannot open a port another
process holds.
How a character reaches the device #
Handle resolution happens in the browser at flash time, not on the device:
handle ──▶ slingshot.microcosm.blue ──▶ { did, pds }
│
▼
chars partition (flashed)
│
▼
device ──▶ {pds}/xrpc/com.atproto.repo.getRecord
device ──▶ {pds}/xrpc/com.atproto.sync.getBlob
Baking in the resolved DID and PDS means the firmware never has to resolve an
identity — no plc.directory, no did:web, no DNS TXT lookups. See
docs/config-partition.md for the blob format and
the tradeoff this makes.
Design notes #
The C6 has 512KB of SRAM and no PSRAM, against a 480×480 RGB565 framebuffer
that would want 450KB. So there is no framebuffer: frames are drawn in
horizontal bands, magnified on the way out. Decoded sheets live in a flash
partition and are read through mmap rather than held in RAM.
Magnification is whole-number only. These are pixel-art frames — a fractional scale lands source pixels on uneven numbers of destination pixels and some rows come out visibly fatter than others. A margin is the better trade.
The character is also kept inside 90% of the screen rather than filling it, so it stands in the scene rather than being cropped to fit: a 48px frame goes to 9x rather than a flush 10x, leaving 24px of sky above and ground below.
The walk cycle ping-pongs. For the usual three-column sheet that is columns 1, 2, 3, 2 repeating, so the legs reverse rather than snapping back.
Issue tracking #
This repo uses beans. beans list --ready shows what is actionable.