Common Patterns
Practical recipes for the things you'll do most often when writing mods.
Accessing the API
Every mod starts with the global API object:
const api = window.SubwayBuilderAPI;
Always check that it exists before using it:
if (!api) {
console.error('[MyMod] SubwayBuilderAPI not found!');
} else {
// Safe to use api
}
Initializing a Mod
Most mods should initialize inside onMapReady, which fires when a city is loaded and the map is
ready. Use a guard to prevent double initialization:
let initialized = false;
api.hooks.onMapReady((map) => {
if (initialized) return;
initialized = true;
// Your setup code here
console.log('[MyMod] Initialized!');
});
onMapReady can fire multiple times (e.g., when switching cities). The initialized guard
ensures your UI and hooks are only set up once.
Adding UI Panels
Floating Panel
The most common way to add a mod UI. Creates a draggable panel accessible from the toolbar:
api.ui.addFloatingPanel({
id: 'my-panel',
title: 'My Panel',
icon: 'Settings', // Lucide icon name (PascalCase)
render: MyComponent, // A React component
});
Toolbar Button
Add a button to the main in-game toolbar:
api.ui.addToolbarButton({
id: 'my-button',
icon: 'Zap',
tooltip: 'Do something',
onClick: () => {
api.ui.showNotification('Button clicked!', 'info');
},
});
Escape Menu Button
Add a button to the in-game escape/pause menu:
api.ui.addButton('escape-menu', {
id: 'my-menu-btn',
label: 'My Mod Settings',
onClick: () => {
// Open settings, toggle features, etc.
},
});
Settings Menu Controls
Add toggles, sliders, and selects to the settings panel:
api.ui.addToggle('settings-menu', {
id: 'my-toggle',
label: 'Enable feature',
defaultValue: true,
onChange: (enabled) => {
console.log('Feature:', enabled);
},
});
api.ui.addSlider('settings-menu', {
id: 'my-slider',
label: 'Speed multiplier',
min: 1,
max: 10,
step: 0.5,
defaultValue: 1,
onChange: (value) => {
console.log('Speed:', value);
},
});
UI Placements
The placement argument controls where UI elements appear:
| Placement | Where it shows up |
|---|---|
'settings-menu' | Settings panel |
'escape-menu' | Escape/pause menu body |
'escape-menu-buttons' | Escape menu button row |
'main-menu' | Main menu screen |
'bottom-bar' | Bottom toolbar area |
'top-bar' | Top bar area |
'debug-panel' | Debug overlay |
Notifications
Show a toast message to the player:
api.ui.showNotification('Map loaded successfully!', 'success');
api.ui.showNotification('Something went wrong.', 'error');
api.ui.showNotification('Tip: try building underground.', 'info');
api.ui.showNotification('Running low on funds!', 'warning');
Reading Game State
All game state is read-only and accessed through api.gameState:
// Stations, routes, tracks, trains
const stations = api.gameState.getStations();
const routes = api.gameState.getRoutes();
const tracks = api.gameState.getTracks();
const trains = api.gameState.getTrains();
// Financial
const budget = api.gameState.getBudget();
const ticketPrice = api.gameState.getTicketPrice();
// Time
const day = api.gameState.getCurrentDay();
const hour = api.gameState.getCurrentHour();
// Performance
const ridership = api.gameState.getRidershipStats();
const modeChoice = api.gameState.getModeChoiceStats();
const lineMetrics = api.gameState.getLineMetrics();
Station Data
Each station has coordinates, track IDs, and nearby station info:
const stations = api.gameState.getStations();
for (const station of stations) {
console.log(station.name, station.coords); // [longitude, latitude]
console.log('Tracks:', station.trackIds.length);
console.log(
'Nearby:',
station.nearbyStations.map((n) => n.stationId),
);
}
Ridership Data
// Overall stats
const stats = api.gameState.getRidershipStats();
console.log(`${stats.totalRidersPerHour} riders/hour`);
// Per-station
const stationData = api.gameState.getStationRidership('station-uuid-here');
console.log(`${stationData.total} riders at this station`);
// Per-route
const routeData = api.gameState.getRouteRidership('route-uuid-here');
Reacting to Events
Register callbacks for game events using api.hooks:
// React to new stations
api.hooks.onStationBuilt((station) => {
console.log(`New station: ${station.name} at ${station.coords}`);
});
// React to money changes
api.hooks.onMoneyChanged((balance, change, type, category) => {
if (type === 'expense' && change > 1000000) {
api.ui.showNotification('Big purchase!', 'info');
}
});
// React to day changes
api.hooks.onDayChange((day) => {
console.log(`Day ${day}`);
});
// React to speed changes
api.hooks.onSpeedChanged((speed) => {
console.log(`Speed: ${speed}`); // 'slow' | 'normal' | 'fast' | 'ultrafast'
});
// React to game saves
api.hooks.onGameSaved((saveName) => {
console.log(`Game saved: ${saveName}`);
});
All Available Hooks
| Hook | Callback arguments |
|---|---|
onGameInit | (none) |
onDayChange | day: number |
onCityLoad | cityCode: string |
onMapReady | map: MapLibreMap |
onStationBuilt | station: Station |
onStationDeleted | stationId: string |
onRouteCreated | route: Route |
onRouteDeleted | routeId: string, routeBullet: string |
onTrackBuilt | tracks: Track[] |
onBlueprintPlaced | tracks: Track[] |
onDemandChange | popCount: number |
onTrackChange | changeType: 'add' | 'delete', count: number |
onTrainSpawned | train: Train |
onTrainDeleted | trainId: string, routeId: string |
onPauseChanged | isPaused: boolean |
onSpeedChanged | newSpeed: GameSpeed |
onMoneyChanged | newBalance, change, type, category? |
onGameSaved | saveName: string |
onGameLoaded | saveName: string |
onWarning | message: string |
onError | error: string |
onGameEnd | (none) |
Modifying Game Actions
Use api.actions to change game state:
// Money
api.actions.setMoney(5000000);
api.actions.addMoney(1000000, 'grant');
api.actions.subtractMoney(500000, 'maintenance');
// Game control
api.actions.setPause(true);
api.actions.setSpeed('fast'); // 'slow' | 'normal' | 'fast' | 'ultrafast'
// Ticket price
api.actions.setTicketPrice(5);
Modifying Game Constants
Tweak the game's rules:
api.modifyConstants({
STARTING_MONEY: 10000000000, // 10 billion
DEFAULT_TICKET_COST: 5,
CONSTRUCTION_COSTS: {
TUNNEL: { SINGLE_MULTIPLIER: 0.5 }, // Half-price tunnels
},
});
Call modifyConstants() early — ideally before onMapReady. Constants affect the
entire game session.
Building Tracks Programmatically
Use api.build to place and construct tracks via code:
// Place blueprint tracks
const result = api.build.placeBlueprintTracks([
{
coords: [
[-74.006, 40.7128],
[-74.009, 40.715],
], // [lng, lat] pairs
trackType: 'heavy-metro',
startElevation: -15, // underground
endElevation: -15,
},
]);
if (result.success) {
console.log(`Placed ${result.trackIds.length} tracks`);
// Build the blueprints (costs money)
const buildResult = await api.build.buildBlueprints();
console.log(`Built ${buildResult.builtTrackCount} tracks, cost: $${buildResult.totalCost}`);
}
Creating Routes and Adding Trains
// Create a new route
const route = api.build.createRoute({
bullet: 'A',
color: '#0039A6',
textColor: '#FFFFFF',
shape: 'circle',
trainType: 'heavy-metro',
});
if (route.success && route.route) {
// Buy trains and add them to the route
api.build.buyTrains(4, 'heavy-metro');
api.build.addTrainToRoute(route.route.id, 0); // Add at first station
}
Persistent Storage
Save mod data between sessions (desktop app only):
// Save
await api.storage.set('highScore', 42);
await api.storage.set('settings', { sound: true, difficulty: 'hard' });
// Load
const score = await api.storage.get('highScore', 0);
const settings = await api.storage.get('settings', { sound: true, difficulty: 'normal' });
// Delete
await api.storage.delete('highScore');
// List all keys
const keys = await api.storage.keys();
Storage only works in the desktop (Electron) app. In the browser version, set does nothing
and get always returns the default value.