storage

Per-mod key-value persistence backed by a JSON file per mod id in the Electron main process.

api.storage. 5 members. Each get, set, delete and keys call resolves a mod id from the active mod context, falling back to the explicit modId argument, then invokes a mod-storage-* IPC channel. The file is <userData>/mod-data/<id>.json, where characters outside [a-zA-Z0-9._-] in the id are percent-encoded per UTF-8 byte. A name longer than 255 bytes is shortened to at most 200 bytes: a prefix of the encoded id, ~, and the first 16 hex digits of the SHA-256 of the id. When that file cannot be read, a legacy file with those characters replaced by _ is read instead, which another id may have written. The legacy file is never written; the first write copies its keys into the new file. Every call reads the whole file. Writes are serialized per mod id and rewrite the whole file. Outside Electron window.electron.invoke is absent, so reads return the default and writes do nothing.

MemberReturnsSummary
get(key, defaultValue?, modId?)Promise of T or undefinedRead one key from the mod's storage file.
set(key, value, modId?)Promise<void>Write one key to the mod's storage file.
delete(key, modId?)Promise<void>Remove one key from the mod's storage file.
keys(modId?)Promise<string[]>Every key in the mod's storage file.
scoped()objectCapture the active mod id at call time into an object whose methods pass it as the modId fallback.

get#

api.storage.get(key: string, defaultValue?: T, modId?: string): Promise<T | undefined>

Read one key from the mod's storage file.

The mod id used is the active mod context when one is set, otherwise modId. An explicit modId is ignored while a mod id is active: during the synchronous part of a mod script and of each hook callback registered while one was active. Resolves to the stored value, or to defaultValue when the key is absent, the stored value is undefined, the IPC call fails, or no mod id is available.

ParamTypeDefaultDescription
keystringrequiredstorage key; property name in the mod's JSON file.
defaultValueTnonevalue resolved when nothing is stored; default undefined.
modIdstringnonefallback mod id used only when no mod context is active.

Returns: Promise<T | undefined>. Promise of the stored value (parsed from JSON, a fresh object per call) or defaultValue.

Errors:

  • no mod id: logs [Modding API] storage.get called outside of mod context, resolves to defaultValue.
  • IPC returns an error: logs [Modding API] Error getting storage value:, resolves to defaultValue.
  • call throws: logs [Modding API] Error getting storage value:, resolves to defaultValue.
  • outside Electron: resolves to defaultValue without logging.
js
api.storage.get('settings', { notifications: true }).then((settings) => {
    console.log('Notifications on:', settings.notifications);
});

set#

api.storage.set(key: string, value: T, modId?: string): Promise<void>

Write one key to the mod's storage file.

The mod id used is the active mod context when one is set, otherwise modId. An explicit modId is ignored while a mod id is active: during the synchronous part of a mod script and of each hook callback registered while one was active. The main process reads the file, sets the key and rewrites the whole file as 2-space JSON. Writes for one mod id run one after another.

ParamTypeDefaultDescription
keystringrequiredstorage key; property name in the mod's JSON file.
valueTrequiredvalue to store; must survive JSON.stringify.
modIdstringnonefallback mod id used only when no mod context is active.

Returns: Promise<void>.

Errors:

  • no mod id: logs [Modding API] storage.set called outside of mod context, does nothing.
  • IPC returns an error: logs [Modding API] Error setting storage value:, nothing stored.
  • call throws: logs [Modding API] Error setting storage value:, nothing stored.
  • outside Electron: does nothing without logging.

Side effects: writes <userData>/mod-data/<id>.json; the main process logs [Mods] Stored <key> for mod <id>.

js
// any JSON-serializable value; call it synchronously or use storage.scoped() after an await
api.storage.set('lastPlayed', { day: api.gameState.getCurrentDay(), at: Date.now() });

delete#

api.storage.delete(key: string, modId?: string): Promise<void>

Remove one key from the mod's storage file.

The mod id used is the active mod context when one is set, otherwise modId. An explicit modId is ignored while a mod id is active: during the synchronous part of a mod script and of each hook callback registered while one was active. Deleting an absent key still writes the file.

ParamTypeDefaultDescription
keystringrequiredstorage key to remove.
modIdstringnonefallback mod id used only when no mod context is active.

Returns: Promise<void>.

Errors:

  • no mod id: logs [Modding API] storage.delete called outside of mod context, does nothing.
  • IPC returns an error: logs [Modding API] Error deleting storage value:, nothing removed.
  • call throws: logs [Modding API] Error deleting storage value:, nothing removed.
  • outside Electron: does nothing without logging.

Side effects: rewrites <userData>/mod-data/<id>.json; the main process logs [Mods] Deleted <key> for mod <id>.

js
api.storage.delete('lastPlayed');

keys#

api.storage.keys(modId?: string): Promise<string[]>

Every key in the mod's storage file.

The mod id used is the active mod context when one is set, otherwise modId. An explicit modId is ignored while a mod id is active: during the synchronous part of a mod script and of each hook callback registered while one was active.

Parameter modId (string): fallback mod id used only when no mod context is active.

Returns: Promise<string[]>. Promise of a fresh array of key names; an empty array when the file is missing, the IPC call fails, or no mod id is available.

Errors:

  • no mod id: logs [Modding API] storage.keys called outside of mod context, resolves to [].
  • IPC returns an error: logs [Modding API] Error getting storage keys:, resolves to [].
  • call throws: logs [Modding API] Error getting storage keys:, resolves to [].
  • outside Electron: resolves to [] without logging.
js
api.storage.keys().then((keys) => console.log(`stored keys: ${keys.join(', ')}`));

scoped#

api.storage.scoped(): {…}

Capture the active mod id at call time into an object whose methods pass it as the modId fallback.

Each method calls the global API's storage method with the captured id, so the active mod context still wins when another mod's script or callback is running at that moment. With no active mod context the captured id is null and the methods behave like the unscoped ones.

Returns: object. Object with get, set, delete, keys bound to the captured id and a modId field holding it.

FieldTypeDescription
get<T>(key: string, defaultValue?: T) => Promise<T | undefined>storage.get with the captured mod id as the fallback; defaultValue is resolved when nothing is stored.
set<T>(key: string, value: T) => Promise<void>storage.set with the captured mod id as the fallback.
delete(key: string) => Promise<void>storage.delete with the captured mod id as the fallback.
keys() => Promise<string[]>storage.keys with the captured mod id as the fallback.
modIdstring or nullMod id captured when scoped() was called; null when it was called outside mod context.

Errors: no mod context: logs [Modding API] storage.scoped() called outside of mod context, returns an object with modId null.

js
const store = api.storage.scoped(); // at the top of the script, before any await
setTimeout(async () => {
    await store.set("lastRun", Date.now()); // still writes to this mod's file
}, 1000);

Data Types#

A value crosses IPC to the main process and is written with JSON.stringify, so store JSON data: strings, numbers, booleans, null, arrays and plain objects. What get returns is parsed from the file, a fresh copy on every call.

ValueStored as
string, number, boolean, nullitself
array, plain objectJSON; nested values follow the same rules
DateISO string
Map, Set, class instance{} (own enumerable fields only for instances)
undefined (as the value or in an object field)dropped; get resolves defaultValue for a dropped key
function, DOM elementthe IPC call fails; logs [Modding API] Error setting storage value:, nothing stored
NaN, Infinitynull
js
const store = api.storage.scoped();
store.set('config', { theme: 'dark', favorites: ['NYC', 'CHI'] }).then(() => store.get('config', {})).then((config) => console.log(config.favorites));

Mod Isolation#

Each mod has one file, <userData>/mod-data/<id>.json, keyed by the id in its manifest.json. Two mods can use the same key name without conflict. While a mod's script or one of its hook callbacks runs, every call uses that mod's file and an explicit modId is ignored, so a mod cannot read another mod's file from inside its own code.

CaseFile name
id of a-z, A-Z, 0-9, ., _, -the id itself
other characterspercent-encoded per UTF-8 byte: a b becomes a%20b.json
encoded name over 255 bytesan encoded prefix, ~, and the first 16 hex digits of the SHA-256 of the id, at most 200 bytes
no file under the new namethe legacy name (those characters replaced by _) is read; the first write creates the new file

File names ignore case on macOS and Windows, so ids that differ only in case share a file there.

Per-Playthrough Data#

Storage belongs to the mod and is shared by every save. Key playthrough data by gameState.getGameSessionId(): a UUID set when a new game starts, written into every save and restored on load. getSaveName() is a poor key: it is null before the first save and changes on rename.

js
const store = api.storage.scoped(); // capture the mod id before any await
api.hooks.onGameLoaded(async () => {
    const runId = api.gameState.getGameSessionId();
    const history = await store.get(`run:${runId}:history`, []);
    history.push({ day: api.gameState.getCurrentDay() });
    await store.set(`run:${runId}:history`, history);
});

Browser Compatibility#

Outside Electron there is no main process: get resolves defaultValue, keys resolves [], and set and delete do nothing, all without logging. The same code runs in both environments.

Complete Example#

Persistent settings with a toggle in the settings screen. storage.scoped() is captured at the top of the script, because storage calls made after an await (or from a React event handler) have no mod context of their own.

js
const api = window.SubwayBuilderAPI;
const store = api.storage.scoped(); // before any await
const DEFAULTS = { showNotifications: true, bonusEvery: 100 };

async function loadSettings() {
    return { ...DEFAULTS, ...(await store.get('settings', DEFAULTS)) };
}

async function saveSetting(key, value) {
    const settings = await loadSettings();
    settings[key] = value;
    await store.set('settings', settings);
}

api.hooks.onGameLoaded(async () => {
    const settings = await loadSettings();
    if (settings.showNotifications) api.ui.showNotification('Settings loaded', 'info');
});

api.ui.addToggle('settings-menu', {
    id: 'my-mod-notifications',
    label: 'Show my mod notifications',
    defaultValue: true,
    onChange: (enabled) => saveSetting('showNotifications', enabled),
});