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:

js
(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#

MemberResultProps passed to render
ui.addFloatingPanelToolbar 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.addToolbarPanelToolbar button in the top bar that opens a dropdown anchored to the buttonnone

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.

js
    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.

js
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:

  • reloadMods removes a mod's components before its script runs again, so the script re-registers them.
  • onGameEnd clears 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:

js
    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:

PlacementWhere it renders
settings-menuA searchable row in the settings screen (home Settings and the pause menu's Settings view)
escape-menu-buttonsEnd of the pause menu's second button grid
escape-menuA separate grid in the pause menu, above the exit buttons
main-menuA grid between the home menu buttons and the exit button
top-barThe right-hand toolbar icon row
bottom-barA strip at the end of the in-game bottom bar
debug-panelThe debug info panel, while it is open
overlayA 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.

js
(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#

js
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#

js
(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:

js
(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}`)
        ])
    });
})();

`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`.

js
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#

MemberTakes
`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
js
(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):

LetterIcons
AActivity, AlertCircle, AlertOctagon, AlertTriangle, AlignCenter, AlignJustify, AlignLeft, AlignRight, ArrowDown, ArrowDownLeft, ArrowDownRight, ArrowLeft, ArrowRight, ArrowUp, ArrowUpLeft, ArrowUpRight
BBarChart, BarChart2, Battery, BatteryCharging, Bell, BellOff, Bike, Building, Building2, Bus
CCalendar, CalendarCheck, CalendarDays, CalendarX, Camera, Car, Check, CheckCircle, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Clipboard, Clock, Cloud, CloudLightning, CloudRain, CloudSnow, Compass, Copy, CreditCard
DDollarSign, Download
EEdit, ExternalLink, Eye, EyeOff
FFastForward, Filter, FilterX
GGauge, Globe, Grid
HHeadphones, Heart, HelpCircle, Home
IImage, ImageOff, Info
KKey
LLayers, LineChart, Link, List, Locate, LocateFixed, LocateOff, Lock
MMail, MailOpen, Map, MapPin, MapPinOff, Maximize, Menu, MessageCircle, MessageSquare, Mic, MicOff, Minimize, Minus, Monitor, Moon, Move
NNavigation
PPause, PauseCircle, Percent, Phone, PhoneCall, PieChart, Pin, Plane, Play, PlayCircle, Plus, Power
RReceipt, RefreshCw, Rewind, RotateCcw, Route
SSave, Search, Settings, Share, Shield, ShieldCheck, Ship, ShoppingCart, SkipBack, SkipForward, SlidersHorizontal, SlidersVertical, Smartphone, SortAsc, SortDesc, Star, StopCircle, Sun
TTablet, Ticket, Timer, Train, Trash2, TrendingDown, TrendingUp, Triangle
UUnlock, Upload, User, UserCheck, UserMinus, UserPlus, UserX, Users
VVideo, VideoOff, Volume, Volume1, Volume2, VolumeX
WWind
XX, XCircle
ZZap

Conventions#

  1. Prefix every component id with the mod name; ids are unique per placement across all mods, and a repeat replaces the earlier entry.
  2. Register in-game UI in `hooks.onMapReady`: the registry is cleared on game end and on reload, and onMapReady fires for every map.
  3. Return the unsubscribe function of a hook used inside useEffect as its cleanup.
  4. Size panel content from the width and height a floating panel passes to render.
  5. Use utils.components so the panel matches the game's style.