Getting started with JavaScript scripting
Since v2, JCM has let resource packs control the rendering and sounds of vehicles, eyecandy and lifts, as well as the display content of PIDS screens, with JavaScript. This page covers registration, the lifecycle, debugging and guidelines; the API details follow on the next three pages.
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
DateandMap; 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).
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:
| Kind | Controls | Typical uses |
|---|---|---|
| Vehicle scripts | MTR vehicles (whole trains) | In-car screens, destination displays, announcement sounds, motor sounds |
| Eyecandy scripts | MTR eyecandy blocks | Dynamic signage, fare-gate lights, interactive installations (block-use events) |
| Lift scripts | MTR lifts (car styles) | Floor displays, arrival chimes |
| PIDS scripts | JCM 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"
}
]
}
| Field | Meaning |
|---|---|
scriptLocations | An 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 |
prependExpressions | JS code inlined directly, executed before the files in scriptLocations |
input | Any JSON object, exposed to the script read-only as SCRIPT_INPUT |
rateLimit | The script's minimum call interval, in seconds. Raise it for low-frequency logic to save performance |
dataFetchMode | Vehicle scripts only: SKIP (the default, fetches no data) / ALL / MANDATORY—see Data fetch modes |
forceLoad | JCM 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) |
isScriptControlled | A 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).
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
| Parameter | Meaning |
|---|---|
ctx | The context: the 'action' entry point for submitting draws and playing sounds; differs per kind (vehicles use VehicleScriptContext, …) |
state | A plain JS object, persistent per instance—each of two trains holds its own, with no cross-talk; starts as {} |
wrapper | The 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.sleepor 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:
renderaims 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 withTiming.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.
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(...)andconsole.log(...)write to the game console; parsing-stage errors produce only a one-line note in chat—see the launcher'slogs/latest.logfor 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. Usevarorletfor globals and alwaysletinside functions. - const is function-scoped: Rhino's
constis not block-scoped like modern JS—declaring one inside aforloop body throws 'redeclared variable' on the second iteration. Useletwhen you need block-scoped semantics. - JS strings vs Java strings: what MTR/JCM returns is mostly Java
String. Take the length withstr.length(); to use the JS.lengthproperty, convert first by concatenating an empty string:(""+str).length. JavaLists supportlist[0]andfor (x of list), but JS array methods (mapand friends) are unavailable. - Nullable values: methods whose return type carries a
?may returnnull. Handle them withif (x != null), the ternary operator, optional chainingx?.getName()and nullish coalescingx?.length() ?? 0; taking a property of, or calling a method on,nullthrows 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 belowVehicleWrapper.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
stateis shared—use prefixes likestate.myfx_ratelimitto 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()). Vector3foperations that returnedvoidin NTE now return the vector itself and support chaining;Resources.manager()returnsnullon MTR 4, and the functions that need it acceptnullas well.- ANTE-style compatibility classes (
SoundHelper/TickableSound/GlobalRegister/ComponentUtil/ the globalasJavaArray()) 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.