map

Map customization: MapLibre sources and layers, per-city tile and routing overrides, per-layer overrides, and default layer visibility.

api.map. 10 members.

MemberReturnsSummary
registerSource(id, source)voidRegister a MapLibre source. Added to the map and re-added after every style reload.
registerLayer(layer, beforeId?)voidRegister a MapLibre layer. Added to the map and re-added after every style reload.
registerStyle(styleUrl)voidRecord a MapLibre style URL in the custom style list.
setTileURLOverride(override)voidReplace the tile URL templates the map style uses for a city.
setLayerOverride(override)voidOverride the source-layer, filter and paint of one basemap layer.
setDefaultLayerVisibility(cityCode, layerDefaults)voidSet layer visibility defaults for a city. Merged into the player's layer settings when the city loads.
getDefaultLayerVisibility(cityCode)Partial<Record<string, boolean>> or undefinedThe layer visibility defaults stored for a city, or undefined.
setRoutingServiceOverride(override)voidRegister a driving-directions service for a city. Inside the game only map.queryRoute uses the registry; map.getRoutingServiceOverride returns the stored entry.
getRoutingServiceOverride(cityCode)RoutingServiceOverride or undefinedThe routing service registered for a city, or undefined.
queryRoute(cityCode, origin, destination)Promise of object or nullFetch a driving route from the city's registered routing service and normalise the response.

Custom Sources, Layers and Styles#

registerSource#

api.map.registerSource(id: string, source: MapSource): void

Register a MapLibre source. Added to the map and re-added after every style reload.

A second registration with the same id replaces the stored source. When the map style is already loaded and the map has no source with that id, the source is added immediately. On map ready, and after each MapLibre styledata event, every registered source missing from the map is added again. The registry is cleared on reloadMods. Sources already on the map stay there. Logs [Modding API] Registering custom source: <id> (development builds only).

ParamTypeDefaultDescription
idstringrequiredsource id.
sourceMapSourcerequiredMapLibre source specification.

Errors:

  • missing id or source: logs [Modding API] Invalid source - must have id and source object, registers nothing.
  • exception: logs [Modding API] Error registering source <id>:.
  • re-adding fails on map ready or after a style reload: records an execution error Error adding custom source <id>: <message>, logged at warn level as [Mod Error] execution: ...; the other sources are still re-added.

Side effects: source registry updated; MapLibre addSource on a loaded map.

js
// re-added automatically after every style reload
api.map.registerSource('my-heat', {
    type: 'vector',
    tiles: ['http://127.0.0.1:8080/heat/{z}/{x}/{y}.mvt'],
    maxzoom: 14,
});

registerLayer#

api.map.registerLayer(layer: MapLayer, beforeId?: string): void

Register a MapLibre layer. Added to the map and re-added after every style reload.

A layer with an id already registered replaces the earlier entry. When the map style is already loaded, an existing layer with the same id is removed and the layer is added immediately with beforeId. On map ready, and after each MapLibre styledata event, every registered layer whose id is missing from the map is added again with its beforeId. The registry is cleared on reloadMods. Layers already on the map stay there. Logs [Modding API] Registering custom layer: <id> (development builds only) (with (before <beforeId>) when given).

ParamTypeDefaultDescription
layerMapLayerrequiredMapLibre layer specification; id required.
beforeIdstringnonepassed to MapLibre addLayer as its beforeId, on the immediate add and on every re-add.

Errors:

  • missing layer or layer.id: logs [Modding API] Invalid layer - must have id, registers nothing.
  • exception: logs [Modding API] Error registering layer <id>:.
  • re-adding fails on map ready or after a style reload: records an execution error Error adding custom layer <id>: <message>, logged at warn level as [Mod Error] execution: ...; the other layers are still re-added.

Side effects: __beforeId written onto the layer object when beforeId is given; layer registry updated; MapLibre removeLayer/addLayer on a loaded map.

js
api.map.registerSource('my-zones', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
api.map.registerLayer(
    { id: 'my-zones-fill', type: 'fill', source: 'my-zones', paint: { 'fill-color': '#22c55e', 'fill-opacity': 0.2 } },
    'road-labels' // beforeId: draw below the road labels
);

Layer Z-Order with beforeId#

beforeId names an existing MapLibre layer; the new layer is drawn directly below it. Without it, MapLibre adds the layer on top of its stack. It orders only against MapLibre layers; the deck.gl overlays cannot be targeted. The game's road label layer is road-labels.

js
api.map.registerSource('my-zones', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
api.map.registerLayer(
    { id: 'my-zones-fill', type: 'fill', source: 'my-zones', paint: { 'fill-color': '#088', 'fill-opacity': 0.3 } },
    'road-labels' // drawn below the road labels
);

Layer Persistence#

On map ready and after every MapLibre styledata event, each registered source missing from the map is added again, and each registered layer whose id is missing is added again with its stored beforeId. A style reload needs no handling in the mod. reloadMods clears both registries; sources and layers already on the map stay there.

registerStyle#

api.map.registerStyle(styleUrl: string): void

Record a MapLibre style URL in the custom style list.

No game code reads the list, so a registered style is never applied to the map. The list is cleared on reloadMods. Logs [Modding API] Registering custom style: <url> (development builds only).

Parameter styleUrl (string): style URL; must be a non-empty string.

Errors:

  • missing or non-string styleUrl: logs [Modding API] Invalid style URL, records nothing.
  • exception: logs [Modding API] Error registering style:.

Side effects: custom style list appended.

js
// recorded only: no game code applies registered styles; use registerSource + registerLayer
api.map.registerStyle('http://127.0.0.1:8080/style.json');

Override Tile URLs#

setTileURLOverride#

api.map.setTileURLOverride(override: TileURLOverride): void

Replace the tile URL templates the map style uses for a city.

The style builder reads the override when it builds the style. tilesUrl replaces map://<code>/tiles/{z}/{x}/{y}.mvt and foundationTilesUrl replaces map://<code>/foundations/{z}/{x}/{y}.mvt. The source maxzoom is maxZoom, or 15 when omitted. The lookup is keyed by the plain city code. A registered city's uid is collapsed to its code before the lookup. Logs [Modding API] Setting tile URL override for <code> (development builds only). In the desktop app the request filter cancels a request whose URL does not match the app's fixed network allowlist of exact hosts; the allowlist admits plain http:// on 127.0.0.1 and localhost; serve tiles from the player's machine.

Parameter override (TileURLOverride): city code and the URL templates to use.

Errors:

  • missing override or override.cityCode: logs [Modding API] Invalid tile URL override - must have cityCode, stores nothing.
  • exception: logs [Modding API] Error setting tile URL override:.

Side effects: tile override registry entry replaced; applied on the next style build.

js
api.map.setTileURLOverride({
    cityCode: 'HFX',
    tilesUrl: 'http://127.0.0.1:8080/HFX/{z}/{x}/{y}.mvt',
    foundationTilesUrl: 'http://127.0.0.1:8080/HFX/foundations/{z}/{x}/{y}.mvt',
    maxZoom: 15,
});

Override Layer Properties#

setLayerOverride#

api.map.setLayerOverride(override: LayerSourceOverride): void

Override the source-layer, filter and paint of one basemap layer.

The style builder honours overrides for water, parks-large, parks-small, commercial and airports (source-layer, filter and paint) and for buildings-3d (filter only, combined with the demolished-buildings filter). paint entries are merged over the layer's defaults. filter replaces the layer's filter. When the map already has the layer, filter and paint are applied to the live layer at once. In that case a sourceLayer also logs a warning [Modding API] setLayerOverride: source-layer for <id> applies on the next style load, for layers the style builder honours (filter/paint apply now). At the next style build the style builder applies a stored sourceLayer for water, parks-large, parks-small, commercial and airports; for buildings-3d it applies only filter, and other layer ids are not read. parks-large and parks-small read the same parks source-layer and split it with the default filters ['>=', ['get', 'area'], 100000] and ['<', ['get', 'area'], 100000]. A filter override replaces that split. commercial draws commercial and college areas from one source-layer, coloured by the type feature property ('college' or anything else). No separate college layer exists. The default fill-extrusion-opacity of parks-large, parks-small and commercial comes from GAME_COLORS.AREA_VISUALS (a flat opacity or a zoom fade). A paint override of that property replaces it. An override for any other layer id is stored and never read by the style builder. Logs [Modding API] Setting layer override for <id> (development builds only).

Parameter override (LayerSourceOverride): layer id and the properties to override.

Errors:

  • missing override or override.layerId: logs [Modding API] Invalid layer override - must have layerId, stores nothing.
  • exception: logs [Modding API] Error setting layer override <id>:.

Side effects: layer override registry entry replaced; MapLibre setFilter and setPaintProperty on a live layer.

Changed: sourceLayer is no longer passed to MapLibre's setLayoutProperty on a live layer; the style builder applies it on the next style load. filter and paint applied to the live layer before too (PR #1090).

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

Commercial & College Districts#

The commercial layer draws commercial districts and college campuses from one source-layer. A feature with type: 'college' gets the map theme's college color; every other feature gets the commercial color. Both share one fade and opacity (GAME_COLORS.AREA_VISUALS). No separate college layer exists, so a custom tileset that stores campuses elsewhere has to be remapped into this layer, with campus features carrying type: 'college':

js
api.map.setLayerOverride({
    layerId: 'commercial',
    sourceLayer: 'landuse',
    // the custom tiles tag districts with `type`, campuses as 'college'
    filter: ['in', ['get', 'type'], ['literal', ['commercial', 'college']]],
});

The fill is semi-transparent, so overlapping district polygons blend. Keep them disjoint.

Set Default Layer Visibility#

setDefaultLayerVisibility#

api.map.setDefaultLayerVisibility(cityCode: string, layerDefaults: Partial<Record<string, boolean>>): void

Set layer visibility defaults for a city. Merged into the player's layer settings when the city loads.

Keys are layer names as used by the Layers panel. The call does not validate them. On city load the defaults are spread over the current layer settings and written to the layer store, logged as [MainStore] Applied layer defaults for <code>: (development builds only). A loaded save that stores layer settings applies its boolean entries for known layers on top. Logs [Modding API] Setting default layer visibility for <code>: (development builds only) with the keys.

ParamTypeDefaultDescription
cityCodestringrequiredplain city code; must be a non-empty string.
layerDefaultsPartial<Record<string, boolean>>requiredmap of layer name to visibility.

Errors:

  • cityCode missing or not a string: logs [Modding API] Invalid city code, stores nothing.
  • layerDefaults missing or not an object: logs [Modding API] Invalid layer defaults - must be an object, stores nothing.
  • exception: logs [Modding API] Error setting default layer visibility:.

Side effects: layer default registry entry replaced; layer store written on city load.

js
// applied when HFX loads; a save's own layer settings win
api.map.setDefaultLayerVisibility('HFX', { oceanFoundations: false, roadLabels: true });

getDefaultLayerVisibility#

api.map.getDefaultLayerVisibility(cityCode: string): Partial<Record<string, boolean>> | undefined

The layer visibility defaults stored for a city, or undefined.

Parameter cityCode (string): plain city code.

Returns: Partial<Record<string, boolean>> | undefined. the object passed to setDefaultLayerVisibility (same reference); undefined when none.

Errors: exception: logs [Modding API] Error getting default layer visibility:, returns undefined.

js
const defaults = api.map.getDefaultLayerVisibility('HFX') || {};
console.log(Object.keys(defaults));

Routing Service Override#

setRoutingServiceOverride#

api.map.setRoutingServiceOverride(override: RoutingServiceOverride): void

Register a driving-directions service for a city. Inside the game only map.queryRoute uses the registry; map.getRoutingServiceOverride returns the stored entry.

The commute simulation does not read the registry. A second call for the same city replaces the entry. Point routingUrl at an http://127.0.0.1:<port> service on the player's machine: in the desktop app the request filter cancels a request whose URL does not match the app's fixed network allowlist of exact hosts, and that allowlist and the content security policy both admit http://127.0.0.1 on any port. Logs [Modding API] Setting routing service override for <code>: <url> (development builds only).

Parameter override (RoutingServiceOverride): city code, URL template, response format and optional parser.

Errors:

  • missing override, cityCode or routingUrl: logs [Modding API] Invalid routing service override - must have cityCode and routingUrl, stores nothing.
  • exception: logs [Modding API] Error setting routing service override:.

Side effects: routing override registry entry replaced.

js
api.map.setRoutingServiceOverride({
    cityCode: 'HFX',
    routingUrl: 'http://127.0.0.1:5000/route/v1/driving/{origin_lon},{origin_lat};{dest_lon},{dest_lat}',
    format: 'osrm',
});

Custom Parser#

With format: 'custom', queryRoute fetches the URL, parses the JSON and returns customParser(json) unchanged. The parser returns { drivingSeconds, drivingDistance, drivingPath? } in s, m and [lng, lat] pairs. A throw in the parser logs [Modding API] Error querying route: and resolves null.

js
api.map.setRoutingServiceOverride({
    cityCode: 'MTL',
    routingUrl: 'http://127.0.0.1:3000/route?from={origin_lon},{origin_lat}&to={dest_lon},{dest_lat}',
    format: 'custom',
    customParser: (response) => ({
        drivingSeconds: response.time_sec,
        drivingDistance: response.dist_m,
        drivingPath: response.coords, // optional
    }),
});

getRoutingServiceOverride#

api.map.getRoutingServiceOverride(cityCode: string): RoutingServiceOverride | undefined

The routing service registered for a city, or undefined.

Parameter cityCode (string): plain city code.

Returns: RoutingServiceOverride | undefined. the object passed to setRoutingServiceOverride (same reference); undefined when none.

Errors: exception: logs [Modding API] Error getting routing service override:, returns undefined.

js
const routing = api.map.getRoutingServiceOverride('HFX');
console.log(routing ? `HFX routes via ${routing.format}` : 'HFX has no routing service');

queryRoute#

api.map.queryRoute(cityCode: string, origin: [number, number], destination: [number, number]): Promise<{ drivingSeconds: number; drivingDistance: number; drivingPath?: [number, number][] } | null>

Fetch a driving route from the city's registered routing service and normalise the response.

The URL is built by replacing the first occurrence of each of {origin_lon}, {origin_lat}, {dest_lon} and {dest_lat} in routingUrl, then fetched with fetch and parsed as JSON. osrm reads routes[0].duration (s), routes[0].distance (m) and routes[0].geometry.coordinates. valhalla reads trip.summary.time (s), trip.summary.length times 1000 (km to m) and decodes trip.legs[0].shape (precision-6 polyline). A missing summary field reads as 0. graphhopper reads paths[0].time divided by 1000 (ms to s), paths[0].distance (m) and paths[0].points.coordinates. A missing time or distance reads as 0. custom returns customParser(json) unchanged. In the desktop app a request to a host outside the app's fixed network allowlist is cancelled, so the call logs [Modding API] Error querying route: and resolves null; an http://127.0.0.1:<port> service on the player's machine passes both that allowlist and the content security policy.

ParamTypeDefaultDescription
cityCodestringrequiredplain city code with a registered routing service.
origin[number, number]required[lng, lat]; degrees.
destination[number, number]required[lng, lat]; degrees.

Returns: Promise<{ drivingSeconds: number; drivingDistance: number; drivingPath?: [number, number][] } | null>. { drivingSeconds, drivingDistance, drivingPath? } with seconds, meters and [lng, lat] pairs; null when there is no usable route.

Errors:

  • no service for the city: logs [Modding API] No routing service registered for <code>, resolves null.
  • non-2xx response: logs [Modding API] Routing request failed: <status>, resolves null.
  • response without a route (routes[0], trip or paths[0] missing): resolves null.
  • format: 'custom' without customParser: logs [Modding API] Custom format requires customParser, resolves null.
  • unknown format: logs [Modding API] Unknown routing format: <format>, resolves null.
  • network or parser exception: logs [Modding API] Error querying route:, resolves null.
js
const r = await api.map.queryRoute('MTL', [-73.5673, 45.5017], [-73.55, 45.52]);
if (r) api.demand.addPops([{ residenceId, jobId, drivingSeconds: r.drivingSeconds, drivingDistance: r.drivingDistance }]);

MapLayer#

A MapLibre layer specification for map.registerLayer. Further MapLibre layer properties pass through.

FieldTypeDefaultDescription
idstringrequiredLayer id; required. A duplicate replaces the earlier registration.
typestringrequiredMapLibre layer type such as 'raster', 'fill' or 'line'.
sourcestringnoneId of the source the layer draws from.

MapSource#

A MapLibre source specification for map.registerSource. Further MapLibre source properties pass through.

FieldTypeDefaultDescription
typestringrequiredMapLibre source type such as 'raster', 'vector' or 'geojson'.
urlstringnonePassed to MapLibre unchanged.
tilesstring[]noneTile URL templates with {z}/{x}/{y} placeholders.

TileURLOverride#

Per-city tile URL replacement for map.setTileURLOverride.

FieldTypeDefaultDescription
cityCodestringrequiredPlain city code the override applies to; required.
tilesUrlstring or string[]noneURL template(s) for the general tiles with {z}/{x}/{y}. A string is wrapped in an array. The array becomes the vector source's tiles list.
foundationTilesUrlstring or string[]noneURL template(s) for the building foundation tiles with {z}/{x}/{y}. A string is wrapped in an array. The array becomes the foundation source's tiles list while building foundations are shown.
maxZoomnumber15Source maxzoom for both tile sources; zoom levels.

LayerSourceOverride#

Basemap layer override for map.setLayerOverride.

FieldTypeDefaultDescription
layerIdstringrequiredId of a map layer; required. filter and paint apply at once to any loaded layer with this id; when it builds the style, the style builder reads the stored override for water, parks-large, parks-small, commercial and airports (and only its filter for buildings-3d).
sourceLayerstringnoneReplacement source-layer; applied at the next style build; ignored for buildings-3d.
filterany[]noneReplacement MapLibre filter expression; applied live and at style build; combined with the demolished-buildings filter for buildings-3d.
paintRecord<string, any>nonePaint properties merged over the layer's defaults; applied live and at style build; the style builder ignores it for buildings-3d.

RoutingServiceOverride#

Driving-directions service for map.setRoutingServiceOverride and map.queryRoute.

FieldTypeDefaultDescription
cityCodestringrequiredPlain city code the service serves; required.
routingUrlstringrequiredURL template with {origin_lon}, {origin_lat}, {dest_lon}, {dest_lat} placeholders (first occurrence of each is replaced); required.
format'osrm' or 'valhalla' or 'graphhopper' or 'custom'requiredResponse format: 'osrm', 'valhalla', 'graphhopper' or 'custom'. Selects the parser in queryRoute.
customParser(response: any) => { drivingSeconds: number; drivingDistance: number; drivingPath?: [number, number][]; }noneParser for format: 'custom'; receives the parsed JSON and returns { drivingSeconds, drivingDistance, drivingPath? } in s, m and [lng, lat] pairs.

Example: Custom Basemap#

registerStyle changes nothing on the map, so a basemap is a raster source plus a layer. The desktop app cancels requests to hosts outside its allowlist, which admits plain http:// on 127.0.0.1 and localhost: serve the tiles from the player's machine. beforeId keeps the game's road labels above the basemap; registered sources and layers come back after every style reload.

js
const api = window.SubwayBuilderAPI;

api.map.registerSource('my-basemap-tiles', {
    type: 'raster',
    tiles: ['http://127.0.0.1:8080/basemap/{z}/{x}/{y}.png'],
    tileSize: 256,
    attribution: '&copy; OpenStreetMap contributors',
});

api.map.registerLayer(
    { id: 'my-basemap', type: 'raster', source: 'my-basemap-tiles', paint: { 'raster-opacity': 0.8 } },
    'road-labels'
);