Hybrid trigger (Feral gamemode hooks + Mod+G toggle), reference-counted so overlapping sources apply once and revert once. Also removes three dead VRR window-rules (panel has no VRR support). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.4 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 two independent
sources and reference-counted, so the two triggers can overlap safely:
- Automatic — Feral
gamemodedcustom start/end hooks (any game launched withgamemoderun). - Manual — a
Mod+Gniri keybind, for games that don't call gamemode.
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.
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.
Files Touched
scripts/gamemode-session.sh— new, the engine.mako/config— new, adds thedo-not-disturbmode (plus current settings).gamemode.ini— new, Feral start/end hooks.niri/config.kdl— addMod+Gbind; remove 3 dead VRR rules; add one comment.install.sh— add thegamemode-sessionsymlink +gamemode.ini/mako/configplacement, matching how existing scripts/configs are linked (or create the symlink manually if install.sh doesn't enumerate).
Usage
- Steam: set launch options to
gamemoderun %command%(per-game or as a global default) → fully automatic enter/revert. - Other games / anything gamemode doesn't catch:
Mod+Gto toggle. - Both converge on
gamemode-session; overlapping is safe.
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.