JS Script Types

Vehicle scripts

A "vehicle" in MTR 4 is a single car model entry; players freely combine cars into trains in the siding screen. A script binds to neither a single car nor a whole train, but to a script entry: vehicle entries reference it via scriptId, and multiple vehicles can reuse the same script — mixed consists, performance, and NTE compatibility all at once.

The three callbacks (ctx is a VehicleScriptContext, vehicle is a VehicleWrapper):

function create(ctx, state, vehicle) { /* Before the first render: set up textures, register callbacks */ }
function render(ctx, state, vehicle) { /* Every frame: read state, submit draws and sounds */ }
function dispose(ctx, state, vehicle) { /* On exit: release textures */ }

VehicleScriptContext (ctx)

MemberDescription
getCarRenderManager(carIndex): RenderManager?The render manager for car number carIndex. The base is the car's center, 1 m above the floor. Returns null for cars not registered under this entry
getCarBogieRenderManager(carIndex, bogieIndex): RenderManager?Bogie rendering: the base is relative to bogie number bogieIndex (0 front, 1 rear; 0 for single-bogie cars)
getCarSoundManager(carIndex): SoundManager?The car's sound source (base = the car's center)
playAnnSound(id, volume, pitch)Plays an announcement — audible only when the player is riding this train, without spatialization
getScriptEntryId(): StringThe script entry's id
getMyCars(): int[]The array of car indices registered under this entry
setDebugInfo(key, value) / setDebugInfo(value)Shown on the debug overlay (every type's ctx has it; a GraphicsTexture value is shown as the image itself)

Deprecated drawCarModel(...) / playCarSound(...) / setDataFetchMode(...) still work, but new code should use the RenderManagers / SoundManagers / declaration fields above. Both overloads of drawConnModel (car connectors) throw NotImplementedException; drawConnStretchTexture is not exposed in this context. Neither API is usable.

VehicleWrapper (vehicle)

MemberDescription
getStops() / getThisRouteStops() / getNextRouteStops()The Stop list for the whole run / this route / the next route
getNextStopIndex(stopList, overrunTolerance=0.5): intThe index of the next stop in the list; the overrun tolerance is in meters; returns stopList.size() once all are passed
isStopsDataFetched(): booleanWhether stop data has been fetched from the server (always false in SKIP mode)
isRendered() / isCarRendered(cars...)Whether the whole train / the given cars are being rendered (not culled)
isClientPlayerRiding(): booleanWhether the local player is riding this train
getId(): long · getSiding(): Siding? · getTransportMode(): TransportModeUnique id / owning siding / transport mode
getVehicleId(carIndex): String? · getLength(carIndex) · getWidth(carIndex) · getCarCount()The resource pack car model id / car length and width / car count
getServiceAcceleration() · getServiceDeceleration() · getMaxManualSpeed() · isManualAllowed()The siding-configured acceleration / deceleration and manual driving speed cap
getSpeedMs() / getSpeedKmh() · getNotchLevel() / getNotchPosition()Speed; the notch (-5..5 regular, ±7 = 140%, -8 emergency brake) and its normalized position
getDoorValue(): double · isDoorOpenable(car, pos[, pos2]) · isDoorOpen(car, pos[, pos2])Door openness 0..1; queries by in-car relative coordinates whether a door can open / is open
getPathData() · getRailProgress(carIndex=0) · getRailIndex(progress, roundDown) · getRailSpeed(pathIndex)Path and progress (MTR 4 only syncs part of the path)
isCurrentlyManual() · isReversed() · isOnRoute() · getManualToAutomaticTime()Manual driving / reversed / on a route / the time limit (milliseconds) before driverless manual running returns to ATO
getDepartureIndex(): long · getTotalDwellTime() / getElapsedDwellTime()The departure index (resets at UTC midnight; -1 = not departed / manual); total and elapsed dwell time (in milliseconds; -1 when not at a platform; elapsed time is output exactly as synced by the server)
getMtrVehicle(): VehicleExtensionMTR's underlying vehicle object — below the internal API boundary; enter at your own risk
Yanyang getDirectionOfDoorOpening(): StringThe remembered door-opening direction: "not" / "left" / "right" / "all" — see Yanyang extensions
Yanyang getTransportType(): StringThe canonical transport type id (including MAGIC subdivisions) — see Yanyang extensions

Deprecated: the same-named NTE-era interfaces (getAllPlatforms / trainCars / speed / doorValue / mtrTrain, and so on) are all kept working, each equivalent to a newer interface in the table above.

Stop (scheduled stops)

Field / methodDescription
route: SimplifiedRoute? · station: Station? · platform: Platform?The route / station / platform it belongs to; null when the data hasn't been fetched (remember to null-check)
destinationName: String · customDestination: String?The destination's display name and the raw custom destination text
routeInterchanges: List<RouteInterchange>The routes you can interchange to at this stop (excluding the current route and hidden routes)
connectingInterchanges: Map<String, List<RouteInterchange>>An interchange table grouped by connected station name (same-named stations are merged)
dwellTimeMillis: long · distance: doubleDwell time (unknown = -1); rail progress at the stop point (-1 in SKIP mode)
isRouteSwitchoverStop: boolean · roundUpRoute: StopWhether this is a route switchover stop; if so, roundUpRoute gives the same stop as seen from the next route's perspective
turnbackPlatform: booleanWhether the train turns back at this stop

RouteInterchange (interchange routes)

color: int (RGB) · name: String; Yanyang adds the fields getTransportMode(): TransportMode?, getTransportType(): String (including MAGIC subdivisions), and getRouteId(): long (the source route's id; 0 when data is missing) — see Yanyang extensions.

Hiding the stock destination display

Vehicles built on the stock models can set hideDisplayParts (MTR 4) / hide_display_cube (MTR 3) to hide the built-in destination display blocks and draw their own in the script instead.

Vehicle data fetch modes

MTR 4 only syncs nearby data, while vehicle scripts often want station names for the whole route. JCM's three-tier model (the dataFetchMode field):

ModeBehavior
SKIPThe MTR 4 default. Requests nothing from the server and pieces together a best-effort stop list locally — completeness not guaranteed. Enough for scripts that only look at speed, like "ding as soon as the train moves"
ALLFetches the full stop list first, then fetches the station / route data for each id in it. Behaves like SKIP until that arrives; even with the stop list in hand, Stop.station / Stop.route can still be null — null-check
MANDATORYLike ALL, but the script doesn't start at all until all data has arrived. The default for the MTR 3 format (old scripts assume data is ready on arrival and can't tolerate null)

Decoration object scripts

Attaches to an MTR decoration object model entry: the script starts working when a decoration object block using that model from the resource pack is rendered. Register with objects + objectScripts (the MTR 3 legacy format writes these in eyecandy.json); setting isScriptControlled: true on the content entry hides the original model and hands the visuals entirely to the script.

The three callbacks: ctx is an EyeCandyScriptContext, eyecandy is an EyecandyWrapper.

EyeCandyScriptContext (ctx)

MemberDescription
getRenderManager(): RenderManagerThe base is the block position + the GUI offset, including the GUI rotation
getSoundManager(): SoundManagerThe base is the block position; there's also the convenience ctx.playSound(id, volume, pitch) to play right at the block
drawModel(model, matrices?)Draws a model (accepts a DynamicModelHolder too) — equivalent to getRenderManager().drawModel(...)
events(): EyecandyEventsEvent access: right-click events().onBlockUse.occurred() → handle it → confirm with events().handled(); the event detail .detail().player() gives the acting PlayerEntity (right-clicks while holding a brush don't count)
setOutlineShape(shape) / setCollisionShape(shape)Custom outline / collision box (VoxelShape); no effect while wielding the brush
setDebugInfo(...)Same as the other types

EyecandyWrapper (eyecandy)

getModelId(): String (the current model entry's id) · getTranslateX/Y/Z(): float (the GUI offset, in meters) · getRotateX/Y/Z(): float (the GUI rotation, in radians) · pos(): Vector3f (block + offset) · blockPos(): Vector3f · facing(): Direction · facingAngle(): double (the facing angle) · isCrosshairTarget(): boolean (the crosshair is on it — handy for tooltips) · getFullBrightness(): boolean · redstoneLevel(): int (0–15).

Deprecated: the direct field-reading forms (eyecandy.translateX, eyecandy.prefabId, and so on) still work.

Example: looping music that dims on right-click

Attach looping music with TickableSoundInstance.create(...) + setRepeatable(true) + play(), drop to setSoundVolume(0.25) within 5 seconds of a right-click, restore 1.0 when time is up, and stop in dispose — full code in the upstream sounds documentation.

Lift scripts

Attaches to a lift car style (texture) entry and takes effect once the player selects that style with the lift refresher. Register with lifts + liftScripts; isScriptControlled: true on the style entry hides the original model.

The three callbacks: ctx is a LiftScriptContext, lift is a LiftWrapper.

LiftScriptContext (ctx)

getRenderManager(): RenderManager (the base is the lift's base position + offset, following the lift's rotation) · getSoundManager(): SoundManager (same base) · setDebugInfo(...).

LiftWrapper (lift) and Floor

MemberDescription
getMtrLift(): Lift · getId(): longMTR's underlying object and the unique id
getWidth() / getHeight() / getDepth()Car dimensions (meters, configured with the refresher)
getOffset(): Vector3f · getPos() / getRawPos()The offset; base position + offset / base position
getAngleDegrees() / getAngleRadians() · isDoubleSided()The facing angle and doors on both sides
getDoorValue(): float · getDirection(): intDoor openness 0..1; travel direction: -1 down / 0 stationary / 1 up
isDoorway1Openable() / isDoorway1Open() / isDoorway2...Whether the front / rear doorway can open and is open
isClientPlayerRiding(): boolean · shouldRender(): booleanWhether the local player is riding; not occlusion-culled
getFloors(): List<Floor>The floors served; each provides getIndex() (0 = the lowest) · getNumber() / getDescription() (configured with the floor rail brush) · getPos() · isCurrentFloor() / isTargetFloor()

PIDS scripts

A separate system

This system is mutually incompatible with PIDS Layout Editor / PIDS Modularization / PIDS JSON (EpicPuppy) and the early MTR 4 beta's info screen customization; the formats cannot be converted between each other — the barrier to entry is higher, but it can build more complex layouts.

In the pids_images array of joban_custom_resources.json, a preset object carrying scriptFiles or scriptTexts becomes a scripted preset (scriptInput / scriptRateLimit mean the same as for the other kinds; Yanyang also accepts scriptLocations to mix in C++, see mixed entries):

{
    "pids_images": [
        {
            "id": "pids_demo",
            "name": "Demo Scripted PIDS",
            "scriptFiles": ["jsblock:scripts/pids_demo.js"],
            "scriptInput": { "theme": "dark" },
            "scriptRateLimit": 0.75
        }
    ]
}

The preset field outOfBound: boolean (upstream v2.3.0-prerelease.3) keeps the preset rendering when the screen leaves the camera's bounds — needed for projector presets (pids_projector) that must keep running constantly.

What you draw on a PIDS is polygons in the world, not a 2D canvas — elements can spill past the screen's bounds, and clipping to the screen is the script's own responsibility (when you truly need complex clipping / image processing, draw into a GraphicsTexture and blit it). Draw order is z-order: later draws cover earlier ones; by default each draw steps a tiny amount (0.0002) along z to prevent z-fighting, which you can disable with ctx.setAutoZOrdering(false) and manage manually via zOrder() (ctx.setZOrderStep(m) adjusts the step).

PIDSScriptContext (ctx) and PIDSBlockEntity (pids)

ctx.getRenderManager() / ctx.getSoundManager() (base = the block position) · ctx.setAutoZOrdering(b) · ctx.setZOrderStep(m) · ctx.setDebugInfo(...).

pids memberDescription
type: StringThe screen model: rv_pids / rv_pids_sil_1 / rv_pids_sil_2 / lcd_pids / pids_projector / pids_1a
width / height / rowsThe usable screen width/height and the number of arrival rows
isRowHidden(i) / getCustomMessage(i)Whether row i is hidden in the block's config; the row's custom message (empty string when unset)
getTargetPlatformIds() · isPlatformAutoDetected() · isPlatformNumberHidden()The detected platform ids; whether the platform is auto-detected; whether the platform number is hidden
blockPos() · isKeyBlock() · station(): Station?The block position; whether this is the "key" block of a double-sided screen; the station (null when off-station or unknown to the client)
arrivals(): ArrivalEntriesArrival data — see below

ArrivalEntries and ArrivalEntry

ArrivalEntries: get(i): ArrivalEntry? (at most 10 arrivals per platform) · length(): int (the total arrivals count, upstream v2.3.0-prerelease.3) · mixedCarLength(): boolean · platforms(): List<Platform> · forEach(consumer) / forEach(platformId, consumer) (iterates entry by entry, sparing you manual null checks).

ArrivalEntry: destination(): String · arrivalTime() / departureTime() (Unix milliseconds; new Date(v) makes a JS date) · arrived() / departed() · deviation(): long (relative to schedule: positive = late, negative = early) · realtime(): boolean (scheduled or extrapolated in real time) · departureIndex(): long · terminating(): boolean (terminates here) · route(): SimplifiedRoute? / routeId() / routeName() / routeNumber() / routeColor() / circularState() · platform(): Platform? / platformId() / platformName() · carCount() / cars(): List<CarDetails>.

Text / Texture / Rect (the drawing trio)

All three are fluent objects — create → configure via chaining → draw(ctx); Rect is a Texture with the texture hardcoded to a plain white PNG:

CommonDescription
create() / create(comment)Creates a new instance (the comment is just for code readability)
pos(x, y) · size(w, h)Position and size (in screen pixel units)
color(rgb)Tint (an RGB integer)
naturalLight() · renderType(str) · matrices(m) · zOrder(n) · draw(ctx)Samples brightness from the world lighting (full-bright by default); the render type (same as QuadDrawCall); an extra transform; the manual z-order (effective once auto-z is off); submits the draw
Text-specificDescription
text(str) · scale(i) · fontMC() / font(id)The content; the overall scale (1 by default; you handle the conversion against size yourself); the native font / a resource pack font (defaults to mtr:mtr; no effect when MTR's "custom font" setting is off)
leftAlign() / centerAlign() / rightAlign()Alignment (left by default)
shadowed() · italic() · bold() · lineHeight(factor)Shadow / italic / bold; the line-height factor (for line wrapping; the default 1 = 9 units)
stretchXY() / scaleXY() / wrapText() / marquee() / marquee(durationTick) / withMarqueeProgress(p)Pick one of four overflow strategies: stretch along the overflowing axis / scale proportionally / wrap lines / marquee (specify a period, or supply the progress 0..1 directly)
measureWidth(): intMeasures the width under the current configuration — returns an int, so the chain ends here

Texture-specific: texture(id) (PNG or mcmeta), uv(u1, v1, u2, v2) (0–1).

Dynamic textures on PIDS

PIDS can blit GraphicsTextures too: new GraphicsTexture(pids.width, pids.height) in create, then in render draw, upload(), and Texture.create().texture(state.tex.identifier).size(pids.width, pids.height).draw(ctx), and close() in dispose — the fallback when text layout can't cut it, at a cost in performance and memory.

Don't need a script? Writing only JSON fields such as background / color / fonts / showWeather / showClock / hidePlatform / hideRow / topPadding / textOverflowMode in a pids_images entry makes it a "JSON preset" — enough for simple reskins, and it's strictly one or the other with a scripted preset; the two can't be mixed.

PSD scripts (platform screen doors)

Scripts for platform screen door glass and top panels. Declare them under the psdScripts category of mtr_custom_resources.json — the same entry shape as vehicleScripts (scriptLocations freely mixes JS and C++ with per-entry language detection) plus a type field selecting the binding surface:

"psdScripts": [
    {
        "id": "psd_glass_pids",
        "type": "glass",
        "scriptLocations": [
            "mtr:my_pack/js/psd_glass_pids/main.js",
            "mtr:my_pack/native/psd_glass_pids"
        ],
        "dataFetchMode": "ALL"
    }
]

In game, use the new PSD Script Editor item (in the same creative tab as MTR's brush): right-click any PSD glass (including glass ends and half-height APG gates — MTR, Tianjin Metro and every addon building on MTR's PSD classes) or psd_top block and pick either an entry of the matching type or any scripted PIDS preset (PIDS scripts run unchanged; the canvas adapts to the PSD surface). "No script" removes the binding. Bindings live on the server and sync to every player in the dimension.

  • Glass gets an opaque backing — black on the platform side (a real-PIDS dark display), plain white on the track side (riders inside a train see a plain white panel, never mirrored content). The script canvas sits on the platform side, inside the pane, and is clipped to the pane's transparent window (derived from the resource pack's model and texture alpha, so frames and bars stay visible in front and occlude the content); the whole glass run between two doors shares one canvas, and a full-height two-block column merges vertically into one. The canvas adapts to the window (96 px per block).
  • Top panels: the whole psd_top stretch over one door run shares one canvas — that stretch stops showing the built-in arrow / route strip and its content is fully script-controlled; other stretches keep the vanilla content. Canvas is 96 px per block wide (minus a 0.125/16 side padding each) × 42 px high.

A continuous glass or top-panel section of at most 64 blocks retains one complete canvas, even across coordinate grid boundaries. Longer sections are divided into aligned 64-block intervals along the signed world coordinate in the canvas-right direction; the first and last intervals may be shorter. Binding, unbinding and hiding the original top-panel content all use the same subsection.

Drawing is identical to PIDS scripts (Text / Texture / Rect + ctx.draw; the canvas origin is the top-left of the visible face). The data API (JS psd argument / C++ mtr::Psd; full accessors in Data snapshots · Psd):

JSDescription
psd.type() / psd.width() / psd.height()Binding surface ("glass" / "top") and canvas size
psd.platform()Platform: number / name (number() / name()), station, dwell time
psd.routes()Every route calling at the platform; with dataFetchMode: "ALL" each carries its full stop list (interchanges / exits)
psd.arrivals()Upcoming trains: destination, route, car count, countdown (arrivalCountdown()), the door numbers that will open (doorNumbers()), realtime / terminating / circular state
psd.stop()Complete station info (ABI 13): name, color (ARGB + hex), fare zones 1–3, every route calling at the station, connecting-station routes (grouped by station name), physically connected station names, exits with destinations, platform numbers + count — null when there is no platform / station. C++ counterpart: has_station() + the mtr::PsdStation accessors (see Data snapshots · Psd)
psd.doors() / psd.doorValue() / psd.isDoorOpen()Door unit list (1-based, live open / locked states) and the wall-wide open amount

On the C++ side this is the fifth script base class mtr::PsdScript<State> (MTR_REGISTER_PSD_SCRIPT) with the JcmPsdSnapshot snapshot (ABI 12; station entries since ABI 13); see native/examples/psd_glass.cpp.

DisplayHelper (the built-in multi-screen helper)

A ready-made solution for trains with many screens: after include(Resources.id("mtrsteamloco:scripts/display_helper.js")) —

let slotCfg = {
    "version": 1,
    "texSize": [2048, 1024],
    "slots": [
        { "name": "lcd_north", "texArea": [0, 0, 2048, 274],
          "pos": [[[-0.59, 2.125, -1.75], [-0.755, 2.03, -1.75],
                   [-0.755, 2.03, -3.25], [-0.59, 2.125, -3.25]]],
          "offsets": [[0, 0, -5], [0, 0, 0], [0, 0, 5], [0, 0, 10]] }
    ]
};
let dhBase = new DisplayHelper(slotCfg);

function create(ctx, state, vehicle) {
    state.rl = new RateLimit(0.05);
    state.dh = dhBase.create();          // Instantiates and binds the texture
}
function render(ctx, state, vehicle) {
    if (state.rl.shouldUpdate()) {
        let g = state.dh.graphicsFor("lcd_north");  // The slot's canvas (offset applied automatically)
        // ... AWT drawing ...
        state.dh.upload();
    }
    for (let car of ctx.getMyCars())
        ctx.getCarRenderManager(car).drawModel(state.dh.model, null);  // Must be drawn every frame
}
function dispose(ctx, state, vehicle) {
    state.dh.close();
}
  • All slots share one big texSize texture; texArea picks the region a slot uses (x, y, w, h).
  • pos is a three-dimensional array: each screen's four corner coordinates (top-left → bottom-left → bottom-right → top-right), in meters (divide Blockbench's 1/16 units by 16), with the origin at the train's center at floor height and +X left / +Y up / +Z backward.
  • offsets clones screens with identical content (saves texture when a row of small door screens repeats); graphicsFor(name) returns the canvas pre-shifted to the slot, graphics() the whole sheet.
  • Put the upload inside the RateLimit and the model drawing outside it — the model must be submitted every frame.

Next up: Yanyang Extensions — mixed JS+C++ entries, door-opening direction memory, transport type subdivisions, and raw pixel uploads.