Lifecycle Hooks

React to game events with lifecycle hooks

Hooks let your mod respond to game events. Register a callback function that will be called when the event occurs. Every registration returns an unsubscribe function — see Unregistering Hooks.

Mod context is preserved automatically — until your callback's first `await`. When you register a hook, the modding API captures your mod's identity and restores it when the callback fires, so api.storage.get() and api.storage.set() work at the start of a callback even though your mod's script has already finished executing. However, in an async callback, any storage call made after an await has lost that context again. If your callback awaits between storage calls, capture a `storage.scoped()` object at the top of your script and use that instead.

Error isolation: If your callback throws an error, it is caught and logged — other mods' callbacks for the same hook will still run. You will not crash the game.

Unregistering Hooks#

Every hook returns an unsubscribe function. Call it to remove the listener; calling it more than once is safe.

javascript
const off = window.SubwayBuilderAPI.hooks.onMoneyChanged((balance) => {
    console.log(`Balance: $${balance}`);
});

// Later, when you no longer need the listener:
off();

<Callout type="warning"> Hooks persist across games in the same app session. They are only cleared on mod hot-reload or app restart — not when the player exits to the menu and starts a new game. Since onGameInit fires again for each new game, registering listeners inside a hook callback without cleaning up stacks a duplicate every game:

javascript
// Wrong — adds a NEW onMoneyChanged listener every game.
// By game 3, each money change fires your callback 3 times.
api.hooks.onGameInit(() => {
    api.hooks.onMoneyChanged(handleMoney);
});

// Correct — unsubscribe the previous game's listener first
let offMoney = null;
api.hooks.onGameInit(() => {
    offMoney?.();
    offMoney = api.hooks.onMoneyChanged(handleMoney);
});

Simplest of all: register hooks once at the top level of your mod script and never inside other callbacks, unless you keep the unsubscribe function.

</Callout>

Available Hooks#

onGameInit#

Called when a new game starts. Use this for first-run setup — defaults, welcome messages, fresh state. It does not fire when a save is loaded, so this setup won't re-run every time the player resumes. For setup that should also run on save loads, use `onGameLoaded`.

javascript
window.SubwayBuilderAPI.hooks.onGameInit(() => {
    console.log('New game started!');
});

onDayChange#

Called when the in-game day changes. Receives the new day number (1-indexed, matching the UI clock).

javascript
window.SubwayBuilderAPI.hooks.onDayChange((day) => {
    if (day % 100 === 0) {
        console.log(`Milestone: Day ${day}!`);
    }
});

onHourChange#

Called every in-game hour. Receives the hour of day (0–23) and the current day (1-indexed, matching gameState.getCurrentDay()). At high game speeds a single simulation update can span more than one hour — the hook fires once per crossed hour boundary, in order, so you never miss one.

Use this instead of polling gameState.getCurrentHour() for rush-hour events, day/night behavior, or hourly economics.

javascript
window.SubwayBuilderAPI.hooks.onHourChange((hour, day) => {
    if (hour === 7) {
        console.log(`Day ${day}: morning rush is starting`);
    }
});

onCityLoad#

Called when a city is loaded. Receives the city code.

javascript
window.SubwayBuilderAPI.hooks.onCityLoad((cityCode) => {
    console.log(`Loaded city: ${cityCode}`);
    if (cityCode === 'MTL') {
        // Do Montreal-specific initialization
    }
});

onMapReady#

Called when the map is fully loaded. Receives the MapLibre map instance.

javascript
window.SubwayBuilderAPI.hooks.onMapReady((map) => {
    console.log('Map is ready!', map);
    // Access raw MapLibre instance
    map.on('click', (e) => {
        console.log('Clicked:', e.lngLat);
    });
});

onStationBuilt#

Called when a station is constructed from blueprint.

javascript
window.SubwayBuilderAPI.hooks.onStationBuilt((station) => {
    console.log(`Station built: ${station.name}`);
});

onStationDeleted#

Called when a station is deleted.

javascript
window.SubwayBuilderAPI.hooks.onStationDeleted((stationId, stationName) => {
    console.log(`Station "${stationName}" (${stationId}) was deleted`);
});

onRouteCreated#

Called when a new route is created.

javascript
window.SubwayBuilderAPI.hooks.onRouteCreated((route) => {
    console.log(`New route: ${route.bullet} (${route.id})`);
});

onRouteDeleted#

Called when a route is deleted.

javascript
window.SubwayBuilderAPI.hooks.onRouteDeleted((routeId, routeBullet) => {
    console.log(`Route ${routeBullet} (${routeId}) deleted`);
});

onTrackBuilt#

Called when blueprint tracks are constructed.

javascript
window.SubwayBuilderAPI.hooks.onTrackBuilt((tracks) => {
    console.log(`${tracks.length} tracks constructed`);
});

onBlueprintPlaced#

Called when the player places blueprint tracks (before construction).

javascript
window.SubwayBuilderAPI.hooks.onBlueprintPlaced((tracks) => {
    console.log(`${tracks.length} blueprint tracks placed`);
});

onDemandChange#

Called when demand/population data is loaded for a city. If you register this callback after demand data has already loaded, it fires immediately with the current data so your mod doesn't miss the event.

javascript
window.SubwayBuilderAPI.hooks.onDemandChange((popCount) => {
    console.log(`Demand data loaded: ${popCount} commuter groups`);
});

onTrackChange#

Called when tracks are added or removed.

javascript
window.SubwayBuilderAPI.hooks.onTrackChange((changeType, count) => {
    console.log(`Tracks ${changeType}: ${count} tracks`);
    // changeType is 'add' or 'delete'
});

onScheduleChange#

Called when a route's schedule changes — its train count, frequency tiers (trainSchedule), or timetable headways (timetableSchedule). Fires for player edits in the route details panel, for mod-driven route updates, and for system changes such as the automatic clamp that reduces a schedule when a route edit lowers its trains-per-hour limit.

Both snapshots are copies — mutating them does not affect game state. It does not fire on save load, and route creation/deletion are covered by onRouteCreated/onRouteDeleted instead.

javascript
window.SubwayBuilderAPI.hooks.onScheduleChange((routeId, schedule, previous) => {
    console.log(`Route ${routeId} schedule changed`);
    // schedule/previous: { idealTrainCount, trainSchedule?, timetableSchedule? }
    if (schedule.trainSchedule && previous.trainSchedule) {
        const delta = schedule.trainSchedule.highDemand - previous.trainSchedule.highDemand;
        if (delta !== 0) console.log(`Peak service ${delta > 0 ? 'increased' : 'cut'} by ${Math.abs(delta)} trains`);
    }
});

onTrainSpawned#

Called when a new train enters service — whether the player spawned it manually or the scheduler added it automatically as demand rose.

javascript
window.SubwayBuilderAPI.hooks.onTrainSpawned((train) => {
    console.log(`Train spawned on route ${train.routeId}`);
});

onTrainDeleted#

Called when a train is removed from service — whether the player deleted it manually or the scheduler withdrew it to the depot at a terminal as demand fell.

javascript
window.SubwayBuilderAPI.hooks.onTrainDeleted((trainId, routeId) => {
    console.log(`Train ${trainId} deleted from route ${routeId}`);
});

<Callout> Train identity: a train's id is a UUID that stays stable for its whole service life, including while it loops its route or transfers back to its parent route from a temporary reroute. IDs are never reused — a train that re-enters service after a depot withdrawal is a new train with a new id. Use these two hooks to keep a roster: key per-train data by id on onTrainSpawned, and drop it on onTrainDeleted. </Callout>

onTrainArrivedAtStation#

Called when a train arrives (stops) at a station. Receives the `Train` and the `Station` it stopped at. Does not fire for the synthetic ticks the game runs while estimating route times.

<Callout type="warning"> On a large network this hook fires very often — every train, every stop, at up to 500× real time. Keep your callback cheap: no await, no heavy loops. Aggregate counts and act on them in onHourChange or onDayChange instead. </Callout>

javascript
const arrivals = {};
window.SubwayBuilderAPI.hooks.onTrainArrivedAtStation((train, station) => {
    arrivals[station.id] = (arrivals[station.id] || 0) + 1;
});

onStationSelected#

Called when the player selects a station — clicking its marker on the map, or navigating to its details panel from anywhere (charts, warnings, nearby-station lists). Receives the `Station`.

Pair this with a floating panel to show your own per-station info.

javascript
window.SubwayBuilderAPI.hooks.onStationSelected((station) => {
    console.log(`Player is looking at ${station.name} (${station.id})`);
});

onStationGroupSelected#

Called when the player selects a station group (a multi-station complex). Receives the `StationGroup`. Note that clicking a single-station group opens the station itself and fires onStationSelected instead.

javascript
window.SubwayBuilderAPI.hooks.onStationGroupSelected((group) => {
    console.log(`Selected group ${group.name} with ${group.stationIds.length} stations`);
});

onTrainSelected#

Called when the player selects a train (map click or a train link in another panel). Receives the `Train`.

javascript
window.SubwayBuilderAPI.hooks.onTrainSelected((train) => {
    console.log(`Selected train ${train.id} on route ${train.routeId}`);
});

onPauseChanged#

Called when the game is paused or resumed.

javascript
window.SubwayBuilderAPI.hooks.onPauseChanged((isPaused) => {
    console.log(`Game ${isPaused ? 'paused' : 'resumed'}`);
});

onSpeedChanged#

Called when game speed changes.

javascript
window.SubwayBuilderAPI.hooks.onSpeedChanged((newSpeed) => {
    console.log(`Game speed changed to: ${newSpeed}`);
    // newSpeed is 'slow', 'normal', 'fast', or 'ultrafast'
});

onMoneyChanged#

Called when the player's money changes (from any source — fares, construction, mods calling setMoney, etc.).

javascript
window.SubwayBuilderAPI.hooks.onMoneyChanged((newBalance, change, type, category) => {
    console.log(`${type}: $${Math.abs(change)} (${category || 'general'})`);
    console.log(`New balance: $${newBalance}`);
    // type is 'revenue' or 'expense'
    // category examples: 'construction', 'trainOperational', 'mod-setMoney'
});

onTicketPriceChanged#

Called when the ticket price changes — from the player's financial dashboard, from a mod calling actions.setTicketPrice, or from a constants override. Receives the new and previous price. It does not fire when a save is loaded (the restored price is not a price change), and not when the price is re-set to its current value.

javascript
window.SubwayBuilderAPI.hooks.onTicketPriceChanged((newPrice, oldPrice) => {
    console.log(`Fare ${newPrice > oldPrice ? 'hike' : 'cut'}: $${oldPrice} → $${newPrice}`);
});

onFareGroupsChanged#

Called when fare groups change — a group is created, deleted, or edited (including fare zones being drawn, reshaped, reordered, or removed), or a route moves between groups (player drag & drop or mod actions). Receives a deep copy of the full fare group list. It does not fire during save load.

javascript
window.SubwayBuilderAPI.hooks.onFareGroupsChanged((fareGroups) => {
    console.log(`${fareGroups.length} fare groups`);
});

onGameSaved#

Called after the game is saved (manual or autosave).

javascript
window.SubwayBuilderAPI.hooks.onGameSaved((saveName) => {
    console.log(`Game saved: ${saveName}`);
});

onGameLoaded#

Called after a save is loaded. Fires every time a save is loaded, including when returning to the main menu and loading a different save. If registered after a save has already been loaded, fires immediately with the current save name so your mod doesn't miss the event.

javascript
window.SubwayBuilderAPI.hooks.onGameLoaded((saveName) => {
    console.log(`Game loaded: ${saveName}`);
});

onWarning#

Called when the game issues a warning (e.g., insufficient funds, track collision).

javascript
window.SubwayBuilderAPI.hooks.onWarning((warning) => {
    console.log(`Warning [${warning.type}]: ${warning.message}`);
    // warning.id - unique warning identifier
    // warning.type - warning category
    // warning.message - human-readable message
});

onError#

Called when a recoverable error occurs. Useful for monitoring and debugging.

javascript
window.SubwayBuilderAPI.hooks.onError((error) => {
    console.log(`Error [${error.type}]: ${error.message} (recoverable: ${error.recoverable})`);
    // error.type - error category
    // error.message - human-readable error description
    // error.recoverable - whether the game can continue
});

onGameEnd#

Called when exiting the game (returning to menu or closing). Use this to clean up resources.

javascript
window.SubwayBuilderAPI.hooks.onGameEnd(() => {
    console.log('Game ending, cleaning up...');
    // Clean up intervals, listeners, etc.
});

Hook Summary#

HookParametersDescription
onGameInitnoneNew game start (not save loads)
onDayChangeday: numberDay changed
onCityLoadcityCode: stringCity loaded
onMapReadymap: MapLibreMap ready
onStationBuiltstation: StationStation constructed
onStationDeletedstationId: string, stationName: stringStation deleted
onRouteCreatedroute: RouteRoute created
onRouteDeletedrouteId: string, routeBullet: stringRoute deleted
onTrackBuilttracks: Track[]Tracks constructed
onBlueprintPlacedtracks: Track[]Blueprints placed
onDemandChangepopCount: numberDemand data loaded (late-fire)
onTrackChange`type: 'add' \'delete', count: number`Tracks added/removed
onScheduleChangerouteId: string, schedule: RouteScheduleSnapshot, previous: RouteScheduleSnapshotRoute schedule changed
onTrainSpawnedtrain: TrainTrain created
onTrainDeletedtrainId: string, routeId: stringTrain removed
onPauseChangedisPaused: booleanPause state changed
onSpeedChanged`speed: 'slow' \'normal' \'fast' \'ultrafast'`Speed changed
onMoneyChanged`balance: number, change: number, type: 'revenue' \'expense', category?: string`Money changed
onGameSavedsaveName: stringGame saved
onGameLoadedsaveName: stringGame loaded (late-fire)
onWarningwarning: { id: string, type: string, message: string }Game warning
onErrorerror: { type: string, message: string, recoverable: boolean }Error occurred
onGameEndnoneExiting the game

All hooks return () => void — an unsubscribe function that removes the listener.

Late-fire hooks: onDemandChange and onGameLoaded fire immediately if registered after the event has already occurred, so mods that load late still get the data they need.