Using a Raspberry Pi as a game console usually starts with flashing RetroPie to an SD card and plugging in a USB controller. But if you are building a custom arcade cabinet or a bartop rig, relying on off-the-shelf USB encoders adds latency, cost, and cable clutter. The professional embedded approach is to wire arcade pushbuttons directly to the Raspberry Pi GPIO header and run a lightweight Linux daemon that translates physical pin state changes into virtual evdev joystick inputs.
This guide targets the Raspberry Pi 5 (8GB variant). We will map physical switches to virtual keyboard/joystick events using Python, handle hardware debouncing, and debug the most common permission and brownout errors that kill custom console builds.
Hardware Spec Sheet & Parts List
Do not underpower a Raspberry Pi 5. The board requires a 5V/5A (27W) USB-C PD power supply to prevent peripheral brownouts, especially when you are drawing current for GPIO pull-ups and button LEDs.
| Component | Exact Variant / Specification | Estimated Cost (2026) |
|---|---|---|
| Compute Board | Raspberry Pi 5 (8GB RAM) | $80.00 |
| Power Supply | Official 27W USB-C PD Power Supply (5.1V/5A) | $12.00 |
| Thermal Management | Raspberry Pi Active Cooler (PWM controlled) | $5.00 |
| Pushbuttons | Sanwa Denshi OBSC-24 (24mm, translucent, microswitch) | $4.50 each |
| Joystick | Sanwa JLX-TP-8YT (4-way/8-way switchable) | $35.00 |
| Wiring | 24 AWG stranded silicone wire with .110" quick disconnects | $15.00 |
| Storage | Samsung EVO Select 128GB microSD (A2 rated) | $14.00 |
GPIO Pin Mapping & Wiring Procedure
Arcade switches are simple single-pole single-throw (SPST) normally-open (NO) mechanical switches. You do not need external pull-up resistors; the Raspberry Pi's internal pull-ups (configured in software) are sufficient. Wire one side of every switch to a common Ground (GND) bus, and the other side to the designated GPIO pin.
| Arcade Function | BCM GPIO Pin | Physical Pin (40-pin Header) | EmulationStation Default Key |
|---|---|---|---|
| Joystick Up | 20 | 38 | KEY_UP |
| Joystick Down | 16 | 36 | KEY_DOWN |
| Joystick Left | 12 | 32 | KEY_LEFT |
| Joystick Right | 25 | 22 | KEY_RIGHT |
| Button 1 (A) | 5 | 29 | KEY_LEFTCTRL |
| Button 2 (B) | 6 | 31 | KEY_LEFTALT |
| Button 3 (X) | 13 | 33 | KEY_SPACE |
| Button 4 (Y) | 19 | 35 | KEY_LEFTSHIFT |
| Start | 26 | 37 | KEY_ENTER |
| Select / Coin | 21 | 40 | KEY_TAB |
- Prepare the Ground Bus: Crimp a .110" quick disconnect onto one terminal of every microswitch. Connect all these terminals together using a continuous 24 AWG ground wire, terminating at Physical Pin 6 (GND) on the Pi header.
- Route the Signal Wires: Crimp disconnects onto the remaining switch terminals and route them to their respective BCM GPIO pins.
- Verify Continuity: Before powering the Pi, use a multimeter in continuity mode. Press a button and verify you get a beep between the signal wire and the ground bus. Ensure no signal wires are shorted to 3.3V or 5V.
The Control Daemon: Python evdev Implementation
To make EmulationStation and RetroArch recognize our raw GPIO inputs, we create a virtual input device using the Linux uinput kernel module via the python-evdev library. This script targets the Raspberry Pi 5 and requires gpiozero and evdev (sudo apt install python3-gpiozero python3-evdev).
#!/usr/bin/env python3
"""
GPIO-to-Keyboard Arcade Daemon for Raspberry Pi 5
Target: Raspberry Pi 5 (8GB) running Raspberry Pi OS (Bookworm/Wormhole)
Dependencies: gpiozero, evdev
"""
import time
import signal
import sys
from gpiozero import Button
from evdev import UInput, ecodes as e
import evdev
# Explicit BCM Pin Definitions mapped to evdev keycodes
GPIO_TO_KEY = {
20: e.KEY_UP,
16: e.KEY_DOWN,
12: e.KEY_LEFT,
25: e.KEY_RIGHT,
5: e.KEY_LEFTCTRL,
6: e.KEY_LEFTALT,
13: e.KEY_SPACE,
19: e.KEY_LEFTSHIFT,
26: e.KEY_ENTER,
21: e.KEY_TAB
}
def graceful_exit(signum, frame):
"""Clean up virtual device on termination."""
print("\nDaemon terminating, releasing uinput device...")
if 'ui' in globals() and ui is not None:
ui.close()
sys.exit(0)
signal.signal(signal.SIGINT, graceful_exit)
signal.signal(signal.SIGTERM, graceful_exit)
def main():
global ui
ui = None
# Attempt to create the virtual input device
try:
capabilities = {e.EV_KEY: list(GPIO_TO_KEY.values())}
ui = UInput(capabilities=capabilities, name="Custom-Arcade-GPIO", version=0x3)
except evdev.uinput.UInputError as err:
print(f"Fatal uinput Error: {err}")
print("Check if /dev/uinput exists and user has permissions.")
sys.exit(1)
except OSError as err:
print(f"Fatal OS Error: {err}")
sys.exit(1)
print(f"Virtual device created at: {ui.device.path}")
# Initialize gpiozero buttons with internal pull-ups
buttons = {}
for pin, keycode in GPIO_TO_KEY.items():
# pull_up=True means pin is HIGH by default, goes LOW (ground) when pressed
btn = Button(pin, pull_up=True, bounce_time=0.02)
# Lambda factory to bind specific keycode to the callback
def make_press_cb(kc):
return lambda: on_state_change(kc, 1)
def make_release_cb(kc):
return lambda: on_state_change(kc, 0)
btn.when_pressed = make_press_cb(keycode)
btn.when_released = make_release_cb(keycode)
buttons[pin] = btn
print("Arcade daemon running. Press Ctrl+C to exit.")
# Keep main thread alive
while True:
time.sleep(1)
def on_state_change(keycode, state):
"""Write state change to the virtual evdev device."""
try:
ui.write(e.EV_KEY, keycode, state)
ui.syn() # Synchronize the event stream
except Exception as write_err:
print(f"Write error: {write_err}")
if __name__ == "__main__":
main()
Debugging: Resolving uinput and Boot Failures
When building a Raspberry Pi as a game console with custom GPIO scripts, you will inevitably hit kernel permission walls or hardware brownouts.
The Exact Error String
The most common showstopper when running the script above is:
evdev.uinput.UInputError: Failed to create uinput device: Permission denied
Ranked Causes & Fixes:
- Missing Kernel Module: The
uinputmodule isn't loaded. Fix: Runsudo modprobe uinput. To make it persistent, adduinputto/etc/modules. - Insufficient User Privileges: Your user isn't in the
inputgroup, or udev rules are blocking access. Fix: Runsudo usermod -aG input $USER, then log out and back in. - Wayland/X11 Interference: If running headless, ensure you aren't conflicting with an existing input grabber. Run the script via
systemdas root if standard user permissions fail.
The First Three Things to Check When It Fails
If the console boots to a black screen or the controls are entirely unresponsive, check these three metrics immediately:
- Check for Under-Voltage: Run
dmesg | grep -i under-voltage. If you see warnings, your power supply is sagging under the Pi 5's load, causing the RP1 chip to drop GPIO interrupts. Upgrade to the official 27W PD supply. - Verify I2C/SPI Conflicts: If you are also wiring an LCD marquee, ensure you haven't assigned GPIO 2/3 (I2C) or 7-11 (SPI) to your buttons without disabling those interfaces in
raspi-config. - Test Raw Pin States: Bypass the Python script. Run
raspi-gpio getin the terminal. Press a button and verify the pin state flips fromHIGHtoLOW. If it staysHIGH, your ground wire is disconnected.
Extending and Simplifying the Build
How to Simplify: If soldering and crimping 20 individual wires sounds like a nightmare, abandon raw GPIO wiring and purchase a dedicated RetroPie GPIO Adapter board (like the Mausberry Circuits adapter or the GPi Case 2 cartridge). These boards plug directly into the 40-pin header, provide screw terminals for your switches, and include pre-compiled C-daemons that handle the uinput mapping and safe shutdown logic out of the box.
How to Extend: To add a trackball or spinners, do not try to bit-bang quadrature encoders via Python GPIO—it will drop counts and ruin your aim in games like Golden Tee. Instead, add an Arduino Pro Micro (ATmega32U4) to the cabinet. Wire the trackball's optical encoders to the Arduino, program it as a native USB HID Mouse using the standard Arduino Mouse.h library, and plug it into the Pi's USB port. This offloads the high-speed interrupt polling from the Pi's Linux kernel to the Arduino's bare-metal AVR chip.
Frequently Asked Questions
How much RAM does a Raspberry Pi need for a game console?
For 8-bit and 16-bit emulation (NES, SNES, Genesis, MAME), the 2GB variant is perfectly adequate; EmulationStation and RetroArch cores rarely exceed 800MB of RAM. However, if you plan to emulate N64, Dreamcast, or PSP, you must use the 8GB variant. The ARM64 architecture and higher-clocked cores on the 8GB Pi 5 provide the necessary headroom for dynamic recompilers (dynarecs) used in 32-bit and 64-bit console emulation.
Can you use a Raspberry Pi as a game console without internet?
Yes. Once RetroPie or Batocera is flashed, the OS and emulators run entirely offline. However, you must have an internet connection during the initial setup phase to download scraper metadata (box art and game descriptions) and to install optional emulator binaries via the RetroPie Setup script. For an offline arcade build, scrape your ROMs on a PC using Skraper, copy the gamelist.xml and images folders to your SD card, and the Pi will never need Wi-Fi again.
Why is my Raspberry Pi game console lagging on N64 and Dreamcast games?
N64 and Dreamcast emulation rely heavily on single-core CPU performance and GPU OpenGL ES compliance. If you are experiencing audio stuttering or frame drops on a Pi 5, check your thermal throttling first using vcgencmd get_throttled. If the active cooler is failing, the Pi will downclock from 2.4GHz to 1.5GHz within seconds. Second, ensure you are using the ParaLLEl RDP core for N64 instead of the older GlideN64 plugin, as ParaLLEl leverages the Pi 5's VideoCore VII GPU much more efficiently for hardware-accurate rasterization.






