Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
Open more actions menu

Repository files navigation

Playhook icon

Playhook

Bring console vibes to your PC.

Platform License: MIT Build Windows Build Linux

Playhook is a background app that turns a removable drive into a console-style game cartridge. It lives in the tray, detects when you insert a card carrying a game (a game.json manifest), and pops up a game card. Press A on an Xbox gamepad (or click Play) and it syncs your saves onto the PC, launches the game, tracks the playtime, then copies the saves back to the card when you quit — so your progress travels with the card across machines.

It runs on Windows and on the Steam Deck / Linux (SteamOS), where the same Windows game cards launch through Proton — see Steam Deck.

The card can carry the game itself, an installer for heavy games (Install mode), or just a pointer to a Steam app by appid that Playhook installs, launches and uninstalls through your local Steam client (Steam mode).

Cross-platform. Playhook runs on Windows and on the Steam Deck / Linux (SteamOS). On Linux the same Windows game cards run through Proton (via umu-launcher); native Linux/ELF games are not a target. macOS is not supported. See Steam Deck.

Security note. The card is untrusted input — every path in the manifest is validated against directory traversal and an allowlist before anything is read or written. See Preparing a card for the exact rules.

Playhook game card — Bloodborne

The Playhook game card, running Bloodborne. Ready-made cards like this — game.json, hero art and sounds — live in the Playhook Collection, whose UI you can also try right in the browser.


Download (for users)

Grab the latest build from the Releases page. Three are published:

  • NSIS installer (.exe, recommended on Windows) — installs the app, configures autostart reliably, and updates itself automatically.
  • portable (.exe) — runs without installation; no auto-update, autostart is best-effort.
  • AppImage — the Steam Deck / Linux build; it self-updates too. See Steam Deck for the setup.

A couple of things to expect on first run on Windows:

  • SmartScreen warning. The builds are not code-signed, so Windows SmartScreen will warn you the first time. This is expected — choose More info → Run anyway.
  • Visual C++ Redistributable. If the app fails to start on a clean Windows install, install the latest Visual C++ Redistributable (x64). (.NET is not required.)

Quick start

  1. Install (or unzip the portable build) and run it — it sits quietly in the tray.
  2. Insert a card that has a game.json in its root (see below).
  3. A game card window appears with the hero art and the game title (playtime stats live in the More ⋯ menu).
  4. Press A on the gamepad (or click Play) — saves sync and the game launches.
  5. Close the game — Playhook counts the time, updates stats, and syncs saves back to the card.

Don't want to hand-write a game.json? The tray has a Configure game editor that writes one onto the inserted card for you — see Settings and the card editor.


How it works

  1. Playhook starts hidden in the tray. With no game card inserted, there is no window (unless you enable Always show the no-card screen in Settings).
  2. Insert a card with a valid game.json and a window appears: the hero art as the background and a bottom bar with the Play button on the left, the game title in the middle and the More (⋯) button on the right (state ready). If the manifest has no heroImage, a bundled wallpaper — or your own, set in Settings — is used.
  3. Press A on the gamepad (the window is force-focused) or click Play.
  4. Saves are synced card → PC and the game launches; it takes the foreground over the launcher.
  5. When the game closes, Playhook counts the play time, updates the statistics, and syncs the saves PC → card. The game card window returns.

The More (⋯) button opens the Details menu — one right-side popup that carries everything besides Play. It always shows the game's stats (last played, playtime, launches) and, depending on the state, offers:

  • Install / Uninstall — for install-mode and Steam-mode cards (an uninstalled game has no Play button at all — you start from here);
  • Force close — while a game is running, kills it and still records the session and syncs saves;
  • Select game — on a card that carries several games (see game.json);
  • System — a submenu with Shutdown / Reboot / Sleep (each behind a confirmation) and Minimize Playhook, which sends the window back to the tray. In Game Mode that last item is Close Playhook (a full quit) instead, since there is no tray to minimize into.

Every confirmation and every error uses that same popup; close it with B or a click on Close. If a launch fails, the reason appears there and you can simply retry.

The empty screen (no card inserted) reuses the same layout over the wallpaper: "Insert a game card", no Play button, and More offering just the System submenu (where Minimize Playhook lives).

When the launcher is hidden you can hold Start + Back on the gamepad to re-summon it (the hotkey can be turned off in Settings). It is intentionally ignored while a game is running — pulling the launcher over a running game only causes focus trouble.

Tray menu: Show launcher, Configure game (the built-in card editor), Settings, Quit — plus Add to Steam / Remove from Steam on the Steam Deck. The log folder opens from Settings → Advanced → Open logs.


Settings and the card editor

Both windows open from the tray, so they are Desktop-Mode only on the Steam Deck.

Settings

  • Updates — automatic mode (download and install / download, install manually / off), the pre-release (beta) channel, and a manual Check for updates with the current version.
  • Appearance — theme (system / light / dark), language (system / English / Russian), and a custom background for the empty screen (any image; Reset restores the bundled wallpaper).
  • General — the gamepad summon hotkey, keep the screen awake while the launcher is open, always show the no-card screen (instead of hiding to the tray), disable silent installer mode (see Install mode), and, on the Steam Deck, the Game Mode auto-launch on card insertion.
  • Audio — the navigation sound set and the background ambience shipped with the app, a volume slider for each, and an only global switch per category that makes the app's sound/ambience win over whatever the card carries in sounds / backgroundMusic.
  • AdvancedOpen logs, Open games folder (the install-mode directory), Reset to defaults.

Settings live in settings.json next to the rest of the app state (%APPDATA%\playhook\ on Windows, ~/.config/playhook/ on Linux); a missing or corrupted file falls back to the defaults.

Configure game (card editor)

Configure game writes the game.json onto the inserted card, so you never have to edit JSON by hand:

  • pick the card (any removable drive — a blank one can be initialized from scratch);
  • a form with sections Basics / Launch / Hero images / Saves / Audio / Advanced, with Browse pickers for the executable, hero images, sounds and save folders (a picked PC save folder is converted back into a %APPDATA%-style prefix automatically, and a file outside the card is rejected);
  • a JSON tab with the raw manifest, live schema validation, error messages and a formatter — the form and the JSON tab are two views of the same document;
  • Add game / Remove current for a multi-game card;
  • Save & Apply writes the file and reloads the launcher immediately (or on the card's next insertion, if a game is running right now); Reset re-reads the card and drops your edits.

Preparing a card: game.json

Don't want to write one by hand? The Playhook Collection is a growing set of ready-made, verified manifests — sounds, hero art, save paths and all. Browse it, preview a game, and grab its game.json + assets for your card. Source and contribution guide: github.com/sevenns/playhook-collection.

Place a game.json in the root of the card. The paths executable / heroImage / saveOnCard are relative to the card root; pcSavePath is absolute and must start with one of the allowed prefixes (see below).

The file holds either one game object (below) or an array of them — a card can carry several games. The launcher opens on the first one and you switch with More (⋯) → Select game; each game keeps its own stats, saves and install state (they are keyed by id). One bad entry doesn't sink the whole card — it is skipped (with a line in the log) and the rest still load; duplicate ids are rejected, since the id keys the PC-side storage. The Configure editor shows the issue per game.

{
  "schemaVersion": 1,
  "id": "hollow-knight",                    // stable id: key of the stats/pending-flush folder on the PC
  "title": "Hollow Knight",
  "executable": "game/hollow_knight.exe",   // relative path to the .exe from the card root
  "args": [],                               // launch arguments (optional)
  "runAsAdmin": false,                      // launch elevated via UAC for .exe requiring admin (optional, default false)
  "heroImage": "assets/hero.jpg",           // window background: one path, or an array of paths that cross-fade every minute (optional; falls back to a bundled wallpaper)
  "saveOnCard": "saves",                    // copy folder for saves on the card (relative to the root)
  "pcSavePath": "%APPDATA%/Team Cherry/Hollow Knight", // where the game actually writes saves on the PC
  "launchTimeoutSec": 30,                   // how long to wait for the process to appear (optional, default 30)
  "killTimeoutSec": 60,                     // how long "Force close" waits for the game to die (optional, default 60)
  "watchProcesses": ["Game-Win64-Shipping.exe"], // for launcher/wrapper games: track THESE process names, not the spawned launcher (optional)
  "sounds": {                               // per-game UI sounds (all optional; omitted slots use the sound set from Settings)
    "play": "audio/play.ogg",               // pressing "Play"
    "navigate": "audio/move.ogg",           // moving focus between controls
    "button": "audio/button.ogg",           // pressing an ordinary button (e.g. an item in the More menu)
    "back": "audio/back.ogg"                // gamepad B closing the info popup
  },
  "backgroundMusic": "audio/theme.ogg"      // looping music while the window is visible (volume from Settings, optional)
}

Example card layout

E:\
├─ game.json
├─ game\
│  └─ hollow_knight.exe
├─ assets\
│  └─ hero.jpg
└─ saves\            ← portable canonical copy of the saves

Rules and security (the card is untrusted input)

  • After resolution, executable / heroImage / saveOnCard must lie inside the card root.. and absolute paths are forbidden (otherwise the game won't launch and an error is shown). The executable must also exist on the card, or launch is rejected.
  • pcSavePath — only from an allowlist of prefixes, with no traversal (..). Otherwise rejected:
    • %DOCUMENTS% — the user's Documents folder, resolved via the system Known Folder API (app.getPath('documents')). Language- and OneDrive-independent: it returns the same physical path the game itself uses, so %DOCUMENTS%/My Games/... works on any machine without caring whether the folder is named Documents, Документы, or sits under OneDrive. Prefer this for games that save under Documents (most Bethesda titles, e.g. Fallout/Skyrim).
    • %APPDATA%, %LOCALAPPDATA%, %USERPROFILE% — resolved from the corresponding environment variables (good for games that save under AppData).
    • %LOCALLOW% — the AppData\LocalLow folder, derived from %USERPROFILE% (it has no environment variable of its own). Common for Unity / Steam games (e.g. Valheim → %LOCALLOW%/IronGate/Valheim). Note: %APPDATA% is AppData\Roaming and is not a parent of LocalLow — use %LOCALLOW% for LocalLow saves.
  • id — only [A-Za-z0-9._-] (used as a folder name on the PC).
  • runAsAdmin — set true only for an .exe whose embedded manifest requires administrator (a plain launch fails with EACCES / ERROR_ELEVATION_REQUIRED). Playhook then launches it elevated via a UAC prompt (ShellExecuteEx runas) and monitors it by process HANDLE instead of tasklist (a non-elevated app can't see an elevated process). Opt-in on purpose — Playhook never silently escalates an untrusted card's exe. On the Steam Deck / Linux there is no elevation under Proton, so runAsAdmin: true is a no-op (logged) rather than an error — the same card stays valid on both platforms.
  • watchProcesses — for launcher / wrapper games, where executable spawns a launcher that starts the game in a separate process and then exits (so watching the spawned pid would wrongly report "closed" the instant the launcher quits). List the game's own process image names here: Playhook still spawns executable, but tracks the session by the presence of these names in tasklist. Playtime starts when a watched process appears and ends when all of them are gone. When omitted, behaviour is unchanged (the spawned pid is tracked directly — the default for a self-contained .exe). Each entry is a bare *.exe name (no quotes, no path separators), matched case-insensitively; 1–16 names. Caveats:
    • anticheat / elevation — Steam / EAC / BattlEye often launch the game elevated or as a service, which a non-elevated tasklist can't see (R4) → Playhook reports "didn't start" and quietly returns without recording a session. This is a common case for launcher games, not a rare edge.
    • generic names — don't use names like game.exe or a shared UnityPlayer-style binary: they can match an unrelated process. Use the game's specific shipping name.
    • already-running instance — don't open the game manually before pressing Play: presence matching would latch onto that pre-existing process.
  • saveOnCard and pcSavePath are set together or both omitted. If both are omitted, the game writes its saves next to its exe on the card and syncing is fully disabled.
  • sounds.* and backgroundMusic — card-relative like heroImage, must lie inside the card root. Any omitted sound slot falls back to the navigation sound set chosen in Settings, so every game has UI sounds out of the box; backgroundMusic is off unless set (unless a global ambience is chosen in Settings), loops and pauses while a game is running or the window is hidden. Both can be overridden globally — only global navigation sounds / only global ambience in Settings make the app's choice win over the card's. Use a common web-playable audio format (mp3, ogg/oga, opus, wav, m4a, aac, flac, webm).
  • killTimeoutSec — how long Force close (More ⋯ menu) waits for the game's processes to vanish before reporting a failure; the wait ends as soon as they're gone (optional, default 60).

Install mode (heavy games on slow media)

Some games are too heavy to run from a micro SD / external drive (performance tanks), but the installer — or the game's own folder, ready to be copied over — fits there fine. Add an optional install block and the card carries a setup.exe (or the game directory) instead of running the game from the card:

{
  "schemaVersion": 1,
  "id": "heavy-game",
  "title": "Heavy Game",
  "executable": "HeavyGame/HeavyGame.exe", // RELATIVE TO THE INSTALL DIR (not the card) in install mode
  "install": {
    "installer": "setup/HeavyGameSetup.exe", // installer path (for "copy": the game DIRECTORY), relative to the card root
    "type": "nsis",                          // nsis | inno | custom | copy
    "runAsAdmin": false,                     // run the installer elevated (optional, default false; forbidden for "copy")
    "args": []                               // type "custom": full argv with exactly one {dir} token (forbidden for "copy")
  },
  "launchTimeoutSec": 30
}

How it works:

  • While the game isn't installed there is no Play button: Install lives in the More (⋯) menu. Pressing it asks for confirmation (the popup also shows the destination path, handy if the installer isn't fully silent), then runs the installer silently and shows an "Installing..." indicator. When the executable appears Play comes back, and from then on the game launches from the install location on the PC.
  • type: "copy" — "move game to PC" without an installer. installer points at the game's own directory on the card and Playhook simply copies its contents into the install dir (executable is relative to that copy; a leading <source>/ is stripped, so both spellings work). Nothing is deleted from the card — it stays the traveling original. This is the sane option when a game needs a fast local disk but has no installer worth driving, and the recommended one on the Steam Deck, where real installers under Proton are a lottery. args / runAsAdmin are rejected here — no process runs.
  • The install location is controlled by the app, not the card: %LOCALAPPDATA%\playhook\games\<id> (per-user, non-roaming, no admin needed). In install mode executable is resolved relative to that directory.
  • The installer runs silently because that's the only way the app keeps control of the install path — a visible wizard would let the user change the destination. (If an installer refuses to behave silently, tick Disable silent installer mode in Settings → General: the wizard is shown and you point it at the destination path yourself — the confirmation popup spells it out for exactly this.) The path is fed through each family's silent dir-key:
    • nsis/S /D=<dir> (the /D= is unquoted and last, per NSIS rules);
    • inno/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /DIR="<dir>"GOG offline installers are Inno Setup, so GOG games use "type": "inno";
    • custom → your own args, with the install dir substituted into the single {dir} token. You own the flags and quoting; the installer must support a silent + target-dir mode.
  • The installer must honour the supplied directory. If it ignores it (or fails), the game's executable won't appear at the expected path → Playhook reports an error and stays on "Install".
  • type: "custom" with runAsAdmin: true is rejected: custom lets the card control the argv, and running that elevated would escalate the attack surface. For nsis/inno the app builds the args itself, so elevated is allowed there.
  • MSI is out of scope for now (its install-directory property name isn't standardized, so the path can't be controlled reliably).
  • Once installed, the More (⋯) menu offers Uninstall instead of Install. It asks for confirmation, then removes the game: Playhook finds the game's own uninstaller inside the install folder (Inno's unins000.exe, NSIS's Uninstall.exe) and runs it silently — so it cleans up the registry (Add/Remove Programs) and Start-Menu shortcuts — and then deletes %LOCALAPPDATA%\playhook\games\<id>. A copy-mode game has no uninstaller: the folder is simply deleted. The menu item turns back into Install.

How to tell your installer's type

Getting type right matters: NSIS and Inno take different, mutually exclusive silent flags, and they quote the target path differently (NSIS /D= is unquoted and last; Inno /DIR="..." is quoted), so the wrong type breaks paths with spaces (e.g. %LOCALAPPDATA% for a user named John Doe).

  • GOG games → always inno. GOG offline installers (setup_<game>_*.exe) are built with Inno Setup.
  • File properties (right-click → Properties → Details): Inno often shows This installation was built with Inno Setup in Comments; NSIS shows Nullsoft Install System (or leaves the version fields blank).
  • Open it with 7-Zip: NSIS archives contain a [NSIS].nsi / NSIS script entries; Inno archives show Inno's structure ({app}, embedded payload). A detector like Detect It Easy (DiE) names the installer family outright.
  • setup.exe /?: Inno pops up a dialog listing /SILENT, /VERYSILENT, /DIR=, etc. — which also confirms it supports the silent + target-dir mode Playhook needs.

The most reliable check is to dry-run the exact flags Playhook will use and confirm the game lands in the target folder with no wizard window:

# Inno (and GOG):
.\setup.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /DIR="C:\test\inno-check"

# NSIS:
.\setup.exe /S /D=C:\test\nsis-check

Installed silently into the folder → that's your type. A wizard appeared, or it installed elsewhere → it's the other type (or neither — use custom).

To reinstall or update, use Uninstall in the More (⋯) menu (or, as a fallback, delete %LOCALAPPDATA%\playhook\games\<id> manually) and press Install again. Saves still sync to/from the card exactly as for a normal game (the install block is orthogonal to saveOnCard/pcSavePath).

Cleaning up the registry and Start-Menu shortcuts is best-effort — it's done by the game's own uninstaller, which Playhook finds in the install folder for typical NSIS/Inno games (with a registry lookup as a fallback for a nonstandard NSIS uninstaller name). For type: "custom", or when the uninstaller can't be found, only the install folder is removed and a registry/shortcut tail may remain. Uninstalling targets the PC, so it's unaffected by the card being removed mid-operation.

Steam mode (launch and install Steam games)

Instead of carrying the game files, a card can be just a pointer to a Steam app by its numeric appid. Add a steam block and Playhook launches, installs and uninstalls the game through your local Steam client (via steam:// URIs) — the card only needs the manifest, the cover art, and (optionally) the saves.

{
  "schemaVersion": 1,
  "id": "valheim",
  "title": "Valheim",
  "steam": { "appid": 892970 },              // the Steam application id (store URL / steamdb.info)
  "watchProcesses": ["valheim.exe"],         // REQUIRED in steam mode (see below)
  "launchTimeoutSec": 120,                    // raise it — Steam cold-start / updates take time
  "heroImage": "assets/hero.jpg",            // optional, card-relative as usual
  "saveOnCard": "saves",                     // optional save sync, exactly like a normal game
  "pcSavePath": "%LOCALLOW%/IronGate/Valheim"
}

There are no game files on the cardexecutable / install are not used (and are rejected if present). heroImage, sounds, backgroundMusic, and saveOnCard / pcSavePath work exactly as for a normal game.

Rules (enforced by the schema):

  • steam.appid — a positive integer. For a base game rungameid == appid (read it off the store URL or steamdb.info). DLC / non-base launch ids are out of scope.
  • watchProcesses is required. steam://rungameid returns instantly and the game has no pid of its own, so the only way to track start/exit is by the game's process name(s) — same field and rules as for launcher games.
  • executable, install and runAsAdmin are forbidden in steam mode (the launch method is exactly one: Steam).
  • Raise launchTimeoutSec (e.g. 120): a Steam cold start, shader pre-cache, or an auto-update before launch can easily exceed the default 30s window (see Known limitations).

How it works:

  • "Installed" is Steam's own truth, read from the app manifest (appmanifest_<appid>.acf) across every Steam library (libraryfolders.vdf); the Steam path comes from the registry. The game counts as installed only when Steam marks it fully installed (a game that's still downloading reads as "not installed").
  • Not installed → no Play button; "Install" sits in the More (⋯) menu. Pressing it confirms ("Open Steam to install this game?") and opens steam://install/<appid> — Steam shows its own install dialog. Playhook does not block on a wizard: it stays on the screen, shows a non-blocking "Installing…" indicator, and a background poll (~5s) brings Play back once Steam reports the game fully installed. The window stays usable meanwhile — a Steam download can run for hours.
  • Pause: if you pause the download in Steam, the indicator becomes "Installing paused on N%…" (the percent is only available while paused — see limitations). While a download is in progress the activity button (the spinning gear) opens Steam's Downloads page so you can pause/resume there — Steam exposes no way to pause/resume a download programmatically.
  • Launch: when installed, pressing Play opens steam://rungameid/<appid>; the session is tracked by watchProcesses (start → running → exit), with saves synced and stats recorded like a normal game.
  • Uninstall: an installed Steam game offers Uninstall in the More (⋯) menu → confirm → steam://uninstall/<appid> (Steam's own removal UI). The background poll flips the item back to Install once the game is gone (an uninstall you trigger directly in Steam is picked up too).
  • Saves sync to/from the card exactly as for a normal game. Steam / Unity games often store saves under AppData\LocalLow — use the %LOCALLOW% prefix (e.g. Valheim → %LOCALLOW%/IronGate/Valheim).

If Steam isn't installed on the PC, Install/Play report "Steam is not installed" instead of opening a URI. Steam's install and uninstall dialogs cannot be made silent — there is no steam:// flag to suppress them (just one confirmation; the rest of the flow is automatic).

Statistics: one card, many PCs

Statistics (hours / last played / launch count) are unified across machines with the card as the carrier:

  • stats.json in the card root is the traveling canonical record. It moves with the card.
  • %APPDATA%\playhook\stats\<id>.json on each PC is a working mirror.

On every insertion the two are reconciled (field-wise: max of the cumulative totals, the later lastPlayedAt) and the merged result is written back to both. Because the card is physically a single device used sequentially, this never loses progress and never double-counts. A fresh card with no stats.json simply adopts the local PC value (and starts carrying it from then on).

Other PC state under %APPDATA%\playhook\:

  • pending-flush\<id>\ — a deferred PC → SD sync with a snapshot of the saves, if the card was removed during play; it is applied on the next insertion of this card (matched by id).
  • settings.json — the app's own settings, and logs\ — the log files.

On Linux the same state lives under ~/.config/playhook/.


Steam Deck (Linux / SteamOS)

Playhook runs on the Steam Deck (and desktop Linux/SteamOS). The same game.json cards work as on Windows — the Windows dictionary (%APPDATA%, executable: *.exe, install.type: nsis|inno|copy) is kept, and on Linux it is interpreted relative to each game's Proton (Wine) prefix. Windows games run through umu-launcher + GE-Proton; native Linux/ELF games are not a target.

Install

  1. Download the .AppImage from the Releases page, mark it executable (chmod +x Playhook.AppImage) and run it once in Desktop Mode.
  2. In the tray menu, click Add to Steam. Playhook registers itself as a non-Steam game — tile, artwork and all — so it appears in your library and launches from Game Mode like any other title. The tile shows up the next time you enter Game Mode. (Adding it by hand via Steam's own "Add a Non-Steam Game" also works, but then Playhook cannot manage the entry: it will ask you to remove yours first.)
  3. First launch needs the internet: umu downloads GE-Proton and the Steam Runtime on the first run (cached afterwards). The status just shows "Running" while this happens — give it a minute.

Remove from Steam in the same menu takes the tile, its artwork and the auto-launch service back out.

python3 must be present (preinstalled on SteamOS; on desktop Linux install it if the app reports it's missing — the bundled umu launcher is a Python zipapp).

Preparing a card for the Deck

Format the card exFAT. SteamOS mounts it (in Desktop Mode and in Game Mode alike), Windows reads and writes it, and it is case-insensitive — so one physical card serves both machines: prepare it on Windows and it works on the Deck with the same game.json. Playhook adds a safety net of its own: in Game Mode it sweeps for an inserted-but-unmounted removable card and mounts it via udisks2 (into the same /run/media/deck/<label> the system uses), so a card the session skipped still hot-swaps. Only removable volumes carrying a real filesystem are ever touched — the internal drive can't match.

The card format itself is identical to Windows, with two caveats that come from Linux not sharing Windows' separator and (on Linux-native filesystems) being case-sensitive:

  • Path case must match exactly on a case-sensitive filesystem: "executable": "Game.EXE" won't find game.exe. Playhook detects this specific case and the error suggests the right name ("found game.exe, fix the case"), but the card should carry the exact on-disk case anyway. On exFAT this never bites — it is case-insensitive.
  • Backslashes are fine. "bin\\game.exe" is normalized to bin/game.exe, so a Windows-authored card loads unchanged.

Where installs and saves live

  • Per-game Proton prefix: ~/.config/playhook/prefixes/<id> — one Wine prefix per game (isolates runtimes, DLL overrides and saves).
  • Install mode puts the game inside that prefix (…/drive_c/playhook/games/<id>) — i.e. on the Deck's internal drive, not the card. On a 64 GB Deck this adds up; keep an eye on it.
  • Saves are resolved inside the prefix: %APPDATA%…/drive_c/users/steamuser/AppData/Roaming, and likewise for %LOCALAPPDATA%/%LOCALLOW%/%USERPROFILE%/%DOCUMENTS%. For Steam-mode cards the save lives in that game's Steam compatdata prefix. The card still holds the traveling copy via saveOnCard, so progress moves between machines as usual.
  • Uninstall clears the whole prefix for that game (game files, provisioned runtimes, and any in-prefix saves), reclaiming the disk. Available for install-mode games and for a plain Executable game once its prefix exists — there the game stays on the card and only the prefix is cleared. The canonical save survives on the card when saveOnCard is set.

Advanced (Linux-only manifest fields)

These are ignored on Windows, so a dual-platform card can carry them safely:

  • winetricks (per game) and install.winetricks — extra winetricks verbs provisioned into the prefix before launch/install. Install mode always gets a baseline set (mfc42 gdiplus vcrun6 vcrun2008 riched20) so skinned Inno installers work; add more for a specific game (e.g. dotnet48, or vd=1920x1080 to force a virtual desktop).
  • umuGameId — a Steam appid or custom umu-<id> so umu applies that game's protonfix.

Game Mode notes

  • No tray in Game Mode. Playhook is a single window that always shows an empty "Insert a game card" screen when no card is present, and surfaces manifest errors on screen. Settings and the card editor (Configure) open from the tray, so they are Desktop-Mode only.
  • Cards are mounted automatically — on top of what the session mounts itself, Playhook sweeps for an inserted-but-unmounted removable card (see Preparing a card for the Deck), so insert and eject just work.
  • Power menu (Shutdown/Reboot/Sleep) works from Game Mode via systemctl (logind).
  • The System submenu offers "Close Playhook" (a full quit) instead of "Minimize Playhook" — there is no tray to minimize into; quitting returns you to the Steam library.
  • Insert a card and Playhook opens itself. Once you have used Add to Steam, a small background service runs while you are in Game Mode and launches Playhook through Steam the moment you insert a card carrying a game.json — no need to find the tile. A card that was already in the slot when you entered Game Mode is deliberately ignored: you may well have come to play something else, and being yanked into Playhook on every entry would be obnoxious. Launch it from the library in that case. An ordinary USB stick or a card without game.json never triggers it. The whole behaviour can be turned off in Settings → General.
  • No autostart in Game Mode — the app lives as a non-Steam game, started by Steam (or by the service above). In Desktop Mode it writes a ~/.config/autostart/playhook.desktop entry instead.

Limitations on Linux

  • Repacks with internal compression won't install. FitGirl/DODI/RG-style repacks whose installer unpacks with its own 32-bit decompressor (ISDone/freearc) exhaust Wine's 32-bit address space and crash mid-install — this is a Wine/32-bit ceiling, not a Playhook bug, and retrying is a lottery. Use a clean distributive, or pre-extract the game on Windows and carry the ready folder — as a plain Executable card, or with install.type: "copy" if it should run from the Deck's internal drive.
  • Installers in general are unpredictable under Proton (the Configure editor says so out loud when you pick the Installer type). Prefer a plain Executable card or install.type: "copy" on the Deck.
  • First run needs network (GE-Proton / Steam Runtime download).
  • Save-sync for Windows dictionary paths only happens once the game's prefix exists (first launch); before that there is simply nothing to sync.

Building from source (for developers)

Requirements

  • Windows 10/11 x64, or Linux / SteamOS (for the Steam Deck build — see below).

  • Node.js 20+ and npm (CI builds on Node 22 — match it if in doubt).

  • Native module build tools — required to rebuild drivelist for Electron:

    • Visual Studio Build Tools with the "Desktop development with C++" component,
    • Python 3.x in PATH.

    (koffi ships prebuilt, so the C++ toolchain is needed only for drivelist.)

  • Xbox gamepad (optional — keyboard and mouse work too).

  • A removable storage device (USB drive, SD card, etc.) with a prepared game.json manifest.

Install, build, run

# from the project root
npm install

npm install runs electron-builder install-app-deps on its own via postinstall — this rebuilds the native drivelist for the Electron version in use. If that step fails (no C++ toolchain), install without scripts and rebuild manually:

npm install --ignore-scripts
npm run rebuild        # electron-rebuild -f -w drivelist

Compile the TypeScript (main + preload + renderer) and copy assets, then run in dev mode:

npm run build          # output goes to dist/ (main, preload, renderer, shared)
npm start              # builds and launches Electron (always starts hidden in the tray)
npm run typecheck      # tsc --noEmit (strict)
npm run lint           # ESLint with type-aware rules
npm test               # vitest

Building the distributable

npm run dist           # build + electron-builder → release/ (NSIS + portable)

The configuration lives in electron-builder.yml. For drivelist, asarUnpack is set so the native .node binary is available in the packaged build.

⚠️ Verify on a real packaged build that drivelist was unpacked from asar and the card is detected. If not, make sure node_modules/drivelist/** is included in asarUnpack.

Linux / Steam Deck build

Build the .AppImage on Linux (or via the Build Linux CI workflow — workflow_dispatch produces an artifact, a v* tag publishes to the release):

npm ci
npm run build:umu      # fetch the bundled umu-launcher zipapp into resources/umu/ (pinned)
npm run dist           # build + electron-builder → release/*.AppImage

build:umu downloads the umu-launcher zipapp that ships as extraResources (Linux target only). The linux block in electron-builder.yml targets AppImage; drivelist is rebuilt for the Electron ABI by electron-builder install-app-deps on the Linux runner. koffi stays a dependency but is never called on Linux (all FFI is behind lazy win32 guards).


Releasing & auto-update

Installed apps update themselves via electron-updater + GitHub Releases (public repo, so the client needs no token). Only the NSIS build self-updates — the portable .exe does not.

Release flow:

  1. Bump version in package.json (e.g. 0.1.10.1.2).
  2. Commit, then push a matching tag: git tag v0.1.2 && git push origin v0.1.2.
  3. The Build Windows and Build Linux workflows build and upload the Windows installer + latest.yml and the Linux .AppImage + latest-linux.yml to the same draft GitHub Release v0.1.2.
  4. Publish the draft release on GitHub to make it live (and visible on the Releases page).
  5. Each running app checks on startup and every 6h, downloads the update silently, and installs it on the next quit (it never interrupts a running game). See [updater] lines in the log. The NSIS build and the AppImage both self-update; the portable .exe does not.

That is the default; Settings → Updates lets the user pick download and install automatically, download automatically, install manually, or off (check manually), opt into the pre-release (beta) channel, and run a check by hand.

Notes:

  • The tag must be v{version} and the version must be higher than the installed one.
  • The publish target (owner / repo) is set in electron-builder.yml — update it if the GitHub repo is renamed.
  • No code signing: the very first install shows a Windows SmartScreen warning, but updates still apply (unlike macOS, Windows auto-update works unsigned).

Autostart

On Windows the app registers itself via app.setLoginItemSettings({ openAtLogin: true }).

  • It always starts hidden in the tray (no flag needed): the window appears only when a valid game card is detected — unless Always show the no-card screen is enabled in Settings.
  • Guaranteed for the NSIS installation; for portable it is best-effort (the path to the exe may change).
  • To disable: Settings → Apps → Startup in Windows.

On Linux setLoginItemSettings doesn't exist. In Desktop Mode the app writes a ~/.config/autostart/playhook.desktop entry (Exec= = the AppImage path, best-effort). In Game Mode Playhook lives as a non-Steam game you launch from the library — or that launches itself when you insert a card, if you used Add to Steam (see Game Mode notes). That is a systemctl --user service, playhook-daemon.service, bound to gamescope-session.target, so it only exists while you are in Game Mode; Remove from Steam deletes it.


Logs

The main process writes a timestamped log, split per calendar day into %APPDATA%\playhook\logs\main-YYYY-MM-DD.log (~/.config/playhook/logs/ on Linux); files older than 14 days are pruned on startup. Open the folder from Settings → Advanced → Open logs.

It records card insertions, manifest validation, the stats reconcile / card-copy result, and launch/exit — useful when a save or stats copy to the card silently fails.


Known limitations

  • UAC / elevation: from a non-elevated app, tasklist cannot see an elevated process. For an .exe that genuinely requires administrator, set runAsAdmin: true — Playhook launches it through a UAC prompt and then monitors it by process handle instead of tasklist. What stays unsolved is elevation Playhook doesn't perform itself: a launcher/anticheat that spawns the game elevated or as a service is invisible → a false "didn't launch" timeout.
  • Launchers / wrappers: by default exit detection relies on the pid from spawn, so a wrapper game (Steam / launcher) that immediately terminates its own process would produce a false "exit". This is now supported via watchProcesses: list the game's own process image names and Playhook tracks those instead of the launcher pid. Caveat: if the launcher runs the game elevated or as a service (common with Steam / EAC / BattlEye), a non-elevated tasklist can't see it — the session won't be tracked.
  • Removing the card during play is handled gracefully (statistics on the PC, PC → SD deferred to pending-flush), but the game itself will most likely crash — the exe is on the card. The UI warns "do not remove the card during play".
  • FAT/exFAT: syncing goes "by direction"; mtime is not used for decisions.
  • Install mode (the install block, see Install mode):
    • The installer must support silent install + a target-directory flag. If it doesn't, use type: "custom" with the right flags — or give up on driving it and either tick Disable silent installer mode in Settings (you drive the wizard yourself) or carry the game with type: "copy" (R-SILENT).
    • If the installer ignores the supplied directory and installs elsewhere, the executable won't appear at the expected path → reported as "not installed". Inherent to running a real setup.exe; doesn't happen with well-behaved NSIS/Inno installers (R-IGNOREDIR).
    • If an installer creates the executable early and then crashes, a presence check can't tell it apart from success. Mitigated by pre-cleaning the install dir and a post-exit grace poll, but a residual risk remains for arbitrary installers (R-PARTIAL).
    • MSI installers are not supported (the install-directory property name isn't standardized).
    • Reinstall/update means uninstall + install again — there is no in-place update: use Uninstall in the More (⋯) menu, then Install (or delete %LOCALAPPDATA%\playhook\games\<id> by hand as a fallback).
    • Progress is a plain "Installing..." indicator (no percentages — unavailable for an arbitrary silent setup.exe, and not reported for a copy either).
  • Steam mode (the steam block, see Steam mode):
    • Steam must be installed on the PC, otherwise Install/Play report "Steam is not installed".
    • Install/uninstall aren't silent — Steam always shows its own dialog (one confirmation); there is no steam:// flag to suppress it. After that the flow is automatic.
    • No live download percent. Steam exposes no real-time progress in any file Playhook can read (the .acf byte counters freeze mid-download; the on-disk download folder is preallocated). The percent is shown only while the download is paused ("Installing paused on N%…").
    • Can't pause/resume programmatically — the activity button just opens Steam's Downloads page so you can do it in Steam itself.
    • Cold start / pre-launch update can exceed launchTimeoutSec → the game process never appears in the window and Playhook quietly returns without recording a session. Raise launchTimeoutSec for Steam games.

Project structure

src/
  main/        # all work with the FS/processes/disks (Electron main)
    platform/  # everything OS-specific (win32 / linux + Proton, umu)
  preload/     # typed contextBridge bridges (launcher + settings)
  renderer/    # UI + gamepad/keyboard input (launcher, settings, configure — no Node)
  shared/      # shared contract of types/IPC channels + the i18n dictionaries
test/          # vitest suites (plain Node, no electron)

npm scripts: typecheck, lint, test, format, build, start, rebuild, build:umu, dist. Contributor conventions (layers, error handling, adding an IPC channel, the daemon's electron-free import graph) live in CLAUDE.md.


Contributing

PRs welcome. The codebase is strict TypeScript (no any, no non-null !, explicit return types, functional style). Please run npm run typecheck, npm run lint and npm test before opening a PR — CI runs all three, on Windows and Linux.


FAQ

  • Do I have to write game.json myself? No, twice over: the Playhook Collection hosts ready-made, verified manifests you can browse and drop straight onto a card, and the tray's Configure game editor fills one in through a form (with a raw JSON tab and live validation for when you do want to poke at it). Hand-write one only for a game the Collection doesn't cover yet (and consider contributing it back).
  • Can one card hold several games? Yes — put an array of game objects in game.json and switch between them with More (⋯) → Select game.
  • Is the SmartScreen warning normal? Yes. The builds aren't code-signed, so Windows warns on first run. Choose More info → Run anyway. Auto-update still works without signing.
  • Does it run on the Steam Deck / Linux? Yes. The same Windows game cards launch through Proton (via umu-launcher) on SteamOS/Linux — see Steam Deck. Native Linux/ELF games and macOS are not supported.
  • Does it work without a gamepad? Yes — mouse and keyboard both work. Keyboard: WASD / arrow keys to move, Space / Enter to activate, Tab / Backspace (or Esc) to go back. Every gamepad action also has a mouse fallback (click Play, etc.).
  • Can I use it with Steam games? Yes — via Steam mode: a game.json with a steam block launches, installs and uninstalls the game through your local Steam client (the card is just a pointer by appid). For a non-Steam wrapper/launcher .exe, point it at the exe and use watchProcesses so exit detection works.
  • Is there a Russian UI? Yes — Settings → Appearance has English and Russian (plus a light/dark/system theme), and by default the app follows the system language.

License

MIT — see LICENSE.

Releases

Contributors

Languages

Morty Proxy This is a proxified and sanitized view of the page, visit original site.