Files
dotfiles/docs/superpowers/specs/2026-07-15-niri-gamemode-design.md
T
funman300 0ab08e8561 gamemode: global auto-trigger via niri window watch
Add scripts/gamemode-watch.py, launched at niri startup, which subscribes
to niri's event stream and toggles gamemode whenever a game window
(steam_app_*, RuneLite, gamescope) is open — so Steam games trigger
gamemode with zero per-game setup instead of needing gamemoderun %command%.

'winwatch' is a third reference-counted source alongside feral + manual.
Pure Watcher class is unit-tested; SIGTERM/finally guarantee cleanup so
the watcher stopping never leaves gamemode stuck on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:51:52 -07:00

11 KiB
Raw Blame History

Niri Gamemode Design

Date: 2026-07-15 Status: Approved

Summary

Add a compositor-level "gamemode": while a game is running, inhibit idle/blanking, silence notifications, switch to the performance power profile (and bump the fan), and hide waybar — then revert everything cleanly when the game ends. One script, scripts/gamemode-session.sh, is the whole engine. It is driven by three independent sources and reference-counted, so the triggers can overlap safely:

  • Window watch (winwatch)scripts/gamemode-watch.py, launched at niri startup, subscribes to niri's event stream and toggles gamemode whenever a game window (app_id matching steam_app_*, RuneLite, or gamescope) is open. This makes Steam games automatic with zero per-game setup — the primary trigger.
  • Feral (feral) — Feral gamemoded custom start/end hooks, for gamemode-aware launchers run with gamemoderun that the window watch may not match.
  • Manual (manual) — a Mod+G niri keybind, for anything else.

Effects apply on the first activation (0→1 active sources) and revert on the last deactivation (1→0). Also removes three dead VRR window-rules found during review.

Addendum (2026-07-15): The window-watch source was added after the initial design so Steam games trigger gamemode globally instead of needing gamemoderun %command% per game. See "Window watch" below.

Current Behaviour

  • Compositor: niri 26.04, single internal panel eDP-1 (BOE, 2256×1504@60, VRR not supported). Config at niri/config.kdl.
  • Idle: hypridle blanks the panel at 5 min, locks at 10 min, suspend-then- hibernate at 30 min. It honors idle inhibitors (ignore_dbus_inhibit = false, ignore_systemd_inhibit = false). A controller/turn-based game with no kbd/mouse input therefore goes dark at 5 minutes.
  • Notifications: mako, currently with no config file (running on defaults) — so toasts pop over fullscreen games and there is no mode to suppress them.
  • Power: power-profiles-daemon (AMD pstate); active profile read/set via powerprofilesctl, waybar module refreshed with pkill -RTMIN+8 waybar (and -RTMIN+9). Fan via fw-fanctrl with a virtual auto mode that maps profile→strategy (performanceagile) when $XDG_STATE_HOME/fan-profile-auto exists.
  • waybar: launched directly (spawn-at-startup "waybar"); waybar-restart script exists.
  • Feral gamemode: gamemoded installed, no gamemode.ini → no custom hooks.
  • Script convention: scripts/<name>.sh is symlinked to ~/.local/bin/<name> (no .sh in the bin name), e.g. power-profile, fan-profile, screenshot.

Review findings (delivered separately, cleanup folded into this spec)

  • 🔴 Three VRR rules are dead code. The panel doesn't support VRR, so all three variable-refresh-rate true rules are no-ops. The match is-focused=true one is also conceptually wrong (it targets every focused window, not games).
  • 🟠 Idle blanks mid-game (the 5-min issue above) — the main problem gamemode fixes.
  • 🟡 Notifications interrupt fullscreen.
  • 🟢 match app-id="steam_app_" is an unanchored regex (looser than the other anchored rules) — noted, left as-is.

Target Behaviour

Sources and reference counting

State lives in a runtime dir ${XDG_RUNTIME_DIR}/gamemode/. A "source" is either feral or manual, represented by a marker file (src.feral, src.manual). Gamemode is active iff at least one source marker exists.

add <source>:
  was_active = (any src.* exists)
  touch src.<source>
  if not was_active:  snapshot baseline + apply effects

del <source>:
  rm -f src.<source>
  if no src.* remain:  read baseline + revert effects, clear state dir

toggle:            # what Mod+G calls
  if src.manual exists → del manual   else → add manual

status:            # for a future waybar module; prints active|inactive, exit 0/1

Applying only on 0→1 and reverting only on 1→0 makes overlap safe: launch a Steam game (feral add) and hit Mod+G (manual add), and effects apply once; effects revert only when both are gone. Idempotent and crash-tolerant — stale markers are harmless, and baseline is re-snapshotted only when transitioning from zero sources.

Baseline snapshot (captured on 0→1)

Written into the state dir so revert restores exactly what was there:

  • baseline.profilepowerprofilesctl get at entry.
  • baseline.waybar1 if waybar was running at entry, else 0.
  • baseline.fanauto1 if $XDG_STATE_HOME/fan-profile-auto exists at entry.
  • idle.pid — PID of the idle-inhibit process (written when it launches).

Effects

Effect Enter Revert
Idle inhibit systemd-inhibit --what=idle --who=gamemode --why="gaming session" --mode=block sleep infinity &, record PID in idle.pid. hypridle honors systemd idle inhibitors, so its blank/lock/hibernate timers stop. kill the recorded PID; remove idle.pid.
Do-not-disturb makoctl mode -a do-not-disturb (requires a [mode=do-not-disturb] block — see mako config below). makoctl mode -r do-not-disturb.
Performance profile if baseline.profile != performance: powerprofilesctl set performance, then pkill -RTMIN+8 waybar. If baseline.fanauto=1: fw-fanctrl use agile. restore baseline.profile (only if we changed it), pkill -RTMIN+8 waybar; if baseline.fanauto=1 remap fan to match the restored profile (fan-profile's existing auto poll also reconciles within 5 s, so this is belt-and-suspenders).
Hide waybar if waybar running: pkill -x waybar. if baseline.waybar=1: relaunch waybar detached (setsid waybar >/dev/null 2>&1 &).

All effect steps are individually guarded (command -v, 2>/dev/null) so a missing tool degrades to a no-op rather than aborting the rest — gamemode must never wedge a session. Revert always runs every restore step regardless of individual failures.

Failure / edge cases

  • Stale markers after a crash: next add sees markers, treats itself as already-active, and won't re-snapshot — but a manual gamemode-session reset (clears the state dir and force-reverts) is provided as an escape hatch. toggle from a stale-active state will del manual and, if that empties the sources, revert — self-healing in the common case.
  • Idle PID already dead: kill failure is ignored.
  • profile unchanged at entry (already performance): we don't touch it and don't restore it, so we never clobber a deliberate user choice.

Implementation

1. scripts/gamemode-session.sh (new)

bash, mirrors the house style of the other scripts. Structure:

  • Constants: STATE_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gamemode", FAN_AUTO="${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto".
  • Helpers: is_active, snapshot_baseline, apply_effects, revert_effects, each effect as a small function.
  • Dispatch on $1: add/del (with $2 = source), toggle, status, reset.
  • Symlinked to ~/.local/bin/gamemode-session (same as every other script; add the symlink line to install.sh if it enumerates scripts, else create it manually to match the existing ones).

Named gamemode-session (not gamemode) to avoid any collision with Feral's gamemoderun/gamemoded namespace.

2. mako/config (new file in dotfiles → ~/.config/mako/config)

mako has no config today; DND needs a mode block. Fold in the current visible settings so nothing changes visually, then add the mode:

background-color=#1d1f21
text-color=#c5c8c6
border-size=2
border-color=#81a2be
default-timeout=4000

[mode=do-not-disturb]
invisible=1

invisible=1 hides new notifications while the mode is active; removing the mode restores normal display. (History is retained — nothing is lost, just not shown.)

3. gamemode.ini (new file in dotfiles → ~/.config/gamemode.ini)

[custom]
start=/home/alex/.local/bin/gamemode-session add feral
end=/home/alex/.local/bin/gamemode-session del feral

Absolute path because gamemoded's exec environment is minimal. No other gamemode tuning (Feral's defaults already set the CPU governor).

4. niri/config.kdl (edited)

Add the keybind (near the other spawn binds):

Mod+G { spawn "gamemode-session" "toggle"; }

Remove the three dead VRR rules:

  • the variable-refresh-rate true line inside the steam_app_ rule,
  • the entire match is-focused=true { variable-refresh-rate true } rule.

Leave a one-line comment on the steam_app_ rule noting to re-add variable-refresh-rate true if a VRR-capable external monitor is ever attached.

5. scripts/gamemode-watch.py (new) — window watch

A Python daemon (Python already used by the theme generator) launched via spawn-at-startup "gamemode-watch". It runs niri msg --json event-stream and maintains the set of open game windows:

  • is_game(app_id) matches steam_app_ (substring), the RuneLite ids (net-runelite-client-RuneLite|runelite|RuneLite), and gamescope (case-insensitive) — mirrors the niri window rules. Bolt's launcher window is deliberately excluded (it would trigger before you're in-game).
  • WindowsChanged (connect snapshot) rebuilds the set; WindowOpenedOrChanged adds/removes by app_id; WindowClosed (id only) discards by id.
  • On empty→non-empty it calls gamemode-session add winwatch; non-empty→empty, del winwatch.
  • Event logic is a pure Watcher class (injected emit) so it unit-tests without niri or subprocesses (scripts/tests/gamemode-watch.test.py).
  • Robustness: reconnects if the stream drops while niri is alive; a SIGTERM handler and a finally guarantee del winwatch on exit, so the watcher stopping can never leave gamemode stuck on.

Files Touched

  • scripts/gamemode-session.shnew, the engine.
  • scripts/gamemode-watch.pynew, niri-event-stream window watch (primary auto-trigger). Tested by scripts/tests/gamemode-watch.test.py.
  • mako/confignew, adds the do-not-disturb mode (plus current settings); also un-ignored in .gitignore (was previously untracked by choice).
  • gamemode.ininew, Feral start/end hooks.
  • niri/config.kdl — add Mod+G bind; spawn-at-startup "gamemode-watch"; remove 3 dead VRR rules; add one comment.
  • install.sh — link gamemode-session, gamemode-watch, gamemode.ini, and mako/config (the last was never linked before).

Usage

  • Steam / RuneLite / gamescope games: nothing to do — the window watch turns gamemode on when a game window opens and off when the last one closes.
  • Gamemode-aware launchers (Lutris/Heroic via gamemoderun): covered by the Feral hooks even if their window app_id isn't matched.
  • Anything else / manual override: Mod+G to toggle.
  • All three converge on gamemode-session; overlapping is safe (reference-counted).

Out of Scope (YAGNI)

  • gamescope wrapper / upscaling / frame limiting — separate concern; can be layered into a game's launch command independently.
  • Per-game profiles — one uniform gamemode for now.
  • waybar gamemode indicator — the status subcommand is provided so a custom/gamemode module can be added later, but no module ships now.
  • Cross-machine sync of gamemode state — state is per-boot runtime only, by design.
  • Touching VRR behaviour for external monitors — left as a comment breadcrumb; no rule added until such a monitor exists.