Skip to content

Gaming session: gamescope + Sunshine/Moonlight + ES-DE

How the living-room gaming/streaming box on av (hostname ascertain-verse) is wired end to end: autologin into a gamescope session as guest, playable locally on the TV via Steam Big Picture, and remotely via SunshineMoonlight (native clients and the browser moonlight-web stack). ES-DE is the front-end hub; Steam and individual Steam games hand off to and from it.

This lives entirely in the gaming stack, separate from the homelab — nothing here runs as root against homelab services. It is the "sacred, don't touch" setup the top-level docs warn around (CONTEXT.md, docs/architecture.md, README.md, docs/host-port-map.md). This file documents it so it's recoverable; it is not a change surface.

Admin access: ssh guest@localhost (skill-lava's key is in guest's authorized_keys). guest is UID 1001, groups video render input gamers, shell bash. The ROM library, per-system emulators, and RetroArch/MAME/Xbox wiring live in rom-library.md; PS3 specifics in rpcs3.md.

The pieces at a glance

Layer What Where it's defined
Display session CachyOS gamescope-session (SteamOS-style), autologin guest /usr/share/wayland-sessions/gamescope-session.desktop, /usr/lib/systemd/user/gamescope-session.{service,target}, cachyos-gamescope-autologin.service
Local entry gamescope → Steam Big Picture (-gamepadui -steamos3) gamescope-steam.service (user)
Remote host Sunshine (LizardByte) as a guest user service /usr/lib/systemd/user/app-dev.lizardbyte.app.Sunshine.service (symlinked ~/.config/systemd/user/sunshine.service)
Remote clients Native Moonlight + browser moonlight-web repo: stacks/moonlight-web/, stacks/traefik/dynamic/sunshine.yaml
Front-end ES-DE in its own gamescope gamescope-esde.service (user)
Steam↔ES-DE glue Session handoff units + Steam→ES-DE sync gamescope-steam*.service, sync-steam-games (timer)
Input makima gamepad remapper + watchdog 50-makima.rules, bigscreen-input-watchdog.service
HA hook Controller-plug → HA webhook 90-controller-ha.rules, ha-game-mode.service
Audio sendspin-player (Music Assistant client) sendspin-player.service (user)

Boot → session

SDDM is installed but disabled; cachyos-gamescope-autologin.service sets the session and autologins guest into gamescope-session.desktop, which runs start-gamescope-sessiongamescope-session.target. The base target starts Steam Big Picture in gamescope (gamescope -f -e -- steam -gamepadui -steamos3). Full desktop (Plasma / plasma-bigscreen) sessions also exist for maintenance.

/usr/bin/start-gamescope.sh is a manual fallback that launches gamescope -f -e -- steam -gamepadui -steamos if nothing is already running.

Remote host: Sunshine

Runs as a guest user service, WantedBy=graphical-session.target (so it only exists while the guest desktop is up), with a 5 s ExecStartPre sleep so the compositor is ready first. Alias sunshine.service.

~/.config/sunshine/sunshine.conf:

csrf_allowed_origins = https://192.168.86.49:47990,https://sunshine.fmm.house
system_tray = disabled
hevc_mode = 2      # allow HEVC
av1_mode = 0       # AV1 off

~/.config/sunshine/apps.json exposes exactly one app — everything routes through ES-DE, not per-game Sunshine entries:

{ "apps": [ { "name": "Games", "cmd": "/home/guest/.local/bin/tile-games", "working-dir": "/home/guest" } ] }

Paired clients: the Moonlight clients that have paired are recorded in sunshine_state.json on the host (self-chosen device names + certs) — kept out of the repo, since the names are personal. Credentials/cert likewise live in ~/.config/sunshine/ (credentials/, portal_token), never committed.

Ports are reserved and off-limits to homelab services: 47984–48010 (+ 1716) — see docs/host-port-map.md. Sunshine web UI is on 47990.

Remote clients (in the repo)

  • stacks/traefik/dynamic/sunshine.yaml — proxies sunshine.fmm.househttps://172.30.0.1:47990 (Sunshine's web UI) with insecureSkipVerify.
  • stacks/moonlight-web/ — browser Moonlight at game.fmm.house. WebRTC media on UDP 40000–40100 published to the host; WEBRTC_NAT_1TO1_HOST is the Tailscale IP; connects to Sunshine at 172.30.0.1:47989; pairs as roth.

The session state machine (ES-DE as hub)

tile-games (the Sunshine "Games" cmd) is the keep-alive that ties a Moonlight session to the whole gamescope chain:

  1. Starts gamescope-esde.service (ES-DE in gamescope, -W 1920 -H 1080 -r 60 --backend wayland).
  2. Polls systemctl --user list-units 'gamescope-*' every 2 s; exits once no gamescope-* unit has been active for ~6 s (3 misses). When tile-games exits, Sunshine ends the stream automatically.

Handoffs between ES-DE, Steam, and a specific Steam game — each conflicts ES-DE and restarts it on exit, so you always land back in ES-DE:

Unit Launches On stop
gamescope-esde.service ES-DE
gamescope-steam.service Steam Big Picture (-steamos3) handoff.conf: ExecStopPost → start gamescope-esde.service
gamescope-steam-game@<appid>.service steam -applaunch <appid> ExecStopPost → start gamescope-esde.service

gamescope-steam.service has an ExecCondition refusing to start if gamescope is already running, plus drop-ins: notify.conf (wants steam-launch-notify), path.conf (prepends ~/.local/bin), relabel.conf (steam-relabel-exit).

Steam → ES-DE sync

~/.local/bin/sync-steam-games (Python) runs via sync-steam-games.timer (2 min after boot, then every 5 min). It:

  • Discovers installed titles from appmanifest_*.acf in /opt/steam-library/steamapps and ~/.local/share/Steam/steamapps (skips Proton / Steam Linux Runtime / Steamworks).
  • Writes a launcher <Game>.sh per title into /opt/games/roms-flat/steam/, each running systemctl --user start --no-block gamescope-steam-game@<appid>.service (plus a "Steam Big Picture" launcher). Stale .sh files are pruned.
  • Scrapes metadata (Steam appdetails), trailers (HLS→mp4), and artwork (SteamGridDB — API key embedded in the script) into ~/ES-DE/downloaded_media/steam/ and ~/ES-DE/gamelists/steam/gamelist.xml, preserving user fields (favorite/playcount/lastplayed/hidden). Incremental: metadata cached in ~/.cache/steam-meta, media fetched only when missing.

ES-DE custom systems

~/ES-DE/custom_systems/es_systems.xml overrides two systems (everything else is ES-DE's bundled defaults):

  • steam.sh/.SH launched via /usr/bin/bash %ROM% (the sync'd launchers above).
  • ps3 — override so disc ISOs launch directly via standalone RPCS3 (%EMULATOR_RPCS3% --no-gui %ROM%). Full detail in rpcs3.md.

ROMs live at /opt/games/roms-flat/<system>/ (group gamers): nes snes gb gbc gba n64 gc genesis psx ps3 steam.

Input

  • makima gamepad→kbd/mouse remapper — /etc/udev/rules.d/50-makima.rules grants uinput access (GROUP=input, uaccess). No config present in ~/.config/makima/ — it runs on defaults.
  • bigscreen-input-watchdog.service (user, Restart=always) clears stuck input suppression and enforces guide-button policy in Big Picture.
  • Steam runs in -gamepadui (Big Picture) so the whole session is controller-navigable.

Home Assistant "game mode" hook

/etc/udev/rules.d/90-controller-ha.rules: when any joystick (js* / ID_INPUT_JOYSTICK) is added, systemd starts the system oneshot ha-game-mode.service/usr/local/bin/ha-game-mode.sh, which POSTs https://ha.fillmemore.house/api/webhook/game-mode. This is how HA knows a controller woke up (e.g. lighting/scene changes).

⚠️ Dead wiring: the same udev file also declares ENV{SYSTEMD_USER_WANTS}="controller-plugged.service", but that user unit does not exist in ~/.config/systemd/user/. The system webhook still fires; the per-user half is a no-op. Either create the unit or drop the line.

Audio: sendspin-player (Music Assistant)

The guest session also runs sendspin-player.service (user): /usr/local/bin/sendspin-player --daemon --no-tui --name "Living Room" --client-id av-living-room — a Music Assistant player exposing the living-room audio, with sendspin-watchdog.timer (every 2 min) reconnecting it. Related to the music stack, not to Moonlight, but part of the same guest AV box.

Recovery / operations

All user units are under ~guest/.config/systemd/user/ (run systemctl --user … as guest). Key knobs:

ssh guest@localhost
systemctl --user status sunshine.service          # Sunshine host
systemctl --user restart gamescope-esde.service   # kick ES-DE
systemctl --user start sync-steam-games.service    # force a Steam→ES-DE sync
journalctl --user -u app-dev.lizardbyte.app.Sunshine.service -e

If a rebuild is ever needed, the reconstruction inputs are: the gamescope-session package (system units under /usr/lib/systemd/user/, autologin), the guest user units above, ~/.config/sunshine/ (config + pairings + cert), ~/ES-DE/ (custom_systems, gamelists, downloaded_media), the ~/.local/bin/ scripts (tile-games, sync-steam-games, steam-relabel-*, steam-launch-notify, bigscreen-input-watchdog), and the two udev rules. None of these are backed up in the repo today — see follow-ups.

Follow-ups

  • controller-plugged.service referenced by udev doesn't exist — resolve (create or remove the reference).
  • makima has no pinned config — captures nothing if the box is rebuilt.
  • Guest-home config (Sunshine, ES-DE custom_systems, ~/.local/bin scripts, user units) is not version-controlled. Consider a tracked mirror like the ESPHome one, or at least a documented backup, given how much bespoke glue lives here.