Custom Cities

Add new cities with custom maps and data

The cities API lets you add custom cities with their own maps, demand data, and buildings.

Register a City#

Use registerCity() to add a new city to the game.

javascript
window.SubwayBuilderAPI.registerCity({
    name: 'Montreal',
    code: 'MTL',
    description: 'Build metros beneath the Underground City',
    population: 4_300_000,
    initialViewState: {
        zoom: 13.5,
        latitude: 45.5017,
        longitude: -73.5673,
        bearing: 0
    },
    minZoom: 10,

    // Optional: Difficulty badge shown on the city select screen
    difficulty: 'hard',

    // Optional: Custom thumbnail for city select screen
    mapImageUrl: 'http://127.0.0.1:8080/MTL/thumbnail.svg'
});

City Properties#

PropertyTypeRequiredDescription
namestringYesDisplay name
codestringYesShort code (uppercase, e.g., 'MTL')
descriptionstringNoCity description
populationnumberNoCity population
difficultystring \nullNoDifficulty rating (see below)
initialViewStateobjectYesStarting map position
initialViewState.zoomnumberYesInitial zoom level
initialViewState.latitudenumberYesInitial latitude
initialViewState.longitudenumberYesInitial longitude
initialViewState.bearingnumberNoInitial rotation (degrees)
minZoomnumberNoMinimum zoom level
buildingZoomOffsetnumberNoShifts when buildings appear (see below)
mapImageUrlstringNoCustom thumbnail URL

Building Zoom Offset#

By default buildings fade in between zoom 12 and 12.5, and the building foundations layer appears at zoom 13. buildingZoomOffset shifts both of those thresholds, in zoom levels:

javascript
window.SubwayBuilderAPI.registerCity({
    name: 'Montreal',
    code: 'MTL',
    // Buildings now fade in between zoom 13.5 and 14, foundations from 14.5
    buildingZoomOffset: 1.5,
    // ...
});

Positive values mean the player has to zoom in further before buildings show, which is worth doing for very dense cities where the footprints are unreadable (and expensive to draw) when zoomed out. Negative values show them earlier. Omit it, or pass 0, for the default thresholds.

This is a separate knob from minZoom, which is how far the camera can zoom out.

Your tiles have to keep up: raising the offset only hides buildings the game would otherwise have drawn, but lowering it makes the game request building tiles at zooms your tileset may not contain, and those zooms will simply be empty. Values must be between -4 and 8; anything outside that range (or NaN/Infinity) is clamped with a validation warning.

Difficulty#

difficulty rates how challenging your city is to build a successful network in. It shows as a colored badge on the city's hover card in the city select screen.

Allowed values: 'very-easy', 'easy', 'medium', 'hard', 'very-hard', or null.

Pass null (or omit the property) to leave the city unrated — no badge is shown. Any other value is rejected with a validation warning and treated as null.

City Codes and Conflicts#

Your city's code does not have to be globally unique. The game identifies a registered city by a uid that combines your mod's id with the code:

Built-in Buffalo    →  uid "BUF"
Your mod's Buffalo  →  uid "my-transit-pack:BUF"

You never write the uid yourself and you never pass it to any API — registerCity derives it from the mod that called it. Everything you do touch keeps using the plain code: setCityDataFiles('BUF', ...), cityCodes: ['BUF'] in a custom tab, your /data/BUF/ files, and your thumbnail path.

What this buys you:

  • Ship any code you like. If your city's code matches a built-in city's — now,

or after a future update adds that city — both appear in the city selector and both stay playable. Neither is dropped.

  • Saves stay with the right city. A save records the uid, so a game started in

your Buffalo reloads your Buffalo, not the built-in one, even if the built-in is added later.

  • Two mods can use the same code. They get different uids, so neither is

rejected.

Registering the same code twice from the same mod is still rejected — that's a duplicate, not a conflict.

<Callout type="info"> A code shared with a built-in city works, but the picker will show two cities with the same code, which is confusing for players. Prefer a distinctive code when you have the choice. Note also that if your mod is uninstalled while a save from your city exists, that save falls back to the built-in city with the same code. </Callout>

If a code you register is already in use, the game logs a warning naming the other owner (a built-in city, or the mod that registered it) so you can see the overlap during development.

Modded Cities Tab#

When you register custom cities, they appear in a dedicated "Modded" tab in the city selector. Modded cities are displayed with:

  • Purple-tinted styling to distinguish from built-in cities
  • A puzzle icon badge
  • "MOD" label if no population data is available
  • A count badge on the tab showing how many modded cities are available

Custom City Tabs#

Group multiple cities under a custom tab (e.g., for a "Canada" or "Europe" pack):

javascript
// First, register your cities
window.SubwayBuilderAPI.registerCity({
    name: 'Montreal',
    code: 'MTL'
    // ... city config
});

window.SubwayBuilderAPI.registerCity({
    name: 'Toronto',
    code: 'YYZ'
    // ... city config
});

// Then register a tab to group them
window.SubwayBuilderAPI.cities.registerTab({
    id: 'canada',
    label: 'Canada',
    emoji: '🇨🇦',
    cityCodes: ['MTL', 'YYZ']
});

Tab properties:

PropertyRequiredDescription
idYesUnique identifier for the tab
labelYesDisplay name shown in the tab button
emojiNoEmoji shown next to the label (e.g., country flag)
cityCodesYesArray of city codes that belong to this tab

cityCodes matches on the plain code, not the uid — so list codes exactly as you registered them. If a code you list is also a built-in city's code, both show up under your tab.

Custom tabs appear between the built-in country tabs (US, UK) and the "Modded" catch-all tab.

City Data Files#

Cities need data files to function properly. Set them using setCityDataFiles():

javascript
window.SubwayBuilderAPI.cities.setCityDataFiles('MTL', {
    buildingsIndex: '/data/MTL/buildings_index.json.gz',
    demandData: '/data/MTL/demand_data.json.gz',
    roads: '/data/MTL/roads.geojson.gz',
    runwaysTaxiways: '/data/MTL/runways_taxiways.geojson.gz',
    oceanDepthIndex: '/data/MTL/ocean_depth_index.json.gz', // Optional
    buildingTags: '/data/MTL/building_tags.json.gz' // Optional
});

Required data files:

FileDescription
demandDataPopulation demand points and commuter groups
buildingsIndexBuilding footprints and foundation depths
roadsRoad network for collision detection
runwaysTaxiwaysAirport areas (optional)
oceanDepthIndexOcean depth data (optional)
buildingTagsOSM tags keyed by OSM id, powering `getBuildingTags` / `getBuildingsAt` (optional)

Data File Schemas#

The modding API exposes Zod schemas for validating your data files:

javascript
const schemas = window.SubwayBuilderAPI.schemas;

// Validate demand data
const demandData = { points: [...], pops: [...] };
const result = schemas.DemandDataSchema.safeParse(demandData);

if (result.success) {
    console.log('Demand data is valid!');
} else {
    console.error('Validation errors:', result.error.errors);
}

Demand Data Format#

javascript
{
    points: [
        {
            id: "dp_001",
            location: [-97.1463, 49.8718],  // [longitude, latitude]
            jobs: 500,
            residents: 1200,
            popIds: ["pop_001", "pop_002"]
        }
    ],
    pops: [
        {
            id: "pop_001",
            size: 100,                       // Number of commuters
            residenceId: "dp_001",          // Where they live
            jobId: "dp_002",                // Where they work
            drivingSeconds: 1800,           // Driving time in seconds
            drivingDistance: 15000,         // Distance in meters
            drivingPath: [                  // Optional: route geometry
                [-97.1463, 49.8718],
                [-97.1320, 49.8850]
            ]
        }
    ]
}

Available schemas:

  • DemandDataSchema - Complete demand file
  • DemandPointSchema - Single demand point
  • PopSchema - Single commuter group
  • RoadsGeojsonSchema - Roads file
  • RunwaysTaxiwaysGeojsonSchema - Airports file

The buildings index uses a packed binary format, not JSON — validate it with api.utils.buildings.validate(buffer) instead of a zod schema. See the Custom Cities guide for the encoder.

Custom Tiles#

For custom map tiles from localhost:

javascript
window.SubwayBuilderAPI.map.setTileURLOverride({
    cityCode: 'MTL',
    tilesUrl: 'http://127.0.0.1:8080/MTL/{z}/{x}/{y}.mvt',
    foundationTilesUrl: 'http://127.0.0.1:8080/MTL/{z}/{x}/{y}.mvt',
    maxZoom: 15
});

Layer Visibility#

Disable map layers by default for cities that don't have certain data:

javascript
window.SubwayBuilderAPI.map.setDefaultLayerVisibility('MTL', {
    buildingFoundations: false,
    oceanFoundations: false,
    trackElevations: false
});

Complete Example#

Here's a complete example adding Winnipeg:

javascript
// 1. Register city
window.SubwayBuilderAPI.registerCity({
    name: 'Winnipeg',
    code: 'YWG',
    description: 'Build a transit system for the Prairie metropolis',
    population: 850000,
    initialViewState: {
        zoom: 13.5,
        latitude: 49.871881,
        longitude: -97.146345,
        bearing: 0
    }
});

// 2. Point to localhost tiles
window.SubwayBuilderAPI.map.setTileURLOverride({
    cityCode: 'YWG',
    tilesUrl: 'http://127.0.0.1:8080/YWG/{z}/{x}/{y}.mvt',
    foundationTilesUrl: 'http://127.0.0.1:8080/YWG/{z}/{x}/{y}.mvt',
    maxZoom: 15
});

// 3. Set data files
window.SubwayBuilderAPI.cities.setCityDataFiles('YWG', {
    buildingsIndex: '/data/YWG/buildings_index.json.gz',
    demandData: '/data/YWG/demand_data.json.gz',
    roads: '/data/YWG/roads.geojson.gz',
    runwaysTaxiways: '/data/YWG/runways_taxiways.geojson.gz'
});

console.log('Winnipeg mod loaded!');

For more details on generating city data, see the Custom Cities Guide.