From Proxmox Node to Retro Console: My Arch Linux RetroDeck Journey

Estimated reading time: 8 minutes

Table of contents

I recently decided it was time for a hardware upgrade in my homelab. I moved my Proxmox setup to a newer HP EliteDesk 800 G5 Mini (with a 9th Gen Intel i5), primarily to take advantage of its dual NVMe slots for a proper btrfs RAID configuration. Consequently, my trusty Lenovo ThinkCentre M93p Tiny (with a 4th Gen Intel i5) was left without a job. I have now repurposed it for an Arch Linux RetroDeck setup to breathe new life into it.

Naturally, I didn’t want it to gather dust. My first thought was, “This would make a perfect little retro gaming console.” I had been looking to replace my Kodi (Raspberry Pi 3B+) and old Steam Link hardware, which were giving me constant quirks with streaming – even when using Moonlight. With Christmas around the corner, I wanted to build something robust and easy enough for my kids to pick up and play without needing tech support every time.

I already had all my game data set up for RetroDeck, so I figured I could just spin up a lightweight Linux install, mount the data, and be gaming in an hour.

Spoiler alert: It wasn’t that simple.

Arch Linux RetroDeck

The “Simple” Project That Wasn’t

This post is a log of my journey to turn this older hardware into a seamless console appliance using Arch Linux. It serves as a reference for how I finally got it working, complete with stable Bluetooth for my controller and headset.

However, I want to be upfront. If you are starting fresh and just want to play games without the headache, you should probably just look into Batocera. It is a purpose-built OS that handles about anything of what I – sort of – successfully (but painfully) engineered here out of the box. I might even write a separate guide on setting up Batocera with some helpful scripts later.

But, if you are stubborn like me, or you specifically want to run RetroDeck on Arch Linux on older hardware without a full blown Desktop Environment, this guide is for you.

The Troubleshooting Journey: What Didn’t Work

Before I get to the working setup, it is worth noting what failed. Getting a modern “console-like” experience on older Intel integrated graphics (Haswell generation) is tricky.

  1. Gamescope: My first choice. It failed because the older iGPUs (3rd and 4th Gen Intel) lack the necessary hardware overlay support required by this Wayland compositor. Note: If you are trying this on a 3rd Gen CPU (like I tried with my other dust collecting Lenovo ThinkCentre M92p Tiny, remember to use the i915 driver instead of i965).
  2. Cage (Wayland Kiosk): I tried this as a lightweight alternative to Gamescope. Unfortunately, it crashed constantly with XWayland assertion errors (xwayland_surface_destroy) due to driver incompatibilities.
  3. Flex Launcher: I hoped this would provide a nice visual menu for managing Bluetooth and updates. However, it was a configuration nightmare with missing assets causing black screens.
  4. KDE Plasma Bigscreen: This would have been a “batteries-included” option. Ultimately, I pivoted back to a simpler approach before fully debugging it, as I wanted something lighter and the current version is outdated, but worth keeping in mind for later, when a new version is released.

The Winner: Openbox on X11

The final, winning combination was Openbox running on X11. It’s minimal, stable, and doesn’t fight with the older drivers. Here is exactly how I built it.

A row of vintage arcade machines in a retro-styled gaming room with dim lighting.
Photo by Corey Dupree on Pexels

The Base Install

I started with a clean slate. I used the standard Arch Linux installer to get the basics up and running.

1. Running Archinstall

First, boot the Arch ISO. If you are on Wi-Fi, you’ll need to configure that first (using iwctl). Then, run archinstall. These are the specific selections I made to ensure compatibility:

  • Disk configuration: btrfs with btrfs subvolumes (default structure).
  • Bootloader: Grub.
  • Hostname: retroconsole.
  • User account: gamer (with sudo rights).
  • Profile: Minimal.
  • Applications: Ensure Bluetooth is enabled and select Pipewire for audio.
  • Network configuration: Use NetworkManager.
  • Additional packages: openssh (Critical for remote management).

Once the installer finishes, select “Reboot”.

2. Enabling Remote Access

Since this is a console, you won’t always have a keyboard attached. I needed SSH access immediately. Log in physically as gamer for this part.

If the Wi-Fi didn’t stick, reconnect it quickly:

nmtui
# Select "Activate a connection" -> Choose your WiFi

Next, start the SSH service and find your IP address:

# Start SSH Service
sudo systemctl enable --now sshd
# Get your IP address
ip a

3. Connect via SSH

Now, head to your main PC. The rest of this work is much easier to do via a terminal.

ssh gamer@<ip-address>

Drivers, Audio, and Bluetooth

This was the trickiest part of the Arch Linux RetroDeck setup. I needed to install a specific set of packages to make the audio and Bluetooth controller work reliably.

1. Setting Up Repositories

I used the Chaotic AUR to grab some pre-compiled binaries to save compilation time.

# Setup Chaotic AUR (for steamlink) & Enable Multilib
sudo pacman-key --recv-key 3056513887B78AEB --keyserver keyserver.ubuntu.com
sudo pacman-key --lsign-key 3056513887B78AEB
sudo pacman -U --noconfirm 'https://cdn-mirror.chaotic.cx/chaotic-aur/chaotic-keyring.pkg.tar.zst'
sudo pacman -U --noconfirm 'https://cdn-mirror.chaotic.cx/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst'
# Add the repo to pacman.conf
echo '[chaotic-aur]
Include = /etc/pacman.d/chaotic-mirrorlist' | sudo tee -a /etc/pacman.conf > /dev/null
# Enable multilib for 32-bit support
sudo sed -i "/\[multilib\]/,/Include/"'s/^#//' /etc/pacman.conf

2. Installing the Essentials

This massive command pulls in the kitchen sink: the graphical environment (Xorg, Openbox), the full audio stack (Pipewire), critical Bluetooth drivers for controller and headset support etc.

# Install all dependencies
sudo pacman -Syu --noconfirm git base-devel flatpak xorg-server xorg-xinit xf86-video-intel openbox \ xorg-xset xorg-xrandr xorg-xhost dialog upower bluez bluez-utils bluez-plugins mesa lib32-mesa \
vulkan-intel lib32-vulkan-intel libva-intel-driver intel-media-driver libva-utils pipewire pipewire-pulse \
wireplumber kodi kodi-addon-peripheral-joystick moonlight-qt tmux python-evdev rxvt-unicode \
ttf-dejavu blueman lxqt-policykit pipewire-alsa pipewire-jack yay game-devices-udev bluez-obex \
unzip wget pavucontrol oxygen-icons

3. User Configuration

Before diving into the hardware configuration, I needed to ensure my user, gamer, had the correct permissions to manage system resources like input devices, power, and video without hitting permission walls. I also enabled passwordless sudo for specific maintenance commands. This is crucial for a “console-like” experience where you might need to reboot or update the system via a script or controller shortcut, without pulling out a keyboard to type a password.

# Add user to groups
sudo usermod -aG input,video,power,wheel gamer
# Allow passwordless sudo for maintenance commands
echo 'gamer ALL=(ALL) NOPASSWD: /usr/bin/pacman, /usr/bin/systemctl, /usr/bin/reboot, /usr/bin/poweroff, /usr/bin/flatpak, /usr/bin/yay' | sudo tee /etc/sudoers.d/gamer_nopasswd > /dev/null

4. Stabilizing Bluetooth

Getting my headset and generic DualShock 4 controller, next to my wired Xbox 360 controller, to connect reliably was a battle. These configurations force the Bluetooth adapter to initialize faster and be more aggressive about reconnecting.

# Configure Bluetooth Main Config
sudo sed -i 's/#AutoEnable=false/AutoEnable=true/' /etc/bluetooth/main.conf
grep -q "AutoEnable=true" /etc/bluetooth/main.conf || echo "AutoEnable=true" | sudo tee -a /etc/bluetooth/main.conf
grep -q "ControllerMode = dual" /etc/bluetooth/main.conf || echo -e "\n[General]\nFastConnectable=true\n[BR]\nControllerMode = dual" | sudo tee -a /etc/bluetooth/main.conf
# Disable plugins that cause issues
sudo sed -i '/\[Input\]/a DisableInput=true' /etc/bluetooth/main.conf
sudo sed -i 's/DisablePlugins=.*/DisablePlugins=pnat,sap/' /etc/bluetooth/main.conf 
# Set aggressive reconnection policy
echo -e "\n[Policy]\nReconnectIntervals=1,2,3,4,5" | sudo tee -a /etc/bluetooth/main.conf > /dev/null

I also needed to apply some kernel-level fixes for the Sony controller driver.

# Kernel Driver Fixes
echo "options bluetooth disable_ertm=1" | sudo tee /etc/modprobe.d/bluetooth.conf
echo "hid-sony" | sudo tee /etc/modules-load.d/hid-sony.conf

Finally, I enabled the audio services for my user so blueman works correctly.

# Enable Pipewire Services for User
sudo loginctl enable-linger gamer
sudo -u gamer mkdir -p /home/gamer/.config/systemd/user/default.target.wants
sudo -u gamer ln -sf /usr/lib/systemd/user/pipewire.service /home/gamer/.config/systemd/user/default.target.wants/pipewire.service
sudo -u gamer ln -sf /usr/lib/systemd/user/pipewire-pulse.service /home/gamer/.config/systemd/user/default.target.wants/pipewire-pulse.service
sudo -u gamer ln -sf /usr/lib/systemd/user/wireplumber.service /home/gamer/.config/systemd/user/default.target.wants/wireplumber.service
# Setup udev rule for the gamepad mapper
echo 'KERNEL=="uinput", MODE="0660", GROUP="input"' | sudo tee /etc/udev/rules.d/99-uinput.rules > /dev/null
sudo udevadm control --reload-rules && sudo udevadm trigger

The Launch Configuration

Now I tied it all together. I needed the system to boot directly into RetroDeck, but still give me a way to manage the system if needed.

1. The .xinitrc File

This file controls what happens when X11 starts. It launches the necessary background agents (PolicyKit, Blueman) and then starts RetroDeck. Crucially, it ensures that if RetroDeck closes, the system shuts down.

cat <<'EOF' > ~/.xinitrc
# Redirect output to log file
exec > ~/.xsession-errors 2>&1
xset s off
xset -dpms
xset s noblank
xhost +local:
# 1. Start PolicyKit agent
/usr/bin/lxqt-policykit-agent &
# 2. Start persistent Blueman Applet
/usr/bin/blueman-applet &
# 3. Window Manager
/usr/bin/openbox &
# 4. Main Loop: RetroDeck
while true; do
    /usr/bin/flatpak run net.retrodeck.retrodeck
    
    # CRITICAL: If RetroDeck exits, shut down the system
    /usr/bin/systemctl poweroff & 
    pkill openbox
done
EOF

2. Configuring Openbox

Since I wasn’t running a full desktop environment, I needed to tell Openbox how to handle windows. This configuration ensures that pop-up windows (like the Bluetooth manager or Volume mixer) actually have borders and close buttons so I could interact with them.

mkdir -p ~/.config/openbox
echo '<?xml version="1.0" encoding="UTF-8"?>
<openbox_config xmlns="http://openbox.org/3.4/rc" xmlns:xi="http://www.w3.org/2001/XInclude">
  <theme>
    <titleLayout>NLIMC</titleLayout>
  </theme>
  <applications>
    <application class="*">
      <decor>no</decor>
      <maximized>yes</maximized>
    </application>
    <application class="Blueman-manager">
      <decor>yes</decor>
      <maximized>yes</maximized>
    </application>
    <application class="pavucontrol">
      <decor>yes</decor>
      <maximized>yes</maximized>
    </application>
    <application class="Pavucontrol">
      <decor>yes</decor>
      <maximized>yes</maximized>
    </application>
  </applications>
</openbox_config>' > ~/.config/openbox/rc.xml

3. The “Ports” and Tools

RetroDeck has a feature called “Ports” that lets you launch external scripts. I used this to integrate system management tools directly into the game interface.

First, I set up the directories and permissions.

# Create directories
mkdir -p ~/retrodeck/roms/ports
mkdir -p ~/.config/autostart
# Set executable permissions
sudo chmod +x /usr/local/bin/*.py /usr/local/bin/*.sh /usr/local/bin/console-session

The Controller Mapper

I wrote a Python script to map controller inputs to keyboard keys. This is essential for navigating system menus that don’t support gamepads natively.

# Note: Run this entire block to create the python script
echo '#!/usr/bin/env python3
import evdev
from evdev import UInput, ecodes as e
import sys, time
# MAPPING CONFIGURATION
MAPPING = {
    e.ABS_HAT0Y: { -1: e.KEY_UP, 1: e.KEY_DOWN },
    e.ABS_HAT0X: { -1: e.KEY_LEFT, 1: e.KEY_RIGHT },
    e.BTN_SOUTH: e.KEY_ENTER,               # A -> Enter
    e.BTN_EAST: [e.KEY_LEFTSHIFT, e.KEY_Q], # B -> Shift + Q (Quit)
    e.BTN_NORTH: e.KEY_S,                   # X -> s (Scan)
    e.BTN_WEST: e.KEY_C,                    # Y -> c (Connect)
    e.BTN_TL: e.KEY_P,                      # L1 -> p (Pair)
    e.BTN_TR: e.KEY_T,                      # R1 -> t (Trust)
    e.BTN_TL2: e.KEY_D,                     # L2 -> d (Remove)
    e.BTN_SELECT: e.KEY_A,                  # Select -> a (Adapter/Menu)
    e.BTN_START: e.KEY_TAB,                 # Start  -> Tab
}
def main():
    gamepad = None
    while gamepad is None:
        try:
            devices = [evdev.InputDevice(path) for path in evdev.list_devices()]
            for dev in devices:
                if e.BTN_SOUTH in dev.capabilities().get(e.EV_KEY, []):
                    gamepad = dev; break
        except: pass
        if gamepad is None: time.sleep(1)
    
    ui = UInput()
    try:
        gamepad.grab()
        for event in gamepad.read_loop():
            if event.type == e.EV_KEY and event.code in MAPPING:
                target = MAPPING[event.code]
                if isinstance(target, list):
                    if event.value == 1: 
                        for k in target: ui.write(e.EV_KEY, k, 1); ui.syn()
                        time.sleep(0.05)
                        for k in reversed(target): ui.write(e.EV_KEY, k, 0); ui.syn()
                else:
                    ui.write(e.EV_KEY, target, event.value, 1); ui.syn()
            elif event.type == e.EV_ABS and event.code in MAPPING:
                sub = MAPPING[event.code]
                if event.value in sub: 
                    ui.write(e.EV_KEY, sub[event.value], 1); ui.syn()
                elif event.value == 0: 
                    for key in sub.values(): ui.write(e.EV_KEY, key, 0); ui.syn()
    except OSError: pass
    finally: ui.close()
if __name__ == "__main__": main()' | sudo tee /usr/local/bin/bt-mapper.py > /dev/null

Creating the Scripts

Next, I created the actual launcher scripts. I broke this down into a few specific tools to handle updates, session management, and launching apps.

1. The System Update Utility

I needed a way to update the entire system (Arch packages, AUR, and Flatpaks) using just a controller. This script launches the background controller mapper we created earlier, presents a simple text-based UI to confirm the update, and handles the heavy lifting.

# System Update Utility
echo '#!/bin/bash
/usr/local/bin/bt-mapper.py &
MAPPER_PID=$!
cleanup() { kill $MAPPER_PID; }
trap cleanup EXIT
GREEN="\033[0;32m"; RED="\033[0;31m"; BLUE="\033[0;34m"; NC="\033[0m"
ask_confirm() {
    clear
    echo -e "${BLUE}=== SYSTEM UPDATE ===${NC}\n"
    echo -e "Updates: Arch (Pacman), AUR (Yay), Flatpaks"
    echo -e "\nPress [ ${GREEN}A${NC} ] to Start | [ ${RED}B${NC} ] to Cancel"
    read -r -n 1 key
    if [[ "$key" == "q" ]] || [[ "$key" == "Q" ]]; then return 1; else return 0; fi
}
if ask_confirm; then
    echo -e "\n${BLUE}:: Updating Arch/AUR...${NC}"; yay -Syu --noconfirm
    echo -e "\n${BLUE}:: Updating Flatpaks...${NC}"; flatpak update -y
    echo -e "\n${BLUE}:: Cleaning Cache...${NC}"; sudo pacman -Sc --noconfirm; flatpak uninstall --unused -y
    
    echo -e "\n${GREEN}Done!${NC} Press [ ${GREEN}A${NC} ] to Reboot, [ ${RED}B${NC} ] to Exit."
    read -r -n 1 key
    if [[ "$key" == "" ]]; then /usr/bin/systemctl reboot; else echo "Exiting..."; fi
else
    echo "Cancelled."
    sleep 1
fi' | sudo tee /usr/local/bin/system-update.sh > /dev/null

2. The Console Session Loop

This simple script is what actually starts the X11 graphical environment. It turns off the blinking cursor for a cleaner look and launches startx.

# Console Session Loop
echo '#!/bin/bash
setterm -cursor off
startx -logverbose 0 > /dev/null 2>&1' | sudo tee /usr/local/bin/console-session > /dev/null

3. The Port Launchers

Finally, I created the scripts that RetroDeck will actually see and launch. Since RetroDeck runs inside a Flatpak container, it can’t just run host commands directly. We have to use flatpak-spawn --host to break out of the sandbox and run our system tools (like Blueman or Kodi) on the host OS.

A Note on Controls: Be aware that for tools like the Bluetooth Manager (Blueman) and Volume Mixer (Pavucontrol), you will need a mouse to navigate the interface. This wasn’t a problem for me, as I only use these tools once in a blue moon – usually just to pair a new controller or connect my Bluetooth headset (which I tested, and it works great!). Once the initial setup is done, volume is handled directly on the headset or TV, so I rarely need to open these menus again.

# Create the Port Launchers
echo '#!/bin/bash
flatpak-spawn --host env DISPLAY=:0 blueman-manager' | tee ~/retrodeck/roms/ports/Bluetooth-Manager.sh > /dev/null
echo '#!/bin/bash
flatpak-spawn --host env DISPLAY=:0 urxvt -fullscreen -bg black -fg white -fn "xft:DejaVu Sans Mono:pixelsize=24" -e bash -c "/usr/local/bin/system-update.sh || read -p \"Error. Press Enter...\""' | tee ~/retrodeck/roms/ports/System-Update.sh > /dev/null
echo '#!/bin/bash
flatpak-spawn --host env DISPLAY=:0 QT_QPA_PLATFORM=xcb LIBVA_DRIVER_NAME=i965 moonlight' | tee ~/retrodeck/roms/ports/Moonlight.sh > /dev/null
echo '#!/bin/bash
flatpak-spawn --host env DISPLAY=:0 /usr/bin/steamlink' | tee ~/retrodeck/roms/ports/SteamLink.sh > /dev/null
echo '#!/bin/bash
flatpak-spawn --host env DISPLAY=:0 kodi --windowing=x11' | tee ~/retrodeck/roms/ports/Kodi.sh > /dev/null
echo '#!/bin/bash
flatpak-spawn --host env DISPLAY=:0 pavucontrol' | tee ~/retrodeck/roms/ports/Volume-Mixer.sh > /dev/null
# Make everything executable
chmod +x ~/retrodeck/roms/ports/*.sh

Final Polish: Autologin

Finally, I configured the system to auto-login the gamer user and start the session automatically.

# Configure Autologin
sudo mkdir -p /etc/systemd/system/[email protected]/
echo '[Service]
ExecStart=
ExecStart=-/sbin/agetty --skip-login --nonewline --noissue --autologin gamer --noclear %I $TERM' | sudo tee /etc/systemd/system/[email protected]/override.conf > /dev/null
# Start X on login
echo 'if [[ -z $DISPLAY ]] && [[ $(tty) = /dev/tty1 ]]; then
    startx -logverbose 0 > /dev/null 2>&1
fi' | tee -a ~/.bash_profile > /dev/null
# GRUB Tweak for cleaner boot
sudo sed -i 's/^GRUB_CMDLINE_LINUX_DEFAULT=".*"/GRUB_CMDLINE_LINUX_DEFAULT="quiet splash loglevel=3 log_buf_len=1M rd.systemd.show_status=false udev.log_priority=3 vt.global_cursor_default=0 i915.fastboot=1"/' /etc/default/grub
sudo sed -i 's/^GRUB_TIMEOUT=.*$/GRUB_TIMEOUT=0/' /etc/default/grub
sudo grub-mkconfig -o /boot/grub/grub.cfg

4. Adding Game Descriptions

To make things look professional in the RetroDeck UI, I added a gamelist.xml to describe my new custom ports.

cat <<'EOF' > ~/retrodeck/roms/ports/gamelist.xml
<?xml version="1.0"?>
<gameList>
    <game>
        <path>./Bluetooth-Manager.sh</path>
        <name>Bluetooth Manager (Blueman)</name>
        <desc>Graphical utility for managing device pairing, trusting, and connection profiles. Required for new controllers or headsets.</desc>
    </game>
    
    <game>
        <path>./Volume-Mixer.sh</path>
        <name>Volume Mixer (Pavucontrol)</name>
        <desc>Access the graphical PulseAudio Volume Control to adjust audio levels for individual applications and switch headset/speaker outputs.</desc>
    </game>
    <game>
        <path>./System-Update.sh</path>
        <name>System Update &amp; Maintenance</name>
        <desc>Run system updates for Arch, AUR packages, and Flatpaks. Use controller to select options.</desc>
    </game>
    <game>
        <path>./Moonlight.sh</path>
        <name>Moonlight Game Streaming</name>
        <desc>Launch the Moonlight client for local network streaming from your desktop PC. Requires manual pairing on first run.</desc>
    </game>
    
    <game>
        <path>./Kodi.sh</path>
        <name>Kodi Media Center</name>
        <desc>Launch the Kodi application for media playback and streaming.</desc>
    </game>
    
    <game>
        <path>./SteamLink.sh</path>
        <name>Steam Link Client</name>
        <desc>Dedicated Steam client for In-Home Streaming and Steam Deck compatibility features.</desc>
    </game>
</gameList>
EOF

Conclusion: Just Use Batocera?

After all that, it works. I have a fully functional Arch Linux RetroDeck setup on my old M93p. I can manage Bluetooth, update the system, and play my games.

But was it worth it? Honestly, probably not for most people, me including. If you just want to play games, the time investment to troubleshoot Wayland/X11 issues, map controllers manually, and configure audio is immense.

So, use this guide if you love tinkering, want a full Arch Linux OS underneath your console, or have specific needs that only a manual install can solve. For everyone else: stay tuned as I will probably write a post on Batocera (which already replaced this Arch Linux RetroDeck setup…) , which gets you to the same destination with a fraction of the effort.

Ready to test it?

sudo reboot

Note: You will need a mouse to pair your Bluetooth controller for the first time. Remember to “Trust” the device so it auto-reconnects!


Buy me a coffee 🙂
If you found this post helpful, informative, or if it saved or made you some money, consider buying me a coffee. Your support means a lot and motivates me to keep writing.
You can do so via bunq.me (bunq, iDeal, Bankcontact and Credit- or Debit cards) or PayPal (PayPal and Credit- or Debit cards). Thank you!

Disclaimer
The blog posts, guides, and scripts provided on this website are for informational and educational purposes only. They are provided “as is” and without any warranty of any kind, either express or implied, including, but not limited to, the implied warranties of merchantability, fitness for a particular purpose, or non-infringement.
By using the information or scripts from this blog, you agree that I am not liable for any direct, indirect, incidental, consequential, or any other damages or losses arising from the use of or inability to use the information, scripts, or instructions contained herein. You assume full responsibility for any and all risks associated with the use of this content.
The blog posts, guides, and pages may contain referral/affiliate links. If you make a purchase through these links, I may receive a commission at no additional cost to you.

 
Posted in Arch Linux, Linux, Linux Tutorials, Retro Gaming, System AdministrationTags:
2 Comments
  • Lazorne of RetroDECK

    Now this is way cool!

    We are releasing a major update soon 🙂

    We would love to feature a link to your blog on our wiki and highlight this post in an upcoming monthly article if possible.

    17:06 January 1, 2026 Reply
    • Sure, go ahead! 🙂

      13:35 January 2, 2026 Reply
Write a comment