hooks

Lifecycle hooks: on* registrations that return an unsubscribe function.

api.hooks. 32 members. Every member takes one callback and returns an unsubscribe function that removes that registration; a second call of it does nothing. A callback that is not a function logs [Modding API] <member> callback must be a function and returns a no-op unsubscribe; a registration that throws logs [Modding API] Error registering <member> callback: and returns a no-op unsubscribe. Each registration stores the callback with the registering mod's id and makes that id the active mod context until the callback returns, so storage calls made before it returns use the mod's own file. The callback's return value is ignored, except that a returned promise that rejects is reported like a throw. A callback that throws is recorded as an onError hook error with the message Error in <member> callback #<index>: <message> (Error in onCityLoad callback #<index> for city <code>: <message> for onCityLoad); the remaining callbacks still run. onError's own callback is the exception: a throw or a rejected promise there is logged as [ModErrorReporter] Error in callback: and not recorded. The immediate calls on registration of onGameLoaded and onTutorialStart log a throw or a rejection as [Modding API] Error in immediate <member> callback: and record nothing. The post-reload replays of onMapReady, onGameInit, onCityLoad, onDemandChange and onGameLoaded record a throw as Error in <member> callback #<index> (post-reload): <message>. Every registration list is emptied by reloadMods. mapReady, gameInit, cityLoad, demandChange and gameLoaded are replayed once after the mods re-execute when those events already happened. Registrations survive exiting to the menu; onGameEnd clears UI components and lifecycle markers, never callback lists. Loading or importing a save from the escape menu reloads the page after onGameEnd, which discards every registration and re-executes the mods.

Hook Summary#

MemberReturnsSummary
onGameInit(callback)() => voidFires when a new game finishes initializing while no save is loaded.
onCityLoad(callback)() => voidFires when the city data load finishes.
onMapReady(callback)() => voidFires when the map finishes loading, with the live maplibre-gl Map instance.
onGameSaved(callback)() => voidFires inside generateSave after the save data validates, before any caller writes a file.
onGameLoaded(callback)() => voidFires when loadSave finishes restoring a save, and immediately on registration when a save is already loaded.
onGameEnd(callback)() => voidFires when the player leaves a game through one of the game's own exits.
onTutorialStart(callback)() => voidFires when the tutorial store starts a tutorial, and immediately on registration while one is active.
onTutorialEnd(callback)() => voidFires when the tutorial store ends an active tutorial.
onDayChange(callback)() => voidFires once per game day boundary crossed by a simulation update, with the day that ended and the day that began.
onHourChange(callback)() => voidFires once per game hour boundary crossed by a simulation update.
onPauseChanged(callback)() => voidFires when a pause-aware code path pauses or resumes the game.
onSpeedChanged(callback)() => voidFires when updateTimeSpeed sets the game speed.
onMoneyChanged(callback)() => voidFires when addRevenue or addExpense runs, and when actions.setMoney changes the balance.
onTicketPriceChanged(callback)() => voidFires when setTransitCost stores a ticket price different from the current one, outside save load.
onFareGroupsChanged(callback)() => voidFires after a fare group store action commits, with a deep copy of the fare groups.
onStationBuilt(callback)() => voidFires when a blueprint build constructs tracks of a station.
onStationDeleted(callback)() => voidFires when setStations drops a station id that was in the store.
onRouteCreated(callback)() => voidFires when a route is created through generateRoute or duplicateRoute.
onRouteDeleted(callback)() => voidFires when deleteRoute removes a route.
onTrackBuilt(callback)() => voidFires when a blueprint build constructs tracks.
onTrackChange(callback)() => voidFires when a track write changes the total number of tracks in the store.
onBlueprintPlaced(callback)() => voidFires when setTracks adds blueprint tracks with ids that were not among its blueprint tracks before.
onScheduleChange(callback)() => voidFires when setRoutes commits a route whose train count, demand-tier counts or timetable differ by value from the previous version.
onTrainSpawned(callback)() => voidFires when generateTrain, spawnTrainAtStation or a simulation update adds a train.
onTrainDeleted(callback)() => voidFires when deleteTrain or a simulation update removes a train.
onTrainArrivedAtStation(callback)() => voidFires once per train arrival event delivered by a simulation update.
onDemandChange(callback)() => voidFires when one of the demand writes listed below sets the store's demand data, with the pop count.
onStationSelected(callback)() => voidFires when the UI navigates to a station details panel.
onStationGroupSelected(callback)() => voidFires when the UI navigates to a station group details panel.
onTrainSelected(callback)() => voidFires when the UI navigates to a train details panel.
onWarning(callback)() => voidFires once for each new simulation warning id in a committed update.
onError(callback)() => voidFires for every error recorded by the mod error reporter, from any mod.

Game Lifecycle#

onGameInit#

api.hooks.onGameInit(callback: () => void): () => void

Fires when a new game finishes initializing while no save is loaded.

Fires when: the store initializer finishes game setup for a new game. Does not fire: when a save was loaded; the trigger logs [Modding API] Skipping onGameInit callbacks because a save-loaded session is active and returns. Does not fire: for a mod registered after the game started; there is no immediate replay on registration. onGameEnd resets the loaded-save marker, so a new game started after exiting to the menu fires it again. No payload. reloadMods clears the registration and replays the event once after the mods re-execute, only in a new-game session. Demand edits made during that replay are not recorded into the save's mod demand overlay.

Parameter callback (() => void): invoked with no arguments.

Returns: () => void. the unsubscribe function.

js
api.hooks.onGameInit(() => {
    // new game only; use onGameLoaded for setup that must also run on save loads
    api.ui.showNotification('New game started', 'info');
});

Trigger call sites (generated from the code): initialize in app/game/components/storeInitializer.tsx.

onCityLoad#

api.hooks.onCityLoad(callback: (cityCode: string) => void): () => void

Fires when the city data load finishes.

Fires when: the city data load completes for a city. Fires when: a blank city (blank: true) skips its data load and starts with empty demand. Demand edits made synchronously inside the callback, or in its post-reload replay, are not recorded into the save's mod demand overlay, even for a mod that opted in with demand.setPersistence(true). Payload: the city code string. reloadMods clears the registration and replays the event once after the mods re-execute when a city is loaded.

Parameter callback ((cityCode: string) => void): receives cityCode.

Returns: () => void. the unsubscribe function.

js
api.hooks.onCityLoad((cityCode) => {
    console.log(`Loaded city: ${cityCode}`);
    if (cityCode === 'MTL') {
        // Montreal-specific setup
    }
});

Trigger call sites (generated from the code): loadInitialData in app/game/mainStore.ts.

onMapReady#

api.hooks.onMapReady(callback: (map: any) => void): () => void

Fires when the map finishes loading, with the live maplibre-gl Map instance.

Fires when: the map component's load handler runs for a city. Before the callbacks run the trigger stores the map instance and applies every custom source and layer registered through the map API. Payload: the live maplibre-gl Map object from react-map-gl; changes to it affect the game map. reloadMods clears the registration and replays the event once after the mods re-execute when the map is already loaded.

Parameter callback ((map: any) => void): receives map, the live maplibre-gl Map.

Returns: () => void. the unsubscribe function.

js
api.hooks.onMapReady((map) => {
    // map is the MapLibre instance
    map.on('click', (e) => console.log('Clicked:', e.lngLat));
});

Trigger call sites (generated from the code): NativeSessionMap in app/game/map/map.tsx.

onGameSaved#

api.hooks.onGameSaved(callback: (saveName: string) => void): () => void

Fires inside generateSave after the save data validates, before any caller writes a file.

Fires when: generateSave runs for an escape-menu save or overwrite, exporting the current game, the autosave, the save before quitting ([Auto] Exit Save), Save and Quit in the quit dialog ([Auto] <date and time>), the save keyboard shortcuts or the agent save tool. Does not fire: when save validation fails (generateSave throws Save validation failed: <error>); when no mod has registered. Payload: the name passed to generateSave. reloadMods clears the registration and does not replay the event.

Parameter callback ((saveName: string) => void): receives saveName.

Returns: () => void. the unsubscribe function.

js
api.hooks.onGameSaved((saveName) => {
    console.log(`Game saved: ${saveName}`);
});

Trigger call sites (generated from the code): generateSave in app/game/mainStore.ts.

onGameLoaded#

api.hooks.onGameLoaded(callback: (saveName: string) => void): () => void

Fires when loadSave finishes restoring a save, and immediately on registration when a save is already loaded.

Fires when: loadSave reaches the end of its restore. Fires when: a mod registers while a save is loaded; the callback runs once synchronously inside the registration with the current save name. Demand edits made synchronously inside the callback (including the immediate call on registration and the post-reload replay) are not recorded into the save's mod demand overlay, even for a mod that opted in with demand.setPersistence(true). onGameEnd clears the loaded-save marker, so a registration after exiting to the menu does not fire immediately. Payload: the save name string. reloadMods clears the registration and replays the event once after the mods re-execute when a save is loaded.

Parameter callback ((saveName: string) => void): receives saveName.

Returns: () => void. the unsubscribe function.

Errors: immediate callback throws: logs [Modding API] Error in immediate onGameLoaded callback:, registration still succeeds.

js
// fires right away when a save is already loaded at registration
api.hooks.onGameLoaded((saveName) => {
    console.log(`Game loaded: ${saveName}`);
});

Trigger call sites (generated from the code): loadSave in app/game/mainStore.ts.

onGameEnd#

api.hooks.onGameEnd(callback: () => void): () => void

Fires when the player leaves a game through one of the game's own exits.

Fires when: the exit confirmation for leaving the game screen is accepted (Exit to Menu, the back button or any other navigation away from /game). Fires when: exit to desktop is confirmed, before the app quits. Fires when: a save is loaded or a save file imported from the escape menu, before the page reloads. Fires when: the tutorial is exited or finished and the game navigates away. After the callbacks run the trigger resets the lifecycle markers (game initialized, map ready, city, demand, loaded save), clears every mod UI component and stops crossing bell audio. Does not fire: when the app quits any other way than Exit to Desktop, such as closing the window. Hook registrations survive exit to menu. Loading or importing a save from the escape menu reloads the page after the callbacks, which discards every registration and re-executes the mods. No payload. reloadMods clears the registration and does not replay the event.

Parameter callback (() => void): invoked with no arguments.

Returns: () => void. the unsubscribe function.

js
const timer = setInterval(() => console.log('tick'), 60_000);
api.hooks.onGameEnd(() => {
    clearInterval(timer);
});

Trigger call sites (generated from the code): handleExitToDesktop in app/game/components/escapeMenu/menu.tsx, handleLoad in app/game/components/escapeMenu/useSaveHandlers.ts, handleImportFile in app/game/components/escapeMenu/useSaveHandlers.ts, TutorialOverlay in app/game/components/tutorial/TutorialOverlay.tsx, useExitConfirmation in app/game/hooks/useExitConfirmation.ts.

onTutorialStart#

api.hooks.onTutorialStart(callback: (approach: 'compressed' | 'newspaper' | 'fail-forward' | 'none') => void): () => void

Fires when the tutorial store starts a tutorial, and immediately on registration while one is active.

Fires when: startTutorial runs while no tutorial is active; approach comes from the tutorial feature flags and defaults to compressed. The trigger is dispatched asynchronously; the callbacks run after startTutorial returns. Fires when: a mod registers while a tutorial is active; the callback runs once synchronously inside the registration with the current approach. Payload: approach, one of compressed, newspaper, fail-forward, none. reloadMods clears the registration. A re-registration during an active tutorial fires immediately. There is no post-reload replay.

Parameter callback ((approach: 'compressed' | 'newspaper' | 'fail-forward' | 'none') => void): receives approach.

Returns: () => void. the unsubscribe function.

Errors: immediate callback throws: logs [Modding API] Error in immediate onTutorialStart callback:, registration still succeeds.

js
// fires right away when a tutorial is already running at registration
api.hooks.onTutorialStart((approach) => {
    console.log(`Tutorial started: ${approach}`);
});

Trigger call sites (generated from the code): notifyTutorialStart in app/game/components/tutorial/TutorialProvider.utils.ts.

onTutorialEnd#

api.hooks.onTutorialEnd(callback: (reason: 'completed' | 'skipped', stepReached: number) => void): () => void

Fires when the tutorial store ends an active tutorial.

Fires when: endTutorial runs while a tutorial is active. reason is completed when the current step equals the total step count and that step is marked completed; otherwise skipped. stepReached is the current step number at the time of the call. The trigger is dispatched asynchronously; the callbacks run after endTutorial returns. Does not fire: when endTutorial runs with no active tutorial. reloadMods clears the registration and does not replay the event.

Parameter callback ((reason: 'completed' | 'skipped', stepReached: number) => void): receives reason (completed or skipped) and stepReached (step number).

Returns: () => void. the unsubscribe function.

js
api.hooks.onTutorialEnd((reason, stepReached) => {
    // reason is 'completed' or 'skipped'
    console.log(`Tutorial ${reason} at step ${stepReached}`);
});

Trigger call sites (generated from the code): notifyTutorialEnd in app/game/components/tutorial/TutorialProvider.utils.ts.

Time and Money#

onDayChange#

api.hooks.onDayChange(callback: (day: number, currentDay: number) => void): () => void

Fires once per game day boundary crossed by a simulation update, with the day that ended and the day that began.

Fires when: a simulation update commits elapsed seconds that cross a multiple of 86400 s; one call per crossed day in ascending order, at most 7 per update (the last 7 days crossed). First argument day: floor(elapsedSeconds / 86400) at the crossed boundary, the 1-indexed day that ended; the first value a new game delivers is 1. Second argument currentDay: day + 1, the day that began; on the last call of an update it equals gameState.getCurrentDay(). The store commits the new elapsed time before the callbacks run, so gameState.getCurrentDay() inside the callback reports the day after the last boundary crossed. Does not fire: on save load; for the day a game starts on. reloadMods clears the registration and does not replay the event.

Parameter callback ((day: number, currentDay: number) => void): receives day (the day that ended) and currentDay (the day that began); game days, 1-indexed.

Returns: () => void. the unsubscribe function.

Changed: fires once per crossed day instead of once per update, and passes currentDay as a second argument; day is unchanged (PR #1090).

js
api.hooks.onDayChange((endedDay, currentDay) => {
    console.log(`Day ${endedDay} is over, day ${currentDay} has begun`);
    if (endedDay % 100 === 0) api.ui.showNotification(`${endedDay} days played`, 'success');
});

Trigger call sites (generated from the code): applyTickResult in app/game/mainStore.ts.

onHourChange#

api.hooks.onHourChange(callback: (hour: number, day: number) => void): () => void

Fires once per game hour boundary crossed by a simulation update.

Fires when: a simulation update commits elapsed seconds that cross a multiple of 3600 s; one call per crossed hour in ascending order, at most 24 per update. hour is 0 to 23; day is floor(hour index * 3600 / 86400) + 1, 1-indexed; the first values a new game delivers are hour 1, day 1. The store commits the new elapsed time before the callbacks run, so gameState.getCurrentHour() inside the callback equals hour only on the last call of a multi-hour update. Does not fire: on save load. reloadMods clears the registration and does not replay the event.

Parameter callback ((hour: number, day: number) => void): receives hour (0 to 23) and day (game days, 1-indexed).

Returns: () => void. the unsubscribe function.

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

Trigger call sites (generated from the code): applyTickResult in app/game/mainStore.ts.

onPauseChanged#

api.hooks.onPauseChanged(callback: (isPaused: boolean) => void): () => void

Fires when a pause-aware code path pauses or resumes the game.

Fires when: togglePause flips the pause flag (clock button, pause shortcut, app menu pause item). Fires when: entering route editing pauses a running game, and leaving it restores a different pause state. Fires when: opening a track edit session pauses a running game, and committing or cancelling the session changes the pause state. Fires when: actions.setPause runs or the agent set_paused tool completes; both fire with the requested value even when the state did not change. Does not fire: for the escape menu pausing on open and restoring on close, or for a tutorial step pausing the game, both of which write the pause flag through setTimeConfig; for pause changes synced from another multiplayer player; for the pause state restored by a save load. Payload: isPaused boolean. reloadMods clears the registration and does not replay the event.

Parameter callback ((isPaused: boolean) => void): receives isPaused.

Returns: () => void. the unsubscribe function.

Changed: route editing and track edit sessions now fire it, and the agent set_paused tool now reaches registered callbacks (PR #1090).

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

Trigger call sites (generated from the code): handler in app/game/agentControl/simTools.ts, setPreviewRoute in app/game/mainStore.ts, togglePause in app/game/mainStore.ts, ensureTrackEditSession in app/game/mainStore.ts, commitTrackEditSession in app/game/mainStore.ts, cancelTrackEditSession in app/game/mainStore.ts, setPause in app/game/moddingAPI.ts.

onSpeedChanged#

api.hooks.onSpeedChanged(callback: (newSpeed: 'slow' | 'normal' | 'fast' | 'ultrafast') => void): () => void

Fires when updateTimeSpeed sets the game speed.

Fires when: the clock speed buttons, the speed shortcuts, the app menu speed items or the agent set_speed tool call updateTimeSpeed. The call fires even when the speed is unchanged. Fires when: actions.setSpeed runs with a speed different from the current one. Does not fire: for the tutorial speed lock, which writes slow through setTimeConfig; for the speed restored by a save load; for the clock synced from a multiplayer host. Payload: newSpeed, one of slow, normal, fast, ultrafast. reloadMods clears the registration and does not replay the event.

Parameter callback ((newSpeed: 'slow' | 'normal' | 'fast' | 'ultrafast') => void): receives newSpeed.

Returns: () => void. the unsubscribe function.

js
api.hooks.onSpeedChanged((newSpeed) => {
    // 'slow', 'normal', 'fast' or 'ultrafast'
    console.log(`Game speed: ${newSpeed}`);
});

Trigger call sites (generated from the code): updateTimeSpeed in app/game/mainStore.ts.

onMoneyChanged#

api.hooks.onMoneyChanged(callback: (newBalance: number, change: number, type: 'revenue' | 'expense', category?: string) => void): () => void

Fires when addRevenue or addExpense runs, and when actions.setMoney changes the balance.

Fires when: addRevenue credits the balance; change is +amount, type is revenue, category is undefined. Fires when: addExpense records an expense; change is -amount, type is expense, category is one of trainOperational, trainPurchase, construction, bondPayments, trackMaintenance, stationMaintenance, yardMaintenance, gradeCrossingMaintenance, other. In sandbox mode, and while the tutorial skips construction costs, addExpense leaves the balance unchanged and still fires with change equal to -amount. Fires when: actions.addMoney (through addRevenue) or actions.subtractMoney (through addExpense with other) runs. Fires when: actions.setMoney changes the balance; change is signed, type follows its sign, category is mod-setMoney; a zero change does not fire. Does not fire: for direct balance writes (for example setMoney from the store, cancelling a track edit session, converting a route's train type, modifyConstants STARTING_MONEY, a new game, save load, multiplayer world sync); when no mod has registered. Payload: newBalance ($), change ($, signed), type, category. reloadMods clears the registration and does not replay the event.

Parameter callback ((newBalance: number, change: number, type: 'revenue' | 'expense', category?: string) => void): receives newBalance ($), change ($, positive for revenue, negative for expense), type (revenue or expense) and category (expense category key, mod-setMoney, or undefined).

Returns: () => void. the unsubscribe function.

Changed: actions.setMoney passes a signed change (PR #1090).

js
api.hooks.onMoneyChanged((newBalance, change, type, category) => {
    // change is signed: positive for revenue, negative for expenses; category is set for expenses
    console.log(`${type}: ${Math.abs(change)} (${category || 'general'}), balance ${newBalance}`);
});

Trigger call sites (generated from the code): addRevenue in app/game/mainStore.ts, addExpense in app/game/mainStore.ts, setMoney in app/game/moddingAPI.ts.

onTicketPriceChanged#

api.hooks.onTicketPriceChanged(callback: (newPrice: number, oldPrice: number) => void): () => void

Fires when setTransitCost stores a ticket price different from the current one, outside save load.

Fires when: actions.setTicketPrice runs. Fires when: modifyConstants receives DEFAULT_TICKET_COST. Fires when: the first fare group's flat fare is edited through updateFareGroup and differs from the global price. Fires when: a multiplayer command or world sync sets the price. Does not fire: on save load (the restore passes silent); when the new price equals the old one. Payload: newPrice ($) and oldPrice ($). reloadMods clears the registration and does not replay the event.

Parameter callback ((newPrice: number, oldPrice: number) => void): receives newPrice ($) and oldPrice ($).

Returns: () => void. the unsubscribe function.

js
api.hooks.onTicketPriceChanged((newPrice, oldPrice) => {
    console.log(`Fare ${newPrice > oldPrice ? 'hike' : 'cut'}: ${oldPrice} -> ${newPrice}`);
});

Trigger call sites (generated from the code): setTransitCost in app/game/mainStore.ts.

onFareGroupsChanged#

api.hooks.onFareGroupsChanged(callback: (fareGroups: FareGroup[]) => void): () => void

Fires after a fare group store action commits, with a deep copy of the fare groups.

Fires when: setFareGroups, updateFareGroup, addFareGroup or deleteFareGroup commits. Does not fire: on save load, which writes fare groups directly; when deleteFareGroup is refused (last group, unknown id); when updateFareGroup is given an unknown id. Payload: a structuredClone of the fare group list, made separately for each callback. reloadMods clears the registration and does not replay the event.

Parameter callback ((fareGroups: FareGroup[]) => void): receives fareGroups, a deep copy of the fare group list.

Returns: () => void. the unsubscribe function.

js
api.hooks.onFareGroupsChanged((fareGroups) => {
    // a deep copy of the full list
    console.log(`${fareGroups.length} fare groups`);
});

Trigger call sites (generated from the code): setFareGroups in app/game/mainStore.ts, updateFareGroup in app/game/mainStore.ts, addFareGroup in app/game/mainStore.ts, deleteFareGroup in app/game/mainStore.ts.

Construction#

onStationBuilt#

api.hooks.onStationBuilt(callback: (station: Station) => void): () => void

Fires when a blueprint build constructs tracks of a station.

Fires when: buildBlueprints finishes, once per non-blueprint station that has a track built in that call among its trackIds. Does not fire: when a station is placed as a blueprint; when a station is loaded from a save. A constructed station fires again when a later build constructs another track in its trackIds. Payload: the live Station object from the store. reloadMods clears the registration and does not replay the event.

Parameter callback ((station: Station) => void): receives station, the live Station.

Returns: () => void. the unsubscribe function.

js
api.hooks.onStationBuilt((station) => {
    console.log(`Station built: ${station.name} (${station.id})`);
});

Trigger call sites (generated from the code): buildBlueprints in app/game/mainStore.ts.

onStationDeleted#

api.hooks.onStationDeleted(callback: (stationId: string, stationName: string) => void): () => void

Fires when setStations drops a station id that was in the store.

Fires when: setStations commits a list missing a station id from the previous list, once per missing station, before the new list is stored. Does not fire: when a new game or an empty-map reset clears the stations (both reset the store to its initial state). Does not fire: when no station is missing; when no mod has registered. Payload: stationId and stationName strings. reloadMods clears the registration and does not replay the event.

Parameter callback ((stationId: string, stationName: string) => void): receives stationId and stationName.

Returns: () => void. the unsubscribe function.

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

Trigger call sites (generated from the code): setStations in app/game/mainStore.ts.

onRouteCreated#

api.hooks.onRouteCreated(callback: (route: Route) => void): () => void

Fires when a route is created through generateRoute or duplicateRoute.

Fires when: generateRoute creates a route; build.createRoute uses this path. Fires when: duplicateRoute copies a route. Does not fire: on save load; when routes are replaced through setRoutes. Payload: the live Route object from the store. reloadMods clears the registration and does not replay the event.

Parameter callback ((route: Route) => void): receives route, the live Route.

Returns: () => void. the unsubscribe function.

js
api.hooks.onRouteCreated((route) => {
    console.log(`New route ${route.bullet} (${route.id})`);
});

Trigger call sites (generated from the code): generateRoute in app/game/mainStore.ts, duplicateRoute in app/game/mainStore.ts.

onRouteDeleted#

api.hooks.onRouteDeleted(callback: (routeId: string, routeBullet: string) => void): () => void

Fires when deleteRoute removes a route.

Fires when: deleteRoute finds the route and removes it; build.deleteRoute uses this path. Does not fire: when routes are replaced through setRoutes; when no mod has registered. Payload: routeId and the route's bullet string. reloadMods clears the registration and does not replay the event.

Parameter callback ((routeId: string, routeBullet: string) => void): receives routeId and routeBullet.

Returns: () => void. the unsubscribe function.

js
api.hooks.onRouteDeleted((routeId, routeBullet) => {
    console.log(`Route ${routeBullet} (${routeId}) deleted`);
});

Trigger call sites (generated from the code): deleteRoute in app/game/mainStore.ts.

onTrackBuilt#

api.hooks.onTrackBuilt(callback: (tracks: Track[]) => void): () => void

Fires when a blueprint build constructs tracks.

Fires when: buildBlueprints finishes with at least one constructed track whose id was not already constructed. Does not fire: when tracks are placed as blueprints; on save load. Payload: a fresh array of the built Track objects from the build result. reloadMods clears the registration and does not replay the event.

Parameter callback ((tracks: Track[]) => void): receives tracks, a fresh array of the built Track objects.

Returns: () => void. the unsubscribe function.

js
api.hooks.onTrackBuilt((tracks) => {
    const meters = tracks.reduce((sum, t) => sum + (t.length || 0), 0);
    console.log(`${tracks.length} tracks constructed, ${Math.round(meters)} m`);
});

Trigger call sites (generated from the code): buildBlueprints in app/game/mainStore.ts.

onTrackChange#

api.hooks.onTrackChange(callback: (changeType: 'add' | 'delete', count: number) => void): () => void

Fires when a track write changes the total number of tracks in the store.

Fires when: setTracks commits a list whose length differs from the previous track count; changeType is add when the count rose and delete when it fell. count is the new total of tracks, blueprint and constructed together. Does not fire: when a write adds and removes the same number of tracks. reloadMods clears the registration and does not replay the event.

Parameter callback ((changeType: 'add' | 'delete', count: number) => void): receives changeType (add or delete) and count (new total number of tracks).

Returns: () => void. the unsubscribe function.

js
api.hooks.onTrackChange((changeType, count) => {
    // changeType is 'add' or 'delete'; count is the new total, not the change
    console.log(`Tracks ${changeType}: network now has ${count} tracks`);
});

Trigger call sites (generated from the code): setTracks in app/game/mainStore.ts.

onBlueprintPlaced#

api.hooks.onBlueprintPlaced(callback: (tracks: Track[]) => void): () => void

Fires when setTracks adds blueprint tracks with ids that were not among its blueprint tracks before.

Fires when: a track write adds at least one new blueprint track. Does not fire: when an existing blueprint is modified. Does not fire: when the write passes skipHistory; the store passes it for undo, redo, track deletion and direction flips inside a track edit session, cancelling a track edit session, save load, multiplayer world sync, the multiplayer company claim and tracks placed by another multiplayer player. Payload: a fresh array of the live blueprint Track objects added in that write. reloadMods clears the registration and does not replay the event.

Parameter callback ((tracks: Track[]) => void): receives tracks, a fresh array of live blueprint Track objects.

Returns: () => void. the unsubscribe function.

js
api.hooks.onBlueprintPlaced((tracks) => {
    console.log(`${tracks.length} blueprint tracks placed`);
});

Trigger call sites (generated from the code): setTracks in app/game/mainStore.ts.

onScheduleChange#

api.hooks.onScheduleChange(callback: (routeId: string, schedule: RouteScheduleSnapshot, previous: RouteScheduleSnapshot) => void): () => void

Fires when setRoutes commits a route whose train count, demand-tier counts or timetable differ by value from the previous version.

Fires when: a route with the same id has a different idealTrainCount, a trainSchedule whose tiers differ, or a timetableSchedule whose mode, period count, or a period's id, startHour, endHour or headwaySeconds differ; renaming a period does not fire it. Comparison is by value, so a replaced route object with unchanged schedule fields does not fire it. A veryLowDemand tier equal to lowDemand counts as unchanged when the other side lacks it. Does not fire: when no mod has registered; the per-route diff is skipped entirely. Does not fire: on route creation or deletion. Payload: schedule and previous are copies built by pickRouteSchedule. Editing them does not touch the store. reloadMods clears the registration and does not replay the event.

Parameter callback ((routeId: string, schedule: RouteScheduleSnapshot, previous: RouteScheduleSnapshot) => void): receives routeId, schedule (new snapshot copy) and previous (old snapshot copy).

Returns: () => void. the unsubscribe function.

js
api.hooks.onScheduleChange((routeId, schedule, previous) => {
    // schedule and previous are copies: { idealTrainCount, trainSchedule?, timetableSchedule? }
    const before = previous?.trainSchedule?.highDemand;
    const after = schedule.trainSchedule?.highDemand;
    if (before !== undefined && after !== undefined && before !== after) {
        console.log(`Route ${routeId}: peak service ${after > before ? 'up' : 'down'} ${Math.abs(after - before)} trains`);
    }
});

Trigger call sites (generated from the code): setRoutes in app/game/mainStore.ts.

Trains and Demand#

onTrainSpawned#

api.hooks.onTrainSpawned(callback: (train: Train) => void): () => void

Fires when generateTrain, spawnTrainAtStation or a simulation update adds a train.

Fires when: generateTrain adds a train, including a stabled train parked on a temp route. Fires when: spawnTrainAtStation adds a train in local mode. Fires when: a simulation update commits a train list containing an id absent from the previous list (for example scheduler additions and sim host spawns). With the sim host active, generateTrain and spawnTrainAtStation send an op and the hook fires from a later host frame's train diff. Does not fire: on save load; when no mod has registered. Payload: the live Train object from the store. reloadMods clears the registration and does not replay the event.

Parameter callback ((train: Train) => void): receives train, the live Train.

Returns: () => void. the unsubscribe function.

Changed: spawnTrainAtStation now fires it in local mode (PR #1090).

js
const roster = new Map();
api.hooks.onTrainSpawned((train) => {
    roster.set(train.id, { routeId: train.routeId, since: api.gameState.getElapsedSeconds() });
});
api.hooks.onTrainDeleted((trainId) => roster.delete(trainId));

Trigger call sites (generated from the code): generateTrain in app/game/mainStore.ts, spawnTrainAtStation in app/game/mainStore.ts, fireTrainLifecycleEvents in app/game/moddingAPI.ts.

onTrainDeleted#

api.hooks.onTrainDeleted(callback: (trainId: string, routeId: string) => void): () => void

Fires when deleteTrain or a simulation update removes a train.

Fires when: deleteTrain removes a train in local mode. Fires when: a simulation update commits a train list missing an id from the previous list (for example scheduler depot withdrawals and sim host deletions). With the sim host active, deleteTrain sends an op and the hook fires from a later host frame's train diff. Does not fire: when no mod has registered; for trains removed by other store writes, such as the trains of the temp routes deleteRoute removes. Payload: trainId and routeId strings. reloadMods clears the registration and does not replay the event.

Parameter callback ((trainId: string, routeId: string) => void): receives trainId and routeId.

Returns: () => void. the unsubscribe function.

Changed: with the sim host active the hook fires once, from a later frame's train diff; before it fired twice, once inside deleteTrain and once from the diff (PR #1090).

js
api.hooks.onTrainDeleted((trainId, routeId) => {
    console.log(`Train ${trainId} left service on route ${routeId}`);
});

Trigger call sites (generated from the code): deleteTrain in app/game/mainStore.ts, fireTrainLifecycleEvents in app/game/moddingAPI.ts.

onTrainArrivedAtStation#

api.hooks.onTrainArrivedAtStation(callback: (train: Train, station: Station) => void): () => void

Fires once per train arrival event delivered by a simulation update.

Fires when: a simulation update carries arrival events and at least one mod has registered; each event whose train id and station node resolve fires one call. The arrival buffer holds at most 1000 events between drains; later arrivals are dropped and never reach the hook. With the sim host active the arrival events travel with each host frame and fire the same way. Does not fire: when no mod has registered; the arrival resolution is skipped. Payload: the live Train and Station objects from the store. reloadMods clears the registration and does not replay the event.

Parameter callback ((train: Train, station: Station) => void): receives train (live Train) and station (live Station).

Returns: () => void. the unsubscribe function.

js
// fires for every stop of every train: keep it cheap, act on the totals hourly
const arrivals = {};
api.hooks.onTrainArrivedAtStation((train, station) => {
    arrivals[station.id] = (arrivals[station.id] || 0) + 1;
});
api.hooks.onHourChange(() => {
    const busiest = Object.entries(arrivals).sort((a, b) => b[1] - a[1])[0];
    if (busiest) console.log(`Busiest stop so far: ${busiest[0]} (${busiest[1]} arrivals)`);
});

Trigger call sites (generated from the code): applyTickResult in app/game/mainStore.ts.

onDemandChange#

api.hooks.onDemandChange(callback: (popCount: number) => void): () => void

Fires when one of the demand writes listed below sets the store's demand data, with the pop count.

Fires when: the city data load sets demand (empty for a blank city). Fires when: a save load restores demand. Fires when: resetting to an empty map reloads the city demand. Fires when: a commute simulation wave completes, with the same count when nothing changed. Fires when: a mod commits a demand edit through the demand API. Payload: popCount, the size of the pops map. Does not fire: when the sim host slims the renderer's copy of the pops map. Does not fire: on registration; a mod that registers after demand loaded reads gameState.getDemandData() instead. reloadMods clears the registration and replays the event once after the mods re-execute when demand is loaded.

Parameter callback ((popCount: number) => void): receives popCount, the number of pops in the demand data.

Returns: () => void. the unsubscribe function.

js
api.hooks.onDemandChange((popCount) => {
    console.log(`Demand: ${popCount} commuter groups`);
});
// no replay for late registrations: read the current data directly
const demand = api.gameState.getDemandData();
if (demand) console.log(`${demand.popsMap.size} pops already loaded`);

Trigger call sites (generated from the code): setDemandData in app/game/mainStore.ts, simulateCommutes in app/game/mainStore.ts.

Selection#

onStationSelected#

api.hooks.onStationSelected(callback: (station: Station) => void): () => void

Fires when the UI navigates to a station details panel.

Fires when: the navigation state becomes station-details with an item id that resolves to a station. Does not fire: when the id resolves to no station; when the panel already shows that item (only a change of panel type or item id fires it). Payload: the live Station object from the store. reloadMods clears the registration and does not replay the event.

Parameter callback ((station: Station) => void): receives station, the live Station.

Returns: () => void. the unsubscribe function.

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

Trigger call sites (generated from the code): notifyModSelectionHooks in app/game/uiContext.tsx.

onStationGroupSelected#

api.hooks.onStationGroupSelected(callback: (group: StationGroup) => void): () => void

Fires when the UI navigates to a station group details panel.

Fires when: the navigation state becomes station-group-details with an item id that resolves to a station group. Does not fire: when the id resolves to no group; when the panel already shows that item (only a change of panel type or item id fires it). Payload: the live StationGroup object from the store. reloadMods clears the registration and does not replay the event.

Parameter callback ((group: StationGroup) => void): receives group, the live StationGroup.

Returns: () => void. the unsubscribe function.

js
api.hooks.onStationGroupSelected((group) => {
    console.log(`Selected group ${group.id} with ${group.stationIds.length} stations`);
});

Trigger call sites (generated from the code): notifyModSelectionHooks in app/game/uiContext.tsx.

onTrainSelected#

api.hooks.onTrainSelected(callback: (train: Train) => void): () => void

Fires when the UI navigates to a train details panel.

Fires when: the navigation state becomes train-details with an item id that resolves to a train. Does not fire: when the id resolves to no train; when the panel already shows that item (only a change of panel type or item id fires it). Payload: the live Train object from the store. reloadMods clears the registration and does not replay the event.

Parameter callback ((train: Train) => void): receives train, the live Train.

Returns: () => void. the unsubscribe function.

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

Trigger call sites (generated from the code): notifyModSelectionHooks in app/game/uiContext.tsx.

Warnings and Errors#

onWarning#

api.hooks.onWarning(callback: (warning: { id: string; type: string; message: string }) => void): () => void

Fires once for each new simulation warning id in a committed update.

Fires when: a simulation update carries a warning whose id is not in the current warning list. type is trains-at-capacity or stuck-passengers. message is Train at <stationName> is at capacity (<currentOccupancy>/<maxCapacity>) or <waitingCount> passengers stuck at <stationName>. An id embeds the station id and a time window (10 game s for trains-at-capacity, 30 game s for stuck-passengers), so the same warning raised in a later window fires again. Does not fire: when no mod has registered. Payload: a new { id, type, message } object shared by every callback of that delivery. reloadMods clears the registration and does not replay the event.

Parameter callback ((warning: { id: string; type: string; message: string }) => void): receives warning with id, type and message.

Returns: () => void. the unsubscribe function.

js
api.hooks.onWarning((warning) => {
    // warning.type is 'trains-at-capacity' or 'stuck-passengers'
    if (warning.type === 'stuck-passengers') api.ui.showNotification(warning.message, 'warning');
});

Trigger call sites (generated from the code): applyTickResult in app/game/mainStore.ts.

The in-game warnings panel also shows a stuck-passenger station's estimated time to clear (minutesToClear, headwaysToClear, and a trainsArrivingFull flag for "service is running but every train is full"). Those three fields are internal to the warnings panel (getStationClearance) and are not part of the onWarning payload: message and the warning object a mod receives are unchanged, still { id, type, message }. Emission is unchanged too, so a flagged station still fires once per 30 game-s window and a mod sees every raw warning exactly as before, without the clearance estimate.

onError#

api.hooks.onError(callback: (error: { type: string; message: string; recoverable: boolean }) => void): () => void

Fires for every error recorded by the mod error reporter, from any mod.

Fires when: a city, city tab or career mission registration throws (registration), city or city tab input fails a check (validation), a mod script throws while executing or a custom map source or layer fails to add (execution), a mod script fails to load or reload (initialization), or a hook callback of any mod throws (hook) outside the exceptions in the namespace summary. The network type exists in the error union; no code path records it. Payload: a new { type, message, recoverable } object per delivery. type is one of 'registration' | 'execution' | 'initialization' | 'hook' | 'network' | 'validation'. recoverable is true on every error the game records; the reporter logs a recoverable error at warn level and any other at error level. A throw inside the callback is caught by the reporter and logged as [ModErrorReporter] Error in callback:. It is not recorded as a new error. reloadMods clears the registration and drops the reporter subscription; the event is not replayed.

Parameter callback ((error: { type: string; message: string; recoverable: boolean }) => void): receives error with type, message and recoverable.

Returns: () => void. Unsubscribe function; removes the callback and its reporter subscription; a second call does nothing.

Changed: reloadMods now drops the reporter subscriptions, so a callback stops firing after a hot reload (PR #1090).

js
api.hooks.onError((error) => {
    // error.type: 'registration' | 'execution' | 'initialization' | 'hook' | 'network' | 'validation'
    console.log(`Error [${error.type}]: ${error.message} (recoverable: ${error.recoverable})`);
});

Unregistering hooks#

Every hooks.on* call returns an unsubscribe function. Calling it again does nothing.

js
const off = api.hooks.onMoneyChanged((balance) => {
    console.log(`Balance: ${balance}`);
});

// later, when the listener is no longer needed
off();

Registrations survive exiting to the menu and starting another game; only reloadMods and a page reload clear them. A hook registered inside another hook's callback stacks one more listener on every fire. Keep the unsubscribe function and call it before registering again:

js
let expensesToday = 0;
function trackExpenses(balance, change, type) {
    if (type === 'expense') expensesToday += Math.abs(change);
}

// wrong: api.hooks.onGameInit(() => api.hooks.onMoneyChanged(trackExpenses));
// adds another listener every new game, so game 3 counts each expense 3 times

let offMoney = null;
api.hooks.onGameInit(() => {
    offMoney?.();
    expensesToday = 0;
    offMoney = api.hooks.onMoneyChanged(trackExpenses);
});

Simplest: register hooks once at the top level of the script and never inside other callbacks.