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.

Project Difficulty: Intermediate
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)
Callout Tip: Never power the Pi Zero 2 W through the 5V GPIO header while simultaneously plugging in USB-C power. The PowerBoost 1000C lacks reverse-current protection on the 5V rail, and backfeeding 5V from a wall wart into the battery boost circuit will fry the inductor.

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)23SPI0 ClockDisplay SCK & MCP3008 CLK
GPIO 10 (MOSI)19SPI0 MOSIDisplay SDA (MOSI) & MCP3008 Din
GPIO 9 (MISO)21SPI0 MISODisplay SDO (MISO) & MCP3008 Dout
GPIO 8 (CE0)24SPI0 CS0Display CS
GPIO 7 (CE1)26SPI0 CS1MCP3008 CS/SHDN
GPIO 18 (PCM_CLK)12I2S Bit ClockMAX98357A BCLK
GPIO 19 (PCM_FS)35I2S Frame SyncMAX98357A LRC
GPIO 21 (PCM_DOUT)40I2S Data OutMAX98357A DIN
GPIO 1711Digital InputButton A (Active Low)
GPIO 2713Digital InputButton B (Active Low)

Wiring Steps

  1. 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.
  2. 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.
  3. 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.
  4. 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 ensure dtparam=spi=on is present and uncommented.
  • Cause 2: max_speed_hz is 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.
The First 3 Things to Check When It Fails:
  1. Verify dtparam=spi=on and dtparam=i2s=on are in /boot/firmware/config.txt.
  2. 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.
  3. Run lsmod | grep spidev in 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.