If you want to know how to create a gaming console with Raspberry Pi hardware, the most robust approach for a dedicated arcade cabinet or bartop build is bypassing pre-packaged USB controllers and wiring physical microswitches directly to the GPIO header. This eliminates USB polling latency, removes the need for external encoder boards, and gives you bare-metal control over the input daemon.

This guide targets the Raspberry Pi 5 (4GB variant) running Raspberry Pi OS Bookworm. We will build a custom Python input daemon using the gpiozero library (backed by lgpio) to map physical arcade button presses directly to RetroArch emulator launches. No USB encoder boards, no fluff.

Hardware BOM and GPIO Pin Mapping

The Pi 5 introduced a stricter power delivery negotiation via USB-C PD. If you use a standard 5V/3A phone charger, the Pi 5 will throttle its USB current limit to 600mA, which will cause external peripherals and active LED buttons to brown out. You must use a 5V/5A (27W) supply to unlock the full 1.6A USB budget.

Bill of Materials (2026 Pricing)

Component Exact Model / Variant Est. Price Technical Specification
Compute Raspberry Pi 5 (4GB) $60.00 Broadcom BCM2712, 2.4GHz Quad-core, PCIe 2.0
Storage SanDisk Extreme 64GB $14.50 A2 App Performance, V30, U3, 160MB/s Read
Power Official 27W USB-C PD $18.00 5V/5A fixed output, unlocks 1.6A USB downstream
Switches Sanwa OBSF-24 (x6) $21.00 24mm mount, 5M cycle microswitch, 50gf actuation
Wiring 20 AWG Silicone Wire $8.50 Stranded copper, pre-crimped .110" QD terminals

GPIO Pin Mapping Table

We use BCM numbering. Avoid GPIO 2 and GPIO 3 (Physical pins 3 and 5) for arcade buttons; these pins have hard 1.8kΩ I2C pull-up resistors on the PCB which can cause ghosting or interfere with active-low microswitch logic.

Function BCM GPIO Physical Pin Pull Resistor Wiring Notes
Player 1 Start 17 11 Internal Pull-Up Switch to GND (Active-low)
Player 1 Coin 27 13 Internal Pull-Up Avoids I2C/SPI bus conflicts
Action A 22 15 Internal Pull-Up Primary fire button
Action B 23 16 Internal Pull-Up Secondary fire button
Joystick Up 5 29 Internal Pull-Up Standard directional input
Joystick Down 6 31 Internal Pull-Up Standard directional input
⚠️ Wiring Safety Callout: Always wire one leg of the microswitch to the GPIO signal pin and the other leg to a common Ground (GND) bus. Never wire a switch to 3.3V or 5V; a physical miswire or frayed insulation will instantly fry the Pi 5's BCM2712 SoC, as the GPIO pins are not 5V tolerant.

The Python GPIO Controller Code

In Raspberry Pi OS Bookworm, the legacy RPi.GPIO library is deprecated and largely broken on the Pi 5 due to the new RP1 southbridge chip. You must use gpiozero with the lgpio backend. The script below maps physical button presses to launch specific RetroArch cores via subprocess, complete with debounce handling and exception catching.

Target Board: Raspberry Pi 5 (4GB)
Target OS: Raspberry Pi OS Bookworm (64-bit)
Dependencies: sudo apt install python3-gpiozero python3-lgpio

import sys
import subprocess
import logging
from gpiozero import Button
from signal import pause

# Configure logging for systemd journal integration
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')

# Pin Definitions (BCM numbering, Active Low)
PIN_START = 17
PIN_COIN = 27
PIN_ACTION_A = 22
PIN_ACTION_B = 23

# RetroArch Execution Paths (Adjust to your specific installation)
RETROARCH_CMD = "/opt/retropie/emulators/retroarch/bin/retroarch"
CORE_PATH = "/opt/retropie/libretrocores/lr-picodrive/picodrive_libretro.so"
ROM_PATH = "/home/pi/RetroPie/roms/megadrive/SonicTheHedgehog.bin"
CFG_PATH = "/opt/retropie/configs/megadrive/retroarch.cfg"

def launch_game():
    """Triggered by Player 1 Start button."""
    logging.info("Start pressed. Launching RetroArch instance...")
    try:
        # Popen is used so the script doesn't block waiting for the emulator to close
        subprocess.Popen([
            RETROARCH_CMD,
            "-L", CORE_PATH,
            "--config", CFG_PATH,
            "--verbose",
            ROM_PATH
        ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    except FileNotFoundError as e:
        logging.error(f"RetroArch binary not found at {RETROARCH_CMD}: {e}")
    except PermissionError as e:
        logging.error(f"Permission denied executing emulator: {e}")
    except Exception as e:
        logging.error(f"Unhandled exception during launch: {e}")

def insert_coin():
    """Triggered by Coin button. Can be mapped to xdotool or menu logic."""
    logging.info("Coin inserted. Triggering menu state.")
    # Example: subprocess.run(["xdotool", "key", "Return"])
    pass

try:
    # Initialize buttons. 
    # pull_up=True enables internal pull-up; switch pulls to GND when pressed.
    # bounce_time=0.05 prevents mechanical microswitch chatter (50ms debounce).
    btn_start = Button(PIN_START, pull_up=True, bounce_time=0.05)
    btn_coin = Button(PIN_COIN, pull_up=True, bounce_time=0.05)
    btn_a = Button(PIN_ACTION_A, pull_up=True, bounce_time=0.05)
    btn_b = Button(PIN_ACTION_B, pull_up=True, bounce_time=0.05)

    # Bind edge detection events
    btn_start.when_pressed = launch_game
    btn_coin.when_pressed = insert_coin

    logging.info("GPIO Arcade Controller initialized via lgpio. Waiting for inputs...")
    pause() # Keeps the script alive and listening for interrupts

except RuntimeError as e:
    logging.critical(f"GPIO Initialization Failed: {e}")
    sys.exit(1)
except KeyboardInterrupt:
    logging.info("Received SIGINT. Shutting down GPIO controller gracefully.")
    sys.exit(0)
except Exception as e:
    logging.critical(f"Fatal daemon error: {e}")
    sys.exit(1)

Debugging GPIO Edge Detection Failures

When working with raw GPIO on the Pi 5's RP1 chip, you will inevitably encounter daemon crashes or unresponsive pins. The most common and frustrating error thrown by the lgpio backend when initializing buttons is:

RuntimeError: Conflicting edge detection already enabled for this GPIO channel

This error means the OS kernel already has an interrupt watcher registered to that specific /dev/gpiochip0 line, and gpiozero is being blocked from attaching a second one.

Ranked Causes and Fixes

  1. Zombie Python Processes (Most Likely): You ran the script previously, hit Ctrl+C, but the gpiozero cleanup routine failed, leaving the file descriptor open in the kernel.
    Fix: Run ps aux | grep python and sudo kill -9 [PID] for any lingering instances of your script.
  2. Background Service Conflict: If you are running RetroPie, the mk_arcade_joystick_rpi kernel module or retroarch-autoconfig daemon might already be polling those specific GPIO pins in the background.
    Fix: Disable the conflicting service via sudo systemctl stop mk_arcade_joystick_rpi or blacklists the module in /etc/modprobe.d/.
  3. Mixed Pin Factories: You have legacy RPi.GPIO code imported somewhere in your project tree alongside gpiozero. The Pi 5 cannot handle both libraries fighting over the RP1 southbridge memory map.
    Fix: Force the lgpio backend explicitly by adding os.environ['GPIOZERO_PIN_FACTORY'] = 'lgpio' at the very top of your script, before importing gpiozero.
💡 The First 3 Things to Check When Inputs Fail:
  1. Multimeter Continuity: Set your DMM to continuity mode. Probe the microswitch terminals while pressing the button. If it doesn't beep, the .110" QD crimp is loose or the switch is dead.
  2. Pull-up State: Measure the voltage at the GPIO pin (Physical pin 11 for GPIO 17) with the button unpressed. It should read exactly 3.3V. If it reads 0V, your internal pull-up failed to initialize, or you have a physical short to ground.
  3. OS Permissions: Ensure your user is in the gpio and dialout groups. Run sudo usermod -a -G gpio,dialout pi and reboot. Without this, lgpio will silently fail to open the character device.

Extending or Simplifying the Build

Depending on your timeline and Linux tolerance, you may want to alter the scope of this project.

How to Simplify (The Pre-Built Route)

If writing custom Python daemons and managing subprocess PIDs sounds like unnecessary friction, abandon the custom code entirely. Flash the RetroPie image for Pi 4/5. During the initial setup, use the retropie_setup.sh menu to install the GPIO Driver (mk_arcade_joystick_rpi). This compiles a kernel-level module that translates GPIO edge interrupts directly into standard Linux /dev/input/js0 joystick events. RetroArch will see it as a standard USB gamepad, requiring zero custom Python code.

How to Extend (I2C OLED Metadata Display)

To turn this from a basic console into a premium arcade experience, add a 128x64 I2C OLED display (SSD1306 driver) to show the currently loaded ROM title, playtime, and Pi 5 CPU thermals. Because we intentionally avoided GPIO 2 and 3 for our buttons, those pins remain free for the I2C bus. Wire the OLED's SDA to Physical Pin 3 (GPIO 2) and SCL to Physical Pin 5 (GPIO 3). Use the adafruit-circuitpython-ssd1306 library to poll the Pi's thermal sensor via vcgencmd measure_temp and render it to the screen using the Python Pillow imaging library. This adds roughly $12 to the BOM but drastically improves the kiosk aesthetic.

For deeper reading on the Pi 5's RP1 southbridge GPIO architecture and the shift to lgpio, consult the official Raspberry Pi 5 hardware documentation and the gpiozero readthedocs repository.