Multi-Game Mode
Every other page in this documentation describes NEShim’s default publish path: one config.json and one ROM next to the exe, one game per publish. Multi-game mode is a second, additive path on the same binary — it never changes single-game behavior, and only activates when you opt in.
What it is
One NEShim binary hosts N games, each with its own ROM, config.json, achievements.json, and artwork. Players pick which one to play from a front-end carousel screen shown before any game’s config is even loaded.
Steam DLC is entirely optional, per game — not a requirement of multi-game mode itself. Two distribution shapes are both fully supported, and can be freely mixed within one build:
- Bundled — leave
steamDlcAppIdat its default0for a game and it shows in the carousel unconditionally, no Steamworks entitlement involved. Set it for every game and the whole library ships as a single deploy with zero DLC depots — the right fit for a fixed anthology sold as one purchase. - Separate Steam DLC — install a game as an independent Steam DLC depot and set that depot’s App ID as
steamDlcAppId; the carousel only shows it onceSteamApps.BIsDlcInstalledconfirms ownership. The right fit for a curated collection where players buy games individually, without maintaining N separate NEShim binaries.
Detection: games/multigame.json
Multi-game mode activates when a games/multigame.json file exists next to the exe. This file:
- Ships once with the base engine publish — it is not generated by any per-game packaging step.
- Uses the same schema as
config.json, but only the shell-level fields matter here:windowMode,language,noLogo,carouselBackgroundPath. Everything else (ROM path, achievements, video/audio settings) is ignored until a game is actually chosen. - Its mere presence is the signal, independent of whether any DLC content has landed on disk yet. This matters concretely: on a fresh install where Steam hasn’t finished downloading a game’s DLC,
games/may not even contain any game subfolders — the carousel correctly shows a “no games available” state rather than the whole app falling back to single-game mode and failing to find aconfig.jsonit was never meant to have.
If games/multigame.json is absent, NEShim behaves exactly as documented everywhere else on this site — this is the default, and requires no changes to a single-game publish.
Per-game content layout
Every game is a games/<gameId>/ subfolder, whether it’s bundled or sold as separate DLC:
<install directory>/
NEShim.exe
games/
multigame.json
kaaz/
config.json
achievements.json
kaaz.nes
mainMenu.png
another-game/
config.json
achievements.json
another-game.nes
For a bundled game, that folder just ships directly as part of the base install/depot — nothing else to configure. For a game sold as separate Steam DLC, its depot mounts its files into that same games/<gameId>/ location inside the base install directory — the standard, simplest Steam content-DLC pattern, no separate install directory or GetAppInstallDir lookup needed — and ownership is checked with SteamApps.BIsDlcInstalled.
<gameId> (the folder name) becomes the game’s stable internal identifier — used for its isolated save-preferences folder (see below) — and is independent of its display title, which comes from config.json’s gameDisplayTitle field (falling back to windowTitle if unset).
New config.json fields (multi-game only)
| Field | Type | Default | Description |
|---|---|---|---|
gameDisplayTitle | string | "" | Label shown on the carousel. Falls back to windowTitle when empty. Ignored — never read — in a single-game config.json. |
steamDlcAppId | integer | 0 | Steam DLC App ID that must be installed (SteamApps.BIsDlcInstalled) for this game to appear on the carousel under a live Steam session. 0 means unfiltered — always shown, deliberately supporting both local dev/test game folders (no Steamworks entitlement needed) and a genuine single-deploy release where every game bundles directly in the base install with no DLC depots at all. Ignored in a single-game config.json. |
thumbnailPath | string | "" | Box art shown for this game in the carousel filmstrip, in the real NES box’s portrait aspect ratio (~1.42:1 Height:Width — taller than wide, like a book on a shelf). Relative to this game’s own folder, or absolute. A missing or unset thumbnail shows a placeholder card — it is not treated as a configuration error. Ignored in a single-game config.json. See Box art sizing for recommended pixel dimensions. |
gameDescription | string | "" | Short blurb shown on the carousel’s flip-card back face when the player presses Up on this game’s tile. Empty shows a generic “No description available” back. Ignored in a single-game config.json. |
New games/multigame.json fields (shell config only)
| Field | Type | Default | Description |
|---|---|---|---|
carouselBackgroundPath | string | "" | Background shown behind the carousel. Accepts a static image or an animated GIF (played back frame-by-frame using its embedded per-frame delays). Relative to the games/ folder (where multigame.json lives), or absolute. Empty shows a plain fill. |
gameDlcAppIds | object | {} | Trusted gameId → steamDlcAppId map used to detect a tampered per-game config.json. See DLC ownership anti-tamper below. |
DLC ownership anti-tamper
Each game’s own config.json — including its steamDlcAppId — lives inside that game’s own folder/DLC depot, which is exactly as player-editable as any other file on disk. Trusting it blindly has a real bypass: someone with a copy of a DLC’s games/<gameId>/ folder (leaked, shared, whatever) could simply edit config.json to set "steamDlcAppId": 0, and the game would show unconditionally — no need to fake anything Steam-related, since 0 always means “show unconditionally.” Note that games/multigame.json itself isn’t meaningfully more protected than a per-game folder either — Steam’s “Verify Integrity of Game Files” is a manual, player-triggered check, not something enforced continuously at runtime, so it’s realistically just as locally-editable.
There are two tiers of protection against this, and they can be combined:
Tier 1: unsigned map (raises the bar, not tamper-proof)
games/multigame.json’s gameDlcAppIds field is a trusted gameId → steamDlcAppId map you author yourself, alongside carouselBackgroundPath:
{
"gameDlcAppIds": {
"kaaz": 3010000,
"another-game": 3010001
}
}
At scan time, for every gameId that appears in this map, GameScanner requires that game’s own config.json-claimed steamDlcAppId to match exactly; a mismatch (including a downgrade to 0) marks the entry invalid — the same “Game Error” treatment as a missing ROM or corrupt config, with the specific reason written to neshim.log. A gameId you leave out of the map is trusted as declared by its own config.json — a bundled game (steamDlcAppId: 0, no entry) needs nothing here, so a pure single-deploy build with no DLC-gated games at all doesn’t need this field either.
This closes the “edit one field” bypass, but not one that edits both the game’s own config and the corresponding map entry — an attacker who knows to keep the two files consistent can still get through. For that, use tier 2.
Tier 2: signed map (real tamper-proofing)
gameDlcAppIds can be signed with ECDSA-P256, verified against a public key that is compiled into the binary, never read from any config file:
- Generate a fresh keypair dedicated to this purpose:
pub-utils --gen-keypair. Do not reuse your achievement-signing keypair here — the two protect different things (gameplay-trigger integrity vs. DLC ownership integrity), and a leaked or rotated key for one must never force touching the other. A second keypair costs nothing to generate. - Embed the printed public key in
NEShim.Signing/DlcMapSigner.cs’sEmbeddedPublicKeyBase64constant, and rebuild from source. This is the only way to configure it — deliberately no config.json equivalent, unlikeachievementPublicKey. A config-driven key would let a tampered install simply supply its own matching keypair alongside a forged map, defeating the whole point. - Seal the map:
pub-utils --seal-dlc-map --key-file private_key.txt games/multigame.json— writesgameDlcAppIdsSignatureinto the manifest in place. Re-run this any timegameDlcAppIdschanges.
Once a public key is compiled in, GameScanner behaves very differently from tier 1:
- Fails closed on any problem. A missing or invalid
gameDlcAppIdsSignature— whether it was never sealed, orgameDlcAppIdswas edited after sealing — marks every scanned game invalid, not just the ones with map entries. A broken signature must never be indistinguishable from “protection turned off.” - The map must be complete. Once verified, every game must appear in it, including bundled ones (listed with value
0) — agameIdmissing from a signed map is rejected too, not trusted through. This closes the loophole tier 1 has: downgrading a game’s own claim and removing its map entry no longer works, because omission itself is now a rejection.
If EmbeddedPublicKeyBase64 is left null (the default), signing never activates at all — regardless of what gameDlcAppIdsSignature contains — and tier 1 (or no check at all, if gameDlcAppIds is empty) applies instead.
What this does and doesn’t protect
This raises the bar against casually copying/editing a DLC folder; tier 2 makes it cryptographically infeasible to forge a passing entry without your private key. Neither is a substitute for Steam’s own depot-delivery boundary — a player who doesn’t own a DLC never receives its files through legitimate Steam channels at all, which is the primary protection. A fully repacked/cracked install is a different threat entirely, outside what any client-side check can prevent. And this is about integrity, not confidentiality: steamDlcAppId values are public Steam data (visible on the store page itself), so there’s no reason to encrypt them — only to authenticate them.
Isolation: configs, saves, achievements, user preferences
Every game’s data is fully separate — nothing bleeds between games in a multi-game install:
config.json/achievements.json/ ROM — each lives inside that game’s owngames/<gameId>/folder, never shared.- Save states and battery RAM —
saveStateDirectory/saveRamPathresolve relative to the game’s own folder, exactly the same relative-path resolution single-game mode has always used, just rooted differently. user.json(player preferences) — single-game mode’s scheme (%APPDATA%\<windowTitle>\user.json) is untouched. Multi-game mode uses a separate, non-colliding scheme keyed by the stable game ID:%APPDATA%\NEShim\Games\<gameId>\user.json. This is deliberate —windowTitleis a display string a publisher could change or duplicate across games; the folder name is stable.shell-user.json(carousel-only preferences) — a third, separate scheme at%APPDATA%\NEShim\shell-user.json, distinct from both schemes above. Today it holds only the carousel’s own fullscreen/windowed preference (see F11/Y below) — a discrete setting independent of any individual game’swindowMode, so it isn’t confused with, or overwritten by, either single-game or per-game player preferences.- Achievement signing key — each game’s own
achievementPublicKeyin itsconfig.jsonis what verifies itsachievements.json(see Publishing for the compile-time-key caveat below).
The carousel
The carousel is a filmstrip shown before any game’s config is loaded: several game tiles are visible at once, the selected game centered and highlighted, with neighbors visible (and slightly scaled/dimmed) on both sides. When a library has fewer games than visible slots, the strip wraps around freely to fill every slot — a 2-game library shows the other game on both the left and right of the centered tile, since it’s genuinely reachable in either direction, and a 3-game library fills every remaining slot the same way rather than sitting on empty gaps. The one game that’s never repeated is the centered/highlighted one itself. A 1-game library is the sole exception: there’s nothing to wrap to at all, so only the centered tile is shown, and Left/Right don’t animate — there’s no slide, since the selection can’t actually change.
- Left/Right — switch games, animated as a short slide rather than an instant cut (except with only one game in the library, where there’s nothing to switch to).
- Up — flip the selected tile like a card to reveal
gameDescriptionon the back; press Up again to flip back. - Enter / gamepad A — select the highlighted game (no-op on an invalid entry — see below).
- Esc / gamepad B — quit the app. The carousel is the first screen shown in multi-game mode, with no parent menu to back out to.
- F11 / gamepad Y — toggle fullscreen. The carousel has its own discrete, persisted window-mode preference — separate from any individual game’s own
windowMode— so it survives both a game selection/return trip and a full quit-and-relaunch, rather than resetting to the shell manifest’s configured default or inheriting whatever mode the last-played game left the window in. Loading a specific game still applies that game’s ownwindowMode, since that’s an explicit, per-game publisher choice.
A short control legend showing these bindings is always visible at the bottom of the carousel, phrased by direction (Left/Right/Up) rather than a specific key or button so it reads the same on keyboard or gamepad. There’s no screen title either — the game titles and the (optionally animated) background are the only text/imagery shown besides the legend. Every string on the carousel — the legend, empty-library message, and the invalid-entry note — is fully localized in all ten built-in languages, resolved from the shell manifest’s language field before any game is chosen (the same setting used everywhere else — see Configuration reference).
There’s no game counter or left/right arrow glyphs — the centered, highlighted tile already shows which game is selected, so a numeric count and “<”/”>” hints were redundant. Non-focused tiles’ title text also scales down with the tile itself (floored so far-offset tiles stay legible rather than shrinking to nothing).
Each tile shows its thumbnailPath box art (contain-fit to the NES-box’s portrait aspect ratio, never cropped) and title. A missing thumbnail shows a grey placeholder card instead of blocking the entry — that’s an art-asset gap, not a configuration problem. A structurally broken entry (missing or unparseable config.json, or a romPath that doesn’t resolve to a real file) still appears in the carousel — using its folder name as a fallback title when no config could be read at all — with a visible “Game Error” note and a prompt to contact the publisher; it cannot be launched. The specific reason is deliberately not shown in-game (it’s almost always either a Steam download problem Steam itself flags, or a packaging mistake caught in publisher testing) — it’s written to neshim.log instead, unconditionally, regardless of the enableLogging setting, so it’s always recoverable for support purposes.
Selecting a game loads its config, ROM, and saves in-process (no restart), then proceeds into the normal single-game experience exactly as if that game’s config.json had been the one next to the exe all along.
If zero games are available (no DLC owned yet, or games/ is empty), the carousel shows a “no games available” message rather than crashing — this is treated as an expected, recoverable runtime state, not a packaging error.
Changing games mid-session
Both the pre-game main menu and the in-game pause menu gain a Change Game item (shown only in multi-game mode, immediately before “Exit” in each). Confirming it eagerly tears down the current game — save, ROM, and audio state are all released immediately — and returns to a freshly re-scanned, freshly ownership-filtered carousel. This is distinct from “Return to Main Menu” (in-game menu only), which stays within the same game.
Publishing a multi-game build
See the Publishing guide for the full checklist. In short, on top of everything a single-game publish needs:
- Build/publish the engine binary as usual (either the pre-built or source path) — then additionally ship
games/multigame.jsonalongside the exe to activate multi-game mode. - Package each game’s content folder independently with
local-publish-game.ps1(repo root), pointing at that game’s source directory. This script never touches the engine binary publish or the csproj. - Decide per game whether it’s bundled or separate DLC (see “What it is” above). For a bundled game, do nothing further — leave
steamDlcAppIdat0. For separate DLC, create a Steam Partner DLC depot for thatgames/<gameId>/folder and set its App ID assteamDlcAppIdin the game’sconfig.json. A single build can mix both. - For every DLC-gated game, add its App ID to
games/multigame.json’sgameDlcAppIdsmap too (see DLC ownership anti-tamper above) — this is the unsigned tier, tamper-resistant rather than tamper-proof. For a real commercial release, seal the map instead:pub-utils --gen-keypair, compile the public key intoDlcMapSigner.EmbeddedPublicKeyBase64and rebuild, thenpub-utils --seal-dlc-map --key-file private_key.txt games/multigame.json. That tier requiresgameDlcAppIdsto list every game (bundled ones too, with value0) and fails closed on any signature problem. - Namespace achievement
steamIds per game. All games in a multi-game build share one Steam base App ID — and therefore one Steamworks achievement schema — sosteamIdvalues must be prefixed uniquely (e.g.KAAZ_ACH_WINvsANOTHERGAME_ACH_WIN) to avoid collisions. - Leave
AchievementSigner.EmbeddedPublicKeyBase64unset for multi-game source builds — there’s no single correct compile-time key for N different games. Each game’s ownconfig.json-suppliedachievementPublicKeyis what actually resolves at runtime. (This is unrelated toDlcMapSigner.EmbeddedPublicKeyBase64in step 4 above, which is a single shell-level key and should be set if you’re using signed DLC anti-tamper — the two keys serve different payloads and don’t share this constraint.) steam_appid.txtand the Steam Inputgame_actions_<appid>.vdfmanifest stay singular, exactly as in a single-game publish — the whole multi-game install is still one Steam base App ID and one running process.