Architecture
Gaming Mode Runbook (Sunshine / Moonlight)
Operate a GPU node as an on-demand game-streaming host. A GamingSession CR
drains inference from the node and starts a headless Sunshine host that a
Moonlight client pairs against; deleting the CR returns the node to the
inference fleet.
- Validated on:
cblevins-7900xtx(AMD RX 7900 XTX, gfx1100/RDNA3), 2026-07-01. - Stack: Sunshine + headless
sway(wlroots, Xwayland) + Mesa RADV (Vulkan render) + VA-API HW encode (H.264/HEVC/AV1 viaradeonsi) + Steam client (running as the non-rootgamersession user). Seebackend/sunshine.go,build/sunshine-headless.sh,build/Dockerfile.runtime(INCLUDE_GAMING), and the kill-test evidence in.loom/killtest-gaming-sunshine-gfx1100-2026-06-30.md.
Architecture
GamingSession CR ──> GamingSessionReconciler ──> RuntimeReconciler.SetMode
(declarative) (drains inference) PUT /api/v1/mode {gaming}
│
▼
runtime pod (gaming image) runs
sunshine-headless.sh: sway + Sunshine
+ RADV render + VA-API HW encode
The gaming node's runtime DaemonSet uses the dedicated gaming image
(runtime:rocm-gfx1100-gaming) and runs with hostNetwork so a LAN Moonlight
client reaches Sunshine on its fixed ports (see below).
Prerequisites (per gaming node)
- Gaming runtime image built + pushed (off-CI on the GPU builder):
Expected:./build/build-runtime.sh gfx1100-gaming --pushDone: gfx1100-gaming → registry.harbor.lan/flexinfer/runtime:rocm-gfx1100-gaming. - Runtime profile for the node pinned to that image with
hostNetwork+gamingindeploy/system/values-k3s.yaml(runtime.profiles[].{image,hostNetwork,gaming}). - Controller on Slice 3+ (has the
gamingsessionscontroller and the chart ClusterRole grantsgamingsessions). - Gaming state volume on the profile (
extraVolumes/extraVolumeMounts): a hostPath (oncblevins-7900xtx:/home/flexinfer-gaming, on the 1.8T NVMe) mounted at/var/lib/flexinfer-gaming(GAMING_STATE_DIR). Layout, managed bysunshine-headless.sh:sunshine/—sunshine.conf, pairing state,apps.json: Moonlight clients stay paired across pod restarts.home/— thegameruser's$HOME: Steam client bootstrap, account login, and the game library (games install tohome/.local/share/Steam/steamappsby default).
Enable gaming on a node
Everything is GitOps-managed — commit to services/flexinfer and let Flux
reconcile (flexinfer-models for the CR/models, the HelmRelease for the runtime
profile). Do not kubectl apply/edit directly.
- Move any warm primary off the node first (avoid a chat gap). Example: promote
the sister instance on another node and de-advertise the node's lane
(
litellm.enabled: false,serverless.minReplicas: 0, clearserviceLabels,warmPolicy: ondemand, and removegpu.forcePromotion— see Troubleshooting). - Add the
GamingSession(seedeploy/gaming/gamingsession-7900xtx.yaml):apiVersion: ai.flexinfer/v1alpha2 kind: GamingSession metadata: { name: gaming-<node>, namespace: flexinfer-system } spec: nodeName: <node> mode: gaming owner: <operator-or-workflow> # Optional hard stop. Once reached, the controller reverts runtime mode to # inference and leaves the session Expired until deleted or extended. # expiresAt: "2026-07-04T03:00:00Z" - After merge, force reconcile if impatient:
export KUBECONFIG=~/workspace/platform/gitops/.kube/k3s.yaml flux reconcile source git flexinfer -n flux-system flux reconcile helmrelease flexinfer -n flexinfer-system # rolls the runtime to the gaming image flux reconcile kustomization flexinfer-models -n flux-system - Verify:
kubectl get gamingsession -n flexinfer-system # NODE MODE PHASE # ... gaming Activephase=Active,observedMode=gamingmeans Sunshine is up. Check the runtime pod log forFound H.264 encoder: h264_vaapi/Configuration UI available.phase=Degradedmeans the node is in gaming mode but the Sunshine subprocess crashed; the runtime restarts it with exponential backoff (2s → 60s) and the phase returns to Active once a restart succeeds. A session stuck in Degraded means every restart is failing — check the runtime pod log for the crash (Backend subprocess crashed) and the restart attempts.
Lease expiry
GamingSession.spec.expiresAt is an optional hard deadline for the session. If
the deadline passes while the node is in gaming mode, the controller requests
SetMode(inference), records status.expiredAt, and reports
phase=Expired. An expired CR is intentionally inert: it will not switch the
node back to gaming again until you either delete it and create a new session,
or extend spec.expiresAt to a future time.
This is separate from runtime idle auto-revert. expiresAt is a control-plane
lease bound that fires even if a Moonlight client is connected; idle auto-revert
only fires after no connected client is observed for the configured timeout.
Pair Moonlight
- Host: the node IP (e.g.
192.168.50.125); auto-discovers via mDNS on the same LAN. From a different subnet/VLAN, mDNS won't cross — add the node by IP. - Ports (hostNetwork): TCP
47984(HTTPS),47989(HTTP),47990(web UI),48010(RTSP); UDP47998/47999/48000/48002. This set covers a single high-resolution stream (e.g. 3440×1440@120) — bitrate rides the existing video port, so no extra ports are needed for the ultrawide. - Resolution/refresh: the host output matches the client automatically — see "Resolution & refresh rate" below (for a Mac/LG34 → Custom 3440×1440@120).
- First run: open
https://<node-ip>:47990, set a username/password. - Pair: in Moonlight add
<node-ip>; enter the PIN it shows into the web UI. - Play: pick an app —
Desktop(Steam autostarts in the session) orSteam Big Picture. An empty headless desktop streams as a static gray screen — that is expected; launch something to render.
Resolution & refresh rate (per-client / ultrawide)
The headless sway output has no physical panel, so it presents whatever mode
we tell it. Two mechanisms set that mode:
-
Base mode —
GAMING_RESOLUTION×GAMING_FPSon the gaming runtime profile env (deploy/system/values-k3s.yaml). This is the pre-connection / desktop mode. Oncblevins-7900xtxit is3440x1440@120— native for the operator's LG 34" 21:9 ultrawide (3440×1440) driven from a Mac. -
Per-client override —
sunshine-headless.shwrites a Sunshineglobal_prep_cmd(thesunshine-resize.shhelper on the gaming volume) that resizes theswayoutput to the connecting client's requested width/height/fps on stream start, then reverts to the base mode on stream end. So a 3440×1440 Mac, a 1920×1080 TV, and a 1280×800 Steam Deck each get a pixel-matched stream with no per-client server config. SetGAMING_DYNAMIC_RESOLUTION=falseto pin the base mode for every client.The helper is best-effort: an invalid or out-of-range client mode (caps at 3840×2160) leaves the client on the base mode rather than aborting the stream. Verify inside the runtime pod after a client connects:
swaymsg -t get_outputs | grep -A2 HEADLESS-1 # current_mode should match the client
Streaming to a Mac on the LG 34" ultrawide (3440×1440)
The Mac and the gaming node are on the same LAN (192.168.50.0/24), so no
router/port-forward changes are needed — the host ports below are already bound
on the node and Moonlight auto-discovers via mDNS (or add 192.168.50.125 by
IP). In Moonlight (macOS) → Settings:
- Resolution: add Custom → 3440×1440 (Moonlight ships common 16:9 modes only; add 3440×1440 as a custom resolution so the aspect ratio matches the LG 34 and the per-client hook sets the host output to 21:9).
- Frame rate:
120FPS (the base mode is 120; the Mac's LG34 must be at a ≥120 Hz refresh for tear-free playback). - Video bitrate: ~100–150 Mbps for 3440×1440@120 over wired GbE (Moonlight's auto value for this mode is a good start; lower it if you see network drops).
- Codec: HEVC or AV1 (both HW-encoded on the 7900 XTX; they hold realtime at this pixel rate with more headroom than H.264 — see the kill-test).
- Fullscreen, and (Mac) disable "Optimize game settings" if a game keeps overriding the streamed resolution.
The 21:9 VA-API encode path (3440×1440@120, H.264/HEVC/AV1) is exercised by
deploy/debug/gaming-sunshine-killtest.yaml alongside the 1080p60 baseline.
Steam (one-time account login)
Steam starts automatically inside the sway session (set
GAMING_STEAM_AUTOSTART=false on the profile env to disable, or
GAMING_LAUNCH_CMD to autostart something else). Because credentials and
Steam Guard are interactive, the first sign-in must be done by a human:
- Connect with Moonlight and pick
Desktop. - In the Steam login window, sign in (approve the Steam Guard prompt on your phone).
- Done — the login token, client, and all installed games persist on the
gaming volume (
/home/flexinfer-gaming/homeon the node's NVMe), so pod restarts and image rolls do not sign you out or lose the library.
Install games from the Steam UI over the stream. For Windows titles enable
Proton: Steam → Settings → Compatibility → "Enable Steam Play for all other
titles". steamcmd is also in the image for scripted/depot installs.
Revert to inference
kubectl delete gamingsession gaming-<node> -n flexinfer-system # via GitOps: remove deploy/gaming/*
The reconciler's finalizer issues SetMode(inference). To fully restore the
inference fleet also revert the runtime image → serving digest and re-advertise
the node's model lane (undo step 1).
Idle auto-revert (opt-in, default OFF)
By default a gaming session persists until an explicit revert. To auto-revert a node to inference after N with no connected Moonlight client, set on the gaming runtime profile's env:
GAMING_IDLE_TIMEOUT=45m
The runtime probes /proc/net/tcp* for established connections on the Moonlight
control/RTSP ports; if none for the timeout it calls SetMode(inference). A
failed probe assumes "active" (never auto-reverts a live session).
Observability
Scraped by the existing flexinfer-runtime PodMonitor (/metrics on the api port):
| Metric | Meaning |
|---|---|
flexinfer_runtime_node_mode{mode="gaming"} | 1 when the node is in gaming mode |
flexinfer_runtime_node_mode{mode="inference"} | 1 when serving inference |
flexinfer_runtime_gaming_idle_reverts_total | count of idle auto-reverts |
flexinfer_runtime_gaming_backend_restarts_total{result} | supervised Sunshine restarts after a crash (ok/error) |
Troubleshooting
| Symptom | Cause / fix |
|---|---|
gemma/model won't leave the node; another model stays Active leader | A shared-GPU member has gpu.forcePromotion: true — it wins the election unconditionally. Remove it. Also clear warmPolicy: primary + set minReplicas: 0 when de-advertising. |
| Node won't warm the new primary after freeing the card | Election prefers the higher-priority incumbent; raise the intended primary's gpu.priority above it (it must also be litellm.enabled: true + minReplicas ≥ 1). |
Controller crashlooping gamingsessions ... is forbidden | Chart ClusterRole missing gamingsessions — add to charts/flexinfer/templates/rbac.yaml (the ai.flexinfer rule). A new CRD needs both config/rbac/role.yaml and the chart ClusterRole. |
| Moonlight can't reach the node | The gaming runtime profile needs hostNetwork: true + gaming: true; NodePort's 30000–32767 range cannot serve Moonlight's fixed ports. Ensure no other process holds 47984/47989/47990/48010 on the host. |
| Stream is letterboxed / pillarboxed / blurry on the ultrawide | The host output did not match the client. Confirm swaymsg -t get_outputs shows HEADLESS-1 at the client's mode after connect; if it is stuck on the base mode, check the runtime pod log for [sunshine-resize] warnings (missing sway socket, out-of-range mode), that GAMING_DYNAMIC_RESOLUTION is not false, and that Moonlight is set to a real 3440×1440 (not "native/auto" scaling a 16:9 mode). |
| Moonlight video connects but controller/keyboard/mouse input does nothing | Sway must run with WLR_BACKENDS=headless,libinput, must not set WLR_LIBINPUT_NO_DEVICES, gamer must be in the host gids that own /dev/input/event*, and the gaming profile must mount /dev/input, /dev/uinput, and /run/udev. Verify with swaymsg -t get_inputs inside the runtime pod; it should include Sunshine passthrough devices after a client connects. |
GamingSession stuck Degraded; no sunshine process in the pod | Sunshine crashes on every supervised restart (e.g. the 2026-07-01 useradd exit-4 wrapper crash). The runtime retries with backoff forever while in gaming mode — fix the crash cause; check flexinfer_runtime_gaming_backend_restarts_total{result="error"} and the pod log. |
Image build fails Steam License Agreement was DECLINED | The steam/steamcmd debconf license preseeds must run before apt-get install in the INCLUDE_GAMING layer (both selectors: steam steam/question and steamcmd steam/question). |
Steam exits immediately / Cannot run as root | Steam refuses uid 0. The session (sway/PipeWire/Steam) runs as the gamer user; only Sunshine + avahi stay root. Don't launch steam from a root shell — use the steam-app.sh wrapper on the gaming volume. |
| Steam window never appears on the stream | Xwayland missing (Steam is X11): the sway config needs xwayland enable and the image needs the xwayland package (sway's Recommends are suppressed by --no-install-recommends). |
| Steam process alive but nothing downloads / no window for many minutes | Debian's launcher gates the first-run bootstrap on a zenity Install/Cancel dialog (--default-cancel) that nobody can click headlessly. sunshine-headless.sh shims that one dialog to "Install" ($XDG_RUNTIME_DIR/shim/zenity); if it regresses, check the shim is on the session PATH. |
| Games/login lost after a pod restart | The gaming state volume isn't mounted — check the profile's extraVolumes (/home/flexinfer-gaming → /var/lib/flexinfer-gaming) and GAMING_STATE_DIR. |
| A dedicated-Deployment model (pvc:// source) still runs on the gaming node | SetMode(gaming) drains runtime-managed models, not dedicated Deployments. De-advertise it (litellm.enabled: false, minReplicas: 0) so it idles out. |
Related
- Plan / evidence:
.loom/30-implementation-plan-gaming-mode-sunshine-2026-06-30.md,.loom/killtest-gaming-sunshine-gfx1100-2026-06-30.md. - Code:
api/v1alpha2/gamingsession_types.go,controllers/gamingsession_controller.go,controllers/runtime_controller.go(SetMode),internal/runtime/manager.go.