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. 🌶️