Appearance
Assets API
Override bundled game assets with loose files on disk - useful for fast iteration during development and for player-made mods (including Steam Workshop). Zero configuration required.
How it works
Every gemshell:// request goes through the following lookup chain. The first match wins:
- Loose files in
<exe-dir>/mods/(auto-mounted at startup) - Subscribed Steam Workshop items (auto-mounted after
steam.init()) - Programmatically added folders via
addFolder() - The encrypted asset bundle (your original game assets)
That's it. Your existing fetch('gemshell://...') and <img src="..."> calls don't change at all - GemShell resolves them transparently.
Quick start: drop a mods/ folder next to your .exe
Say your game has gemshell://sprites/hero.png baked in. To override it without rebuilding:
MyGame.exe (or MyGame.app on macOS)
mods/
sprites/
hero.png <-- this wins over the bundled hero.pngThat's the whole setup. Reload the game and the new sprite is live.
Subfolders work as you'd expect:
mods/
sprites/
enemies/
orc.png
ui/
cursor.png
audio/
music/
title.oggSteam Workshop
If your game has Steamworks enabled, Workshop integration is fully automatic right after await gemshell.steam.init() succeeds:
- Items that are already installed are mounted immediately
- Items that are subscribed but not downloaded yet are kicked off with a background download
- A 1 Hz background poll mounts each item the moment Steam finishes downloading it, and fires
onWorkshopChanged
Workshop items sit on top of the encrypted bundle but below the local mods/ folder, so a developer override always wins over Workshop content.
javascript
await gemshell.steam.init();
// Done - subscribed items will appear automatically as they download.
gemshell.assets.onWorkshopChanged((items) => {
console.log(`${items.length} new workshop items mounted`);
});For finer control (state queries, manual force-update etc.) see the Steam Workshop API.
What can be overridden?
Asset files only. Code files are blocked to prevent third-party mods from replacing game logic.
| Allowed | Blocked |
|---|---|
.png .jpg .jpeg .gif .webp .svg .ico | .html |
.mp3 .ogg .wav .m4a .aac | .js .mjs |
.mp4 .webm | .wasm |
.ttf .woff .woff2 | .css |
.json .txt and any other extension |
Methods
listFolders()
Get the currently mounted folders, in lookup priority order.
javascript
const folders = await gemshell.assets.listFolders();
// [
// '/Users/.../MyGame.app/Contents/mods',
// '/Users/.../steamapps/workshop/content/480/123456'
// ]addFolder(absolutePath)
Add a custom folder to the lookup chain. Returns true if the folder exists and was added (or was already mounted). Useful for loading mod packs from arbitrary paths.
javascript
const ok = await gemshell.assets.addFolder('/path/to/my/modpack');The folder is appended to the end of the chain (lowest priority among external folders, but still above the bundle).
removeFolder(absolutePath)
Remove a previously added folder.
javascript
await gemshell.assets.removeFolder('/path/to/my/modpack');refreshWorkshop()
Force a re-scan of subscribed Steam Workshop items. Workshop items already auto-mount in the background as soon as Steam finishes downloading them - this is a manual trigger for the rare cases where you want to scan immediately (e.g. right after the user clicked "subscribe" in your in-game browser).
javascript
const added = await gemshell.assets.refreshWorkshop();
if (added > 0) showToast(`${added} new mods loaded`);onWorkshopChanged(listener)
Subscribe to background notifications about Workshop items that just finished downloading and were auto-mounted into the asset chain. Returns an unsubscribe function.
javascript
const unsubscribe = gemshell.assets.onWorkshopChanged((items) => {
for (const it of items) {
console.log(`Mounted workshop item ${it.id} from ${it.folder}`);
}
showToast(`${items.length} new mods activated`);
});
unsubscribe();This is the cleanest way to react to live Workshop changes without polling.
getLocalModsDir()
Absolute path of the local mods/ folder. Useful for showing the user where to drop mod files, or for building an in-game "Open mods folder" button:
javascript
const dir = await gemshell.assets.getLocalModsDir();
await gemshell.os.showInFolder(dir);Common patterns
"Open mods folder" button
javascript
async function openModsFolder() {
const dir = await gemshell.assets.getLocalModsDir();
await gemshell.os.showInFolder(dir);
}Show mod count on the title screen
javascript
const folders = await gemshell.assets.listFolders();
titleScreen.showSubtitle(`${folders.length} mods active`);Workshop integration with progress
javascript
await gemshell.steam.init();
// Workshop items auto-mount on init.
// Listen for new subscribes (e.g. from in-game workshop browser)
async function onSubscribed() {
const added = await gemshell.assets.refreshWorkshop();
if (added > 0) showToast(`${added} new mods activated`);
}Path safety
Paths are normalized and .. is stripped before lookup, so a malicious asset cannot escape the mounted folders to read arbitrary files on disk.
Tips
- Asset files are read fresh on every request (no in-memory cache yet). This is intentional during early iteration; if you have thousands of asset requests per second a future option will enable caching. Until then, mounting folders is essentially free as long as files aren't hit on the hot path.
- The local
mods/folder is automatically mounted at startup even if it doesn't exist yet - GemShell will start using it as soon as you create it (no restart needed for the folder itself, only for the files inside, since the OS only writes them once you drop them in). mods/lives next to the executable (next to the.appon macOS, not inside it - keeping the signed bundle untouched).
