Skip to content
Oxillen Glow edited this page Jul 15, 2026 · 5 revisions

MtSharpGrain JS Modding

Scripts are organized into mod packs — each top-level subfolder under worlds/my_world/mod/ gets its own isolated GraalVM Context. All .js files within a pack's folder (including subfolders) are loaded recursively on startup, sorted alphabetically. One bad script does not block others in the same pack, or any other pack — errors are logged and that script is skipped.

If you are new to JavaScript: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide or any other place to learn basic JavaScript.


File placement & pack isolation

worlds/
  my_world/
    chunkgen.js           ← Special file for generating the world, 
    mod/
      loose.js            ← NOT loaded (no pack folder = skipped)
      ui/                 ← mod pack folder "ui" — has its own Context
        hud.js            ← loaded into ui's context
        inventory.js      ← loaded into ui's context
        widgets/
          button.js       ← still ui's context (subfolders included)
      gameplay/           ← mod pack "gameplay" — separate Context
        movement.js       ← isolated from ui entirely
        blocks.js

see here for more on chunkgen.js

  • Subdirectories only. A .js file placed directly in mod/ (not inside a subfolder) is not loaded. Every pack needs its own folder.
  • One Context per pack. ui and gameplay above don't share globals, variables, or state. A crash or infinite loop in one pack does not affect another.
  • Pack load order. Packs are initialized alphabetically by folder name (gameplay before ui).
  • File load order within a pack. Alphabetical, recursive through subfolders — same rule as before, just scoped to one pack now.
  • Init-order dependencies. If a script needs a global set by another script in the same pack, prefix filenames: 00_lib.js, 01_feature.js. This no longer works across packs — see below.

Tip

Globals don't leak between packs, so naming conflicts across different mods are no longer a concern the way they used to be.

In pack ui/:

globalThis.Toolbar = { ... };

In pack gameplay/:

globalThis.Toolbar = { ... }; // totally separate, no collision

Within a single pack, the old advice still applies — use one global slot per script to avoid stepping on your own other scripts:

globalThis.ExampleName = {
    getSomething() { return 42; }
};

Warning

Engine.onBlockChange validators are not pack-scoped in their effect. Every pack's validators run on every block edit, and if any pack's validator rejects the change, the whole edit is rejected — regardless of which pack triggered it. There is currently no way to scope a validator to only your own pack's edits. Write validators to fail open (return true) unless you specifically mean to block something globally, and keep them cheap since they all run inline on every Block.place/Block.destroy.


Globals

Five objects are injected into every script automatically, fresh per pack (each pack gets its own instances, not shared references). You cannot access arbitrary Java classes — only what is listed here.

Global Purpose
Scene Create/move/destroy 3D objects in the world
Block Read and edit voxel blocks
Engine Register per-tick callbacks and block-change rules
Gui Create screen-space text elements
Player Read/set the player camera's position

Note: Scene node handles, Gui element handles, and tick tags are only valid within the pack that created them. A handle created in gameplay means nothing to a script in ui — there's no cross-pack handoff mechanism yet.


Scene

All Scene methods operate on handles — opaque numbers that refer to a scene node. Never try to store or manipulate the underlying Java object; always go through the handle.

Handle 0 is the world root node in every pack's context — each pack sees the same root node, so objects from different packs still end up in the same visible scene graph, they just can't reference each other's handles.

// createNode(name) → handle
const group = Scene.createNode("myGroup");

// createCube(name, size) → handle
// size is the full edge length, e.g. 1.0 = a standard voxel-sized cube
const cube = Scene.createCube("myCube", 1.0);

// attachChild(parentHandle, childHandle)
Scene.attachChild(0, group);       // attach group to world root
Scene.attachChild(group, cube);    // attach cube under group

// setPosition(handle, x, y, z)
Scene.setPosition(cube, 10, 5, 0);

// getPosition(handle) → [x, y, z]  (world position, not local)
const p = Scene.getPosition(cube);
const wx = p[0], wy = p[1], wz = p[2];

// setColor(handle, r, g, b, a)   — values 0..1
Scene.setColor(cube, 1, 0, 0, 1); // red

// destroy(handle)  — removes from scene and frees the handle
Scene.destroy(cube);

// getBlockId(x, y, z) → int  — read-only voxel query at integer block coords
const id = Scene.getBlockId(10, 4, 0);

Block

Read and write voxel blocks. Changes trigger a mesh rebuild for the affected chunk and its neighbors automatically. Block calls affect the shared world state — this is one of the few things that is effectively cross-pack, since there's only one world.

// get(x, y, z) → int
const id = Block.get(10, 4, 0);

// place(x, y, z, blockId)
Block.place(10, 5, 0, 2); // place stone

// destroy(x, y, z)
Block.destroy(10, 5, 0);  // set to air

A Block.place/Block.destroy call can be rejected — see Engine.onBlockChange below. This now runs validators from every loaded pack, not just your own. A rejected call throws; if it happens inside an Engine.onTick callback, that counts toward the callback's failure count (see Engine).

Block IDs

ID Name
0 Air
2 Stone
3 Dirt
4 Grass
5 Crystal Ore
6 Ice Sludge
7 Silicon
8 Sulfur
9 Metal Block
10 Glass

for the newest see this java class


Player

Read or move the player's camera directly. Shared across packs — there's only one player/camera, same as with Block.

// getPosition() → [x, y, z]
const p = Player.getPosition();
const px = p[0], py = p[1], pz = p[2];

// setPosition(x, y, z)  — teleports the camera
Player.setPosition(0, 20, 0);

Player wraps the game camera, not a separate physics body — moving it moves the view instantly with no collision checks.


Engine

Per-tick callbacks

Register a function to run on a specific tag group. Tags are scoped to the pack that registers them — a "placeStoneBtn" tag in gameplay/ and a "placeStoneBtn" tag in ui/ are two entirely separate registrations, each only dispatched within its own pack's context.

// Engine.onTick(fn, tag)
// fn receives (tpf, tag):
//   tpf  — time per frame in seconds (use for frame-rate independent movement)
//   tag  — the tag this callback was registered under

Engine.onTick(function(tpf, tag) {
    const p = Scene.getPosition(cube);
    Scene.setPosition(cube, p[0], p[1] + tpf, p[2]); // float upward
}, "Update");

Only the "Update" tag runs automatically every frame, per pack. Each pack's "Update" group ticks once per frame, independently of every other pack's "Update" group. If you register a callback under any other tag, it will never fire on its own; it only runs when a Gui element in the same pack sharing that exact tag is clicked (see below). If you want per-frame logic, tag it "Update". If you want a click handler, give it whatever tag your button uses — don't tag a button "Update" unless you also want it firing every single frame.

A tag can have multiple callbacks — all of them fire when that group ticks. Callbacks that throw 5 times in a row are automatically disabled with an error in the log.

Validating block changes

Register a function that runs on every Block.place / Block.destroy call (from any script in any pack, or from player-driven edits) and decides whether it's allowed.

// Engine.onBlockChange(fn)
// fn receives (x, y, z, blockId) and must return true (allow) or false (reject)

Engine.onBlockChange(function(x, y, z, blockId) {
    if (blockId === 9 && y > 200) return false; // no metal above y=200
    return true;
});

If a validator returns false — or throws — the change is rejected and the call that triggered it (Block.place, Block.destroy, or a player edit) throws instead of applying. This now applies across every pack: your validator gets a say on edits made by scripts in other packs too, and their validators get a say on yours. Multiple validators can be registered across all packs; the first one to reject wins. There's no way to know which validator (or which pack) rejected a given change from JS — write validators to log their own rejections if you need that.


Gui

Create screen-space text. Coordinates are normalized: 0,0 = bottom-left, 1,1 = top-right. Draw order matches the element list — elements added later draw on top, within the same pack. Elements from different packs are drawn pack-by-pack (in the same alphabetical order packs load in), not interleaved by creation time across packs. toTop/toBottom only reorder within the calling pack's own element list.

guiWord is upsert by tag, scoped to the pack. Calling it again with the same tag from the same pack updates the existing element instead of creating a duplicate. A tag "myLabel" in ui/ and a tag "myLabel" in gameplay/ are two different elements. Call it from an "Update" tick callback if you want it to animate every frame.

// guiWord(word, x, y, z, sizePixels, tag) → handle
// z is there because I got a bit carried over from all the 3d stuff — just pass 0 for now
const handle = Gui.guiWord("Hello world", 0.5, 0.9, 0, 0.02, "myLabel");

// setColor(handle, r, g, b, a)
Gui.setColor(handle, 0, 1, 0, 1); // green

// toTop(handle) / toBottom(handle) — reorder in draw list
Gui.toTop(handle);
Gui.toBottom(handle);

// removeWord(handle) — removes the element entirely
Gui.removeWord(handle);

// getHandleByTag(tag) → handle  — look up a handle you may have lost
// (only finds handles created by this same pack)
const h = Gui.getHandleByTag("myLabel");

Clickable GUI elements

Every GUI element is clickable. When clicked, the engine fires tick(tpf, tag) for that element's tag within the same pack that created the element — so a button is just a guiWord whose tag matches an Engine.onTick callback registered in the same pack's script(s). This works independently of the "Update" rule above — clicks dispatch directly to their tag, they don't need to be named "Update":

const btn = Gui.guiWord("[ Place Stone ]", 0.5, 0.05, 0, 0.025, "placeStoneBtn");
Gui.setColor(btn, 0.4, 0.8, 1.0, 1.0);

Engine.onTick(function(tpf, tag) {
    Block.place(10, 5, 0, 2);
    Gui.setColor(btn, 0, 1, 0, 1); // flash green on click
}, "placeStoneBtn");

Click dispatch and regular per-frame ticking share the same callback type — a callback tagged "Update" runs every frame and would run again if a GUI element in the same pack also happened to be tagged "Update" and got clicked. Design callbacks to be idempotent where possible.


WorldGenerator

This is a must-have in the game so it comes as a default, and runs completely separately from mod packs — it is not part of the pack system at all, has no isolation concerns, and isn't affected by anything above. Just edit https://github.com/OxillenGlow/MtSharpGrain/blob/main/worlds/my_world/chunkgen.js to modify how your world is built.

Specialized Chunks

You could also duplicate a chunk you have built on and put it in worlds/<name(default is my_world)>/storageGround or worlds/<name(default is my_world)>/storageAir so that it shows up randomly inside the game as you walk around.


Full examples

Ground indicator

// worlds/my_world/mod/gameplay/ground_indicator.js
// Colors a cube green when over solid ground, red when over air.

const cube = Scene.createCube("indicator", 0.5);
Scene.attachChild(0, cube);
Scene.setPosition(cube, 10, 20, 0);

Engine.onTick(function(tpf, tag) {
    const p = Scene.getPosition(cube);
    const blockBelow = Scene.getBlockId(
        Math.floor(p[0]),
        Math.floor(p[1]) - 1,
        Math.floor(p[2])
    );

    if (blockBelow !== 0) {
        Scene.setColor(cube, 0, 1, 0, 1);   // green — solid ground
        Gui.guiWord("Ground: solid", 0.5, 0.95, 0, 0.02, "groundLabel");
    } else {
        Scene.setColor(cube, 1, 0, 0, 1);   // red — air below
        Gui.guiWord("Ground: air", 0.5, 0.95, 0, 0.02, "groundLabel");
    }
}, "Update");

Block above player (demo)

// worlds/my_world/mod/gameplay/demo_block_above_player.js
// Every tick, sets the block 1 meter above the player to Glass (id 10).

Engine.onTick(function(tpf, tag) {
    const p = Player.getPosition();
    const x = Math.floor(p[0]);
    const y = Math.floor(p[1]) + 1;
    const z = Math.floor(p[2]);
    Block.place(x, y, z, 10);
}, "Update");

Note both examples now live under mod/gameplay/ rather than directly in mod/ — a loose mod/ground_indicator.js would silently not load under the new pack system.


Limitations

  • Scripts run on the render thread. Do not spin-wait or do heavy computation inside Engine.onTick — keep callbacks short.
  • Only the "Update" tag is driven every frame by the engine, per pack. Any other tag only runs when a matching Gui element in the same pack is clicked.
  • Each pack is its own GraalVM context. A global variable declared in one pack is not visible to scripts in another pack — only to other scripts within the same pack, in load order. This is a change from earlier versions where all mods shared one context; if you're upgrading old mods, check for cross-file globals that now need to move into the same pack folder.
  • Loose .js files directly inside mod/ (not in a subfolder) are not loaded at all — every mod needs a pack folder.
  • Engine.onBlockChange validators still run synchronously on every block edit and are still cheap-only, but now that includes validators from every other pack too — a slow or misbehaving validator in one pack can affect edits triggered by any other pack.
  • Player moves the camera with no collision checks — it's a teleport, not physics-driven movement.
  • No filesystem, network, or Java class access from scripts. (unless i have a sudden urge to add them)
  • console.log output appears in the game's standard output. (currently only accessible by players via running with terminal)

Image MtSharpGrain


Clone this wiki locally