Two changes to the Steam Backlog Enforcer — a 4-day window to undo a manual pick, and support for up to three locked-in games at once. Commits eb1472a and 05e070c, 19 July 2026.
The Steam Backlog Enforcer is a self-discipline tool. It picks one game from your Steam
backlog, then makes it genuinely hard to play anything else: it uninstalls every other game,
hides them from the Steam library, blocks the store, and runs a daemon that kills unauthorized
game processes on sight. You finish the assigned game to 100% achievements, run done,
and it hands you the next one.
Most of that machinery answers a single question — which game is allowed to exist right now? Before this change, the answer lived in one field of the persisted state:
@dataclass
class State:
current_app_id: int | None = None
current_game_name: str = ""
finished_app_ids: list[int] = field(default_factory=list)
...
and every enforcement primitive took that one id as a parameter — uninstall_other_games(allowed_app_id),
hide_other_games(owned, allowed_app_id), enforce_allowed_game(allowed_app_id),
_guard_installed_games(allowed_app_id). Each one compared with == and removed
everything else.
Normally the enforcer chooses for you. pick-manual <app_id> lets you override that
and name the game yourself — but because self-chosen commitments are the easy ones to wriggle
out of, it was deliberately built as the strictest mode in the tool. Picking a game recorded
three fields and slammed a door:
manual_pick_app_id: int | None
manual_pick_game_name: str
manual_pick_started_at: str # ISO-8601 UTC
From then on, main() ran a check before dispatching any command:
def _enforce_manual_pick_lock(command: str, state: State) -> None:
if not _is_manual_pick_locked(state):
return
if command in _MANUAL_LOCK_EXEMPT_COMMANDS:
return
_show_manual_pick_lock_message(state)
sys.exit(1)
Only six commands survived that gate: done, check, status,
enforce, setup, serve. The lock released on exactly two
conditions — the picked game appearing in finished_app_ids, or 14 days elapsing.
Which is fine right up until you pick the wrong game. That is what prompted this change: a
manual pick of Age of Empires II that turned out to be a mistake, with no in-app path out of it
for two weeks. scan, pick, list, reset — all
of them exited 1.
Two independent problems, and the second one is what makes the first one safe.
A lock is only useful if it survives the moment you want to break it — that is the whole point. But there's a difference between "I don't feel like this game today" (the lock should absolutely win) and "I typed the wrong app id" or "this isn't the game I thought it was". The fix is a short window where the second reading is still plausible:
abandon-pick <id> works. Advertised in the lock message.Four days, and the window is deliberately awkward to use: you must pass the app id explicitly
and it must match an active pick, so no one abandons a commitment by muscle memory. Abandoning
also costs something — the game goes onto the existing 30-day skipped_until cooldown,
so scan will not hand it back tomorrow.
skipped_until? The state already had a
skip_for_days(app_id, days) helper, used when you decline a freshly-picked game in
cmd_done. An abandoned pick is the same idea with a longer fuse, so it reuses the
mechanism rather than inventing a parallel one.
Here's the loop that a naive grace period opens: pick a game → abandon it → pick another →
abandon that one. Each new pick stamps a fresh started_at, so the grace clock resets
forever and the enforcer enforces nothing.
The chosen answer was not to forbid re-picking, but to make a second concurrent pick a
first-class, capped feature. If you may legitimately have several games locked in, correcting
a pick no longer requires a pick→abandon→pick cycle, and the cap is what bounds the whole thing.
Config.max_manual_picks defaults to 3.
That turns the enforcer's central question from "which game?" into "which set of games?" — and that is the real substance of the diff:
current_app_id = 1794680
allowed_app_ids = {1771300, 1794680}
One function now answers that question for the whole system:
def allowed_app_ids(state: State) -> set[int]:
"""Return every app id the enforcer must leave installed and visible."""
return {app_id for app_id, _ in allowed_games(state)}
It is the union of the active manual picks and the current assignment. Every primitive that
used to take one id now takes this set, and every call site passes allowed_app_ids(state).
"Active" does real work here — a pick drops out of the set the moment its game is finished or its
own 14-day deadline passes, so nothing has to be explicitly cleaned up.
The widget below runs the actual new logic — _pick_is_active, the grace test, the
cap, and the allowed set — reimplemented faithfully in JS. Advance the clock and watch picks fall
out of the set on their own.
Library below is what's installed. Advance days, finish a game, or abandon a pick, and watch the allowed set and lock recompute.
The three manual_pick_* fields became a list of entries. The subtlety is that a
lock was active on the author's own machine while this was being written — dropping the
old fields would have silently released it. So they are still read, migrated once, and cleared:
def _migrate_legacy_manual_pick(self) -> None:
"""Fold a pre-multi-pick single manual pick into ``manual_picks``."""
if self.manual_pick_app_id is None or self.manual_picks:
return
self.manual_picks = [{
"app_id": self.manual_pick_app_id,
"game_name": self.manual_pick_game_name,
"started_at": self.manual_pick_started_at,
}]
self.manual_pick_app_id = None
...
Called from State.load(), so it happens in memory on read and persists on the next
save. Entries are plain dicts rather than a dataclass because save() serialises
self.__dict__ straight to JSON.
_pick_is_active holds the per-pick rule. Note the defensive branch — a corrupt
timestamp keeps the pick active, because for an enforcement tool the safe answer to "I
can't tell when this expires" is "it hasn't":
started_at = pick.get("started_at") or ""
if started_at:
try:
started = datetime.fromisoformat(started_at)
except ValueError:
return True
if datetime.now(timezone.utc) >= started + timedelta(days=MANUAL_LOCK_DAYS):
return False
return True
Everything else is a thin layer on top: active_manual_picks filters the list,
is_manual_pick_locked is bool(active_manual_picks(state)), and the grace
helpers take an app_id so each pick has its own window.
Four functions changed shape from allowed_app_id: int | None to
allowed_app_ids: set[int], swapping equality for membership. The interesting one is
the library hider, because its comparison lives inside JavaScript injected into Steam over the
DevTools Protocol:
- allowed_js = str(allowed_app_id) if allowed_app_id is not None else "null"
- extra_ids = sorted(aid for aid in owned_app_ids if aid != allowed_app_id)
+ allowed_json = json.dumps(sorted(allowed_app_ids))
+ extra_ids = sorted(aid for aid in owned_app_ids if aid not in allowed_app_ids)
- const allowed = {allowed_js};
+ const allowed = new Set({allowed_json});
...
- .filter(id => id !== allowed)
+ .filter(id => !allowed.has(id))
...
- if (allowed !== null) await collectionStore.SetAppsAsHidden([allowed], false);
+ if (allowed.size > 0) await collectionStore.SetAppsAsHidden([...allowed], false);
Two commands had to become reachable while locked, which sounds like weakening the lock but isn't:
_MANUAL_LOCK_EXEMPT_COMMANDS = frozenset({
"done", "check", "status", "enforce", "setup", "serve",
"abandon-pick",
# Allowed so a second game can be locked in alongside the first; the
# cap inside cmd_pick_manual is what stops this being a way out.
"pick-manual",
})
abandon-pick is obvious — it is the only way out, so the pre-dispatch gate would
otherwise make it dead on arrival. pick-manual is the subtle one: the gate no longer
limits it, so the limit moved into the command itself, where _report_pick_slots
exits 1 once manual_pick_slots_left hits zero. The lock check answers "may I run this
command", the cap answers "may I add another game" — two different questions that used to share
one mechanism.
The post-pick cascade moved from a single id to the whole set, which is what stops a second pick from tearing down the first:
allowed = allowed_app_ids(state)
if config.uninstall_other_games:
count = uninstall_other_games(allowed)
for app_id, name in allowed_games(state):
if not is_game_installed(app_id):
install_game(app_id, name, config.steam_id, use_steam_protocol=True)
hidden, skipped = try_hide_other_games(owned_ids, allowed)
Picking a replacement game surfaced an unrelated bug: SteamUpdateInProgressError
(raised when a game update defers a Steam restart) was only caught in the enforce loop. The five
interactive call sites called hide_other_games bare, so an undrivable Steam aborted
the command with a traceback — after the pick had already been saved. A small wrapper
turns that class of failure into a return value:
def try_hide_other_games(owned_app_ids, allowed_app_ids) -> tuple[int, str | None]:
try:
return hide_other_games(owned_app_ids, allowed_app_ids), None
except SteamUnavailableError as exc:
return 0, str(exc)
All six sites now report Library hiding: skipped (<reason>) and carry on.
WorkingDirectory=/home/kuhy/steam-backlog-enforcer) but only reloads on restart.
During development, the still-running pre-upgrade daemon knew only current_app_id —
which the second pick had just moved — so within one 3-second loop it uninstalled the other pick as
"unauthorized" and deleted an in-progress download. The code was right; the running process was
stale. sudo systemctl restart steam-backlog-enforcer is now part of deploying any
change to this logic.
Five questions on the reasoning behind the change. Click an option to check it.