trains

Train type registry. Register, patch and read the TrainTypeStatsItem records that define both trains and the track types that share their ids.

api.trains. 4 members.

MemberReturnsSummary
registerTrainType(trainType)voidRegister a train type.
modifyTrainType(id, overrides)voidPatch a registered train type.
getTrainTypes()Record<string, TrainTypeStatsItem>The live train type registry, keyed by id.
getTrainType(id)TrainTypeStatsItem or undefinedOne train type record by id.

registerTrainType#

api.trains.registerTrainType(trainType: TrainTypeStatsItem): void

Register a train type.

Stores trainType in the registry under trainType.id, replacing any existing entry wholesale.

No stat defaults are filled. A missing required stat stays undefined. trainType.id is prepended to compatibleTrackTypes when absent (an omitted array becomes [id]), and to compatibleStationTrackTypes when that array is present and lacks it. The registry is pushed to the sim host worker when one is running; the worker replaces its own copy. An id absent from the owned-cars ledger gets a 0 entry. In development builds logs [Modding API] Registering train type: <name> (<id>), or Updating for an existing id. reloadMods keeps registered types.

Parameter trainType (TrainTypeStatsItem): complete TrainTypeStatsItem; id required.

Errors: falsy object or missing id: logs [Modding API] Invalid train type - must have id, does nothing.

Side effects: TRAIN_TYPES[id]; sim host worker constants push when one is running; store ownedCarsByType[id] = 0 when absent.

js
// registerTrainType fills no defaults: start from a built-in type and change what differs
const base = api.trains.getTrainType('heavy-metro');
api.trains.registerTrainType({
    ...base,
    id: 'my-express-metro',
    name: 'Express Metro',
    description: 'Faster, longer trains for trunk lines',
    compatibleTrackTypes: ['heavy-metro'],
    stats: { ...base.stats, maxSpeed: 33, maxCars: 12, carCost: 3_000_000 },
    appearance: { ...base.appearance, color: '#f59e0b' },
});

modifyTrainType#

api.trains.modifyTrainType(id: string, overrides: Partial<TrainTypeStatsItem>): void

Patch a registered train type.

Deep-merges overrides into the existing record.

Object values merge recursively into object values. Arrays, primitives, null and undefined replace. After the merge, id is prepended to compatibleTrackTypes when absent, and to compatibleStationTrackTypes when present and lacking it. The registry is pushed to the sim host worker when one is running. In development builds logs [Modding API] Modifying train type: <id>.

ParamTypeDefaultDescription
idstringrequiredid of a registered train type, built-in or modded.
overridesPartial<TrainTypeStatsItem>requiredpartial TrainTypeStatsItem; nested stats and appearance objects merge field by field.

Errors: unknown id: logs [Modding API] Train type "<id>" not found, does nothing.

Side effects: TRAIN_TYPES[id]; sim host worker constants push when one is running.

Changed: the namesake id is restored in compatibleTrackTypes after the merge, and in compatibleStationTrackTypes when that array is present (PR #1090).

js
api.trains.modifyTrainType('heavy-metro', { stats: { maxSpeed: 30 }, appearance: { color: '#ef4444' } });
// other stats and appearance fields keep their values

getTrainTypes#

api.trains.getTrainTypes(): Record<string, TrainTypeStatsItem>

The live train type registry, keyed by id.

Returns: Record<string, TrainTypeStatsItem>. the registry object itself; a direct mutation is not pushed to the sim host worker.

js
for (const [id, t] of Object.entries(api.trains.getTrainTypes())) {
    console.log(`${id}: ${t.name}, ${t.stats.maxSpeed} m/s, ${t.stats.capacityPerCar} per car`);
}

getTrainType#

api.trains.getTrainType(id: string): TrainTypeStatsItem | undefined

One train type record by id.

Parameter id (string): train type id.

Returns: TrainTypeStatsItem | undefined. the live record; undefined for an unknown id. The game's internal getTrainType helper falls back to heavy-metro.

js
const t = api.trains.getTrainType('heavy-metro');
if (t) console.log(`${t.name}: ${(t.stats.maxSpeed * 3.6).toFixed(0)} km/h`);

TrainTypeStatsItem#

One registry record. Defines a train type and the track type of the same id.

Every track type id in any compatibleTrackTypes must itself be a registry key. The namesake record supplies the track's build cost, geometry limits and grade-crossing rules. Built-in ids: heavy-metro, light-metro, light-rail, commuter-rail, commuter-rail-diesel, plus tram behind the ENABLE_TRAM feature flag. The game's internal getTrainType helper resolves an unknown id to heavy-metro.

FieldTypeDefaultDescription
idstringrequiredRegistry key; also the id of the track type this record defines.
namestringrequiredDisplay name in the train type pickers and the train types panel.
descriptionstringrequiredText under the name in the train types panel and in the construction track type tooltip.
statsobjectrequiredPerformance, geometry, capacity and cost figures. Fields: see TrainTypeStatsItem.stats below.
compatibleTrackTypesstring[][id]Track type ids this train runs on; each must be a registry key. registerTrainType and modifyTrainType prepend the record's own id when absent. Two track types can be joined when some train type lists both.
compatibleStationTrackTypesstring[]noneSubset of compatibleTrackTypes whose stations this train can stop at. See TrainTypeStatsItem.compatibleStationTrackTypes below.
appearanceobjectrequiredColors and livery. Fields: see TrainTypeStatsItem.appearance below.
invisiblebooleanfalsetrue hides the type from the buy-trains picker, the new-route and route-details type selectors (the new-route selector keeps it while selected), route type switch offers, the construction track picker, the train types … See TrainTypeStatsItem.invisible below.
elevationMultipliersElevationMultipliersnonePer-band cost multiplier overrides (ElevationMultipliers). Listed in the train types panel. Construction cost reads them; maintenance costs do not.
allowAtGradeRoadCrossingbooleanfalsetrue permits street running: at-grade track on roads other than highways passes road-collision validation, the track drawing cursor can snap to roads, and the track gets street-running metadata (road segments, speed limit).
canCrossRoadsbooleannoneFallback for allowAtGradeRoadCrossing read by road-collision validation and the train types panel only; street-running metadata and the build input handler read allowAtGradeRoadCrossing alone.
allowGradeCrossingbooleanfalsetrue lets this type's non-station track cross non-highway roads at ground level (except road classes that gradeCrossingTphLimit sets to null), at no less than the minimum crossing angle, in road-collision validation, and generates a gated grade crossing at each such point.
allowHighwayAtGradeCrossingbooleanfalsetrue lifts the hardcoded highway block in road-collision validation for this type. See TrainTypeStatsItem.allowHighwayAtGradeCrossing below.
gradeCrossingBaseCostnumber0$ per grade crossing before the road-class, lane-count and crossing-angle multipliers; omitted or 0 makes crossings free.
gradeCrossingMaintenancePerDaynumber0$ per constructed grade crossing per in-game day, charged with the other infrastructure maintenance under the gradeCrossingMaintenance category; wins over gradeCrossingMaintenancePerYear.
gradeCrossingMaintenancePerYearnumber0Alias of gradeCrossingMaintenancePerDay, read only when that field is unset; charged per in-game day, the same as the per-day field.
gradeCrossingTphLimitRecord<'highway' | 'major' | 'medium' | 'minor', number | null>noneCombined trains-per-hour cap per crossing by road class (highway, major, medium, minor); a null entry forbids crossings of that class and, when one exists anyway, caps it at 0; a missing class is uncapped; an absent field enforces no crossing cap.
gradeCrossingSpeedLimitPartial<Record<'highway' | 'major' | 'medium' | 'minor', number>>noneSpeed cap per road class through grade crossings; m/s; applied within gradeCrossingSpeedLimitApproachDistance of the crossing point along the track and across joints onto connected tracks; a missing class leaves that crossing uncapped.
gradeCrossingSpeedLimitApproachDistancenumber0Rail on each side of a grade crossing covered by gradeCrossingSpeedLimit; m; measured along the track and continued onto welded tracks; 0 caps only the speed segment that contains the crossing point.
trackConversionsArray<{ to: string; costPerMeter: number; stationCost?: number; }>noneDirectional upgrades the electrify tool offers for track of this type. See TrainTypeStatsItem.trackConversions below.
portalCostnumber0$ per portal where a blueprint moves between open air and cut-and-cover; charged once per portal in the construction cost, scaled by the track-count multiplier of the tunnel side.
rampCostnumber0$ per ramp where a blueprint moves between at-grade and elevated; charged once per ramp in the construction cost, scaled by the track-count multiplier of the elevated side.
maxOverpassSpannumber0Longest track span over one building's footprint that a track of this type can bridge; m; the track must also clear the roof by RULES.CONSTRUCTION_COSTS.BUILDING_OVERPASS.VERTICAL_CLEARANCE; a longer span demolishes the building; omitted or 0 disables overpasses.

TrainTypeStatsItem.stats#

FieldTypeDefaultDescription
maxAccelerationnumberrequiredm/s^2; the flat rate without accelerationCurve, the cap on the curve with one.
maxDecelerationnumberrequiredm/s^2; braking rate for stopping-distance and speed-profile calculations.
accelerationCurveAccelerationCurve or nullnoneoptional AccelerationCurve; omitted or null keeps the flat maxAcceleration rate.
maxSpeednumberrequiredm/s; line speed cap.
maxSpeedLocalStationnumber or nullnonem/s; cap on station track; any value other than a finite positive number falls back to maxSpeed.
capacityPerCarnumberrequiredriders per car.
carLengthnumberrequiredm.
minCarsnumberrequiredcar count bounds per train.
maxCarsnumberrequiredcar count bounds per train.
defaultCarsnumbernoneCar count a brand-new route of this type is seeded with (newRoutePanel's create flow, generateRoute). Full text below the table.
carsPerCarSetnumberrequiredcars bought and added together.
carCostnumberrequired$ per car.
trainWidthnumberrequiredm; body width drawn on the map and used for platform clearance.
minStationLengthnumberrequiredm; platform length bounds enforced by track validation.
maxStationLengthnumberrequiredm; platform length bounds enforced by track validation.
baseTrackCostnumberrequired$ per meter of track group before the track-count, yard, street-running, elevation and water multipliers.
baseStationCostnumberrequired$ per station before the station layout, platform-length, elevation and water multipliers.
trainOperationalCostPerHournumberrequired$ per in-game hour of operation, multiplied by 365 when charged.
carOperationalCostPerHournumberrequired$ per in-game hour of operation, multiplied by 365 when charged.
operationalCostsByTrackTypeRecord< string, { trainOperationalCostPerHour?: number; carOperationalCostPerHour?: number } >noneoptional record keyed by track type id with either hourly rate; a train pays the route-length-weighted blend (base rates when its route is not found or has no path); a missing key, missing sub-field or non-finite or negative value falls back to the base rate.
stopTimeSecondsnumberrequireds; scheduled dwell at every stop, plus the station type's extraDwellTime; the dynamic dwell model only extends it.
doorsPerCarPerSidenumbernoneoptional; any value other than a finite positive number derives carLength divided by DWELL.DEFAULT_DOOR_SPACING_M (6), rounded, clamped to 1 to 8.
seatsPerCarnumbernoneoptional; any value other than a finite number >= 0 falls back to capacityPerCar times DWELL.DEFAULT_SEAT_FRACTION (0.35), rounded.
turnaroundTimeSecondsnumbernones; optional; added where the train reverses; omitted falls back to 120.
parallelTrackSpacingnumberrequiredm; edge-to-edge gap between parallel tracks; center-to-center adds RULES.TRACK_WIDTH (1.435 m).
trackClearancenumberrequiredm; padding the drawn track base adds beyond half of parallelTrackSpacing outside the outer track and beyond the platform edges; station bases also extend this far past the platform ends.
maxLateralAccelerationnumberrequiredm/s^2; curve speed limits.
maxCantMmnumbernonemm; optional; omitted falls back to RULES.CANT_MAX_APPLIED_MM (150 mm). Full text below the table.
maxCantDeficiencyMmnumbernonemm; optional; omitted falls back to maxLateralAcceleration times 150.
minTurnRadiusnumberrequiredm; track drawing rejects a running-track centerline radius below 95% of it and a station track radius below it; curve speed limits floor the radius at it, and scissors crossover length and yard ladder curves are sized from it.
minStationTurnRadiusnumbernonem; mean platform radius; optional; any value other than a finite positive number falls back to the rule MIN_STATION_TURN_RADIUS (150).
maxSlopePercentagenumberrequired%; track validation rejects a track whose start-to-end grade exceeds it, and speed limits drop to 90, 75 and 60 percent of maxSpeed on grades of at least 60, 80 and 95 percent of it.
trackMaintenanceCostPerMeternumberrequired$ per meter per in-game day before the track-count, yard-track, elevation and water multipliers; charged pro rata every RULES.INFRASTRUCTURE_COST_CHARGE_INTERVAL (300) game seconds.
stationMaintenanceCostPerYearnumberrequired$ per station per in-game day before the track-count, platform-length, elevation and water multipliers; charged pro rata every RULES.INFRASTRUCTURE_COST_CHARGE_INTERVAL (300) game seconds.
tphLimitnumberrequiredtrains per hour; cap for a route of this type, and the combined cap of all routes on a track of this type.
crossoverSpeednumberrequiredm/s; flat cap on scissors-crossover tracks, bounded by maxSpeed.
yardSpeedLimitnumbernonem/s; optional; any value other than a finite positive number falls back to the rule YARDS.DEFAULT_YARD_SPEED_LIMIT (4.47).

defaultCars: Car count a brand-new route of this type is seeded with (newRoutePanel's create flow, generateRoute). Optional: unset falls back to minCars, which is right whenever minCars is itself a sane starting train. Set this explicitly when minCars is lowered to widen a floor (e.g. heavy metro's 2-car minimum for light-metro upgrades, #1179) without also shrinking the default size of every new line. Read via getDefaultCarsForTrainType.

maxCantMm: mm; optional; omitted falls back to RULES.CANT_MAX_APPLIED_MM (150 mm); applied-cant cap on cant-enabled running track; RULES.CANT_STATION_MAX_MM (0 mm) caps it on station track and RULES.CANT_STREET_RUNNING_MAX_MM (0 mm) on street-running track; with feature flag CANT_MECHANICS on, RULES.CANT_MAX_STOPPED_MM (150 mm) caps it within RULES.CANT_STOP_ZONE_LENGTH_M (200 m) of a junction or station end and cant builds at RULES.CANT_RAMP_MM_PER_S (45 mm/s) along the track; legacy tracks ignore it and use maxLateralAcceleration.

Performance, geometry, capacity and cost figures. Required fields have no fallback. An omitted one stays undefined. In sandbox mode the costs below are recorded as expenses without reducing money, except a route train-type conversion (behind the ROUTE_TYPE_CONVERSION feature flag, off by default), which takes its cost straight off money in every mode.

Curve speed on cant-enabled track: sqrt((cant + maxCantDeficiencyMm) / 150 * radius) m/s with radius floored at minTurnRadius, rounded, and never above the limit already in force; with feature flag CANT_MECHANICS off, cant is the full context cap; with it on, cant is the ramped profile and the sum is floored at 20 mm.

TrainTypeStatsItem.appearance#

FieldTypeDefaultDescription
colorstringrequiredhex accent for this type in the measure tool (built-in ids only), the map key, the track types overlay, the route type switch cards, and the track conversion map previews.
bodyColorstring or { light: string; dark: string }noneoptional hex, or { light, dark } per map theme, for the train body on the map when trains are not drawn in route colors; omitted uses the theme default.
roofStyle'rounded' or 'boxy'noneoptional 'rounded' or 'boxy'; 'boxy' draws a flat roof.
couplerWidthMultipliernumbernoneoptional ratio for the inter-car coupler width; 1 when omitted.
catenarybooleannoneoptional; true draws overhead wire and masts on this type's tracks at close zoom while the detailed tracks display setting is on.

TrainTypeStatsItem.compatibleStationTrackTypes#

Subset of compatibleTrackTypes whose stations this train can stop at. The other compatible track types are run-through only. Absent means every compatible track type's stations. Ids outside compatibleTrackTypes have no effect. The record's own id is prepended when the array is present and lacks it.

TrainTypeStatsItem.invisible#

true hides the type from the buy-trains picker, the new-route and route-details type selectors (the new-route selector keeps it while selected), route type switch offers, the construction track picker, the train types panel, the by-type grouping of the trains list, the measure tool and the map key. Lookups by id, existing trains, tracks and routes keep working. Only the exact value true hides.

TrainTypeStatsItem.allowHighwayAtGradeCrossing#

true lifts the hardcoded highway block in road-collision validation for this type: roads with roadClass/index-type highway are then treated like any other road for allowAtGradeRoadCrossing (street-running) and allowGradeCrossing (gated grade crossing) checks, instead of always being blocked. Workaround for OSM roads miscategorized as highways that a real tram/bus route should be able to cross at grade. gradeCrossingTphLimit.highway (when set to null) still forbids crossings for allowGradeCrossing types even with this on. Does not affect street-running metadata (speed limit/road name), which still excludes highways.

TrainTypeStatsItem.trackConversions#

Directional upgrades the electrify tool offers for track of this type: { to, costPerMeter, stationCost? }. The first valid entry is used; an entry is skipped when to is missing, this id or not a registry key, or costPerMeter is not a finite number >= 0; a non-finite or negative stationCost reads as 0. Absent or empty means not convertible. Route type switch offers also use it: a blocking track group counts as reachable when its conversion target can host the new type.

AccelerationCurve#

Three-stage tractive-effort model, evaluated per tick to cap acceleration at the current speed (SI units).

speed <= v1: a = a0 + (a1 - a0) * speed / v1 (a0 when v1 is 0). v1 < speed <= v2: a = v1 * a1 / speed. speed > v2: a = v1 * a1 * v2^(exponent - 1) / speed^exponent. The result is capped at stats.maxAcceleration and floored at 0.05 m/s^2. A NaN result falls back to stats.maxAcceleration.

FieldTypeDefaultDescription
a0numberrequiredAcceleration at standstill; m/s^2.
a1numberrequiredAcceleration at speed v1; m/s^2.
v1numberrequiredSpeed where the tractive-effort stage ends and constant power begins; m/s.
v2numberrequiredSpeed where the constant-power stage ends and the exponent stage begins; m/s.
exponentnumber1Falloff exponent above v2; dimensionless.

ElevationMultipliers#

Per-train-type construction cost multipliers keyed by elevation band.

The train types panel lists the overrides. Construction cost (the charge and the drawing preview) uses a set band in place of the global rule; unset bands and maintenance costs read the rule. Bands by track elevation (m): DEEP_BORE <= -24; STANDARD_TUNNEL -24 to -10; CUT_AND_COVER -10 to -4; TRENCHED -4 to -1; AT_GRADE -1 to 0; RAMP 0 to 5 exclusive; ELEVATED >= 5. Each upper bound belongs to the deeper band; 5 belongs to ELEVATED.

FieldTypeDefaultDescription
DEEP_BOREnumbernoneCost multiplier override for deep-bore tunnel, elevation <= -24 m; ratio. Replaces the rule CONSTRUCTION_COSTS.ELEVATION_MULTIPLIERS.DEEP_BORE (default 4.5) in this type's construction cost; maintenance reads the rule.
STANDARD_TUNNELnumbernoneCost multiplier override for standard tunnel, -24 m to -10 m; ratio. Replaces the rule CONSTRUCTION_COSTS.ELEVATION_MULTIPLIERS.STANDARD_TUNNEL (default 2.0) in this type's construction cost; maintenance reads the rule.
CUT_AND_COVERnumbernoneCost multiplier override for cut-and-cover, -10 m to -4 m; ratio. Replaces the rule CONSTRUCTION_COSTS.ELEVATION_MULTIPLIERS.CUT_AND_COVER (default 1.0) in this type's construction cost; maintenance reads the rule.
TRENCHEDnumbernoneCost multiplier override for open trench, -4 m to -1 m; ratio. Replaces the rule CONSTRUCTION_COSTS.ELEVATION_MULTIPLIERS.TRENCHED (default 0.5) in this type's construction cost; maintenance reads the rule.
AT_GRADEnumbernoneCost multiplier override for at grade, -1 m to 0 m; ratio. Replaces the rule CONSTRUCTION_COSTS.ELEVATION_MULTIPLIERS.AT_GRADE (default 0.35) in this type's construction cost; maintenance reads the rule.
RAMPnumbernoneCost multiplier override for low ramp or embankment, 0 m to 5 m exclusive; ratio. Replaces the rule CONSTRUCTION_COSTS.ELEVATION_MULTIPLIERS.RAMP (default 0.5) in this type's construction cost; maintenance reads the rule.
ELEVATEDnumbernoneCost multiplier override for elevated viaduct, >= 5 m; ratio. Replaces the rule CONSTRUCTION_COSTS.ELEVATION_MULTIPLIERS.ELEVATED (default 0.8) in this type's construction cost; maintenance reads the rule.

Grade Crossings#

A train type with allowGradeCrossing: true builds track that crosses roads at ground level; the game places a gated grade crossing at each crossing point. The fields sit at the top level of the train type, beside stats. Built in, commuter-rail, commuter-rail-diesel and light-rail allow grade crossings; heavy-metro and light-metro do not.

Build cost per crossing is gradeCrossingBaseCost times three multipliers:

MultiplierValuesRule
Road classminor 1, medium 1.25, major 1.5, highway 2CONSTRUCTION_COSTS.GRADE_CROSSING.ROAD_CLASS_MULTIPLIER
Track lanes crossing the road1 lane 0.75, 2 lanes 1, 4 lanes 1.5; other counts 0.5 + 0.25 per laneCONSTRUCTION_COSTS.GRADE_CROSSING.LANE_COUNT_MULTIPLIER
Crossing angle1 / sin(angle), at most 3fixed

Both rule tables are read at call time, so modifyConstants changes take effect on the next price. An omitted or 0 gradeCrossingBaseCost makes crossings free.

js
api.trains.modifyTrainType('commuter-rail', {
    gradeCrossingBaseCost: 400_000,
    gradeCrossingMaintenancePerDay: 6_000, // $ per built crossing per in-game day
    gradeCrossingTphLimit: { highway: null, major: 10, medium: 12, minor: 14 }, // null forbids the class
    gradeCrossingSpeedLimit: { major: 11.2, medium: 13.4 }, // m/s; minor stays uncapped
    gradeCrossingSpeedLimitApproachDistance: 150, // m on each side of the crossing
});

Custom Elevation Cost Multipliers#

elevationMultipliers on a train type stores per-band overrides (see ElevationMultipliers above). The train types panel lists them, but construction and maintenance costs do not read them: every train type pays the global CONSTRUCTION_COSTS.ELEVATION_MULTIPLIERS. Change those with modifyConstants to reprice elevation bands for all types.

Track cost also scales with lane count: CONSTRUCTION_COSTS.TUNNEL.SINGLE_MULTIPLIER (0.75) for single track and CONSTRUCTION_COSTS.TUNNEL.QUAD_MULTIPLIER (1.5) for quad track, in construction and maintenance.

At-Grade Road Crossing#

allowAtGradeRoadCrossing: true permits street running: at-grade track may run along roads other than highways, the track drawing cursor snaps to roads, and the track carries street-running metadata (road segments, speed limit). The built-in tram type, registered only when the ENABLE_TRAM feature flag is on, sets it. canCrossRoads is an older alias that only road-collision validation and the train types panel read, so set allowAtGradeRoadCrossing.

This is separate from grade crossings: street running lets track occupy the road, while allowGradeCrossing lets track cross it.

js
api.trains.modifyTrainType('light-rail', { allowAtGradeRoadCrossing: true });

Highway At-Grade Crossing#

allowHighwayAtGradeCrossing: true is a mod-only opt-in that lifts the hardcoded highway block in road-collision validation for this type: roads OSM tags highway (sometimes a miscategorized road a tram or bus route should realistically cross) are then checked like any other road class for both allowAtGradeRoadCrossing (street running) and allowGradeCrossing (gated crossing), instead of always failing. It's off by default and no built-in train type sets it. gradeCrossingTphLimit.highway: null still forbids the crossing even with this on, as a separate, explicit "forbidden" signal that wins over the flag. It doesn't change street-running metadata (speed limit/road name), which still excludes highways.

js
api.trains.modifyTrainType('light-rail', { allowHighwayAtGradeCrossing: true });

Example: Light Rail System#

A complete train type written out field by field. registerTrainType fills no defaults, so every required stat is present. The game ships its own light-rail; registering that id replaces the built-in wholesale, so this uses a new id. To tweak the built-in, use modifyTrainType('light-rail', ...).

js
const api = window.SubwayBuilderAPI;

api.trains.registerTrainType({
    id: 'my-light-rail',
    name: 'My Light Rail',
    description: 'Modern light rail for urban transit',
    stats: {
        maxSpeed: 25, // m/s
        maxSpeedLocalStation: 12,
        maxAcceleration: 1.2, // m/s²
        maxDeceleration: 1.3,
        maxLateralAcceleration: 0.9,
        maxSlopePercentage: 6,
        crossoverSpeed: 6.7,
        stopTimeSeconds: 20,
        turnaroundTimeSeconds: 90,
        minTurnRadius: 25, // m
        minStationTurnRadius: 100,
        parallelTrackSpacing: 3.0,
        trackClearance: 0.75,
        minCars: 1,
        maxCars: 4,
        carsPerCarSet: 1,
        capacityPerCar: 80,
        carLength: 20,
        trainWidth: 2.65,
        minStationLength: 20,
        maxStationLength: 80,
        carCost: 1_500_000,
        baseTrackCost: 20_000, // $ per m
        baseStationCost: 15_000_000,
        trainOperationalCostPerHour: 200,
        carOperationalCostPerHour: 30,
        trackMaintenanceCostPerMeter: 150,
        stationMaintenanceCostPerYear: 40_000,
        tphLimit: 50,
    },
    compatibleTrackTypes: ['my-light-rail'], // optional, defaults to [id]
    appearance: { color: '#10b981', roofStyle: 'rounded' },
    allowAtGradeRoadCrossing: true, // street running on roads other than highways
    portalCost: 4_000_000, // $ per tunnel portal
    rampCost: 2_000_000, // $ per ramp up to elevated track
    maxOverpassSpan: 80, // m
});