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.
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.
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).
(
.NETis not required.)
- Install (or unzip the portable build) and run it — it sits quietly in the tray.
- Insert a card that has a
game.jsonin its root (see below). - A game card window appears with the hero art and the game title (playtime stats live in the More ⋯ menu).
- Press A on the gamepad (or click Play) — saves sync and the game launches.
- 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.
- 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).
- Insert a card with a valid
game.jsonand 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 (stateready). If the manifest has noheroImage, a bundled wallpaper — or your own, set in Settings — is used. - Press A on the gamepad (the window is force-focused) or click Play.
- Saves are synced card → PC and the game launches; it takes the foreground over the launcher.
- 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.
Both windows open from the tray, so they are Desktop-Mode only on the Steam Deck.
- 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. - Advanced — Open 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 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.
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.
E:\
├─ game.json
├─ game\
│ └─ hollow_knight.exe
├─ assets\
│ └─ hero.jpg
└─ saves\ ← portable canonical copy of the saves
- After resolution,
executable/heroImage/saveOnCardmust lie inside the card root —..and absolute paths are forbidden (otherwise the game won't launch and an error is shown). Theexecutablemust 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 namedDocuments,Документы, 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%— theAppData\LocalLowfolder, derived from%USERPROFILE%(it has no environment variable of its own). Common for Unity / Steam games (e.g. Valheim →%LOCALLOW%/IronGate/Valheim). Note:%APPDATA%isAppData\Roamingand is not a parent ofLocalLow— use%LOCALLOW%for LocalLow saves.
id— only[A-Za-z0-9._-](used as a folder name on the PC).runAsAdmin— settrueonly for an.exewhose embedded manifest requires administrator (a plain launch fails withEACCES/ERROR_ELEVATION_REQUIRED). Playhook then launches it elevated via a UAC prompt (ShellExecuteExrunas) and monitors it by process HANDLE instead oftasklist(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, sorunAsAdmin: trueis a no-op (logged) rather than an error — the same card stays valid on both platforms.watchProcesses— for launcher / wrapper games, whereexecutablespawns 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 spawnsexecutable, but tracks the session by the presence of these names intasklist. 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*.exename (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
tasklistcan'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.exeor a sharedUnityPlayer-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.
- anticheat / elevation — Steam / EAC / BattlEye often launch the game elevated or as a
service, which a non-elevated
saveOnCardandpcSavePathare 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.*andbackgroundMusic— card-relative likeheroImage, 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;backgroundMusicis 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).
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.installerpoints at the game's own directory on the card and Playhook simply copies its contents into the install dir (executableis 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/runAsAdminare 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 modeexecutableis 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 ownargs, 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"withrunAsAdmin: trueis rejected:customlets the card control the argv, and running that elevated would escalate the attack surface. Fornsis/innothe 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'sUninstall.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>. Acopy-mode game has no uninstaller: the folder is simply deleted. The menu item turns back into Install.
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 Setupin Comments; NSIS showsNullsoft 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-checkInstalled 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.
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 card — executable / 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 gamerungameid == appid(read it off the store URL or steamdb.info). DLC / non-base launch ids are out of scope.watchProcessesis required.steam://rungameidreturns 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,installandrunAsAdminare 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 bywatchProcesses(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 (hours / last played / launch count) are unified across machines with the card as the carrier:
stats.jsonin the card root is the traveling canonical record. It moves with the card.%APPDATA%\playhook\stats\<id>.jsonon 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 byid).settings.json— the app's own settings, andlogs\— the log files.
On Linux the same state lives under ~/.config/playhook/.
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.
- Download the
.AppImagefrom the Releases page, mark it executable (chmod +x Playhook.AppImage) and run it once in Desktop Mode. - 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.)
- 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).
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 findgame.exe. Playhook detects this specific case and the error suggests the right name ("foundgame.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 tobin/game.exe, so a Windows-authored card loads unchanged.
- 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 viasaveOnCard, 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
saveOnCardis set.
These are ignored on Windows, so a dual-platform card can carry them safely:
winetricks(per game) andinstall.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, orvd=1920x1080to force a virtual desktop).umuGameId— a Steam appid or customumu-<id>so umu applies that game's protonfix.
- 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 withoutgame.jsonnever 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.desktopentry instead.
- 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.
-
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
drivelistfor Electron:- Visual Studio Build Tools with the "Desktop development with C++" component,
- Python 3.x in
PATH.
(
koffiships prebuilt, so the C++ toolchain is needed only fordrivelist.) -
Xbox gamepad (optional — keyboard and mouse work too).
-
A removable storage device (USB drive, SD card, etc.) with a prepared
game.jsonmanifest.
# from the project root
npm installnpm 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 drivelistCompile 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 # vitestnpm 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 thatdrivelistwas unpacked from asar and the card is detected. If not, make surenode_modules/drivelist/**is included inasarUnpack.
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/*.AppImagebuild: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).
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:
- Bump
versioninpackage.json(e.g.0.1.1→0.1.2). - Commit, then push a matching tag:
git tag v0.1.2 && git push origin v0.1.2. - The Build Windows and Build Linux
workflows build and upload the Windows installer +
latest.ymland the Linux.AppImage+latest-linux.ymlto the same draft GitHub Releasev0.1.2. - Publish the draft release on GitHub to make it live (and visible on the Releases page).
- 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.exedoes 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 inelectron-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).
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.
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.
- UAC / elevation: from a non-elevated app,
tasklistcannot see an elevated process. For an.exethat genuinely requires administrator, setrunAsAdmin: true— Playhook launches it through a UAC prompt and then monitors it by process handle instead oftasklist. 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 viawatchProcesses: 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-elevatedtasklistcan'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";
mtimeis not used for decisions. - Install mode (the
installblock, 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 withtype: "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 acopyeither).
- The installer must support silent install + a target-directory flag. If it doesn't, use
- Steam mode (the
steamblock, 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
.acfbyte 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. RaiselaunchTimeoutSecfor Steam games.
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.
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.
- Do I have to write
game.jsonmyself? 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.jsonand 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.jsonwith asteamblock launches, installs and uninstalls the game through your local Steam client (the card is just a pointer byappid). For a non-Steam wrapper/launcher.exe, point it at the exe and usewatchProcessesso 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.
MIT — see LICENSE.
{ "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) }