# 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 (`performance` → `agile`) 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/.sh` is symlinked to `~/.local/bin/` (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 : was_active = (any src.* exists) touch src. if not was_active: snapshot baseline + apply effects del : rm -f src. 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 get` at entry. - `baseline.waybar` — `1` if waybar was running at entry, else `0`. - `baseline.fanauto` — `1` 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: ```ini 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`) ```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.sh` — **new**, the engine. - `scripts/gamemode-watch.py` — **new**, niri-event-stream window watch (primary auto-trigger). Tested by `scripts/tests/gamemode-watch.test.py`. - `mako/config` — **new**, adds the `do-not-disturb` mode (plus current settings); also un-ignored in `.gitignore` (was previously untracked by choice). - `gamemode.ini` — **new**, 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.