Project Overview
RetroDS is a battery-powered handheld NES emulator I designed and built on an ESP32-WROOM-32E. The firmware is based on the open-source NESCAT emulator by Nathalis (GPL-3.0), which provides the 6502 CPU emulator, PPU graphics renderer and APU audio; I adapted it to my hardware. Game output goes to a 240×240 IPS TFT display. ROMs are loaded from a MicroSD card at runtime. Audio was written in firmware but not wired into the prototype — it is implemented on the PCB.
The build progressed from a breadboard prototype to a hand-soldered perfboard console. The front board carries the ST7789 display, D-pad, and action buttons; the back carries the ESP32, MicroSD breakout, and power management ICs. That perfboard stack has since been consolidated onto a single custom 4-layer KiCad PCB, shipped as RetroDS v2.
Components
All components are off-the-shelf breakout modules wired onto perfboard. The ESP32 operates at 3.3 V logic throughout, with the power management chain stepping up from the LiPo cell and regulating down to logic level.
| Ref | Component | Part / Value | Interface | Notes |
|---|---|---|---|---|
| U1 | Microcontroller | ESP32-WROOM-32E | — | 240 MHz dual-core Xtensa LX6, 4 MB Flash, 520 kB SRAM |
| U2 | Display | ST7789 1.3″ IPS TFT 240×240 | SPI @ 27 MHz | TFT_eSPI library — requires User_Setup.h edit (Section 05) |
| U3 | Audio Amplifier | MAX98357A — PCB only | I2S | Class D amp + DAC in one chip — not in prototype; drives speaker directly on PCB build |
| U4 | SD Card Module | MicroSD SPI Breakout | Soft-SPI | FAT32, tested up to 30 GB; software SPI avoids display bus conflict |
| U5 | Power Management IC | IP5306 | I²C | 5 V boost converter with USB-C charge indicator LEDs |
| U6 | Charge Controller | TP4056 USB-C Module | — | 1 A LiPo charge controller, over-current and over-voltage protection |
| BT1 | Battery | LiPo 3.7 V cell | — | Rechargeable lithium polymer; capacity sized for portable use |
| SW1–8 | Input Buttons | Tactile push switches ×8 | GPIO pull-up | D-pad (4) + A/B + START/SELECT; internal pull-ups, software debounce |
| C1–C4 | Decoupling Capacitors | 100 nF + 10 μF | — | Per VDD rail, placed adjacent to MCU power pins |
Hardware Architecture
Five subsystems are interconnected on perfboard via point-to-point soldered wiring. The hardware SPI bus is reserved exclusively for the display — CS is tied permanently low, so no other device can share it. The SD card uses a separate set of GPIO pins running software SPI. All subsystems were validated on breadboard before final perfboard assembly.
| Subsystem | Component | Interface | Key Notes |
|---|---|---|---|
| Microcontroller | ESP32-WROOM-32E | — | 240 MHz dual-core, 4 MB Flash, 520 kB SRAM |
| Display | ST7789 1.3″ IPS 240×240 | HW SPI @ 27 MHz | CS tied low (always active); pins defined in TFT_eSPI User_Setup.h |
| Audio | Not in prototype — PCB only | I2S | Firmware written and ready; MAX98357A amp wired on PCB build |
| Storage | MicroSD Breakout | Soft-SPI | MOSI GPIO 17 · MISO GPIO 13 · SCK GPIO 14 · CS GPIO 22 |
| User Input | 8× Tactile Buttons | GPIO Pull-up | D-pad (4) + A/B + Start/Select — internal pull-ups enabled |
| Power | LiPo 3.7 V + IP5306 | — | USB-C charge via TP4056 · 5 V boost · 3.3 V regulated logic |
| Decoupling | 100 nF + 10 μF | — | Per VDD rail, placed adjacent to MCU power pins |
Firmware
Written in C++ using the Arduino-ESP32 framework. The NES emulation engine handles CPU, graphics, and audio synthesis in a coordinated loop. The I2S audio DMA task runs at elevated FreeRTOS priority to prevent dropouts, while a dedicated video task on Core 0 handles frame conversion and SPI push.
0x00300000 and memory-mapped via spi_flash_mmap() — the emulator reads ROM as a const uint8_t* pointer without consuming DRAM.tft.pushImage(). A FreeRTOS queue syncs frames between the emulation loop and the display task. tft.setSwapBytes(true) corrects the byte-order expected by the ST7789 panel. Backlight brightness is controlled via the LEDC PWM peripheral.DEFAULT_FRAGSIZE samples). The audio task runs at higher FreeRTOS priority than the PPU loop. On the PCB, this drives a MAX98357A Class D amplifier directly connected to a speaker.mmc_create(). Over 40 mapper types are supported — including Mapper 0 (NROM — Donkey Kong, Super Mario Bros.), Mapper 1 (MMC1 — Mega Man 2, Metroid, Zelda), Mapper 2 (UNROM), Mapper 3 (CNROM), Mapper 4 (MMC3), Mapper 5 (MMC5), plus a few VRC variants. Maximum ROM size: 512 kB via SPI flash memory mapping — no heap allocation needed.// GPIO & I2S Pin Defines — NesCat.ino #define PIN_UP 2 // D-Pad Up #define PIN_DOWN 15 // D-Pad Down #define PIN_LEFT 33 // D-Pad Left #define PIN_RIGHT 34 // D-Pad Right #define PIN_A 21 // Action A #define PIN_B 26 // Action B #define PIN_START 35 // Start #define PIN_SELECT 4 // Select #define I2S_BCK_IO 27 // Bit Clock #define I2S_WS_IO 32 // Word Select (LRC) #define I2S_DO_IO 25 // Data Out → MAX98357A (PCB build) #define SOFTSD_MOSI_PIN 17 // SD on software SPI #define SOFTSD_MISO_PIN 13 #define SOFTSD_SCK_PIN 14 #define SD_CS_PIN 22
Display Library Setup
The TFT_eSPI library does not auto-detect which display is connected. Before the firmware will compile correctly, you need to edit a configuration file inside the Arduino libraries folder on your machine — this is not part of the project code. Without this step, the library targets the wrong driver and the screen shows nothing or corrupted output.
User_Setup.h file that determines which display driver is compiled, along with the SPI pin assignments and screen resolution. The default targets an ILI9341 240×320 display — not the ST7789 240×240 used here. Flashing without editing this file results in a blank or garbled screen. The change lives in your local Arduino installation, not inside the project sketch folder.Arduino\libraries\TFT_eSPI\User_Setup.hComment out all existing content in the file, then paste in the configuration shown below and save. The next compile will target the ST7789 at 240×240 with the correct pins.
Note on TFT_CS –1: setting CS to –1 means chip select is permanently active — the display is the only device on that SPI bus, so no switching is needed. The SPI frequency is set to 27 MHz, which is reliable for ESP32 with short perfboard wiring.
// TFT_eSPI — User_Setup.h // File location: Arduino\libraries\TFT_eSPI\User_Setup.h // Comment out ALL existing content in this file, then add: #define ST7789_DRIVER // Select ST7789 display driver #define TFT_WIDTH 240 // Display width in pixels #define TFT_HEIGHT 240 // Display height in pixels #define TFT_MOSI 23 // SPI Data #define TFT_SCLK 18 // SPI Clock #define TFT_DC 5 // Data / Command select #define TFT_RST 19 // Reset #define TFT_CS -1 // CS always active (display-only SPI bus) #define SPI_FREQUENCY 27000000 // 27 MHz — reliable for perfboard wiring #define LOAD_GLCD // Font 1 #define LOAD_FONT2 // Font 2 (small) #define LOAD_FONT4 // Font 4 (medium) #define LOAD_FONT6 // Font 6 (large numerics) #define LOAD_FONT7 // Font 7 (segment numerics) #define LOAD_FONT8 // Font 8 (large numerics) #define LOAD_GFXFF // FreeFonts
Build Progression
Each build phase was fully tested before moving to the next — starting with individual component validation, then breadboard bring-up, perfboard assembly, and finally power integration. This approach caught issues early and kept rework to a minimum.
Engineering Challenges
TFT_CS set to –1, the display holds the hardware SPI bus permanently. Moving the SD card to software SPI (bit-banged) on dedicated pins (MOSI 17 · MISO 13 · SCK 14) freed the hardware bus exclusively for the display at 27 MHz, with no arbitration overhead or frame rate penalty.spi_flash_mmap API to memory-map ROM data directly from SPI flash starting at address 0x00300000. The emulator accesses ROM as a const uint8_t* pointer without occupying any DRAM, enabling support for ROMs up to 512 kB.PCB — Shipped as RetroDS v2
With the perfboard prototype validated, the next step was a custom 4-layer PCB in KiCad — a dedicated ground plane, separated analogue and digital ground zones for cleaner audio, and a single compact board replacing the two-board perfboard stack. The PCB design also upgraded the microcontroller to the ESP32-S3-WROOM-1U — adding native USB, 8 MB flash, and improved performance for emulation and audio. That board is built, brought up, and working — see RetroDS v2 for the full PCB design, the fault-finding session that got it running, and the firmware rewrite that added sound, volume control, save games, and an on-screen game-select menu.
Microcontroller: ESP32-WROOM-32E → ESP32-S3-WROOM-1U. The S3 has native USB (connects directly to the USB-C port — no extra chip needed), 8 MB of flash (this N8 module has no PSRAM), and a faster processor.
Audio: No audio in the prototype. The PCB adds a MAX98357A — a single chip that handles both digital-to-analog conversion and amplification, driving a speaker directly from the I2S bus, with a 15-step volume control.
Power management: The prototype used two off-the-shelf breakout modules (IP5306 boost converter + TP4056 charger). The PCB replaces these with dedicated ICs: MCP73871 (proper LiPo charger with charge status outputs) and AP2112K (clean 3.3 V regulator).
USB protection: Added PRTR5V0U2X ESD protection on the USB data lines — important for a finished PCB connected to real USB ports.
SD card bus: The prototype bit-banged a software SPI to avoid sharing the hardware bus with the display. The PCB has two dedicated hardware SPI buses — one for the display, one for the SD card — no workaround needed.
View implementation details — display config, pin defines, ROM loading, and audio setup