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,
initialViewState: {
zoom: 13.5,
latitude: 49.871881,
longitude: -97.146345,
bearing: 0
}
});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:
{
"cs": 0.01, // cell size
"bbox": [-97.3, 49.7, -96.9, 50.0],
"grid": [40, 30],
"cells": [[0, 0, 1, 2, 3], [1, 0, 4, 5]],
"buildings": [
{
"b": [-97.15, 49.85, -97.14, 49.86],
"f": -5, // foundation depth
"p": [...] // polygon coordinates
}
],
"stats": { "count": 5, "maxDepth": -30 }
}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
});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.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'
});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']
});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
const buildingsResult = schemas.OptimizedBuildingIndexSchema.safeParse(buildings);
if (!buildingsResult.success) {
console.error('Buildings errors:', buildingsResult.error.errors);
}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