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.
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#
| Property | Type | Required | Description | |
|---|---|---|---|---|
name | string | Yes | Display name | |
code | string | Yes | Short code (uppercase, e.g., 'MTL') | |
description | string | No | City description | |
population | number | No | City population | |
difficulty | string \ | null | No | Difficulty rating (see below) |
initialViewState | object | Yes | Starting map position | |
initialViewState.zoom | number | Yes | Initial zoom level | |
initialViewState.latitude | number | Yes | Initial latitude | |
initialViewState.longitude | number | Yes | Initial longitude | |
initialViewState.bearing | number | No | Initial rotation (degrees) | |
minZoom | number | No | Minimum zoom level | |
buildingZoomOffset | number | No | Shifts when buildings appear (see below) | |
mapImageUrl | string | No | Custom 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:
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):
// 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:
| Property | Required | Description |
|---|---|---|
id | Yes | Unique identifier for the tab |
label | Yes | Display name shown in the tab button |
emoji | No | Emoji shown next to the label (e.g., country flag) |
cityCodes | Yes | Array 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():
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:
| File | Description |
|---|---|
demandData | Population demand points and commuter groups |
buildingsIndex | Building footprints and foundation depths |
roads | Road network for collision detection |
runwaysTaxiways | Airport areas (optional) |
oceanDepthIndex | Ocean depth data (optional) |
buildingTags | OSM tags keyed by OSM id, powering `getBuildingTags` / `getBuildingsAt` (optional) |
Data File Schemas#
The modding API exposes Zod schemas for validating your data files:
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#
{
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 fileDemandPointSchema- Single demand pointPopSchema- Single commuter groupRoadsGeojsonSchema- Roads fileRunwaysTaxiwaysGeojsonSchema- 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:
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:
window.SubwayBuilderAPI.map.setDefaultLayerVisibility('MTL', {
buildingFoundations: false,
oceanFoundations: false,
trackElevations: false
});Complete Example#
Here's a complete example adding Winnipeg:
// 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.