Engineering Design Portfolio — Embedded Systems

RetroDS v2
Custom-PCB Handheld

4-Layer PCB · ESP32-S3 · Arduino Framework  /  Design → Assembly → Bring-Up → Firmware  /  2026
ESP32-S3-WROOM-1U · 240 MHz ST7789 · 240×320 IPS MCP73871 Li-ion charger
Games running Sound + volume Battery saves Portable case
RetroDS v2, assembled and powered on, background removed RetroDS v2 bare PCB running Donkey Kong, background removed

RetroDS v1 proved the emulator worked on a hand-soldered perfboard. v2 takes it to a custom 4-layer PCB — a proper Li-ion charger, an audio amp, a dedicated SD slot, and an ESP32-S3. Bringing it up meant a full session of structured fault-finding: the board wouldn't enumerate over USB, and once that was fixed a 3.3 V rail read a dead short to ground on a joint I could not see. Voltage-ladder and resistance measurements — not guesswork — located both. The firmware then needed a real rewrite: a new display driver, correct frame timing, and the two features v1 never had — sound and save games.

RetroDS v2 assembled in its 3D-printed case, powered on, game-select menu showing
Finished — game-select menu
v2 in its case, powered on
v2 case rear
v2 battery compartment
v2 running Donkey Kong
v1 vs v2
v2 board detail
v2 front
v2 angled
v1 and v2
detail
detail
detail
On the board
ESP32-S3
ST7789 240×320
MCP73871
MAX98357A
microSD
USB-C
Bring-up journey
✓
PCB design
✓
Assembly
✓
USB / power
✓
Display + SD
✓
Firmware
✓
Enclosure
What this build taught me
  • A dead short from a supply rail to ground does not have to be visible. This one sat under a plug-in module; the board looked perfect under a loupe. Measuring resistance rail-to-ground — both probe directions, watching whether it settled — and walking the voltage ladder from USB in to the regulated 3.3 V is what found it.
  • "Same code, worked yesterday, black screen today" on a display is a firmware-completeness signal, not automatically a hardware one. A minimal ST7789 init half-works off the panel's power-on defaults; it needs the full power / VCOM / gamma sequence. Reach for the maintained library first.
  • The USB port re-enumerating on a fixed ~2 s cycle is the ESP32-S3 brown-out-resetting in a loop — that points straight at the power path, not at USB or firmware, and saved a lot of wasted debugging.
4 layers
PCB stack-up
15 steps
Master volume
10/10
Buttons verified
2 parts
Left unpopulated (U4, D1)
Overview, the bring-up log, and the firmware rewrite below ↓
01

Project overview

RetroDS is a handheld NES emulator I've been building on an ESP32. Version 1 was a hand-soldered perfboard — a classic ESP32, a 240×240 display, ROMs from an SD card, USB power. It ran games, but the wiring was fragile, there was no sound, and there was no way to keep save-game progress.

Version 2 is a custom 4-layer PCB. The emulator core (the open-source NESCAT emulator by Nathalis, GPL-3.0) carries straight across; almost everything around it is new hardware: an ESP32-S3 with native USB-C, a Li-ion charger with load sharing, an I²S audio amplifier, a dedicated SD interface, and silk-screened controls on the board itself.

This page covers the part that isn't the schematic: getting the assembled board to work. Two faults had to be found with a multimeter before a single line of firmware mattered, and then the firmware needed a rewrite for the new silicon.

At a Glance
TypePersonal project
My roleSchematic, 4-layer layout, hand assembly, fault-finding, firmware port
CodePorted with AI coding help (Claude), tested on the board
SkillsPCB design · power paths · SMD rework · meter fault-finding · embedded C++
ToolsKiCad · JLCPCB · hotplate/hot air · multimeter · Arduino IDE
StatusWorking: games, sound, saves, charging
Built On (Credits)
Emulator coreNESCAT by Nathalis (GPL-3.0)
Display libraryLovyanGFX
SD librarySdFat
Coding assistantClaude (Anthropic)
My workThe v2 hardware and the port to it: display, audio, saves, charge status, SD hot-insert
02

What changed from v1

Subsystemv1 — perfboardv2 — custom PCB
MCUESP32-WROOM-32 (D1 mini board), USB-UART bridge chipESP32-S3-WROOM-1U-N8 — native USB, 8 MB flash
DisplayST7789 240×240, TFT_eSPI libraryST7789 240×320, LovyanGFX (DMA)
AudiononeMAX98357A I²S class-D amp + 4Ω 3W speaker, 15-step volume
PowerLiPo 3.7 V cell, TP4056 USB-C charger + IP5306 boost (2 chips)MCP73871 load-sharing charger + AP2112K 3.3 V regulator, USB-C
SD cardbit-banged soft-SPIdedicated hardware SPI bus, hot-insert detection
Save gamesnonebattery-backed SRAM → /NES/<rom>.nes.sav, autosave
ESD / protectionnoneESD-protection footprint on the USB data lines (D1, left unpopulated)
Interconnectpoint-to-point hand-run wiringrouted 4-layer board, GND plane, silk-screened controls
03

Hardware architecture

The power path is a load-sharing design: USB VBUS → MCP73871 → VOUT → SW3 → VSYS → AP2112K LDO → 3V3. With USB connected the charger runs the system and tops up the cell, giving the system-load priority so the total never exceeds the USB current limit; unplug USB and it hands over to the battery through an internal ideal-diode MOSFET. The battery charges whether the power switch is on or off — the charge path doesn't pass through it.

The display and the SD card sit on separate SPI peripherals (SPI3 and SPI2) so neither blocks the other. All buttons are active-low to internal pull-ups.

Component placement was also an assembly decision. Nearly everything that needs a hot-air station or a hotplate — the ESP32-S3 module, the MCP73871 QFN and the SOT-23 AP2112K regulator — is on the back, along with the plug-in MAX98357A amp and SD modules. The front carries only the buttons (small SMD tactile switches) and the through-hole slide switch, which hand-solder fine with an iron and don't fight for space against silicon. That split meant soldering the back first, while the board was easiest to handle and before any tall components were in the way, then finishing with the buttons — and it leaves the front face mechanically clean so the button caps sit flush against the 3D-printed case.

Display — ST7789 (SPI3)
SCLKGPIO 18
MOSI / SDAGPIO 9
DCGPIO 5
RSTGPIO 16
CStied to GND on the board
SD (SPI2), audio & charger sense
SD SCK / MISO / MOSI / CS14 / 13 / 17 / 42
I²S BCLK / WS / DOUT7 / 8 / 40
MCP73871 PG / STAT1 / STAT210 / 11 / 12
Controls — active-low
UP / DOWN / LEFT / RIGHT2 / 15 / 38 / 39
A / B21 / 41
START / SELECT47 / 4
VOL + / VOL −43 / 44 — UART0 pins, released in firmware
Build target
BoardESP32S3 Dev Module (esp32:esp32:esp32s3)
Flash / PSRAM8 MB / none (N8)
USB CDC on bootenabled
Partition schemecustom — dedicated romdata
04

Bring-up — the board wouldn't enumerate

First power-up: the host would see a USB device attach and drop it a second later, repeating on a fixed cycle. dmesg showed the descriptor read failing (error -71) and /dev/ttyACM0 appearing and vanishing every couple of seconds. No serial output ever arrived.

Rather than assume a bad solder joint on the USB connector, I read the cycle: the ESP32-S3's USB peripheral re-enumerates on every chip reset, and a fixed ~2 s period means the chip is resetting in a loop — a brown-out. That moved the problem from "USB / firmware" to "power path", so I measured it, one node at a time, with USB connected:

Voltage ladder — first measurement
VBUS (charger input)5.0 V
VOUT (charger output)0 V
VSYS0 V
3V3 (regulated rail)0 V
Conclusion
USB power reaches the boardyes — connector fine
Charger conducting IN→OUTno — U5
Everything downstreamdead — no input

The MCP73871 is a 20-pin QFN; its exposed centre pad carries the ground return and the charge-current heat. An unwetted centre pad or an open pin on the input/output side leaves it non-conducting. I reflowed the pad and the IN/OUT pins — VOUT went to ~5 V, the 3.3 V rail came up, and the board enumerated and took a firmware flash.

05

Bring-up — a hidden 3.3 V-to-ground short

With USB working I fitted the display, SD and volume-button modules — and the board went dark again. This time the 3.3 V rail measured a dead short to ground. Under magnification the PCB was clean; nothing was bridged.

A continuity beep is not a short test — the display bus alone carries roughly 10 µF, which beeps briefly while the capacitors charge. So I measured resistance instead: 3V3 to GND in ohms, both probe directions, watching whether the reading settled.

Measurement
0–3 Ω, identical both directions, stable — a genuine dead short, not the module's internal path plus capacitance (which reads differently each way and creeps upward).
Isolation
Lifting one plug-in module at a time cleared the short. It was between a module and its footprint — a joint on the underside I physically could not see. Reflowing that connector brought the rail back to 3.30 V and the display started drawing.
Takeaway
For short-hunting on a populated board, measure ohms with the meter, both directions, and read the trend. Then bisect — pull sections until it clears. It's faster and more certain than staring at joints.

Two smaller issues fell out of the same session. The SD card mounted nothing and SdFat reported "check SD format" — the card responded fine over SPI but was formatted NTFS; reformatting FAT32 and restoring the ROMs to /NES/ fixed it. And D1, the USB ESD array, was deliberately left unpopulated during bring-up as a check — the data lines pass straight through its footprint, so leaving it off first confirmed the USB routing itself was good, independent of the part. USB enumerated and ran fine without it, so it's stayed unpopulated: no functional loss, just less transient/ESD margin than the design allows for.

06

Firmware — the rewrite for the S3

The v1 firmware assumed the classic ESP32 and TFT_eSPI. On the ESP32-S3 with the current Arduino core that library crashes on init() — a known incompatibility. The display layer was re-done, and several things v1 never had were built in.

On tooling: I worked through this rewrite with Claude, an AI coding assistant, as a pair-programmer — adapting the v1 code to the S3's APIs, working out the LovyanGFX and I²S configs, and using it as a second reader in code review. The hardware fault-finding, the design choices, and the testing on the bench were mine; every AI-suggested change was read, understood and checked on the target board before it stayed in.

Display driver
TFT_eSPI → Arduino_GFX → LovyanGFX
TFT_eSPI crashes on core 3.x. Arduino_GFX ran but its SPI push held a full-frame blit at ~8 fps. LovyanGFX with DMA, plus a large row-block buffer allocated at boot (before the emulator fragments the heap), does it in ~15 fps. The 256-wide NES frame is scaled to 320 with a precomputed column map so it fills the whole panel.
Frame pacing
Capped to the real NES frame rate
The render loop was free-running at ~82 fps, so games and music played ~37% too fast. It's now paced with a micros() deadline to NES_REFRESH_RATE — 60 Hz NTSC, to match the ROM set (the core it forked from defaulted to PAL/50, which ran these US and Japanese titles ~17% slow). The video task also yields after each blit so it stops starving the emulator of internal-RAM bandwidth — there's no PSRAM on this module.
Sound & volume
v1 had none — v2 has a 15-step volume with an on-screen bar
MAX98357A over I²S, driving a 4Ω 3W speaker. A perceptual (roughly exponential) volume curve — the amp's fixed +9 dB gain makes a linear scale far too loud at the bottom. VOL + / − are edge-detected; a bar slides up on screen and fades after ~1.4 s. Exiting a game stops the I²S peripheral so the amplifier self-mutes — an earlier attempt that cleared the audio callback raced the 60 Hz timer interrupt and jumped to a null pointer.
Save games
Battery-backed SRAM mirrored to the card
Games with a battery in the original cartridge (Zelda, Metroid, Kirby…) write their 8 KB save RAM to /NES/<rom>.nes.sav — loaded on start, written on exit, and autosaved every 3 minutes and the moment the charger reports a low battery.
Charge status
Read from the charger — no fuel gauge fitted
U4 isn't populated and there's no analogue path from the battery to the MCU, so there's no percentage. The MCP73871's PG / STAT1 / STAT2 pins go to GPIO; decoded against the datasheet they give CHARGING / FULL / BATTERY / LOW BATT, shown in the menu and as an in-game low-battery banner.
Robustness
No more hard freezes
A code review found an 8–16 KB-per-game-switch memory leak (the ROM-info structure was reused without freeing its buffers) that would exhaust the heap after a dozen game changes, and five while(1) lock-ups on recoverable errors — a bad ROM, an unsupported mapper, a missing /NES/ folder. All are now an on-screen message and a return to the menu.
07

Results & what's left

Games
Running
from FAT32 SD, with saves
Display blit
~15 fps
emulator + input paced to 60 Hz NTSC
Buttons
10 / 10
incl. the two UART0-pin ones
Unpopulated
U4, D1
fuel gauge (stock) & ESD array (by choice)
Battery percentage — U4 not populated
A nice-to-have, not a core feature — leaving it off doesn't break anything else. Out of stock everywhere at build time with no direct replacement (see Firmware for how charge status is covered without it). A future revision would add a VBAT-to-ADC divider as a fallback state-of-charge estimate.
USB ESD protection — D1 not populated
Left unpopulated by choice, not oversight (see Bring-up) — USB has run fine without it. Populating it would only add transient/ESD margin the board hasn't needed so far.
Enclosure
The 3D-printed case is finished — board seated, every button registers cleanly, battery fitted. It runs as a self-contained portable.
Display framerate
The blit is the ceiling at ~15 fps while the emulator and input logic hold the 60 Hz NTSC cap. Async double-buffered DMA in the video task should roughly double the visual rate.
08

Enclosure & final build

The board was designed to fit a 3D-printed handheld shell. The case is printed, the PCB seats in it, and every button presses cleanly through the front face. It runs as a self-contained portable — a 3.7 V 1500 mAh Li-ion pouch cell, speaker, SD and screen all on the one board, no bench leads.

09

The code

v1 (perfboard) and v2 (this board) share the emulator core and are kept in separate folders with a code map. Key v2 snippets — the display config, the frame blit, the save path, the charge-status decode — are in the 📋 Code Notes panel (top right).

One quirk cost an afternoon: the Arduino build step auto-generates function prototypes and hoists them above your typedefs and enums. Any function that took the new enum charge-state type got a prototype placed before the type existed, and the sketch wouldn't compile. The fix was to drop the enum for plain integer constants and pass buffers by raw pointer — undramatic, but the kind of thing that only bites once you know to expect it.

v2 firmware — NES EMULATOR CODE/NesCat/: NesCat.ino · LGFX_RetroDS.h · v2_pins.h · partitions.csv · BUILD_v2.md

v1 firmware (archived) — NES EMULATOR CODE/v1-perfboard/

View key code →

Full repository link will be added when it's public.

← RetroDS v1 ⌂ Portfolio VHDL Digital Systems →
CODE NOTES — RetroDS v2

LovyanGFX display config — LGFX_RetroDS.h

No User_Setup file to hand-edit. The panel config lives next to the sketch; pins come from the schematic.

class LGFX_RetroDS : public lgfx::LGFX_Device {
  lgfx::Panel_ST7789 _panel;
  lgfx::Bus_SPI      _bus;
public:
  LGFX_RetroDS() {
    { auto c = _bus.config();
      c.spi_host    = SPI3_HOST;      // SD card keeps SPI2
      c.spi_mode    = 3;             // ST7789 on ESP32 wants mode 3
      c.freq_write  = 80000000;
      c.pin_sclk = 18; c.pin_mosi = 9; c.pin_miso = -1; c.pin_dc = 5;
      _bus.config(c); _panel.setBus(&_bus); }
    { auto c = _panel.config();
      c.pin_cs = -1;   // tied to GND on the PCB
      c.pin_rst = 16;
      c.panel_width = 240; c.panel_height = 320;
      c.invert = true;   // IPS
      _panel.config(c); }
    setPanel(&_panel);
  }
};

Frame blit — scale 256→320, DMA row-blocks

// column map + block buffer, allocated at boot before the heap fragments
for (int xd = 0; xd < 320; xd++)
    xmap[xd] = (uint8_t)((xd * 256) / 320);   // 0..255

gfx->startWrite();
for (i = 0; i < height; i += g_fb_rows) {
  int rows = min(g_fb_rows, height - i);
  uint16_t *out = g_fb;
  for (int r = 0; r < rows; r++) {
    const uint8_t *src = (const uint8_t*)SCREENMEMORY[i + r];
    for (X_ = 0; X_ < 320; X_++)
      *out++ = nes_16bit[0x3F & src[xmap[X_]]];   // scale + palette
  }
  gfx->pushImage(0, i, 320, rows, g_fb);          // DMA
}
gfx->endWrite();

Battery-backed save — SRAM ↔ /NES/<rom>.nes.sav

// on game start, after the ROM loads:
sram_load(rominfo, ROMFILENAME);   // read 8 KB if a .sav exists

// on exit, every 3 min, and on the first low-battery event:
sram_save(rominfo, ROMFILENAME);   // only if the iNES header has the battery flag

// exit path - stop I2S so the amp self-mutes; do NOT null the
// audio callback (races the 60 Hz timer ISR -> jump to PC=0):
g_sound_on = false;
i2s_zero_dma_buffer(I2S_NUM_1);
i2s_stop(I2S_NUM_1);

Charge status — MCP73871 PG/STAT pins

int read_charge_state() {
  bool pg = digitalRead(CHG_PG_PIN);      // 0 = USB power present
  bool s1 = digitalRead(CHG_STAT1_PIN);
  bool s2 = digitalRead(CHG_STAT2_PIN);
  if (!pg) {
    if (!s1 &&  s2) return CHG_CHARGING;   // datasheet TABLE 5-1
    if ( s1 && !s2) return CHG_FULL;
    return CHG_USB;
  }
  if (!s1 && s2) return CHG_LOW_BATTERY;
  return CHG_ON_BATTERY;
}