Debugging

Reloading mods, what a reload clears and keeps, common issues, where errors surface, the log files, and validating data files.

Installing Mods#

  1. Extract the mod into the mods folder (Settings > Advanced > Mod Manager > Open Mods Folder). The result is one folder per mod with manifest.json at its top level.
  2. If the mod is missing from the Mod Manager list, click the refresh icon (tooltip Scan for new mods).
  3. Toggle it on, then click Reload Now in the toast or restart the game.

A new mod starts disabled. Getting started has the folder paths and the manifest rules.

Hot Reload#

These call `reloadMods`: Ctrl+Shift+R (Cmd+Shift+R on macOS; the default binding, listed as Reload Mods in the shortcuts dialog under Settings > Controls), the Reload Mods button the Mod Manager shows after a toggle and the Reload Now action in its toggle toasts, and await window.SubwayBuilderAPI.reloadMods() in the console. The sequence:

  1. The API clears the state listed below and logs [Modding API] State cleared for hot reload (development builds only).
  2. The loader rescans the mods folder, so toggles made in the Mod Manager take effect.
  3. For each enabled mod, the UI components it registered last time are removed, then its script runs again.
  4. Lifecycle hooks that already fired this session are replayed.
  5. The promise resolves and [Modding API] Hot reload complete (development builds only) is logged.

Development Workflow#

  1. Edit the mod's script.
  2. Press Ctrl+Shift+R (Cmd+Shift+R on macOS) in game.
  3. Check the console and the Issues section of the Mod Manager.

The game does not watch the mods folder; nothing reloads until one of the triggers above runs.

What Gets Cleared#

  • Every hooks.on* callback, including onMapReady, onError, onTutorialStart and onTutorialEnd.
  • Every UI component in every placement: toolbar buttons, toolbar panels, floating panels, settings controls, registered components.
  • Custom map layer, source and style registrations. Layers already on the map stay there.
  • Speed multipliers from actions.setSpeedMultiplier. The speed table goes back to the values before the first multiplier.
  • popTiming commute windows and dampening, back to the defaults.
  • Newspaper templates.
  • Modded cities. Built-in cities stay.
  • Custom city tabs.
  • Demand persistence opt-ins.

What Is Not Cleared#

  • Train types and station types. Re-registering the same id overwrites it.
  • Tile URL overrides, layer overrides, routing service overrides, city data files and default layer visibility.
  • Changes made with modifyConstants, modifyColors and modifyPathfindingRules. Each merges into the live object. One exception: once actions.setSpeedMultiplier has run, a reload writes RULES.GAME_SECONDS_PER_SECOND back to the values it captured on that first call, undoing later modifyConstants changes to it.
  • storage data. See Mod Storage.
  • localStorage, including each floating panel's saved position and size under floating-panel-<id>.

Replayed hooks#

After the scripts re-run, the loader replays the hooks whose event already happened this session, in this order: onMapReady (when the map is up), onGameInit (when a new game was started), onCityLoad (when a city is loaded), onDemandChange (when demand data is loaded), onGameLoaded (when a save was loaded). Demand edits made inside any replayed hook are not saved.

Timeouts#

reloadMods() rejects with Mod reload timed out when the loader has not reported completion after 10 000 ms. A mod script that throws does not reject the promise; the error is recorded and the other mods still reload.

At launch the loader waits for an idle callback (2 000 ms timeout), then polls for window.SubwayBuilderAPI every 100 ms until it exists.

Mod Storage#

`storage` data lives on disk in mod-data/<id>.json next to the mods folder, one file per manifest id. Each byte of the id outside [a-zA-Z0-9._-] is percent-encoded in the file name, and a name over 255 characters is shortened and suffixed with a hash. A reload keeps it; deleting the file clears it.

TypeScript Support#

No type package ships. A mod script runs through new Function, so import statements are syntax errors in it; type-check a mod written in TypeScript at build time and ship the compiled JavaScript.

Type Definitions#

The Types page lists the object shapes the getters return and the hooks pass. Each reference page gives every member's signature.

Debugging#

Console Logging#

Most API log lines start with [Modding API], loader lines with [ModLoader] and main-process mod-scan lines with [Mods]. Many [Modding API] lines are logged in development builds only. Prefix a mod's own lines ([My Mod] ...) so they can be filtered.

Inspect Registered Content#

js
console.table(api.utils.getCities());
api.utils.getMap();                 // MapLibre instance or null
api.utils.getConstants().STARTING_MONEY;
api.utils.getPathfindingRules().MAX_TRANSFERS;
api.mods.getLoadedMods();

Inspect Game State#

js
console.table(api.gameState.getStations().map((s) => ({ id: s.id, name: s.name })));
console.table(api.gameState.getRoutes().map((r) => ({ id: r.id, bullet: r.bullet })));
api.gameState.getTrains().length;
api.gameState.getBudget();
api.gameState.getBonds().length;

Load City Data Files#

`utils.loadCityData` reads a city data file; use it instead of fetch. A call made synchronously from a mod's script or hook callback shows the player, once per mod and path, a security notice naming the mod id. A missing file rejects, so wrap it in try.

Where errors surface#

Every error the loader or the API records goes to three places: the console, with the prefix [Mod: <name>] when the record carries a mod name and [Mod Error] otherwise; the Issues section of the Mod Manager; and every `hooks.onError` callback, which receives { type, message, recoverable }. Recording an error while 100 are stored first drops the oldest 50.

TypeWhen
executionThe script failed to parse or threw while running (console: [ModLoader] Failed to execute mod <name>:), or a registered map source or layer could not be re-added after the map loaded or its style reloaded (Error adding custom source <id>: ...).
initializationThe script could not be fetched. The message carries the reason, such as Mod not found: <id> (also reported for a missing or unreadable script file) or Invalid script path
hookA hook callback threw when its event fired or was replayed. Message starts with Error in <hook> callback #<n>. A throw in an onError callback, or in the immediate call onGameLoaded and onTutorialStart make at registration, is only logged
validationA registration was rejected or one of its values was ignored or clamped, for example registerCity without code or name
registrationA registration threw

onError sees errors from every mod. Hook callbacks are wrapped individually: one callback throwing does not stop the others.

Renderer console output, including a mod's own console.log, is also copied into the log file with the prefix [RENDERER], each message cut at 8192 characters.

Log files#

The main process writes logs/metro-maker-current.log inside the saves folder. Every second launch renames it to metro-maker-previous.log and starts a new current file. Outside Steam builds the saves folder is the custom saves folder when one is set and is an existing directory other than the filesystem root or the home folder (a root or home setting is cleared); otherwise Documents/SubwayBuilder/saves/, or saves/ in the app data folder when that cannot be created; on Steam builds it is SubwayBuilder/saves/ under %APPDATA%, ~/Library/Application Support or $XDG_DATA_HOME (default ~/.local/share). Settings > Advanced > Export Logs downloads the current file and, when present, the previous one as one .log.

Main-process lines such as [Perf] System: ..., [Flags] Applied <n> user flag(s): ... and [Mods] Discovered mod: <name> (<id>) - enabled are in the file and in the terminal that launched the app; DevTools does not show them.

Debug Info Header#

Every log starts with a === DEBUG INFO === header, useful when triaging a user's report: a --- Hardware --- block (RAM, CPU, displays), a --- Memory --- block (the effective --max-old-space-size, auto vs. user override, and the MEM-PRESSURE guard's thresholds), and a --- Mods --- block listing every installed mod (id, name, version, enabled/disabled, source) with a total count. A [DebugInfo][Flags] channel=... non-default=... line (logged once per session) lists every feature flag whose effective value differs from its default, plus the beta/stable channel and the actual sim-host mode. Each save load adds one [LoadSummary] city=... fileSize=... stations=... routes=... trains=... mods=... simHost=... line. Together these mean a modder reading a report no longer has to ask which mods were installed, which beta flags were on, or what actually ran: it's in the header and the load line.

Per-Mod Runtime Cost#

Every 5 real minutes (and on save/quit), the log gets one [ModPerf] line: each mod's cost as milliseconds of wall time per second (not game time), call count and the slowest single call, worst first, for example [ModPerf] my-mod: 1.23ms/s (40 calls, max 5.10ms) | other-mod: 0.02ms/s (2 calls, max 0.90ms). It covers every place the game calls into a mod: hook callbacks, registered UI components, and onGameLoaded/onError. Nothing is logged when no mods are loaded. See Performance for how to read it and keep a mod's numbers low.

Common Issues#

Mod Not Loading#

CheckDetail
The Mod Manager lists itMissing: click the refresh icon (Scan for new mods). Still missing: manifest.json is not at the top level of a folder in mods/, or the folder name starts with .
The toggle is enabledA disabled toggle with an error means the manifest lacks id, name or version, or id contains /, \ or ..
The mod is onNew mods start off. Toggle on, then reload
The script runsIssues lists Mod not found: <id> when the main file is missing, Invalid script path when main leaves the mod folder, and an execution error when the script fails to parse (an import, export or top-level await) or throws

Hook Not Firing#

CheckDetail
The name is exactapi.hooks.ondaychange(...) throws is not a function at load, recorded as an execution error, and nothing after it in the script runs
The event happens in this flow`onGameInit` fires for a new game only, `onGameLoaded` for a loaded save only; `onCityLoad` fires for both
The callback was not unsubscribedEvery hooks.on* call returns an unsubscribe function; a reload drops every callback until the script registers it again
The callback does not throwA throw is recorded as a hook error (Error in <hook> callback #<n>) and listed under Issues

UI Not Appearing#

CheckDetail
The placement is one of the UIPlacement values'settings-menu', 'escape-menu', 'escape-menu-buttons', 'main-menu', 'bottom-bar', 'top-bar', 'debug-panel' or 'overlay'. Any other string is stored and rendered by no host
It was registered after the last game endThe UI registry is cleared on game end and on reload. Register in `onMapReady`, which fires for every map
The component rendersA value that is neither a function component nor a React element logs Invalid component type for mod "<id>" and renders nothing; a render error shows in the console
The registration logged[Modding API] Invalid UI component - must have placement and component with id means placement, component or component.id was falsy

City Not Appearing#

CheckDetail
code and name are setWithout them `registerCity` records a validation error and registers nothing
The code is freeWith no active mod id (code after an await) a code another city already uses is rejected
The picker saw itThe city picker polls the city list for up to 15 s after it opens; register in the script's synchronous part
The data files existFiles registered with `cities.setCityDataFiles` must exist at those paths; check them with utils.loadCityData in the console

Data File Validation#

`api.schemas` holds Zod schemas for the city data files. safeParse returns { success, data } or { success: false, error }.

js
const result = api.schemas.DemandDataSchema.safeParse({ points: [], pops: [] });
if (!result.success) console.error(result.error.issues);

Available Schemas#

SchemaValidates
DemandDataSchemademand_data.json
DemandPointSchemaOne entry of points
PopSchemaOne entry of pops
RoadsGeojsonSchemaroads.geojson
RoadPropertiesSchemaproperties of one road feature
RunwaysTaxiwaysGeojsonSchemarunways_taxiways.geojson

The buildings index is binary; check it with `utils.buildings.validate`.

Working Habits#

  1. Register game-dependent work in onCityLoad or onMapReady; both fire for new games and loaded saves.
  2. Keep the unsubscribe function each hooks.on* call returns.
  3. Add one feature at a time and reload after each.
  4. Watch the Issues section of the Mod Manager; it lists every recorded error.
  5. A custom city needs its data files registered with cities.setCityDataFiles. Custom cities has the steps.