Panels
A React panel that reads live game state, plus settings controls, toolbar buttons, main-menu buttons, theme control and the guaranteed icons.
The task: a toolbar button that opens a panel showing station, route and budget figures that update while the game runs. The chain is `hooks.onMapReady`, `ui.addFloatingPanel` or `ui.addToolbarPanel`, `utils.React`, `utils.components`, the `gameState` getters and `hooks.onGameEnd`.
Wait for the Map#
onMapReady fires each time the map is created: a new game, a loaded save, and a game started after returning to the main menu. reloadMods replays it when the map is already up. Register the panel inside it and guard everything that must run once:
(function () {
const api = window.SubwayBuilderAPI;
const { React, components } = api.utils;
const h = React.createElement;
let initialized = false;
api.hooks.onMapReady(() => {
api.ui.addFloatingPanel({
id: 'my-mod-stats',
icon: 'BarChart3',
title: 'Network Stats',
defaultWidth: 420,
defaultHeight: 300,
render: StatsPanel
});
if (initialized) return;
initialized = true;
});addFloatingPanel replaces a panel with the same id, so a call on every onMapReady registers one panel. Hook registrations and timers are not deduplicated; those stay behind the guard. StatsPanel is a function declaration later in the same script (Custom React Components), so the registration sees it.
Choose the Panel Type#
| Member | Result | Props passed to render |
|---|---|---|
ui.addFloatingPanel | Toolbar button in the top bar that opens a draggable, resizable window. Position and size persist in localStorage under floating-panel-<id> | { width, height } in px: the panel width and the panel height minus 50 |
ui.addToolbarPanel | Toolbar button in the top bar that opens a dropdown anchored to the button | none |
icon is a Lucide icon name ('BarChart3', 'Train', 'Settings'). The size options are on the reference entries.
Custom React Components#
utils.React is the game's React. Without JSX, build elements with React.createElement. This continues the script from Wait for the Map, inside the IIFE.
function StatsPanel({ width, height }) {
const [stats, setStats] = React.useState(read());
React.useEffect(() => {
const id = setInterval(() => setStats(read()), 1000);
return () => clearInterval(id);
}, []);
const { Card, CardHeader, CardTitle, CardContent, Badge, Button } = components;
return h(Card, { style: { width, height, overflow: 'auto' } }, [
h(CardHeader, { key: 'header' }, h(CardTitle, null, 'Network Stats')),
h(CardContent, { key: 'content', className: 'space-y-2' }, [
row('Stations', stats.stations, 'stations'),
row('Routes', stats.routes, 'routes'),
row('Budget', `$${stats.budget.toLocaleString()}`, 'budget'),
h(Button, { key: 'refresh', onClick: () => setStats(read()) }, 'Refresh')
])
]);
function row(label, value, key) {
return h('div', { key, className: 'flex items-center justify-between' }, [
h('span', { key: 'l' }, label),
h(Badge, { key: 'v', variant: 'secondary' }, String(value))
]);
}
}
function read() {
return {
stations: api.gameState.getStations().length,
routes: api.gameState.getRoutes().length,
budget: api.gameState.getBudget()
};
}utils.components holds Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Progress, Slider, Switch, Label, Input, Badge, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, SubwayButton and MainMenuButton. utils.icons holds the Lucide icon components for use inside render.
Read Live State#
Two ways to refresh. Polling, as above, with setInterval inside useEffect and clearInterval in the cleanup. Or a hook: every hooks.on* call returns its unsubscribe function, which doubles as the effect cleanup.
React.useEffect(() => {
return api.hooks.onHourChange((hour, day) => setStats(read()));
}, []);`hooks.onHourChange` fires once per crossed game hour (at most 24 per simulation update) with hour (0-23) and day. `gameState.getStations`, `gameState.getRoutes` and `gameState.getBudget` read the store at call time.
Clean Up#
Three events remove or reset mod UI:
reloadModsremoves a mod's components before its script runs again, so the script re-registers them.onGameEndclears every UI component and resets the map-ready state. It fires on a confirmed exit from the game screen (Exit to Menu or any other navigation away), on Exit to Desktop, before a save is loaded or imported from the pause menu, and when the tutorial is exited or completed.- `ui.unregisterComponent`
('top-bar', id)removes one component while the game runs.
Stop timers and subscriptions that live outside React in onGameEnd, and reset the guard so the next map gets a fresh setup:
api.hooks.onGameEnd(() => {
initialized = false;
});
})();Clear timers started inside useEffect in that effect's cleanup function, as StatsPanel does with clearInterval.
Quick UI Primitives#
The ui.add* primitives need no React. Each takes a placement first:
| Placement | Where it renders |
|---|---|
settings-menu | A searchable row in the settings screen (home Settings and the pause menu's Settings view) |
escape-menu-buttons | End of the pause menu's second button grid |
escape-menu | A separate grid in the pause menu, above the exit buttons |
main-menu | A grid between the home menu buttons and the exit button |
top-bar | The right-hand toolbar icon row |
bottom-bar | A strip at the end of the in-game bottom bar |
debug-panel | The debug info panel, while it is open |
overlay | A full-viewport layer above the game view; children opt into pointer events |
`UIPlacement` has the details.
Settings Panel#
Controls in 'settings-menu' render as rows of the settings screen. There is no per-mod section, so prefix labels and ids with the mod name.
(function () {
const api = window.SubwayBuilderAPI;
const settings = { enabled: true, bonus: 100000, difficulty: 'normal' };
api.ui.addToggle('settings-menu', {
id: 'my-mod-enabled',
label: 'My Mod: enabled',
defaultValue: settings.enabled,
onChange: (value) => { settings.enabled = value; }
});
api.ui.addSlider('settings-menu', {
id: 'my-mod-bonus',
label: 'My Mod: daily bonus',
min: 10000, max: 1000000, step: 10000,
defaultValue: settings.bonus,
onChange: (value) => { settings.bonus = value; }
});
api.ui.addSelect('settings-menu', {
id: 'my-mod-difficulty',
label: 'My Mod: difficulty',
options: [{ value: 'easy', label: 'Easy' }, { value: 'normal', label: 'Normal' }, { value: 'hard', label: 'Hard' }],
defaultValue: settings.difficulty,
onChange: (value) => { settings.difficulty = value; }
});
api.ui.addSeparator('settings-menu', { id: 'my-mod-sep' });
api.ui.addText('settings-menu', { id: 'my-mod-version', text: 'My Mod 1.0.0' });
})();The controls keep their own state for the session only; persist values with `storage` and pass them back as defaultValue.
Toolbar Buttons#
Simple Button#
window.SubwayBuilderAPI.ui.addToolbarButton({
id: 'my-mod-pause',
icon: 'Pause',
tooltip: 'Pause',
onClick: () => window.SubwayBuilderAPI.actions.setPause(true)
});icon is a Lucide icon name; an unknown name logs [Modding API] Icon "<icon>" not found and renders nothing.
Button with Active State#
(function () {
const api = window.SubwayBuilderAPI;
let autoMode = false;
api.ui.addToolbarButton({
id: 'my-mod-auto',
icon: 'Zap',
tooltip: 'Auto mode',
onClick: () => {
autoMode = !autoMode;
api.ui.showNotification(autoMode ? 'Auto mode on' : 'Auto mode off', 'info');
},
isActive: () => autoMode
});
})();With isActive the button re-renders every 500 ms to poll it, so keep it cheap.
Button with Panel#
`ui.addToolbarPanel` adds a toolbar button that opens a dropdown rendering a component:
(function () {
const api = window.SubwayBuilderAPI;
const h = api.utils.React.createElement;
api.ui.addToolbarPanel({
id: 'my-mod-summary',
icon: 'BarChart3',
tooltip: 'Network summary',
title: 'Network summary',
width: 320,
render: () => h('div', { className: 'space-y-1 p-2' }, [
h('div', { key: 's' }, `Stations: ${api.gameState.getStations().length}`),
h('div', { key: 'r' }, `Routes: ${api.gameState.getRoutes().length}`)
])
});
})();Main Menu Button#
`ui.addButton` in 'main-menu' renders a button styled like the game's own menu buttons; icon is ignored there. For a custom arrow or layout, register a component that renders utils.components.MainMenuButton with `ui.registerComponent`.
window.SubwayBuilderAPI.ui.addButton('main-menu', {
id: 'my-mod-menu',
label: 'My Mod',
onClick: () => console.log('[My Mod] menu button clicked')
});The UI registry is cleared when a game ends, after the onGameEnd callbacks run, so a main-menu or settings control registered at script load is gone once the player leaves a game, until the next reload.
Theme Control#
| Member | Takes |
|---|---|
| `ui.setTheme` | 'light', 'dark' or 'system' |
| `ui.setAccentColor`, `ui.setPrimaryColor` | An HSL triple without hsl(): '262 83% 58%'. Hex does not work |
| `ui.setCSSVariable` | Any CSS variable name and value |
| `ui.resetColors` | Nothing; restores the defaults |
(function () {
const api = window.SubwayBuilderAPI;
// Light theme by day, dark by night.
api.hooks.onHourChange((hour) => api.ui.setTheme(hour >= 6 && hour < 18 ? 'light' : 'dark'));
api.ui.setAccentColor('262 83% 58%');
})();Available Icons#
utils.icons spreads the whole lucide-react export, so most Lucide names work, and string icon options are looked up the same way ('BarChart3' works). These 153 are guaranteed (curatedIcons.ts in the game):
| Letter | Icons |
|---|---|
| A | Activity, AlertCircle, AlertOctagon, AlertTriangle, AlignCenter, AlignJustify, AlignLeft, AlignRight, ArrowDown, ArrowDownLeft, ArrowDownRight, ArrowLeft, ArrowRight, ArrowUp, ArrowUpLeft, ArrowUpRight |
| B | BarChart, BarChart2, Battery, BatteryCharging, Bell, BellOff, Bike, Building, Building2, Bus |
| C | Calendar, CalendarCheck, CalendarDays, CalendarX, Camera, Car, Check, CheckCircle, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Clipboard, Clock, Cloud, CloudLightning, CloudRain, CloudSnow, Compass, Copy, CreditCard |
| D | DollarSign, Download |
| E | Edit, ExternalLink, Eye, EyeOff |
| F | FastForward, Filter, FilterX |
| G | Gauge, Globe, Grid |
| H | Headphones, Heart, HelpCircle, Home |
| I | Image, ImageOff, Info |
| K | Key |
| L | Layers, LineChart, Link, List, Locate, LocateFixed, LocateOff, Lock |
| M | Mail, MailOpen, Map, MapPin, MapPinOff, Maximize, Menu, MessageCircle, MessageSquare, Mic, MicOff, Minimize, Minus, Monitor, Moon, Move |
| N | Navigation |
| P | Pause, PauseCircle, Percent, Phone, PhoneCall, PieChart, Pin, Plane, Play, PlayCircle, Plus, Power |
| R | Receipt, RefreshCw, Rewind, RotateCcw, Route |
| S | Save, Search, Settings, Share, Shield, ShieldCheck, Ship, ShoppingCart, SkipBack, SkipForward, SlidersHorizontal, SlidersVertical, Smartphone, SortAsc, SortDesc, Star, StopCircle, Sun |
| T | Tablet, Ticket, Timer, Train, Trash2, TrendingDown, TrendingUp, Triangle |
| U | Unlock, Upload, User, UserCheck, UserMinus, UserPlus, UserX, Users |
| V | Video, VideoOff, Volume, Volume1, Volume2, VolumeX |
| W | Wind |
| X | X, XCircle |
| Z | Zap |
Conventions#
- Prefix every component id with the mod name; ids are unique per placement across all mods, and a repeat replaces the earlier entry.
- Register in-game UI in `hooks.onMapReady`: the registry is cleared on game end and on reload, and
onMapReadyfires for every map. - Return the unsubscribe function of a hook used inside
useEffectas its cleanup. - Size panel content from the
widthandheighta floating panel passes torender. - Use
utils.componentsso the panel matches the game's style.