Turn an esp32-c6-AMOLED device into a portable rpg.actor display!
README.md

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.sprite collection 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 a did TXT 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 once
    

    It 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.