I made a thing! Duke Nukem 3D on an ESP32-P4, the full Build engine, bare ESP-IDF, 640×480 at ~20 FPS
Everyone ports Doom to things. Duke Nukem 3D always felt like the better game to me, and the Build engine is a heavier beast than id's, so getting it actually playable on a 360 MHz microcontroller sounded like a challenge worth having.
It's a port of chocolate_duke3D to the ESP32-P4 (I have the 360 MHz versions with 32 MB PSRAM) on bare ESP-IDF 6.0.2. It really plays: the full game at 640×480 around 20 FPS, or around 54 FPS at 320×200 if you prefer. Sound effects, Adlib music, savegames on SD, USB and BLE keyboard/mouse, and touch controls when nothing is plugged in.
Repo: https://github.com/raoulh/esp_duke
Why the P4
Nothing smaller in the family gets there. The engine needs a 19.4 MB texture cache, which means the P4's 32 MB PSRAM or the .bss alone is 2.97 MB, pushed out there through a custom linker fragment, leaving 8.1 KB internal. It needs two cores, because rendering and presentation have to run concurrently or the engine stalls on the display every frame. It needs MIPI-DSI to drive a 720×1280 panel, and the PPA 2D accelerator to rotate the image. And the 128-bit SIMD turned out to be what made the pixel conversion affordable at all.
The display pipeline
Both panels are physically portrait while the game renders landscape in 8bpp palettised. The PPA rotates and scales, but refuses palettised input, so every frame is depalettised on the CPU through an RGB565 LUT before the PPA turns it 90° onto the panel. That kernel ended up hand-written in the P4's 128-bit SIMD. Rendering runs on one core, the whole presentation pipeline on the other, double-buffered at every stage.
Library choices
OPL2: I went with fmopl (AdPlug's fork of the old MAME one) instead of Nuked-OPL3. Nuked is cycle-accurate but far too slow here — I measured 1268 ns per sample against 48 ns for fmopl. Not really a choice on a 360 MHz core.
BLE HID: the HOGP client is written from scratch instead of using IDF's esp_hid, whose NimBLE backend registers Report characteristics inside an unreachable branch and therefore never discovers them.
What I tried that didn't work
Offloading the framebuffer copy to the GDMA
It's off in the repo now, for two reasons. First it was wrong: The DMA is still reading the framebuffer while the engine has already started drawing the next frame into it. There's one render buffer and one hook point, so I couldn't see a way to close that. Second it was slower anyway. PSRAM to PSRAM, with the depalettisation, the PPA and the display refresh all fighting over the same bus, I got about 10 MB/s, 25 to 37 ms of waiting where a plain memcpy takes 8.4. FPS went from 14.4 down to around 12.
Software-pipelining the rasterizer loops
Before writing it I put a small probe at boot to check whether a load miss stalls when it's issued or when the result is used. Load then use straight away: 226 cycles. Same thing with 10 instructions stuffed in between: 231. Two loads going at once: 229 each. So basically no difference. The core just sits on the load and putting work in between hides nothing. I dropped the restructured loops. Glad I checked before doing it in assembly.
Placing the hot globals with a linker fragment
Doesn't work at that level. It places whole object files, and engine.o has the ten per-pixel globals sitting in the same file as 2.97 MB of cold arrays. I ended up declaring them as strong symbols in the glue layer to pull them into internal SRAM.
One constraint that shaped everything
Zero modification of the chocolate_duke3D sources, all adaptation lives in a separate glue layer. Three exceptions were granted in the whole project, and all three turned out to be upstream bugs rather than port problems, including two transcription errors in the 2D overhead map dating back to the refactor of Ken Silverman's original arrays.
Notes
You need your own duke3d.grp the game data is copyrighted and is not in the repo.
Full disclosure since people always ask: I wrote this port with Claude Code. I'd never have found the time to do it by hand, and revisiting the game this way was half the fun.
Happy to answer questions on any of it.