The controller layer was the chip
The Nucleo runs the desktop and the PICO-8 cart; the Mac viewer got dropped a while back, which left
the console with a screen and a speaker and no way to actually play it. The plan for input was the
one I'd written into the architecture notes from the start: an off-board ESP32 running Bluepad32 pairs a
Bluetooth controller, decodes it, and streams a tiny button frame to the Nucleo over a single UART
wire. The Nucleo merges that into the same InputState.keys the cart's btn() already reads, so
cartridges need zero changes.
The frame is deliberately dumb: two bytes, [0xA5, mask & 0x3f], at 115200, about 100 times a
second. 0xA5 is the sync byte and the payload is always <= 0x3f, so any byte with the top bits
set is unambiguously a header and a dropped byte just self-realigns on the next one. A gamepad is
roughly half a kilobyte per second; UART has 20x the headroom. The right tool, not a compromise.
I started on a Seeed XIAO ESP32-C6 because it's tiny, modern, USB-C, WiFi 6. I had the architecture all worked out: the C6 is the permanent wireless co-processor, the controller decode is just one swappable layer on top, and if Bluepad32 ever let me down I'd replace that layer and keep the chip.
The wall
The C6 booted, ran my firmware, scanned, and saw nothing. Not my 8BitDo SN30 Pro, not the PS5 pad, not the Switch Pro. The console banner had the answer in plain text:
BR/EDR support: disabled
BLE support: enabled
BR/EDR is Bluetooth Classic. The ESP32-C6 doesn't have it. Not "off", not "needs a flag" - the radio hardware for Classic is not in the silicon. I spent an embarrassing while hoping it was a setting ("maybe these chips need something turned on?") before I read Espressif's own datasheet: the C6 is Bluetooth LE only, and if you want Classic you use a different chip. There is no firmware, no sdkconfig option, no library that adds a radio the chip does not physically have.
And the one pad I own that is BLE, an Xbox One controller, would not complete the bond either. It advertised, the C6 saw it, and the connection just stalled. That one is the pad's firmware (the Xbox needs a reasonably current update to do BLE-HID with a non-Microsoft host), but the result was the same: zero of my controllers worked on the C6.
Here's the part that stung. My fallback plan was "swap the controller layer, keep the hardware". But the blocker was not the software layer at all. It was the radio. No NimBLE-instead-of-Bluepad32 rewrite gets a BLE-only chip to speak Classic. "Swap the controller layer" turned out to mean swap the chip, not the code.
The pivot
I had an original ESP32 in a drawer, a bog-standard WROOM-32 dev board. The original ESP32 has both
Bluetooth Classic and BLE. So I pointed the exact same ESP-IDF and Bluepad32 sketch at it, changed
one build flag (idf.py set-target esp32 instead of esp32c6), flashed it, and read the banner:
BR/EDR support: enabled
BLE support: enabled
...
BR/EDR scan -> 1
BLE scan -> 1
Both radios, both scans. I put the SN30 Pro in pairing mode and watched the whole Bluetooth Classic
handshake roll past in the console: the L2CAP channels opening, the SSP confirmation auto-accepting,
the HID interrupt channel, Device is ready, the controller's firmware version read back. The pad
the C6 literally could not hear was connected.
Then I held a direction and the hero walked the Solais dungeon. After a day and a half of this, that was a good moment.
Two things that bit me on the way
The TX pin is a trap. My first wiring used the C6's "D6" pin, because the silk screen helpfully labels it "TX". That is exactly why it's wrong: D6 is GPIO16, the chip's UART0 console pin (U0TXD). So the chip was driving that one pin with two things at once, its own boot and console log on UART0 and my gamepad frames on UART1, and the garbage poured straight into the Nucleo's RX. The panel thrashed and rebooted every time the boards were wired together. The fix is to use any pin that is not the console pin: GPIO21 on the C6, GPIO17 on the WROOM. Avoid each chip's U0TXD.
Send the held state, not the change. Once a pad was connected, holding a direction made the hero
stutter, stop-move-stop-move, instead of walking. My loop only built the button mask when Bluepad32
reported fresh data (hasData()), and sent zero otherwise. So a held direction went out as
dir, 0, dir, 0, ... and the Nucleo read it as rapid taps. The pad object already holds the current
state; you read dpad() every frame regardless of whether a new HID report arrived. One condition
removed, smooth walking.
And one that cost me real time before I noticed it: an underpowered ESP32 browns out the Bluetooth radio. On a Mac dock that did not deliver enough current, the C6's power LED was faint, and the chip would scan, see the pad, start the handshake, and then never finish it. Bright LED on proper power, clean bond. If a board sees the device but won't complete the connection, check the power before you blame the protocol.
What it means for the design
The C6 isn't wasted. It's still the better chip for the network half of the co-processor: WiFi 6, the future cart downloads and multiplayer. But for the controller bridge specifically, the radio it is missing is the whole job, and the original ESP32 is the right part. The protocol, the wiring, and the entire Nucleo side did not change between the two; only the chip and one build flag did. Which, in the end, is the swappable-layer plan working exactly as designed. I just had the layers wrong: the swappable thing was the silicon, not the software.
Next: it currently waits for the USB host before it starts the cart, a holdover from the viewer days that needs to go for a truly standalone console. And the link is UART for now; when WiFi and downloads arrive it moves to SPI. But tonight it plays.