← All posts

AAA gaming on a Raspberry Pi in the living room

How an old Raspberry Pi 3+ brings a Linux gaming PC in the basement to the TV, using Kodi, Jellyfin and Moonlight.

  • Raspberry Pi
  • Moonlight
  • Sunshine
  • Kodi
  • Linux
  • Game Streaming

A Raspberry Pi 3+ obviously can’t run a modern AAA game. It takes on the much lighter job: decoding a video stream while the real workhorse in the basement does the actual work.

That was exactly the point. The existing gaming PC was meant to stay in the basement, but its games should feel as effortless on the living room’s Samsung TV as they would on a console. No second gaming PC, no extra console, and ideally no keyboard or mouse either.

Gaming PC in the basement
Linux Mint 22.3
Ryzen 9 3900X
Radeon RX 9070 XT
Sunshine
        |
        | 5 GHz WiFi
        v
Raspberry Pi 3+
Raspberry Pi OS Lite
Kodi + Jellyfin + Moonlight
        |
        v
Samsung TV + controller

Here’s the payoff behind all of it: instead of a loud, bulky gaming tower sitting in the middle of the living room, there’s just a Pi, tucked away out of sight behind the TV. No cable clutter running across the room, no fan noise next to the couch, nothing that even looks like a gaming PC. Because Sunshine runs as a normal network service, the same basement gaming PC can later be reached from other devices too, and with a bit more effort around port forwarding and securing access, even from outside the house over the internet. That’s a topic for another post, though, and stays deliberately out of scope here.

The plan sounded simple at first: Sunshine on the gaming PC, Moonlight on the Pi, and a network connection in between. The interesting part started where a working stream needed to become a living-room-ready system.

The gaming PC becomes the streaming host

The machine runs Linux Mint 22.3. It stays a normal gaming PC and additionally takes on the role of Sunshine host. No special server install, no Windows, and no cloud infrastructure of any kind is needed for that.

After installing Sunshine, its web interface gets configured, the host is paired with a Moonlight client, and the desktop or the desired applications are shared. Before the Raspberry Pi enters the picture, this connection should already work with a different Moonlight client.

That order saves a lot of time later. If Sunshine isn’t reliable yet, host problems mix with problems on the Pi. A successful test with a normal client establishes a known-good baseline.

Autostart alone isn’t enough

The machine in the basement should be reachable after power-on without a keyboard, a mouse, or a trip down the stairs:

Power on
    |
    v
Linux Mint
    |
    v
automatic login
    |
    v
Sunshine starts
    |
    v
Moonlight can connect

Sunshine therefore starts through Linux Mint’s autostart mechanism. An attempt with systemctl --user didn’t get there, because no matching Sunshine user unit existed. Autostart was the more direct path for this desktop machine.

That alone didn’t finish unattended startup, though. Mint logged the user in automatically, but then showed the GNOME keyring’s password dialog. Right in front of the PC that would be harmless. From the living room, the supposed game console instead ends up stuck at a prompt that can’t be answered without a keyboard.

The login keyring was therefore configured so that no further input is required after the automatic login. In this setup, that meant removing its password.

That convenience has a price: secrets stored in the keyring are no longer protected by an additional keyring password. Whether that trade-off is acceptable depends on what else the user account stores, and who has physical access to the machine.

Game streaming isn’t done once the picture arrives. It’s done once the host can start unattended.

Enabling Wake-on-LAN permanently

The gaming PC shouldn’t run around the clock. Wake-on-LAN, or WoL, makes it possible to wake it up over the network with a so-called magic packet whenever it’s needed.

The machine’s Ethernet interface is called enp39s0. Its state can be checked with ethtool:

sudo ethtool enp39s0 | grep -i wake

The output that matters is:

Wake-on: g

The g stands for waking on a magic packet. A one-off change with ethtool can disappear again after the next reboot, though. To make it permanent, the setting is stored in the NetworkManager profile in use:

sudo nmcli connection modify \
  "Wired connection 1" \
  802-3-ethernet.wake-on-lan magic

Afterwards, the stored value can be checked:

nmcli connection show "Wired connection 1" | grep wake-on-lan

The motherboard has to cooperate too. In the BIOS or UEFI, options such as Wake on LAN or waking via PCIe need to be enabled. ErP or EuP often has to be disabled so the network card still gets enough power while the machine is off.

The connection and BIOS entry names are system-specific. They shouldn’t be copied blindly from this example.

No power when nobody’s playing

Once the gaming PC had moved into the basement, two more problems showed up: it shouldn’t burn power there when nobody’s using it, and removing the physical monitor made Linux lose its entire display configuration.

A watchdog script for idle time

Linux Mint’s normal desktop idle detection wasn’t enough for this. The machine is no longer just a desktop: Sunshine might be streaming a game while the local desktop appears idle, and AI workloads can keep the GPU busy without any keyboard or mouse activity at all.

Instead, a small systemd timer takes over, periodically checking whether the machine is actually doing something useful. The host counts as active whenever one of the following conditions is true:

Sunshine client connected
        OR
AMD GPU utilization >= threshold
        OR
active interactive SSH session

Whenever one of these is true, an activity timestamp gets updated. If none of them has been true for 30 minutes, this follows:

systemctl poweroff

A systemd timer runs the check once per minute:

[Timer]
OnBootSec=5min
OnUnitActiveSec=1min

One important detail was Sunshine detection. Checking whether the sunshine process exists doesn’t work, because Sunshine runs permanently. Instead, the script reads Sunshine’s log and looks at the most recent event:

CLIENT CONNECTED
CLIENT DISCONNECTED

GPU utilization can be read without any extra monitoring daemon:

cat /sys/class/drm/card*/device/gpu_busy_percent

SSH sessions need similar care: checking for sshd itself gives a false positive, since the SSH server always runs as a listening process regardless. An actual interactive remote session can instead be detected through who, by looking for a pts/* session with a remote address.

The result looks like this:

Wake-on-LAN
     |
     v
Gaming PC boots
     |
     v
Sunshine / AI / SSH activity
     |
     v
stays awake
     |
     v
30 minutes genuinely idle
     |
     v
power off

Combined with Wake-on-LAN, the basement PC can spend most of its life fully powered off instead of idling all day.

The script in detail

The script lives at /usr/local/sbin/idle-poweroff and is run every minute by the systemd timer:

#!/bin/bash

STATE="/run/idle-poweroff-last-active"
SUNSHINE_LOG="/home/luschee/.config/sunshine/sunshine.log"

IDLE_LIMIT=1800
GPU_BUSY_LIMIT=10
NOW=$(date +%s)

active=false
reason=""

#
# 1. Active Sunshine/Moonlight session?
#
if [[ -f "$SUNSHINE_LOG" ]]; then
    LAST_CLIENT_EVENT=$(
        grep -E 'CLIENT CONNECTED|CLIENT DISCONNECTED' "$SUNSHINE_LOG" \
        | tail -n 1
    )

    if [[ "$LAST_CLIENT_EVENT" == *"CLIENT CONNECTED"* ]]; then
        active=true
        reason="Sunshine client connected"
    fi
fi

#
# 2. AMD GPU busy?
#
for GPU_BUSY_FILE in /sys/class/drm/card*/device/gpu_busy_percent; do
    [[ -r "$GPU_BUSY_FILE" ]] || continue

    GPU_BUSY=$(cat "$GPU_BUSY_FILE" 2>/dev/null)

    if [[ "$GPU_BUSY" =~ ^[0-9]+$ ]] && (( GPU_BUSY >= GPU_BUSY_LIMIT )); then
        active=true
        reason="GPU busy: ${GPU_BUSY}%"
        break
    fi
done

#
# 3. Active interactive SSH session?
#
if who | awk '$2 ~ /^pts\// && $NF ~ /^\(.*\)$/ {found=1} END {exit !found}'; then
    active=true
    reason="SSH session active"
fi

#
# Active -> reset the idle timer
#
if [[ "$active" == true ]]; then
    echo "$NOW" > "$STATE"
    logger -t idle-poweroff "Active: $reason"
    exit 0
fi

#
# No idle timestamp yet -> start the timer
#
if [[ ! -f "$STATE" ]]; then
    echo "$NOW" > "$STATE"
    logger -t idle-poweroff "Idle timer started"
    exit 0
fi

LAST=$(cat "$STATE")

# Handle a corrupted state file safely
if ! [[ "$LAST" =~ ^[0-9]+$ ]]; then
    echo "$NOW" > "$STATE"
    exit 0
fi

IDLE_TIME=$(( NOW - LAST ))

logger -t idle-poweroff "Idle for ${IDLE_TIME}s"

#
# Genuinely idle for 30 minutes -> power off
#
if (( IDLE_TIME >= IDLE_LIMIT )); then
    logger -t idle-poweroff "Idle for 30 minutes - powering off"
    systemctl poweroff
fi

The matching systemd timer takes care of running it every minute:

[Unit]
Description=Check gaming/AI PC idle state

[Timer]
OnBootSec=5min
OnUnitActiveSec=1min

[Install]
WantedBy=timers.target

The logs can be followed live without any extra effort:

journalctl -t idle-poweroff -f

No monitor, no resolution

Moving the PC into the basement introduced another surprisingly annoying problem. Without a monitor attached, the Radeon reported every connector as disconnected:

card1-DP-1/status: disconnected
card1-DP-2/status: disconnected
card1-DP-3/status: disconnected
card1-HDMI-A-1/status: disconnected

Xorg consequently fell back to:

current 1024 x 768

That’s exactly what Sunshine saw and streamed as well. The reason is EDID. Normally, the monitor tells the GPU which resolutions, refresh rates and other capabilities it supports. Remove the monitor, and that information disappears with it.

A hardware HDMI/DisplayPort dummy plug would have solved this, but the goal here was a software-only solution.

A fake EDID for the basement PC

So a small EDID file was generated, describing a virtual 2560×1440 display:

Detailed Timing:
2560x1440
59.95 Hz

and installed as:

/lib/firmware/edid/headless-1440p.bin

The kernel is then told, via boot parameters, to use that EDID for the otherwise disconnected DisplayPort connector:

drm.edid_firmware=DP-1:edid/headless-1440p.bin
video=DP-1:2560x1440@60e

The e at the end of video=... forces the connector to be enabled even though no physical display is detected.

There’s one additional catch: amdgpu needs the EDID early during boot. So it’s pulled into the initramfs through a small hook at /etc/initramfs-tools/hooks/edid:

#!/bin/sh
set -e

case "$1" in
    prereqs)
        exit 0
        ;;
esac

mkdir -p "${DESTDIR}/lib/firmware/edid"

cp /lib/firmware/edid/headless-1440p.bin \
   "${DESTDIR}/lib/firmware/edid/headless-1440p.bin"

The hook also makes the solution reasonably update-proof: whenever Ubuntu or Mint builds an initramfs for a new kernel, the custom EDID gets pulled in again automatically.

After a reboot, the difference is obvious:

Before:
DP-1 -> disconnected
Xorg -> 1024x768

After:
DP-1 -> connected
Xorg -> 2560x1440 @ 60 Hz

From the software stack’s point of view, this results in:

headless-1440p.bin
        |
        v
Linux DRM / amdgpu
        |
        v
"DP-1 has a monitor attached"
        |
        v
Xorg / Cinnamon
        |
        v
2560x1440 desktop
        |
        v
Sunshine
        |
        v
Moonlight

The gaming PC can now live completely headless in the basement: Wake-on-LAN turns it on, the fake EDID gives Sunshine a proper display, and the idle watchdog powers it back down as soon as nobody’s gaming, no AI jobs are running, and no SSH session is connected.

Cable to the basement, WiFi to the living room

The gaming PC in the basement is wired into the gigabit network via Ethernet:

Gaming PC --- Ethernet --- router or switch

For the Raspberry Pi in the living room, that would have meant another network cable running across the apartment. Instead, the Pi joins the same network over 5 GHz WiFi:

Gaming PC --- Ethernet --- router or switch --- 5 GHz WiFi --- Raspberry Pi

At short range from the access point, that’s enough for a stable Moonlight stream, without any noticeable latency or picture glitches. Skipping a cable in the living room was a deliberate trade-off against an extra, visible cable run.

Anyone who does notice dropouts or stutter should still check the network connection first: WiFi signal strength and possible interference from 2.4 GHz devices nearby, before touching codecs or bitrates in Sunshine and Moonlight.

The obvious plan: LibreELEC

For the living room, LibreELEC initially looked perfect:

LibreELEC
├── Kodi
├── Jellyfin
└── Moonlight

Kodi is already the center of the system, Jellyfin adds the personal media library, and Moonlight delivers the games. In practice, though, the current combination of Jellyfin and Moonlight there wasn’t nearly as smooth as expected. Rather than keep fighting the finished appliance, LibreELEC got dropped.

That was the first turning point: the distribution with the most living-room features wasn’t the best foundation. The one that hides the least between Kodi, Moonlight and the hardware was.

Raspberry Pi OS Lite without a desktop

The new base became Raspberry Pi OS Lite. No desktop, no window manager, and no graphical overhead that this use case doesn’t need. Only the required components run on top of it:

Raspberry Pi OS Lite
├── Kodi
│   └── Jellyfin integration
└── Moonlight Qt

Moonlight Qt needs neither X11 nor Wayland on the Pi. Without a display server, Qt can use the EGLFS path and output directly to HDMI through DRM/KMS:

Moonlight Qt
    |
    v
Qt EGLFS
    |
    v
DRM/KMS
    |
    v
HDMI

On a Raspberry Pi 3+, this lean graphics path performs surprisingly well.

Testing Moonlight directly first

Before Kodi was involved at all, Moonlight was started directly on tty1:

moonlight-qt

That worked. This answered several important questions at once:

  • Moonlight starts on the Pi.
  • The direct DRM graphics path works.
  • Decoding and rendering work in principle.
  • The controller is recognized.
  • The connection to Sunshine is established.

This test later turned out to be the single most useful clue in the entire troubleshooting process.

When Kodi should launch Moonlight

The desired flow looked like a console:

Kodi
  |
  v
select Moonlight
  |
  v
play
  |
  v
quit Moonlight
  |
  v
back to Kodi

The first attempt was a small Kodi add-on that launched Moonlight with subprocess.Popen(...). That seemed logical: Kodi is already running, a menu entry spawns a process, and once it ends, control returns to the media UI.

It didn’t work anyway.

DRM/KMS says no

Launched from Kodi, the decisive error eventually showed up:

qt warning:
could not queue drm page flip on screen HDMI1
(permission denied)

The very same moonlight-qt invocation kept working directly from the console. The user also had access to the devices under /dev/dri. So it was clear: neither Moonlight itself nor a simple file permission was the actual problem.

The difference was the DRM context. Kodi still held on to the display and DRM resources while the Moonlight process spawned by Kodi tried to take over those same resources. Two programs wanted direct control of the same display output at the same time.

The parent process was therefore the wrong place to orchestrate the switch.

tty1 becomes the supervisor

The working solution flips the responsibility around: Kodi no longer launches Moonlight. Instead, the login shell process on tty1 manages both applications, one after the other.

Login shell on tty1
    |
    +-- start Kodi
    |
    +-- Kodi exits
    |
    +-- was Moonlight requested?
    |
    +-- wait until Kodi releases DRM
    |
    +-- start Moonlight directly
    |
    +-- Moonlight exits
    |
    `-- start Kodi again

This launches Moonlight from exactly the context where the manual test had already worked: directly from tty1, and only after Kodi has released the graphics path.

The Kodi add-on now just sets a flag

The add-on shrinks down to two jobs. It drops a file as a start signal and quits Kodi:

import os

import xbmc

with open(os.path.expanduser("~/.launch-moonlight"), "w") as marker:
    marker.write("1")

xbmc.executebuiltin("Quit")

No process management, no special DRM handling, and no waiting inside Kodi. The add-on only tells the outer loop why Kodi was closed.

The switch happens outside of Kodi

The actual orchestration is handled by a small shell script:

#!/bin/bash

while true; do
    kodi-standalone

    if [ -f "$HOME/.launch-moonlight" ]; then
        rm -f "$HOME/.launch-moonlight"

        while pgrep -x kodi >/dev/null || pgrep -x kodi.bin >/dev/null; do
            sleep 1
        done

        /usr/bin/moonlight-qt
        continue
    fi

    break
done

This script is started on login on tty1. When the add-on quits Kodi with the marker set, the loop waits until no Kodi processes are left running. Only then does Moonlight take over the screen. Once Moonlight exits, the loop starts over and launches Kodi again.

The key point isn’t the flag itself. It’s that Kodi and Moonlight are siblings under one shared supervisor, and that they should never hold DRM at the same time.

One controller for both worlds

The Raspberry Pi recognizes the generic Xbox controller. Kodi uses it for navigation; Moonlight forwards its input to Sunshine.

Controller on the Raspberry Pi
            |
            v
         Moonlight
            |
            v
virtual controller on the gaming PC
            |
            v
           game

The gaming PC itself no longer needs a keyboard or mouse in the ideal case. This is the moment a technical streaming test actually turns into a living-room console.

Picture, controller, no sound

After solving DRM, the next problem was already waiting:

picture     yes
controller  yes
audio       no

ALSA detected both the analog output and HDMI:

card 0: bcm2835 Headphones
card 1: vc4-hdmi

A test of the audio device still failed:

Playback open error: -524

At first, this looked like a problem with Raspberry Pi OS, ALSA, or Moonlight. But the decisive clue didn’t come from the application at all — it came from the connected display.

EDID reveals the real culprit

For setup, the Pi was temporarily hooked up to a Fujitsu B23T-7 LED. The retrieved ELD data contained:

monitor_name    B23T-7 LED
connection_type HDMI
sad_count       0

There was also no CEA extension block present in the EDID data:

no CEA EDID Timing Extension block present

That display simply wasn’t advertising any usable HDMI audio capabilities to the Raspberry Pi. The HDMI output showed up as vc4-hdmi, but an audio stream couldn’t be opened over it.

Sometimes an HDMI audio error isn’t an audio error at all. It’s just the monitor politely telling Linux that audio doesn’t exist for it.

Back on the Samsung TV, sound works

The counter-check was simple: reconnect the Raspberry Pi to the Samsung TV and run speaker-test again. Sound came through immediately.

Moonlight  not broken
ALSA       not broken
Pi         not broken
the test monitor's EDID was the cause

Step-by-step diagnosis paid off here too. A visible HDMI device doesn’t prove that the connected display actually advertised audio formats through EDID.

The finished system

In the end, every device only handles the job it’s suited for:

Controller
    |
    v
Raspberry Pi 3+
    |
    +-- Kodi
    |    `-- Jellyfin
    |
    `-- Moonlight
          |
          | 5 GHz WiFi
          v
       Sunshine
          |
          v
Linux Mint gaming PC
Ryzen 9 + Radeon RX 9070 XT
in the basement

The flow in the living room is accordingly short:

turn on the TV
      |
      v
Pi boots into Kodi
      |
      v
select Moonlight
      |
      v
wake the gaming PC via WoL if needed
      |
      v
Sunshine streams the game
      |
      v
quit Moonlight
      |
      v
Kodi shows up again

No desktop on the Pi. No X11. No keyboard. No mouse. And no extra gaming rig in the living room.

Can a Raspberry Pi 3+ run modern AAA games now?

Absolutely not.

It only decodes the finished video stream, and even there it hits a hard ceiling: the Pi 3+ can’t do more than 1080p at 30 fps. Anyone who wants higher resolutions or frame rates needs to invest in a Raspberry Pi 4, 5, or comparably capable hardware.

But it can make a Ryzen 9 and a Radeon RX 9070 XT in the basement feel like they’re sitting right under the TV. 🌶️