Getting started with JavaScript scripting

What is a JS script?

A script is a piece of JavaScript distributed with a resource pack: the game parses it while loading resources and calls it frame by frame at runtime, using it to render models, text and textures, or play sounds, on top of the built-in mechanisms.

  • The engine is Rhino—a JavaScript engine implemented in pure Java. You only need the language itself (variables, functions, conditional logic) and built-in objects such as Date and Map; the browser environment (HTML/CSS/DOM) never comes into play, so don't take your IDE's web-oriented completions at face value.
  • Rhino provides LiveConnect interoperability: scripts can call Java classes and methods directly. Many of JCM's built-in APIs are themselves Java classes exposed to scripts.
  • JavaScript and Java are two different languages; knowing Java is not a prerequisite for writing scripts, but understanding Java's type system helps you avoid interop pitfalls (see the authoring guidelines).
Relationship to C++ native scripting

The Yanyang fork adds C++ native scripting alongside JS; the two share the same lifecycle and rendering pipeline, and can even be mixed within the same declaration entry (see Yanyang extensions). Simple one-off logic iterates fastest in JS; high-resolution per-pixel repainting and heavy computation belong to C++.

The four script kinds

Each kind targets a specific scenario, and the available global objects and callbacks differ slightly:

KindControlsTypical uses
Vehicle scriptsMTR vehicles (whole trains)In-car screens, destination displays, announcement sounds, motor sounds
Eyecandy scriptsMTR eyecandy blocksDynamic signage, fare-gate lights, interactive installations (block-use events)
Lift scriptsMTR lifts (car styles)Floor displays, arrival chimes
PIDS scriptsJCM arrival screens (presets)Custom arrival display layouts, dynamic textures

A given script is parsed only once; every concrete instance in the game (each train, each screen) holds its own independent state and is called back separately.

Registering scripts

Scripts load with the resource pack. First make sure the game recognises the pack—put a pack.mcmeta in the pack's root directory:

{
    "pack_format": 13,
    "description": "你的资源包描述"
}

pack_format imposes no constraint on JCM scripts (there is no need to change it for every MC version), but it must be a valid JSON number.

Declaration entries

Vehicle / eyecandy / lift scripts are declared in mtr_custom_resources.json (placed under assets/mtr/); PIDS scripts are declared in joban_custom_resources.json (placed under assets/jsblock/), in its pids_images array. The MTR 4 format splits 'content' and 'scripts' into two arrays, with content entries referencing script entries via scriptId—the same script can be reused by multiple vehicles:

{
    "vehicles": [
        { "id": "demo_sp1900", "name": "Demo SP1900", "scriptId": "demo_lcd" }
    ],
    "vehicleScripts": [
        {
            "id": "demo_lcd",
            "scriptLocations": ["demo:js/sp1900_lcd.js"],
            "prependExpressions": ["print('Hello world')"],
            "input": { "era": 2004 },
            "rateLimit": 0.75,
            "dataFetchMode": "ALL"
        }
    ]
}
FieldMeaning
scriptLocationsAn array of JS file locations (any number). Since JCM v2.3, most places that accept one also take a plain string id. Entries without an extension may be recognised as C++ modules under the Yanyang rules—see Mixed entries
prependExpressionsJS code inlined directly, executed before the files in scriptLocations
inputAny JSON object, exposed to the script read-only as SCRIPT_INPUT
rateLimitThe script's minimum call interval, in seconds. Raise it for low-frequency logic to save performance
dataFetchModeVehicle scripts only: SKIP (the default, fetches no data) / ALL / MANDATORY—see Data fetch modes
forceLoadJCM refuses to load a script entry by default when no content in the same pack references it; set true to force loading (used when sharing script entries across packs)
isScriptControlledA content-entry field of eyecandy / lifts: hides the original model and hands rendering entirely over to the script

The array names for the three kinds: vehicleScripts (vehicles, content in vehicles), objectScripts (eyecandy, content in objects), liftScripts (lifts, content in lifts). PIDS has no scriptId step—writing scriptFiles / scriptTexts directly on a preset object in pids_images makes it a script preset (the key names follow JCM convention, see PIDS scripts).

Tip

Every field is optional, but a script entry needs at least one of scriptLocations or prependExpressions to be parsed. Yanyang also accepts the script_input spelling used in the NTE documentation's examples (equivalent to scriptInput), so packs copied from the old docs keep working.

MTR 3 / NTE legacy formats

Declarations from the MTR 3 era remain valid: vehicles go in custom_trains (field names script_files / script_texts / script_input / script_rate_limit), eyecandy in eyecandy.json. Vehicles in the legacy format fetch data in MANDATORY mode by default.

Lifecycle

The parsing stage

When the game reloads resources (at startup or on F3+T), the top-level code of every script is executed once on the main thread; JCM then records its create / render / dispose functions for runtime invocation. One-off work such as loading models and reading textures belongs here—but heavy operations hold up the entire resource reload, so keep them to a minimum.

The runtime stage

let displaySpeed = 1;            // Runs during the parsing stage

function create(ctx, state, vehicle) {
    state.speed = 0.75;           // Runs once per instance, before its first render
}

function render(ctx, state, vehicle) {
    state.speed += Timing.delta();  // Runs every frame (best effort)
}

function dispose(ctx, state, vehicle) { }   // Runs when the instance retires

print(displaySpeed);             // Runs during the parsing stage; prints 1
ParameterMeaning
ctxThe context: the 'action' entry point for submitting draws and playing sounds; differs per kind (vehicles use VehicleScriptContext, …)
stateA plain JS object, persistent per instance—each of two trains holds its own, with no cross-talk; starts as {}
wrapperThe read-only information source: speed, door values, screen dimensions, arrival lists, …; differs per kind
  • Asynchronous execution: runtime callbacks happen on a dedicated pool of 4 background threads (the parsing stage still runs on the main thread). Do not hold up a thread with Thread.sleep or busy loops—each script entry is pinned to one fixed thread, and other scripts may be queuing on it.
  • Per-frame, but not guaranteed per-frame: render aims for once per frame but is not re-entered until the previous frame has finished; when the frame rate fluctuates, don't assume 'exactly N times per second'. Scale anything that must advance in real time with Timing.delta().
  • Runtime errors: the log reports the error under a [Scripting] Error executing script! heading with the file and line number; the whole script then pauses for 4 seconds before retrying. In game, enable the debug overlay to see the errors directly on screen.
  • No JS thread guarantees: local variables inside one callback function are invisible to the others—data shared across calls goes in state.
Correspondence with the C++ lifecycle

create / render / dispose plus per-instance state is precisely the model the C++ native scripts adopt (see Script model and lifecycle); scripts can be migrated between the two languages section by section.

Development aids

  • Script debug overlay: Mod Menu (Fabric) or the mod configuration screen (Forge) → JCM → Configure → tick Script debug mode. The top-left corner of the screen lists every script entry and its instances; instances are colour-coded by time cost—blue (fine), yellow (slowish), red (slow). A single execution that exceeds 5 seconds is flagged as unresponsive, and after a further 5 seconds it is terminated outright. Press [ / ] to switch between the [MTR] and [JCM] sources.
  • Quick reload: with debug mode on, Ctrl+R reloads the MTR-side scripts (vehicles / eyecandy / lifts) and Shift+R the JCM-side scripts (PIDS presets)—no full F3+T needed. The three keys are actually bound to the 'sprint', 'sneak' and 'JCM reload' keys, and can be changed in the keybind settings.
  • Logging: print(...) and console.log(...) write to the game console; parsing-stage errors produce only a one-line note in chat—see the launcher's logs/latest.log for the details.
  • IDE completions: VSCode has a third-party extension, MTR Scripting Tools (searchable on the Visual Studio Marketplace / Open VSX), that provides smart completions for the JCM script types; report issues to the extension's author.

Authoring guidelines

Language level

  • Strict mode: assigning to an undeclared variable (x = 1) throws immediately. Use var or let for globals and always let inside functions.
  • const is function-scoped: Rhino's const is not block-scoped like modern JS—declaring one inside a for loop body throws 'redeclared variable' on the second iteration. Use let when you need block-scoped semantics.
  • JS strings vs Java strings: what MTR/JCM returns is mostly Java String. Take the length with str.length(); to use the JS .length property, convert first by concatenating an empty string: (""+str).length. Java Lists support list[0] and for (x of list), but JS array methods (map and friends) are unavailable.
  • Nullable values: methods whose return type carries a ? may return null. Handle them with if (x != null), the ternary operator, optional chaining x?.getName() and nullish coalescing x?.length() ?? 0; taking a property of, or calling a method on, null throws immediately.
  • Overload resolution: Java methods pick their overload by argument type, with numbers defaulting to double; when unsure, call by signature suffix: graphics["drawString(java.lang.String,int,int)"]("文字", 15, 60), new Color["(int,int,int)"](40, 68, 130). When you see 'method not found', suspect the argument types first.
  • Feature range: Rhino does not support every recent JS feature—see Mozilla's compatibility table.

Behaviour level

  • Never block, never busy-loop: there is no 'wait a bit and continue' construct—schedule the action into a later callback instead; hand long operations (networking, large files) to the BackgroundWorker. A busy loop is forcibly terminated after about 10 seconds and handled as a runtime error.
  • The internal API boundary: methods / fields not documented here, the internal structure of MTRClientData, and the deep objects below VehicleWrapper.getMtrVehicle() are all internal implementation and may change outright between versions; publicly distributed resource packs must not rely on them.
  • The class access allowlist: by default only these packages are admitted—com.lx862.jcm.mod.scripting, com.lx862.mtrscripting.util, com.lx862.mtrscripting.wrapper, java.lang, java.awt, java.text, java.math, java.util, java.time, javax.imageio, sun.java2d, sun.font, sun.awt, org.mtr. Players can lift the restriction in the settings; if your distributed pack requires it to be lifted, say so prominently in its description.
  • Prefix your state variables: several scripts can attach to the same instance, and state is shared—use prefixes like state.myfx_ratelimit to avoid name collisions.
  • Performance is on a budget: 16 ms per frame looks roomy until dozens of script instances are running at once. Do the arithmetic before repainting a large texture every frame: a train of 8 cars with 10 screens each means 80 full-texture upload()s per frame—lowering the resolution, sharing screens across doors and reducing the update frequency (RateLimit) are the right ways. A client-side script only needs to be good enough; don't chase a 1:1 replica.
  • Scripts are not a security boundary: scripts run purely client-side and anyone can rewrite them locally—no permission check (such as hasPermissionLevel) stops a determined user, so never rely on them for access control.

Relationship to NTE / MTR 3

The JCM scripting system is inspired by Nemo Transit Expansion (NTE) and is an unofficial continuation of NTE's scripting features on MTR 4, retaining a large number of same-named or equivalent compatibility APIs (the parts marked 'Deprecated' in this documentation). Key differences:

  • Legacy-format registration works as before; scripts that must run on both MTR 3 and MTR 4 can branch on the version via Resources.getAddonVersion("mtr") (the old scripts' Resources.getMTRVersion()).
  • Vector3f operations that returned void in NTE now return the vector itself and support chaining; Resources.manager() returns null on MTR 4, and the functions that need it accept null as well.
  • ANTE-style compatibility classes (SoundHelper / TickableSound / GlobalRegister / ComponentUtil / the global asJavaArray()) are available since v2.3, with method signatures matching ANTE's.
  • MTR 4 only synchronises data for nearby stations / routes to the client—a script being unable to see far-away data is not a bug but deliberate bandwidth saving; see Data fetch modes for the vehicle script's three modes.

Next: JS common APIs — the classes and functions shared by every script; JS script types — each of the four kinds' ctx and wrapper.