Getting started with JavaScript scripting

What is a JS script?

A script is a piece of JavaScript code shipped inside 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 โ€” beyond what vanilla mechanisms offer.

  • The engine is Rhino, a JavaScript engine implemented in pure Java. You only need to know the language itself (variables, functions, conditional logic) plus 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 authoring guidelines).
How this relates 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; hand high-resolution per-pixel repaints and heavy computation to C++.

The four script types

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

TypeWhat it controlsTypical uses
Vehicle scriptsMTR vehicles (the whole train)In-car screens, destination displays, announcement sounds, motor noise
Decoration object scriptsMTR decoration object blocksDynamic signage, fare gate lights, interactive installations (right-click events)
Lift scriptsMTR lifts (car styles)Floor displays, arrival chimes
PIDS scriptsJCM arrival screens (presets)Custom arrival display layouts, dynamic textures

Each script file is parsed only once; every concrete instance in game (each train, each screen) holds its own independent state and receives its own callbacks.

Registering scripts

Scripts load together with the resource pack. First make sure the game recognizes your pack โ€” put a pack.mcmeta in the pack's root:

{
    "pack_format": 13,
    "description": "Your resource pack description"
}

pack_format doesn't constrain JCM scripts (no need to change it for every MC version), but it must be a valid JSON number.

Declaration entries

Vehicle / decoration object / lift scripts are declared in mtr_custom_resources.json (placed under assets/mtr/); PIDS scripts are declared in the pids_images array of joban_custom_resources.json (placed under assets/jsblock/). The MTR 4 format splits "content" and "scripts" into two arrays, with content entries referencing script entries through 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 (you can list several). Since JCM v2.3, most of these APIs also accept a plain string id. Extension-less entries may be detected as C++ modules under Yanyang rules โ€” see mixed entries
prependExpressionsJS code inlined directly, executed before the scriptLocations files
inputAn arbitrary JSON object, exposed read-only to the script as SCRIPT_INPUT
rateLimitThe minimum interval between script runs, 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 that no content entry in the same pack references; set true to force loading (used when sharing script entries across packs)
isScriptControlledA content-entry field for decoration objects / lifts: hides the original model and hands rendering entirely to the script

The array names for the three script kinds: vehicleScripts (vehicles; content in vehicles), objectScripts (decoration objects; content in objects), and liftScripts (lifts; content in lifts). PIDS has no scriptId step โ€” writing scriptFiles / scriptTexts directly on a preset object inside pids_images makes it a scripted 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 before it gets parsed. Yanyang also accepts the script_input spelling used in the NTE docs' examples (equivalent to scriptInput), so packs copied from the old documentation keep working.

MTR 3 / NTE legacy format

Declarations from the MTR 3 era keep working: vehicles live in custom_trains (field names script_files / script_texts / script_input / script_rate_limit), and decoration objects live in eyecandy.json. Legacy-format vehicles fetch data in MANDATORY mode by default.

The lifecycle

Parse stage

When the game reloads resources (at startup or on F3+T), each script's top-level code runs once on the main thread; JCM then records the create / render / dispose functions inside it for runtime invocation. One-time work such as model loading and texture reads belongs here โ€” but heavy operations slow down the entire resource reload, so keep them lean where you can.

Runtime stage

let displaySpeed = 1;            // Runs at parse time

function create(ctx, state, vehicle) {
    state.speed = 0.75;           // Once per instance, before the 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 at parse time; prints 1
ParameterMeaning
ctxThe context: the "action" entry point for submitting draws and playing sounds; differs per type (vehicles use VehicleScriptContext, and so on)
stateA plain JS object, per-instance persistent โ€” two trains each get their own copy that never interfere; starts as {}
wrapperA read-only information source: vehicle speed, door values, screen size, arrival lists, and more; differs per type
  • Asynchronous execution: runtime callbacks run on 4 dedicated background threads (parse time is still on the main thread). Don't hog a thread with Thread.sleep or endless loops โ€” a script entry is pinned to one fixed thread, and other scripts may be queued behind you.
  • Per frame โ€” but not guaranteed per frame: render targets once per frame, but it will not re-enter while the previous run is still going; when frame rates fluctuate, don't assume "exactly N times per second". Scale anything that should advance in real time with Timing.delta().
  • Runtime errors: the log reports the error starting with [Scripting] Error executing script! and points to the file and line; afterwards the whole script pauses for 4 seconds before retrying. Enable the debug overlay in game to see errors directly on screen.
  • No JS threading guarantees: local variables from one callback are invisible to the others โ€” data shared across calls belongs in state.
Correspondence with the C++ lifecycle

create / render / dispose plus per-instance state is exactly the model C++ native scripting adopts (see Script model and lifecycle), so scripts can be migrated between the two languages section by section.

Development aids

  • Script debug overlay: Mod Menu (Fabric) or the mod config screen (Forge) โ†’ JCM โ†’ Configure โ†’ check Script debug mode. The top-left corner of the screen lists every script entry and its instances, color-coded by execution time: blue (normal) / yellow (slowish) / red (slow). A single execution over 5 seconds is marked unresponsive and, 5 more seconds later, terminated outright. Press [ / ] to switch between the two sources, [MTR] and [JCM].
  • Quick reload: with debug mode on, Ctrl+R reloads the MTR-side scripts (vehicle / decoration object / lift), and Shift+R reloads the JCM side (PIDS presets) โ€” no full F3+T needed. The three keys are actually bound to the "sprint", "sneak", and "JCM reload" keybinds, which you can change in the controls settings.
  • Logging: print(...) and console.log(...) write to the game console; parse-stage errors only get a one-line notice in the chat bar โ€” for the details, check the launcher's logs/latest.log.
  • IDE completions: a third-party VSCode extension, MTR Scripting Tools (searchable on Visual Studio Marketplace / Open VSX), offers IntelliSense for JCM script types; report issues to the extension's author.

Authoring guidelines

Language level

  • Strict mode: assigning without declaring (x = 1) throws immediately. Use var or let for globals, and let exclusively inside functions.
  • const is function-scoped: Rhino's const isn't block-scoped like in modern JS โ€” declaring one inside a for loop body throws a "redeclaration" error on the second iteration. Use let when you need block-scoping semantics.
  • JS strings vs Java strings: MTR/JCM mostly return Java Strings. Get the length with str.length(); to use the JS .length property, first convert by concatenating an empty string: (""+str).length. A Java List supports list[0] and for (x of list), but JS array methods (map and friends) don't work.
  • Nullable values: methods whose return type carries ? 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 or calling a method on null throws immediately.
  • Overload resolution: Java methods pick overloads by argument types, with numbers favoring double by default; when in doubt, call through the signature suffix: graphics["drawString(java.lang.String,int,int)"]("Text", 15, 60), new Color["(int,int,int)"](40, 68, 130). When you get "method not found", suspect your argument types first.
  • Feature coverage: Rhino doesn't support every recent JS feature; see Mozilla's compatibility table.

Behavior level

  • Don't block, don't spin: there is no "wait a bit, then continue" construct โ€” schedule the action into a later callback instead; hand long operations (networking, large files) to BackgroundWorker. An endless loop is force-terminated after about 10 seconds and treated as a runtime error.
  • The internal API boundary: methods and fields this documentation doesn't cover, MTRClientData's internal structure, and anything below VehicleWrapper.getMtrVehicle() are internal implementation details that can change between versions; publicly distributed resource packs must not depend on them.
  • Class access whitelist: only these packages are allowed by default โ€” 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 settings, but if your distributed pack requires that, 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 run at once. Do the math before repainting a large texture every frame: an 8-car train with 10 screens per car means 80 full upload() texture transfers per frame โ€” lowering resolution, reusing across doors, and reducing update frequency (RateLimit) are all the right moves. A client-side script only needs to be "good enough"; don't chase a 1:1 recreation.
  • 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 one for access control.

Relation to NTE / MTR 3

JCM's 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 set of same-named or equivalent compatibility APIs (the parts marked "Deprecated" in these docs). Key differences:

  • Legacy-format registrations work as before; scripts that straddle MTR 3 and MTR 4 can branch on the version via Resources.getAddonVersion("mtr") (the old scripts' Resources.getMTRVersion()).
  • The Vector3f operations that returned void in NTE now return the vector itself, supporting chaining; Resources.manager() returns null on MTR 4, and the functions that need it accept null too.
  • ANTE-style compatibility classes (SoundHelper / TickableSound / GlobalRegister / ComponentUtil / the global asJavaArray()) are available since v2.3, with method signatures matching ANTE.
  • MTR 4 only syncs nearby station / route data to the client โ€” a script failing to find distant data is not a bug but deliberate bandwidth saving; see data fetch modes for the vehicle script's three fetch modes.

Next up: JS Common APIs โ€” the classes and functions shared by every script; JS Script Types โ€” the per-type ctx and wrapper for each of the four script kinds.