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 |
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
- Zombie Python Processes (Most Likely): You ran the script previously, hit Ctrl+C, but the
gpiozerocleanup routine failed, leaving the file descriptor open in the kernel.
Fix: Runps aux | grep pythonandsudo kill -9 [PID]for any lingering instances of your script. - Background Service Conflict: If you are running RetroPie, the
mk_arcade_joystick_rpikernel module orretroarch-autoconfigdaemon might already be polling those specific GPIO pins in the background.
Fix: Disable the conflicting service viasudo systemctl stop mk_arcade_joystick_rpior blacklists the module in/etc/modprobe.d/. - Mixed Pin Factories: You have legacy
RPi.GPIOcode imported somewhere in your project tree alongsidegpiozero. The Pi 5 cannot handle both libraries fighting over the RP1 southbridge memory map.
Fix: Force the lgpio backend explicitly by addingos.environ['GPIOZERO_PIN_FACTORY'] = 'lgpio'at the very top of your script, before importinggpiozero.
- 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.
- 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.
- OS Permissions: Ensure your user is in the
gpioanddialoutgroups. Runsudo usermod -a -G gpio,dialout piand reboot. Without this,lgpiowill 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.






