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>
11 KiB
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 matchingsteam_app_*, RuneLite, or gamescope) is open. This makes Steam games automatic with zero per-game setup — the primary trigger. - Feral (
feral) — Feralgamemodedcustom start/end hooks, for gamemode-aware launchers run withgamemoderunthat the window watch may not match. - Manual (
manual) — aMod+Gniri 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 atniri/config.kdl. - Idle:
hypridleblanks 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 viapowerprofilesctl, waybar module refreshed withpkill -RTMIN+8 waybar(and-RTMIN+9). Fan viafw-fanctrlwith a virtualautomode that maps profile→strategy (performance→agile) when$XDG_STATE_HOME/fan-profile-autoexists. - waybar: launched directly (
spawn-at-startup "waybar");waybar-restartscript exists. - Feral gamemode:
gamemodedinstalled, nogamemode.ini→ no custom hooks. - Script convention:
scripts/<name>.shis symlinked to~/.local/bin/<name>(no.shin 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 truerules are no-ops. Thematch is-focused=trueone 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.profile—powerprofilesctl getat entry.baseline.waybar—1if waybar was running at entry, else0.baseline.fanauto—1if$XDG_STATE_HOME/fan-profile-autoexists 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
addsees markers, treats itself as already-active, and won't re-snapshot — but a manualgamemode-session reset(clears the state dir and force-reverts) is provided as an escape hatch.togglefrom a stale-active state willdel manualand, if that empties the sources, revert — self-healing in the common case. - Idle PID already dead:
killfailure 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 toinstall.shif 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 trueline inside thesteam_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)matchessteam_app_(substring), the RuneLite ids (net-runelite-client-RuneLite|runelite|RuneLite), andgamescope(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;WindowOpenedOrChangedadds/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
Watcherclass (injectedemit) 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
finallyguaranteedel winwatchon exit, so the watcher stopping can never leave gamemode stuck on.
Files Touched
scripts/gamemode-session.sh— new, the engine.scripts/gamemode-watch.py— new, niri-event-stream window watch (primary auto-trigger). Tested byscripts/tests/gamemode-watch.test.py.mako/config— new, adds thedo-not-disturbmode (plus current settings); also un-ignored in.gitignore(was previously untracked by choice).gamemode.ini— new, Feral start/end hooks.niri/config.kdl— addMod+Gbind;spawn-at-startup "gamemode-watch"; remove 3 dead VRR rules; add one comment.install.sh— linkgamemode-session,gamemode-watch,gamemode.ini, andmako/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+Gto 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
statussubcommand is provided so acustom/gamemodemodule 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.