Building a custom retro gaming console with Raspberry Pi hardware is a rite of passage for embedded hobbyists, but most online guides stop at flashing RetroPie to an SD card. If you want a truly portable, battery-powered handheld with a custom SPI display and analog stick inputs, you need to wire raw components and write a daemon to bridge GPIO events to the emulator. This guide walks through building a handheld retro gaming console with Raspberry Pi Zero 2 W, targeting the ILI9486 SPI display, MAX98357A I2S audio DAC, and an MCP3008 ADC for analog inputs.
Estimated Time: 4-5 hours (excluding 3D printing)
Target Board Variant: Raspberry Pi Zero 2 W (Rev 1.0, Bookworm OS 64-bit)
Core Skill: SPI/I2S bus configuration, Python
evdev uinput injection
Hardware Spec Sheet & Power Budget
Before soldering, you must verify your power budget. The Pi Zero 2 W can draw up to 1.2A under heavy emulation loads (like PS1 or N64 via RetroArch), and adding a backlight display and audio amp pushes the peak draw close to the limits of a standard 18650 LiPo cell and basic buck converter. Below is the exact power and thermal data for the components used in this build.
| Component | Exact Variant / IC | Nominal Voltage | Max Current Draw | Quiescent Power | Thermal Throttle Threshold |
|---|---|---|---|---|---|
| Compute Module | Pi Zero 2 W (BCM2710A1) | 5.0V | 850 mA (peak) | 120 mA | 80°C (soft), 85°C (hard) |
| SPI Display | Waveshare 3.5" (ILI9486) | 3.3V / 5.0V | 220 mA (backlight 100%) | 45 mA | N/A (Passive) |
| Audio DAC | Adafruit MAX98357A | 2.5V - 5.5V | 300 mA (at 4Ω 3W) | 12 mA | 150°C (IC junction) |
| ADC (Analog Stick) | Microchip MCP3008 | 2.7V - 5.5V | 2 mA | 0.5 mA | N/A |
| Power Management | Adafruit PowerBoost 1000C | 3.7V in / 5.2V out | 1000 mA (continuous) | 50 mA | 120°C (IC junction) |
BCM Pin Mapping & Wiring Procedure
The Pi Zero 2 W has a single 40-pin header. We are multiplexing the SPI0 bus for both the display and the MCP3008 ADC, while reserving the PCM/I2S pins for the audio DAC. Below is the exact pin mapping.
| Pi Zero 2 W (BCM) | Pi Header Pin | Function | Target Component Pin |
|---|---|---|---|
| GPIO 11 (SCLK) | 23 | SPI0 Clock | Display SCK & MCP3008 CLK |
| GPIO 10 (MOSI) | 19 | SPI0 MOSI | Display SDA (MOSI) & MCP3008 Din |
| GPIO 9 (MISO) | 21 | SPI0 MISO | Display SDO (MISO) & MCP3008 Dout |
| GPIO 8 (CE0) | 24 | SPI0 CS0 | Display CS |
| GPIO 7 (CE1) | 26 | SPI0 CS1 | MCP3008 CS/SHDN |
| GPIO 18 (PCM_CLK) | 12 | I2S Bit Clock | MAX98357A BCLK |
| GPIO 19 (PCM_FS) | 35 | I2S Frame Sync | MAX98357A LRC |
| GPIO 21 (PCM_DOUT) | 40 | I2S Data Out | MAX98357A DIN |
| GPIO 17 | 11 | Digital Input | Button A (Active Low) |
| GPIO 27 | 13 | Digital Input | Button B (Active Low) |
Wiring Steps
- Prep the SPI Bus: Solder the display and MCP3008 CLK, MOSI, and MISO lines together in parallel. Use 30 AWG silicone wire to keep capacitance low on the SPI bus; high capacitance at 32MHz causes signal ringing and dropped frames.
- Wire the I2S DAC: Connect the MAX98357A BCLK, LRC, and DIN to BCM 18, 19, and 21. Tie the MAX98357A GAIN pin to GND for 15dB gain (ideal for small 3W 4Ω speakers) or leave it floating for 12dB.
- Configure the ADC: Tie the MCP3008 VDD and VREF to the Pi's 3.3V rail (Pin 1). Do not use 5V for VREF, or the analog stick will output 3.3V+ into the MISO line, risking damage to the Pi's GPIO.
- Verify Continuity: Before applying power, use a multimeter in continuity mode to verify no shorts between the 5V rail (Pin 2) and 3.3V rail (Pin 1).
GPIO & Analog Controller Python Daemon
Modern Raspberry Pi OS (Bookworm and later) uses evdev to inject virtual keyboard and joystick events into RetroArch. The following Python script targets the Raspberry Pi Zero 2 W, reads the MCP3008 analog stick via SPI, polls the digital buttons, and emits uinput events.
#!/usr/bin/env python3
"""
Custom GPIO & Analog Controller Daemon for RetroPie/RetroArch
Target: Raspberry Pi Zero 2 W (Bookworm OS)
Dependencies: spidev, RPi.GPIO, evdev
"""
import spidev
import RPi.GPIO as GPIO
from evdev import UInput, ecodes
import time
import sys
# --- EXACT PIN DEFINITIONS (BCM) ---
BTN_A = 17
BTN_B = 27
MCP_CS = 7 # CE1 for MCP3008
# Analog stick thresholds (0-1023)
AXIS_DEADZONE = 150
AXIS_MAX = 1023
def setup_gpio():
GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False)
GPIO.setup(BTN_A, GPIO.IN, pull_up_down=GPIO.PUD_UP)
GPIO.setup(BTN_B, GPIO.IN, pull_up_down=GPIO.PUD_UP)
def setup_spi():
spi = spidev.SpiDev()
spi.open(0, 1) # Bus 0, CS1 (CE1 = BCM 7)
spi.max_speed_hz = 1350000 # MCP3008 max reliable speed on Pi
spi.mode = 0
return spi
def read_adc(spi, channel):
"""Reads MCP3008 channel (0-7) and returns 10-bit value."""
if channel < 0 or channel > 7:
raise ValueError("MCP3008 channel must be 0-7")
r = spi.xfer2([1, (8 + channel) << 4, 0])
out = ((r[1] & 3) << 8) + r[2]
return out
def main():
try:
setup_gpio()
spi = setup_spi()
except RuntimeError as e:
print(f"GPIO Setup Failed: {e}. Are you running as root/sudo?")
sys.exit(1)
except OSError as e:
print(f"SPI Open Failed: {e}. Is spidev enabled in config.txt?")
sys.exit(1)
# Create virtual gamepad capability
cap = {
ecodes.EV_KEY: [ecodes.KEY_A, ecodes.KEY_S], # A and B buttons
ecodes.EV_ABS: [
(ecodes.ABS_X, (0, 0, AXIS_MAX, 0, 0)),
(ecodes.ABS_Y, (0, 0, AXIS_MAX, 0, 0))
]
}
try:
ui = UInput(cap, name='Custom-Pi-Gamepad', version=1)
except Exception as e:
print(f"UInput creation failed: {e}. Ensure uinput kernel module is loaded.")
sys.exit(1)
prev_a = 1
prev_b = 1
try:
while True:
# Poll Digital Buttons (Active Low)
curr_a = GPIO.input(BTN_A)
curr_b = GPIO.input(BTN_B)
if curr_a != prev_a:
ui.write(ecodes.EV_KEY, ecodes.KEY_A, 0 if curr_a else 1)
prev_a = curr_a
if curr_b != prev_b:
ui.write(ecodes.EV_KEY, ecodes.KEY_S, 0 if curr_b else 1)
prev_b = curr_b
# Poll Analog Stick (X on CH0, Y on CH1)
x_val = read_adc(spi, 0)
y_val = read_adc(spi, 1)
ui.write(ecodes.EV_ABS, ecodes.ABS_X, x_val)
ui.write(ecodes.EV_ABS, ecodes.ABS_Y, y_val)
ui.syn()
time.sleep(0.016) # ~60Hz polling rate
except KeyboardInterrupt:
print("Daemon shutting down.")
finally:
ui.close()
spi.close()
GPIO.cleanup()
if __name__ == '__main__':
main()
Debugging Blank Displays & SPI Errors
When building a retro gaming console with Raspberry Pi hardware, the SPI display and custom controller daemon are the most common points of failure. If your build fails, check these exact error strings and follow the ranked causes.
Error 1: OSError: [Errno 22] Invalid argument
This occurs on the spi.open() or spi.max_speed_hz lines in the Python script.
- Cause 1 (Most Likely): The SPI interface is disabled. In Raspberry Pi OS Bookworm, the config file moved. You must edit
/boot/firmware/config.txt(not/boot/config.txt) and ensuredtparam=spi=onis present and uncommented. - Cause 2:
max_speed_hzis set too high. The MCP3008 datasheet limits clock speed to 3.6 MHz at 5V and 1.35 MHz at 3.3V. Since we run it at 3.3V, setting it to 20 MHz will throw this OS-level rejection. - Cause 3: The SPI kernel module (
spidev) failed to load due to a conflicting device tree overlay.
Error 2: Display Remains Blank (White or Black Screen)
The Pi boots, you hear audio, but the ILI9486 display shows nothing.
- Cause 1: Missing framebuffer overlay. Add
dtoverlay=waveshare35b(or the specific overlay for your ILI9486 variant) to/boot/firmware/config.txt. - Cause 2: Backlight pin is floating. The Waveshare 3.5" LCD requires the BL (Backlight) pin tied to 3.3V or a PWM GPIO pin to turn on the LEDs.
- Cause 3: Logic level mismatch. If you are using a level shifter for the SPI lines, ensure the high-side reference is tied to 5V and the low-side to 3.3V. Swapping these will result in no data transmission.
- Verify
dtparam=spi=onanddtparam=i2s=onare in/boot/firmware/config.txt. - Measure the 3.3V rail (Pin 1) with a multimeter. If it reads below 3.1V under load, your power supply is browning out the Pi, causing SPI bus resets.
- Run
lsmod | grep spidevin the terminal. If it returns empty, the SPI driver is not loading at the kernel level.
Extending or Simplifying the Build
Depending on your enclosure constraints and budget, you may want to modify this retro gaming console with Raspberry Pi architecture. Here is a comparison of how to scale the project up or down.
| Modification Path | Hardware Changes | Software / Config Impact | Best Use Case |
|---|---|---|---|
| Simplify (Digital Only) | Remove MCP3008 ADC. Use 4 tactile switches for a D-Pad wired directly to GPIOs. | Delete SPI ADC code. Map GPIOs directly to evdev directional keys. Frees up SPI bus for display only. |
8-bit and 16-bit era games (NES, SNES, Genesis) that don't require analog inputs. |
| Extend (Add Battery Telemetry) | Add an INA219 I2C current/voltage sensor on the battery line. | Add smbus2 library. Write a Python script to read I2C registers and render a battery overlay via RetroArch shaders. |
True portable builds where knowing exact remaining mAh prevents sudden shutoffs and SD card corruption. |
| Extend (HDMI Out) | Wire a micro-HDMI pigtail to the Pi's HDMI port. Add a physical toggle switch for display routing. | Requires dynamic config.txt boot profiles or a script to toggle dtoverlay for SPI vs HDMI output on reboot. |
Hybrid handhelds that dock to a TV for multiplayer or larger-screen gaming. |
Building a handheld emulator from raw components teaches you more about embedded Linux, bus protocols, and power management than any plug-and-play kit. By understanding the exact current draw of your SPI display and handling GPIO interrupts via evdev, you ensure your retro gaming console with Raspberry Pi hardware is both responsive and thermally stable.






