Custom Cities Guide
Complete walkthrough for creating custom cities
This guide walks you through creating a complete custom city mod, including data generation, tile serving, and validation.
Overview#
Adding a custom city requires:
- City registration - Basic city info and map position
- Map tiles - Vector tiles for rendering buildings, roads, etc.
- Demand data - Population points and commuter routes
- Buildings index - Building footprints for collision detection
- Roads data - Road network for routing
Step 1: Register the City#
Start with basic city registration:
window.SubwayBuilderAPI.registerCity({
name: 'Winnipeg',
code: 'YWG',
description: 'Build transit for the Prairie metropolis',
population: 850000,
// Optional difficulty badge: 'easy' | 'medium' | 'hard' | 'very-hard' | null (unrated)
difficulty: 'medium',
initialViewState: {
zoom: 13.5,
latitude: 49.871881,
longitude: -97.146345,
bearing: 0
}
});The code is what names your data folder, your tile paths and your thumbnail — use it consistently everywhere below. It doesn't have to be globally unique: the game identifies your city by your mod id plus this code, so it won't be dropped or confused with a built-in city that happens to share the code, now or after a future update adds one. See City Codes and Conflicts.
Step 2: Generate Data Files#
You'll need to generate several data files from OpenStreetMap or other sources.
Demand Data#
The demand data file contains population points and commuter groups:
{
"points": [
{
"id": "dp_001",
"location": [-97.1463, 49.8718],
"jobs": 500,
"residents": 1200,
"popIds": ["pop_001", "pop_002"]
}
],
"pops": [
{
"id": "pop_001",
"size": 100,
"residenceId": "dp_001",
"jobId": "dp_002",
"drivingSeconds": 1800,
"drivingDistance": 15000
}
]
}Computing Driving Routes#
The game needs drivingSeconds and drivingDistance for each pop. Use a routing service like OSRM:
# Download OSM data
wget https://download.geofabrik.de/north-america/canada/manitoba-latest.osm.pbf
# Run OSRM with Docker
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.osrmQuery routes:
const response = await fetch(
`http://localhost:5000/route/v1/driving/${originLon},${originLat};${destLon},${destLat}?overview=full&geometries=geojson`
);
const data = await response.json();
const pop = {
id: 'pop_001',
size: 100,
residenceId: 'dp_001',
jobId: 'dp_002',
drivingSeconds: data.routes[0].duration,
drivingDistance: data.routes[0].distance,
drivingPath: data.routes[0].geometry.coordinates
};Buildings Index#
The buildings index provides collision detection for underground construction. It ships as a packed binary file (buildings-index.bin or buildings-index.bin.gz) — JSON is not supported because the parsed-object overhead is too high for large cities (LA's index is ~1 GB as JSON in V8 memory, ~50 MB as typed arrays).
Build the binary in your mod's data-prep step using the encoder exposed on the modding API:
const api = window.SubwayBuilderAPI;
// One entry per building. Polygon is an array of rings (outer first, then holes).
const buildings = [
{
bounds: [-97.15, 49.85, -97.14, 49.86], // [minLon, minLat, maxLon, maxLat]
foundationDepth: 5, // meters below ground (positive)
polygon: [
[
[-97.15, 49.85],
[-97.14, 49.85],
[-97.14, 49.86],
[-97.15, 49.86],
[-97.15, 49.85] // ring must be closed
]
],
// Optional: OSM ids of the buildings in this footprint (positive = way,
// negative = relation). These must match the `id` attribute on your building
// tiles. Without them, buildings this footprint covers can't be tracked as
// demolished — they'll stay visible on the map after a track demolishes them.
osmIds: [45479154]
}
// ...
];
// Bucket buildings into a uniform 100m grid for fast collision lookup.
const cellSize = 0.0009; // ~100 m of latitude
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) => {
const c0 = Math.floor((b.bounds[0] - minLon) / cellSizeLon);
const c1 = Math.floor((b.bounds[2] - minLon) / cellSizeLon);
const r0 = Math.floor((b.bounds[1] - minLat) / cellSize);
const r1 = Math.floor((b.bounds[3] - minLat) / cellSize);
for (let col = c0; col <= c1; col++) {
for (let row = r0; row <= r1; row++) {
const key = `${col},${row}`;
if (!cellMap.has(key)) cellMap.set(key, []);
cellMap.get(key).push(id);
}
}
});
const cells = Array.from(cellMap, ([k, ids]) => {
const [col, row] = k.split(',').map(Number);
return { col, row, buildingIds: ids };
});
const buffer = api.utils.buildings.encode({
buildings,
cells,
cols,
rows,
cellSize,
maxFoundationDepth: Math.max(...buildings.map((b) => b.foundationDepth)),
minLon,
minLat,
maxLon,
maxLat
});
// `buffer` is an ArrayBuffer. Write it to disk as buildings-index.bin and serve it
// (optionally gzipped) from your mod's data directory.After encoding, you can sanity-check the buffer before shipping:
const result = api.utils.buildings.validate(buffer);
if (!result.ok) {
console.error('Buildings index is invalid:', result.error);
} else {
console.log(`Encoded ${result.buildingCount} buildings, ${result.nonEmptyCells} non-empty cells`);
console.log('bbox:', result.bbox);
}<Callout type="tip"> Want to read, write, or verify a buildings-index.bin outside the game? The community building-index kit is a standalone, zero-dependency createBinaryDetector.ts — load a .bin and inspect buildings (detector.getBuilding(0), b.polygon, b.foundationDepth), iterate a grid cell (detector.forEachBuildingInCell(col, row, id => ...)), or run its demo/verify scripts to confirm your encoded file round-trips correctly. Handy for debugging a data-prep pipeline without a game checkout. </Callout>
Building Tags (Optional)#
A plain JSON object mapping OSM id → tags, powering `gameState.getBuildingTags` and `getBuildingsAt`. Serve it as .json or .json.gz:
{
"45479154": { "building": "retail", "name": "Portage Place", "shop": "mall" },
"-1234567": { "building": "civic", "name": "City Hall", "amenity": "townhall" }
}Keys are the same signed OSM ids as the buildings index (positive = way, negative = relation). Only include buildings that carry something worth looking up (a name, amenity, shop, etc.) — lookups for anything else return null, so there's no reason to bloat the file with untagged building=yes entries.
Step 3: Serve Map Tiles#
Set up a tile server to serve vector tiles. You can use:
- tileserver-gl - Easy to set up
- PMTiles - Single-file format
- Custom server - Any HTTP server
Point the game to your 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
});maxZoom is the deepest zoom your tileset stores; the game overzooms past it. The shallow end is set on the city itself, with `buildingZoomOffset` — buildings fade in from zoom 12 and foundations from zoom 13 by default, so if your building tiles start deeper than that, offset the city by the difference and the game won't ask for zooms you don't have.
Step 4: Configure Data Files#
Tell the game where to find your data files:
window.SubwayBuilderAPI.cities.setCityDataFiles('YWG', {
buildingsIndex: 'http://127.0.0.1:8080/data/buildings-index.bin.gz',
demandData: 'http://127.0.0.1:8080/data/demand_data.json.gz',
roads: 'http://127.0.0.1:8080/data/roads.geojson.gz',
runwaysTaxiways: 'http://127.0.0.1:8080/data/runways_taxiways.geojson.gz',
buildingTags: 'http://127.0.0.1:8080/data/building_tags.json.gz' // Optional
});If a mod needs to read these files at runtime, do not use `fetch()` (it breaks on Electron file:// and gz paths). Use api.utils.loadCityData():
const api = window.SubwayBuilderAPI;
const cityCode = api.utils.getCityCode();
const demandData = await api.utils.loadCityData(`/data/${cityCode}/demand_data.json.gz`);
console.log('Pops:', demandData.pops.length);Step 5: Handle Layer Differences#
If your tiles use different layer names, configure overrides:
window.SubwayBuilderAPI.map.setLayerOverride({
layerId: 'parks-large',
sourceLayer: 'landuse',
filter: ['==', ['get', 'kind'], 'park']
});
window.SubwayBuilderAPI.map.setLayerOverride({
layerId: 'airports',
sourceLayer: 'landuse',
filter: ['==', ['get', 'kind'], 'aerodrome']
});
// Commercial + college districts share one layer. The game colors features
// with `type: 'college'` as campuses; everything else gets the commercial color.
window.SubwayBuilderAPI.map.setLayerOverride({
layerId: 'commercial',
sourceLayer: 'landuse',
filter: ['in', ['get', 'kind'], ['literal', ['commercial', 'university']]]
});See Commercial & College Districts for how official tilesets structure this layer.
Step 6: Disable Missing Layers#
If your city doesn't have certain data, disable those layers:
window.SubwayBuilderAPI.map.setDefaultLayerVisibility('YWG', {
buildingFoundations: false,
oceanFoundations: false
});Step 7: Validate Your Data#
Use the built-in schemas to validate your data files:
const schemas = window.SubwayBuilderAPI.schemas;
// Validate demand data
const demandResult = schemas.DemandDataSchema.safeParse(demandData);
if (!demandResult.success) {
console.error('Demand data errors:', demandResult.error.errors);
}
// Validate buildings index (binary format — pass the ArrayBuffer)
const buildingsResult = api.utils.buildings.validate(buildingsBuffer);
if (!buildingsResult.ok) {
console.error('Buildings errors:', buildingsResult.error);
}Complete Example#
Here's a complete mod file for Winnipeg:
(function () {
const api = window.SubwayBuilderAPI;
// 1. Register city
api.registerCity({
name: 'Winnipeg',
code: 'YWG',
description: 'Build transit for the Prairie metropolis',
population: 850000,
initialViewState: {
zoom: 13.5,
latitude: 49.871881,
longitude: -97.146345,
bearing: 0
}
});
// 2. Set tile URLs
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
});
// 3. Configure layers
api.map.setLayerOverride({
layerId: 'parks-large',
sourceLayer: 'landuse',
filter: ['==', ['get', 'kind'], 'park']
});
// 4. Set data files
api.cities.setCityDataFiles('YWG', {
buildingsIndex: 'http://127.0.0.1:8080/data/buildings_index.json.gz',
demandData: 'http://127.0.0.1:8080/data/demand_data.json.gz',
roads: 'http://127.0.0.1:8080/data/roads.geojson.gz',
runwaysTaxiways: 'http://127.0.0.1:8080/data/runways_taxiways.geojson.gz'
});
// 5. Disable missing layers
api.map.setDefaultLayerVisibility('YWG', {
buildingFoundations: true,
oceanFoundations: false
});
console.log('[Winnipeg Mod] Loaded successfully!');
})();Tools You'll Need#
- Overpass Turbo - Query OSM data
- GDAL/OGR - Process GeoJSON
- Turf.js - Spatial operations in JavaScript
- PMTiles - Serve vector tiles
- OSRM/Valhalla/GraphHopper - Route calculation
- Docker - Run tile and routing servers