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.
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:
// 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`.
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).
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.
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.
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.
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.
window.SubwayBuilderAPI.hooks.onStationBuilt((station) => {
console.log(`Station built: ${station.name}`);
});onStationDeleted#
Called when a station is deleted.
window.SubwayBuilderAPI.hooks.onStationDeleted((stationId, stationName) => {
console.log(`Station "${stationName}" (${stationId}) was deleted`);
});onRouteCreated#
Called when a new route is created.
window.SubwayBuilderAPI.hooks.onRouteCreated((route) => {
console.log(`New route: ${route.bullet} (${route.id})`);
});onRouteDeleted#
Called when a route is deleted.
window.SubwayBuilderAPI.hooks.onRouteDeleted((routeId, routeBullet) => {
console.log(`Route ${routeBullet} (${routeId}) deleted`);
});onTrackBuilt#
Called when blueprint tracks are constructed.
window.SubwayBuilderAPI.hooks.onTrackBuilt((tracks) => {
console.log(`${tracks.length} tracks constructed`);
});onBlueprintPlaced#
Called when the player places blueprint tracks (before construction).
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.
window.SubwayBuilderAPI.hooks.onDemandChange((popCount) => {
console.log(`Demand data loaded: ${popCount} commuter groups`);
});onTrackChange#
Called when tracks are added or removed.
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.
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.
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.
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>
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.
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.
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`.
window.SubwayBuilderAPI.hooks.onTrainSelected((train) => {
console.log(`Selected train ${train.id} on route ${train.routeId}`);
});onPauseChanged#
Called when the game is paused or resumed.
window.SubwayBuilderAPI.hooks.onPauseChanged((isPaused) => {
console.log(`Game ${isPaused ? 'paused' : 'resumed'}`);
});onSpeedChanged#
Called when game speed changes.
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.).
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.
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.
window.SubwayBuilderAPI.hooks.onFareGroupsChanged((fareGroups) => {
console.log(`${fareGroups.length} fare groups`);
});onGameSaved#
Called after the game is saved (manual or autosave).
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.
window.SubwayBuilderAPI.hooks.onGameLoaded((saveName) => {
console.log(`Game loaded: ${saveName}`);
});onWarning#
Called when the game issues a warning (e.g., insufficient funds, track collision).
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.
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.
window.SubwayBuilderAPI.hooks.onGameEnd(() => {
console.log('Game ending, cleaning up...');
// Clean up intervals, listeners, etc.
});Hook Summary#
| Hook | Parameters | Description | |||
|---|---|---|---|---|---|
onGameInit | none | New game start (not save loads) | |||
onDayChange | day: number | Day changed | |||
onCityLoad | cityCode: string | City loaded | |||
onMapReady | map: MapLibre | Map ready | |||
onStationBuilt | station: Station | Station constructed | |||
onStationDeleted | stationId: string, stationName: string | Station deleted | |||
onRouteCreated | route: Route | Route created | |||
onRouteDeleted | routeId: string, routeBullet: string | Route deleted | |||
onTrackBuilt | tracks: Track[] | Tracks constructed | |||
onBlueprintPlaced | tracks: Track[] | Blueprints placed | |||
onDemandChange | popCount: number | Demand data loaded (late-fire) | |||
onTrackChange | `type: 'add' \ | 'delete', count: number` | Tracks added/removed | ||
onScheduleChange | routeId: string, schedule: RouteScheduleSnapshot, previous: RouteScheduleSnapshot | Route schedule changed | |||
onTrainSpawned | train: Train | Train created | |||
onTrainDeleted | trainId: string, routeId: string | Train removed | |||
onPauseChanged | isPaused: boolean | Pause state changed | |||
onSpeedChanged | `speed: 'slow' \ | 'normal' \ | 'fast' \ | 'ultrafast'` | Speed changed |
onMoneyChanged | `balance: number, change: number, type: 'revenue' \ | 'expense', category?: string` | Money changed | ||
onGameSaved | saveName: string | Game saved | |||
onGameLoaded | saveName: string | Game loaded (late-fire) | |||
onWarning | warning: { id: string, type: string, message: string } | Game warning | |||
onError | error: { type: string, message: string, recoverable: boolean } | Error occurred | |||
onGameEnd | none | Exiting 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.