Headless Game Streaming on Linux with Sunshine
Background
I’ve been dabbling with gaming on Linux for many years, before the launch of the Steam Deck. However, competitive shooters and their anti-cheat systems meant my “main” gaming system pretty much always stayed on Windows.
However, as I’ve been getting older and getting more responsibilities I have had less and less time for that type of gaming. Nowadays I am leaning more into story driven, single player experiences that I can pick up and put down whenever I happen to have the time. And along with Windows becoming more and more frustrating to use, switching my PC over to Linux has finally reached a tipping point.
A common use case for me is to stream games from that PC to other devices like the Odin 2 Portal or my MacBook Air using Sunshine and Moonlight. If you haven’t come across them before, the tl;dr is that Sunshine runs on your gaming PC and captures the screen, and Moonlight runs on whatever you actually want to play on. A laptop, a phone, a handheld, a TV box.
On Windows I was actually using a fork of Sunshine called VibeShine which automated the process of setting up a virtual display for streaming which matches the client’s (running Moonlight) resolution and refresh rate. Unfortunately, on Linux this fork and other similar ones don’t exist, so I needed another approach. I had previously experimented with a HDMI dummy plug before I settled on VibeShine on the Windows side, but now I wondered whether I could make use of it once again.
What I wanted
Three things, none of them exotic:
1. When I connect from another room, the desk monitor should go dark. The stream should render on an HDMI dummy plug instead. If you haven’t seen one of these before, it’s a tiny plug that goes into a spare display output and pretends to be a monitor. The PC thinks there’s a screen there and will happily render to it, but there’s no actual display in the room.
2. When I disconnect, the desk monitor should come back and the dummy plug should go away again. This matters more than it sounds. An output that’s enabled but has no real screen attached still attracts windows, notifications and new-window placement. Leave it on and you end up launching something, hearing it open, and then not being able to find it anywhere.
3. The whole thing should survive a cold boot with nobody in the room. I want to wake the PC from my phone and connect to it, without going upstairs first.
On Windows, VibeShine did all of this for me. On Linux, with no equivalent fork to fall back on, none of it comes for free.
Switching the displays around
The first job was simply telling the PC to change which monitor is enabled, from a script.
This turned out to be more annoying than expected. My desktop runs GNOME on Wayland, and the usual tool for this, xrandr, doesn’t work there. It only sees XWayland, which is the compatibility layer for older apps, so it can’t see or change the real display setup. The alternatives other desktops use don’t work either, because GNOME’s window manager (mutter) doesn’t implement them.
What mutter does have is its own D-Bus API, which is a way for programs on your machine to talk to each other. So I wrote a small Python script to call it and switch the outputs around.
The API takes an argument telling it how to apply your new display config: verify, temporary, or persistent. Persistent sounded obviously right. I want the change to stick, not evaporate.
It worked perfectly. Then, about twenty seconds later, the monitor came back on by itself.
It turns out PERSISTENT is the exact code path GNOME Settings uses when you click Apply. As well as saving your config, it puts up the “Keep these display settings?” dialog with a countdown, the one that saves you when you’ve picked a resolution your monitor can’t actually display. If nobody clicks it, it quietly puts everything back.
Which is completely right when there’s a person sitting there, and completely wrong when the entire point is that there isn’t. My script would switch the displays, a dialog would appear on a screen nobody was looking at, and twenty seconds later it would all snap back. The script reported success every time, because as far as it was concerned the call had succeeded.
The fix is to use TEMPORARY instead, which applies immediately with no dialog and no countdown. I did go looking for a way to dismiss the dialog from a script, but that lives inside GNOME Shell and there’s nothing to call. Not using PERSISTENT is the whole answer.
A disconnect is not a quit
Sunshine has a really handy config option called global_prep_cmd, which lets you run a command before your stream starts and another one after it finishes. Perfect for this. I wired my script in, tested it, watched the desk monitor go dark when I connected and come back when I stopped, and thought I was done.
I wasn’t. It had worked because I’d used Moonlight’s Quit button. When I just disconnected, nothing happened at all.
The docs describe these as running before and after “all applications”, which I read as before and after a stream. It actually means before and after the application, and a client disconnecting doesn’t end the application. Sunshine deliberately keeps your session alive so you can reconnect and carry on where you left off. That’s sensible behaviour, it just means the “after” command doesn’t run when you close Moonlight, or when your wifi drops, or when someone rings you.
So I wrote a small service that watches Sunshine’s logs and puts the desk monitor back whenever it sees a client disconnect. Tested it, worked, about a second from disconnecting to the monitor coming back on.
Then I reconnected, and the desk monitor stayed on.
This is the half I couldn’t find written down anywhere. Because that first session was still alive, reconnecting resumed it instead of starting the application again, so the prep command never ran at all. The stream came back up on the wrong screen and there was nothing in the logs, because nothing had failed. Something just hadn’t happened.
My first thought was to have the watcher handle connecting too, but that doesn’t work either and the reason is worth knowing. Sunshine picks which display output to capture before it logs that a client has connected. By the time anything watching the logs finds out, the decision has already been made, and switching displays at that point would pull the output out from under a capture that’s already running.
The fix is blunter than I’d like. When you disconnect, my watcher puts the desk monitor back and then restarts Sunshine, which clears out the stale session so the next connection is genuinely a fresh one and the prep command runs at the right moment.
That worked, and I left it running like that for a few days until it did something I hadn’t thought about at all. I was part way through a game, the stream dropped for a second as the client roamed between access points, and the watcher saw a disconnect, dutifully restarted Sunshine, and killed Steam and the game along with it.
Sunshine launches whatever you’re playing as its own child process, so Steam and the game live inside the service. Restarting the service takes the lot down with it. On a deliberate disconnect that’s fine, because you’ve stopped playing anyway. On a network blip it costs you whatever you hadn’t saved.
So the restart is now conditional. It only happens if switching the display actually changed something, and if nothing is running under Sunshine. If you’re mid-game it leaves well alone, and reconnecting just resumes the session where you left it, which is what you’d want in that situation anyway.
Then I rebooted
At this point everything worked. Connect, disconnect, reconnect, all clean and all confirmed in the logs. I wrote it up and called it finished.
Then I rebooted to check, and the desk monitor came up dark.
Two separate things were wrong, and both were completely invisible until I actually rebooted.
The first was a leftover config file. Remember PERSISTENT saving your display setup? Those early experiments had left a file behind recording the streaming layout, desk monitor off and dummy plug on. GNOME was faithfully restoring it on every single boot. My scripts were fine. They were fighting a file written hours earlier by code I’d already deleted.
The second was worse. The service that restores my monitor when I disconnect, the one I’d tested over and over and watched work, had never once run after a boot.
I’d told systemd to start it after Sunshine, which seemed tidy. But Sunshine is itself set to start after the desktop session, so I’d created a loop. My service waits for Sunshine, Sunshine waits for the session, and the session was waiting for my service. When systemd finds a loop like that, it breaks it by simply throwing one of the jobs away:
Found ordering cycle: sunshine-display-watch.service/start after
app-dev.lizardbyte.app.Sunshine.service/start after graphical-session.target/start
- after sunshine-display-watch.service
Job sunshine-display-watch.service/start deleted to break ordering cycle
It’s sat there in the logs, clearly worded, and I never once looked, because the service worked perfectly every time I started it by hand. It was dead on exactly the unattended path it existed for. The ordering wasn’t even necessary in the first place.
If you take one thing from this post, make it this. When the thing you’re building is supposed to work on its own after a reboot, test it by rebooting. Not by restarting the service.
The good idea that didn’t work
Getting a cold boot all the way to a streamable desktop means turning on automatic login, because Sunshine can’t capture the login screen. It needs a session that already exists. That does mean the machine now boots straight to an unlocked desktop, which I wasn’t thrilled about.
So, obvious idea: lock the screen immediately after logging in automatically. Best of both.
It very nearly works, and I think the way it fails is genuinely interesting. Sunshine captures the screen at a level below the lock screen, and sends input as if it were a real keyboard and mouse plugged into the machine, so neither of them cares that the session is locked. I locked it, connected from another room, got a clean view of my own lock screen, typed my password over Moonlight and watched the desktop appear. Very satisfying.
Then I rebooted, and Moonlight showed the PC as offline.
A Sunshine that’s already running copes with a locked session fine. A Sunshine starting up in one does not. It asks the system what resolution the screen is, gets told zero by zero, fails to set up capture, and then every single encoder fails after it. Meanwhile the process stays running and keeps listening on all its ports, so from the outside it looks completely healthy while refusing every connection. And since you can’t unlock a GNOME session remotely, the only fix is going and typing the password at the machine, which defeats the entire point.
Reverted. I’ve written it up as a dead end rather than pretending I never tried it, because the half that works is convincing enough that I’d absolutely try it again in six months.
I did also look at whether I could encrypt the disk to make up for the automatic login, and talked myself out of that too. With automatic unlock, someone who steals the whole machine just boots it into my desktop anyway, so it would only protect against them pulling the drive out. Requiring a PIN at boot would fix that and destroy the unattended startup this whole project exists for. Sometimes you genuinely can’t have both.
Matching the client’s resolution
One thing changed after I first got this working. Stream mode used to be pinned to 1920x1080, which wasn’t arbitrary. It matched my Odin 2 Portal, which was what I was mostly streaming to.
It only became a problem when I started streaming to my MacBook Air instead. That’s a 16:10 screen, so a 16:9 stream never quite fits it properly. Which is, of course, exactly the thing VibeShine had been quietly handling for me on Windows all along.
That sent me looking at what the dummy plug could actually do, which turned out to be far more than I’d ever checked. modetest lists 4K and 2560x1600 among its real modes. The more obvious tool, edid-decode, only shows what the plug advertises rather than what the kernel will actually let you select, which is why I’d never noticed. Handily, Sunshine also tells your prep commands what resolution and frame rate the connecting client has asked for, on every platform and not just Windows. So the script now reads that and picks the closest matching mode on the dummy, only falling back to 1080p if there’s nothing suitable. The Mac brings it up at 2560x1600. It’s the VibeShine behaviour back again, just with rather more steps involved to get there.
Where it ended up
Sixteen seconds from power-on to stream-ready, with nobody in the room. Connect and the desk monitor goes dark within a second, disconnect and it comes back. The dummy plug stays disabled while I’m actually sat at the desk, so nothing wanders onto it. There’s also a timer running every thirty seconds that checks whether anything at all is enabled and turns an output back on if not, so a dropped cable can’t leave the machine with no display and no way back in.
I also sent a documentation PR upstream to Sunshine to clarify how the prep commands actually behave, since the reconnect half in particular didn’t seem to be documented anywhere and it’s the bit that had me convinced my own code was broken. Update, September 2026: that PR was closed unmerged. The maintainer flagged it as heavy AI usage and pointed me at the project’s contributing guidelines and AI usage policy.
If you want to try this yourself
The scripts are on GitHub as sunshine-display-swap, MIT licensed, with all of the above written up in the README. Fair warning, it’s been tested on exactly one machine and the README says so. It’s written for GNOME on Wayland with an AMD GPU, so you may well need to adjust things.
The one thing I’d pass on, though, has nothing to do with displays. Everything that went wrong here looked like it had worked. “No errors in the log” and “it’s working” are not the same claim, and it’s very easy to spend an afternoon believing they are.