Custom cities

Register a city, generate its data files, point it at tiles and data, adjust layers, validate, and react when it loads.

What a City Needs#

The task: ship a playable city the game does not know. The chain is `registerCity`, `cities.registerTab`, `map.setTileURLOverride`, `cities.setCityDataFiles`, `map.setLayerOverride` and `hooks.onCityLoad`. All registration calls go at the top level of the mod script, which the loader runs at launch.

A custom city needs:

PieceWhatStep
RegistrationName, code and starting camera1
Data filesDemand, roads, runways, buildings index; optional walk graph, building tags, ocean depth2, 4
Map tilesVector tiles for land use, water, parks and building foundations3
Layer fixesSource-layer and filter overrides when the tileset's names differ5, 6

Step 1: Register the City#

js
const api = window.SubwayBuilderAPI;

api.registerCity({
    name: 'Winnipeg',
    code: 'YWG',
    country: 'CA',
    description: 'Build transit for the Prairie metropolis',
    difficulty: 'medium',
    initialViewState: { zoom: 13.5, latitude: 49.871881, longitude: -97.146345, bearing: 0 }
});

The call rejects a missing code or name with the validation error Invalid city object - must have code and name. code keys the data folder and the overrides below. The city is identified by mod id plus code, so a code shared with a built-in city works; the API logs a warning and both appear in the picker. Registering the same code twice from one mod is rejected. difficulty outside very-easy, easy, medium, hard, very-hard is replaced by null (no badge) with a validation error.

Group Cities in a Tab#

js
api.cities.registerTab({ id: 'canada', label: 'Canada', emoji: '🇨🇦', cityCodes: ['YWG'] });

id, label and a cityCodes array of strings are required; a missing one or a duplicate id is recorded as a validation error and the tab is skipped. Tabs are cleared on reload and re-register with the script.

Step 2: Generate Data Files#

Demand Data#

Demand data is { points, pops }. A point has id, location ([lon, lat]), jobs, residents and popIds. A pop has id, size, residenceId, jobId, drivingSeconds, drivingDistance (m) and an optional drivingPath of [lon, lat] pairs. `schemas.PopSchema` and `schemas.DemandPointSchema` hold the constraints.

json
{
    "points": [
        { "id": "dp_001", "location": [-97.1463, 49.8718], "jobs": 500, "residents": 1200, "popIds": ["pop_001"] },
        { "id": "dp_002", "location": [-97.1380, 49.8951], "jobs": 2400, "residents": 300, "popIds": ["pop_001"] }
    ],
    "pops": [
        { "id": "pop_001", "size": 100, "residenceId": "dp_001", "jobId": "dp_002", "drivingSeconds": 1800, "drivingDistance": 15000 }
    ]
}

Computing Driving Routes#

Each pop needs drivingSeconds and drivingDistance. Any car router works; with OSRM in Docker:

bash
wget https://download.geofabrik.de/north-america/canada/manitoba-latest.osm.pbf
docker run -t -v "${PWD}:/data" osrm/osrm-backend osrm-extract -p /opt/car.lua /data/manitoba-latest.osm.pbf
docker run -t -v "${PWD}:/data" osrm/osrm-backend osrm-partition /data/manitoba-latest.osrm
docker run -t -v "${PWD}:/data" osrm/osrm-backend osrm-customize /data/manitoba-latest.osrm
docker run -t -p 5000:5000 -v "${PWD}:/data" osrm/osrm-backend osrm-routed --algorithm mld /data/manitoba-latest.osrm
js
const url = `http://localhost:5000/route/v1/driving/${home[0]},${home[1]};${job[0]},${job[1]}?overview=full&geometries=geojson`;
const route = (await (await fetch(url)).json()).routes[0];
const pop = {
    id: 'pop_001', size: 100, residenceId: 'dp_001', jobId: 'dp_002',
    drivingSeconds: route.duration,
    drivingDistance: route.distance,
    drivingPath: route.geometry.coordinates
};

Buildings Index#

The buildings index is binary. Build it in the DevTools console, after const api = window.SubwayBuilderAPI;, from a buildings array and write the returned ArrayBuffer to buildings_index.bin:

js
const buildings = [
    {
        bounds: [-97.15, 49.85, -97.14, 49.86], // minLon, minLat, maxLon, maxLat
        foundationDepth: 5,                     // m below ground
        polygon: [[[-97.15, 49.85], [-97.14, 49.85], [-97.14, 49.86], [-97.15, 49.86], [-97.15, 49.85]]],
        osmIds: [45479154]                      // optional
    }
];
const cellSize = 0.0009;                        // degrees of latitude per grid cell
const minLon = -97.3, minLat = 49.7, maxLon = -96.9, maxLat = 50.0;
const cellSizeLon = cellSize / Math.cos((((minLat + maxLat) / 2) * Math.PI) / 180);
const cols = Math.ceil((maxLon - minLon) / cellSizeLon);
const rows = Math.ceil((maxLat - minLat) / cellSize);

const cellMap = new Map();
buildings.forEach((b, id) => {
    for (let col = Math.floor((b.bounds[0] - minLon) / cellSizeLon); col <= Math.floor((b.bounds[2] - minLon) / cellSizeLon); col++) {
        for (let row = Math.floor((b.bounds[1] - minLat) / cellSize); row <= Math.floor((b.bounds[3] - minLat) / cellSize); row++) {
            const key = `${col},${row}`;
            if (!cellMap.has(key)) cellMap.set(key, []);
            cellMap.get(key).push(id);
        }
    }
});
const cells = Array.from(cellMap, ([key, buildingIds]) => {
    const [col, row] = key.split(',').map(Number);
    return { col, row, buildingIds };
});

const buffer = api.utils.buildings.encode({
    buildings, cells, cols, rows, cellSize,
    maxFoundationDepth: Math.max(...buildings.map((b) => b.foundationDepth)),
    minLon, minLat, maxLon, maxLat
});
api.utils.buildings.validate(buffer); // { ok: true, buildingCount, nonEmptyCells, bbox }

Building Tags (Optional)#

building_tags.json is a JSON object keyed by signed OSM id (the ids in the buildings index), with each building's tags. It feeds `gameState.getBuildingTags` and `gameState.getBuildingsAt`.

json
{
    "45479154": { "building": "retail", "name": "Portage Place", "shop": "mall" },
    "-1234567": { "building": "civic", "name": "City Hall", "amenity": "townhall" }
}

Include only buildings with something to look up (a name, amenity, shop, ...); a lookup for an id missing from the file returns null.

Walk Graph (Optional)#

The walk graph is optional. Nodes are [lng, lat] pairs whose array index is the node id; edges are undirected with lengthM in metres and optional interior geometry:

js
const walkBuffer = api.utils.walkGraph.encode({
    nodes: [[-97.14, 49.89], [-97.13, 49.89], [-97.13, 49.9]],
    edges: [
        { a: 0, b: 1, lengthM: 720 },
        { a: 1, b: 2, lengthM: 1113, geometry: [[-97.128, 49.895]] }
    ]
});
api.utils.walkGraph.validate(walkBuffer); // { ok: true, nodeCount, edgeCount, bbox }

An edge that references a node outside nodes makes encode throw.

Step 3: Serve Map Tiles#

js
api.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
});

tilesUrl and foundationTilesUrl take a {z}/{x}/{y} template or an array of templates. A missing cityCode logs [Modding API] Invalid tile URL override - must have cityCode and does nothing. In the desktop app a tile request is cancelled unless its URL matches the app's fixed network allowlist of exact hosts and none of its blocked patterns. For a local server the allowlist admits plain http:// on 127.0.0.1 and localhost; run the tile server on the player's machine.

Step 4: Configure Data Files#

The game reads a custom city's files from <app data>/cities/data/<CODE>/, where <app data> is the folder that holds mods/ (see Getting started). With the default names, no call is needed:

FileDefault pathFormat
demandData/data/<CODE>/demand_data.jsonJSON, `schemas.DemandDataSchema`
roads/data/<CODE>/roads.geojsonGeoJSON, `schemas.RoadsGeojsonSchema`
runwaysTaxiways/data/<CODE>/runways_taxiways.geojsonGeoJSON, `schemas.RunwaysTaxiwaysGeojsonSchema`
buildingsIndex/data/<CODE>/buildings_index.binPacked binary from `utils.buildings.encode`
walkGraph/data/<CODE>/walk_graph.bin.gzPacked binary from `utils.walkGraph.encode`. Optional. The simulation workers fetch this path as given, without the .gz retry below
buildingTags/data/<CODE>/building_tags.jsonJSON object keyed by signed OSM id. Optional
oceanDepthIndex/data/<CODE>/ocean_depth_index.jsonJSON. Optional

An app-relative /data/ path without .gz is tried with .gz appended first, then as given. A path ending in .gz is fetched as given and decompressed.

Other names or locations go through setCityDataFiles:

js
api.cities.setCityDataFiles('YWG', {
    demandData: '/data/YWG/demand_data.json.gz',
    roads: '/data/YWG/roads.geojson.gz',
    runwaysTaxiways: '/data/YWG/runways_taxiways.geojson.gz',
    buildingsIndex: '/data/YWG/buildings_index.bin.gz'
});

Two path forms are accepted: app-relative /data/<CODE>/<file>, and an http://127.0.0.1:<port>/... or http://localhost:<port>/... URL to a server the mod runs. A URL is fetched exactly as given; a .gz suffix is decompressed. The city loader refuses redirects: a redirect response fails the load. The walk graph is then fetched a second time by the simulation workers, and that fetch follows redirects. A URL on any other host throws. A filesystem path (/Users/..., C:\...) is rejected when the file loads: the console shows [loadData] Absolute file paths are not supported ([loadBinaryData] for the binary files) and the load throws. A mod ships its data by asking the player to copy the folder into <app data>/cities/data/<CODE>/ or by serving it over local HTTP.

Serve Data over Local HTTP#

Any static file server bound to 127.0.0.1 works. With the city folder served at http://127.0.0.1:8081/YWG/:

js
api.cities.setCityDataFiles('YWG', {
    demandData: 'http://127.0.0.1:8081/YWG/demand_data.json.gz',
    roads: 'http://127.0.0.1:8081/YWG/roads.geojson.gz',
    runwaysTaxiways: 'http://127.0.0.1:8081/YWG/runways_taxiways.geojson.gz',
    buildingsIndex: 'http://127.0.0.1:8081/YWG/buildings_index.bin.gz',
    walkGraph: 'http://127.0.0.1:8081/YWG/walk_graph.bin.gz'
});

Step 5: Handle Layer Differences#

When the tileset's layer names differ from the game's, override the source layer and filter per game layer. The style builder applies sourceLayer to water, parks-large, parks-small, commercial and airports:

js
api.map.setLayerOverride({
    layerId: 'parks-large',
    sourceLayer: 'landuse',
    filter: ['==', ['get', 'kind'], 'park']
});

When the layer is already on the map, filter and paint apply at once, and a sourceLayer logs a warning that it applies on the next style load. A missing layerId logs [Modding API] Invalid layer override - must have layerId.

Step 6: Disable Missing Layers#

Turn off layers the city has no data for:

js
api.map.setDefaultLayerVisibility('YWG', { buildingFoundations: false, oceanFoundations: false });

The object is merged over the player's layer toggles when the city loads; a loaded save then restores its own toggles. `ui.getAvailableLayers` lists the keys.

Step 7: Validate the Data#

js
const demand = await api.utils.loadCityData('/data/YWG/demand_data.json.gz');
const result = api.schemas.DemandDataSchema.safeParse(demand);
if (!result.success) console.error(result.error.issues);

const index = await api.utils.buildings.load('/data/YWG/buildings_index.bin.gz');
console.log(api.utils.buildings.validate(index));

`utils.loadCityData` and `utils.buildings.load` resolve paths with the same rules as step 4. A loadCityData call made synchronously from a mod's script or hook callback shows the player, once per mod and path, a security notice naming the mod id.

React When the City Loads#

js
api.hooks.onCityLoad((cityCode) => {
    if (cityCode !== 'YWG') return;
    api.ui.showNotification('Welcome to Winnipeg', 'info');
});

onCityLoad fires after the city's data has loaded, for new games and loaded saves, and is replayed by reloadMods. Demand edits made in the callback are not written into saves.

Saves Need the City's Mod Installed#

A save's cityCode has to resolve to either a built-in city or one a currently-installed, enabled mod registered with registerCity. If the mod that added the city isn't installed (or is disabled), the game now fails fast with a toast: This save needs the "<code>" city, which isn't installed. Install the mod that adds it, then try again. Before this, an unresolved city code silently fell through to loading with no data: every per-file fetch degrades to empty data on a 404 instead of throwing, so the player was left on the loading screen forever with no error. A mod distributing a custom city should tell players the mod id and version the save depends on; the game has no way to fetch or install a missing mod on its own.

Complete Example#

js
(function () {
    const api = window.SubwayBuilderAPI;

    api.registerCity({
        name: 'Winnipeg',
        code: 'YWG',
        country: 'CA',
        description: 'Build transit for the Prairie metropolis',
        initialViewState: { zoom: 13.5, latitude: 49.871881, longitude: -97.146345, bearing: 0 }
    });

    api.cities.registerTab({ id: 'canada', label: 'Canada', cityCodes: ['YWG'] });

    api.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
    });

    api.cities.setCityDataFiles('YWG', {
        demandData: '/data/YWG/demand_data.json.gz',
        roads: '/data/YWG/roads.geojson.gz',
        runwaysTaxiways: '/data/YWG/runways_taxiways.geojson.gz',
        buildingsIndex: '/data/YWG/buildings_index.bin.gz'
    });

    api.map.setLayerOverride({
        layerId: 'parks-large',
        sourceLayer: 'landuse',
        filter: ['==', ['get', 'kind'], 'park']
    });

    api.map.setDefaultLayerVisibility('YWG', { buildingFoundations: false, oceanFoundations: false });

    api.hooks.onCityLoad((cityCode) => {
        if (cityCode === 'YWG') api.ui.showNotification('Welcome to Winnipeg', 'info');
    });
})();

reloadMods clears modded cities and tabs; the script re-registers both when it re-runs. The tile URL override, layer overrides, default layer visibility and city data files survive a reload untouched.

Tools#

ToolUse
Overpass TurboQuery OpenStreetMap for roads, buildings and land use
GDAL/OGRConvert and clip GeoJSON
Turf.jsSpatial operations in JavaScript
PMTiles or any MVT serverServe vector tiles on 127.0.0.1
OSRM, Valhalla or GraphHopperDriving times and distances for pops
DockerRun the tile and routing servers