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:

  1. City registration - Basic city info and map position
  2. Map tiles - Vector tiles for rendering buildings, roads, etc.
  3. Demand data - Population points and commuter routes
  4. Buildings index - Building footprints for collision detection
  5. Roads data - Road network for routing

Step 1: Register the City#

Start with basic city registration:

javascript
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:

javascript
{
    "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:

bash
# 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.osrm

Query routes:

javascript
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:

javascript
{
    "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:

javascript
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:

javascript
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():

javascript
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:

javascript
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:

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

Step 7: Validate Your Data#

Use the built-in schemas to validate your data files:

javascript
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:

javascript
(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