In-game JavaScript + Lua scripting for NeoForge 1.21.1 — like KubeJS, but you edit the scripts in a fullscreen GUI inside the game instead of in external files, and like ComputerCraft, but with a real editor (syntax highlighting, multi-error linter, undo/redo, hot-reload, console) instead of a 51×19 text terminal.
Current state (v0.12.x): All five recipe types (shaped, shapeless, smelting,
smoking, blasting, campfire, stonecutting), @startup-annotated scripts that
auto-run on server start, custom item registration via items.json with
runtime resource-pack provider for textures + display names, custom
result-stack appearance (name + lore + glow), 18-event listener catalogue with
hot-reload, Linter-Squigglies, folder tree, a usable text editor (selection,
clipboard, find/replace, code-folding), startup-output replay for operators,
and CurseForge update check.
LiveScript embeds Mozilla Rhino (JavaScript) and LuaJ (Lua) as scripting engines. Both engines are sandboxed:
- No filesystem access.
java.io.Fileandjava.nio.file.*are blocked via Rhino'sClassShutter(seeJsEngine.java). Lua'siolibrary is set tonil. - No subprocess execution.
java.lang.Runtimeandjava.lang.ProcessBuilderare blocked inClassShutter. Lua'sos.execute,os.exit,os.remove,os.rename,os.getenv,os.tmpnameare stripped. - No reflection.
java.lang.reflect.*is blocked. - No network from scripts.
java.net.*is blocked. (LiveScript itself makes one outbound request toapi.cfwidget.comfor the CurseForge update check; that's mod code, not script code, and you can disable it by editingUpdateChecker.java.) - CPU timeout. Every script has a configurable timeout (default 5 s).
JS uses Rhino's instruction-count hook; Lua uses
debug.sethook. - Operator-gated.
/scripteditorrequires permission level 2 (/op). Non-ops cannot open the editor or execute scripts.
The same security model is used by KubeJS, GroovyScript, CraftTweaker, and ComputerCraft. The relevant files to audit are:
engine/JsEngine.java— Rhino setup,ClassShutter, instruction-count hookengine/LuaEngine.java— LuaJ setup,debugGlobals, strippedosConfig.java— toggles for the sandbox (default: max-restrictive)
./gradlew build
# JAR ends up in build/libs/livescript-<version>-neoforge-1.21.1.jarRequires JDK 21. Gradle wrapper is included via the NeoForge moddev plugin.
Don't have Gradle installed? sdk install gradle 8.8 via SDKMAN is the
fastest path on Linux/macOS. On Windows, grab the Gradle 8.8 binary distribution
from gradle.org/releases.
Drop the JAR in mods/, start the server, then in-game:
/scripteditor
You'll need OP (permission level 2 by default — configurable in
config/livescript-common.toml).
| Concern | Class |
|---|---|
| Mod entrypoint | LiveScriptMod |
| Config | Config |
| Script engines | engine/JsEngine (Rhino), engine/LuaEngine (LuaJ) |
| Sandbox + listener cleanup | engine/ScriptContext |
| Pre-run syntax check | engine/Linter |
| Script-exposed events | api/EventBindings |
| Recipe/tag/item APIs | api/RecipeApi, api/TagApi, api/ItemApi |
| Recipe-spec normalization (JS/Lua/Map → uniform) | api/SpecAdapter |
| Storage + history | storage/ScriptStorage, storage/HistoryManager |
| Async IO | storage/AsyncIO |
| Network payloads | network/* |
| Editor GUI | client/gui/ScriptEditorScreen |
| Editor buffer + undo | client/gui/EditorState, client/gui/UndoStack |
| Stateful syntax highlighting | client/gui/highlight/* |
| Autocomplete | client/gui/autocomplete/CompletionProvider |
| Indent-based code folding | client/gui/FoldRegion |
| Find/Replace | client/gui/FindState |
Both languages expose the same surface. Extend EventBindings / RecipeApi
/ TagApi / ItemApi to add more.
print(msg)— write to the editor consolebroadcast(msg)— chat message to all online playersserver—MinecraftServerinstance (use carefully)on(eventName, handler)— subscribe to an eventcancel(event)— cancel a cancellable event from inside a handler
Ticks:
server.tick— every server tick (fires 20×/sec; keep handlers tiny)
Player lifecycle:
player.join/player.leave— receives theServerPlayerplayer.respawn— after death respawnplayer.dimension_change— portal traversal
Chat:
player.chat— receivesServerChatEvent; cancel to suppress, mutate to rewrite
Death and damage:
player.death—LivingDeathEventfiltered to playersentity.death—LivingDeathEventfor any mobentity.damage—LivingIncomingDamageEvent; cancel to absorb the hit
Blocks:
block.break/block.place— cancel to deny
Items + interactions:
item.use_block— right-click an item on a blockitem.use— right-click an item in air (food, throwables)item.craft— item taken from a crafting result slot (PlayerEvent.ItemCraftedEvent)item.smelt— item taken from a furnace result slotitem.pickup— item picked up off the ground
Entities:
entity.spawn—EntityJoinLevelEvent; cancel to deny spawnmob.target—LivingChangeTargetEvent; mob aggro switch
World:
explosion.start— before the boom; cancel to preventexplosion.detonate— after; modify affected blocks/entities
Read + delete:
recipe.removeByOutput('minecraft:torch'); // remove all recipes producing torches
recipe.removeById('minecraft:stick'); // remove a specific recipe
recipe.count(); // total recipe countCrafting:
recipe.shapedAdd({
id: 'livescript:my_torch',
pattern: ['C', 'C', 'S'],
key: { C: 'minecraft:coal', S: 'minecraft:stick' },
result: { item: 'minecraft:torch', count: 8 }
});
recipe.shapelessAdd({
id: 'livescript:bread_fast',
ingredients: ['#c:crops', 'minecraft:water_bucket'],
result: 'minecraft:bread'
});Cooking — smeltAdd (furnace), smokerAdd, blastAdd, campfireAdd. Same
schema; default cook times are 200 (smelt), 100 (smoker, blast), 600 (campfire).
recipe.smeltAdd({
id: 'livescript:cobble_to_diamond',
ingredient: 'minecraft:cobblestone',
result: 'minecraft:diamond',
experience: 1.0,
cookTime: 100 // ticks; optional
// category: 'MISC' // optional: FOOD, BLOCKS, MISC
});Stonecutter — no experience, no cook time:
recipe.stoneCutAdd({
id: 'livescript:diamond_block_to_diamonds',
ingredient: 'minecraft:diamond_block',
result: { item: 'minecraft:diamond', count: 9 }
});Ingredient syntax everywhere:
- Plain item:
'minecraft:iron_ingot' - Tag:
'#c:logs'(NeoForge common tags use thec:namespace, notminecraft:) - Object form:
{ item: 'minecraft:iron_ingot' }or{ tag: 'c:logs' }
Custom result item appearance — works for any recipe type:
recipe.shapedAdd({
id: 'livescript:magic_diamond',
pattern: ['DDD', 'DND', 'DDD'],
key: { D: 'minecraft:diamond', N: 'minecraft:nether_star' },
result: {
item: 'minecraft:diamond', // underlying item — unchanged
count: 1,
name: '§bMagic Diamond', // sets DataComponents.CUSTOM_NAME
lore: ['§7Pulsates with arcane energy', // each entry = one line
'§8Artisan-crafted'],
glow: true // adds enchantment shimmer w/o real enchants
}
});Notes on cosmetic output:
- The output is still the underlying item (here,
minecraft:diamond) — it stacks with other named-diamonds that have the same name+lore but not with vanilla diamonds. Minecraft treats stacks with different data components as distinct. - Color codes use the section sign (
§); copy from/give-style commands. - Lore can be a single string or an array. Both render upright (not italic), matching what most users expect from Custom-NBT items.
- No restart, no resource pack, no client install required — the data components travel with the ItemStack like any vanilla enchanted item.
Caveats: changes are NOT persisted. Re-apply on every server start (auto-run on
save = on, plus a @startup script, is the recommended pattern). Client recipe
books may show stale data until next reconnect.
tag.itemsIn('minecraft:logs'); // [String] of item ids
tag.itemHas(itemStack, 'minecraft:logs'); // bool
tag.blocksIn('minecraft:mineable/pickaxe'); // [String] of block idsitem.find('minecraft:diamond'); // → Item
item.stack('minecraft:diamond', 64); // → ItemStack
item.idOf(someItem); // → 'minecraft:diamond'
item.findByPrefix('minecraft:diamond'); // [String] of matching idsLua scripts use : for method calls on Java objects, not .:
recipe:removeByOutput('minecraft:torch') -- correct
recipe.removeByOutput('minecraft:torch') -- WRONG — would pass recipe as wrong argThis is because the script gets CoerceJavaToLua wrappers, and LuaJ treats :
as "pass self as first arg" — which is what Java instance methods expect.
On first server start, the following are seeded into
livescript/scripts/examples/ (never overwritten if you've edited them):
welcome.js, welcome.lua, no_creeper_grief.js, recipes.js, recipes.lua,
tag_query.js.
| Shortcut | Action |
|---|---|
| Ctrl+S | Save current script (lints first; refuses on syntax error) |
| Ctrl+Enter | Run current buffer (without saving; also lints) |
| F2 | Rename current script or selected folder |
| + Folder | Create a new folder (also for nested paths like tools/utils) |
| Click folder | Toggle expand/collapse; also selects it for Rename/Delete |
| Ctrl+Z | Undo |
| Ctrl+Y / Ctrl+Shift+Z | Redo |
| Ctrl+A | Select all |
| Ctrl+C | Copy selection (or current line if no selection) |
| Ctrl+X | Cut selection (or current line if no selection) |
| Ctrl+V | Paste — replaces selection if any |
| Shift+Arrow / Home / End | Extend selection |
| Mouse drag | Drag-select text |
| Shift+Click | Extend selection to click point |
| Ctrl+F | Open find bar |
| Ctrl+H | Toggle replace mode (only when find bar is open) |
| Enter (in bar) | Jump to next match |
| Shift+Enter (in bar) | Jump to previous match |
| Tab (in bar, replace mode) | Switch between find and replace field |
| Esc | Close find bar / cancel a confirm dialog / close editor |
| Click in gutter | Toggle fold for that region (header lines show > or v) |
| Drag splitter | Resize file tree / console (cyan when hovered) |
| Tab (in editor) | Insert 2 spaces |
| Up/Down/Tab in popup | Navigate autocomplete |
| Enter in popup | Accept completion |
| Y / N | Confirm or cancel a delete prompt |
LiveScript can register entirely new items with their own IDs, display names, and item properties. These work like vanilla items: craftable, stackable, usable as recipe ingredients/outputs, food, fire-resistant, etc.
Restart caveat: Minecraft's item registry freezes after mod load and stays
frozen for the entire process. That means changes to items.json need a
full Minecraft restart to take effect — quitting to the title screen and
opening a new world is NOT enough, because the mod constructor (where items
get registered) only runs once per JVM. Server admins running a dedicated
server have it easier: a server restart is enough.
Items live in data/livescript/items.json. On first run, three demo items
(magic_dust, phoenix_feather, soul_bread) are seeded along with a paired
script scripts/examples/items_demo.js that registers crafting recipes for
them. Delete or replace as you like.
Minimal schema:
{
"items": {
"magic_dust": {
"display_name": "Magic Dust",
"max_stack_size": 64,
"rarity": "rare"
},
"phoenix_feather": {
"display_name": "Phoenix Feather",
"max_stack_size": 16,
"fire_resistant": true
},
"soul_bread": {
"display_name": "Soul Bread",
"max_stack_size": 16,
"food": {
"nutrition": 6,
"saturation": 0.8,
"always_edible": true
}
}
}
}Schema reference for each entry:
| Field | Type | Default | Notes |
|---|---|---|---|
display_name |
string | titlecase of id | Shows in tooltips and inventories |
max_stack_size |
int 1..99 | 64 | Stack size in inventory |
rarity |
string | common |
One of common, uncommon, rare, epic — colors the tooltip name |
fire_resistant |
bool | false | Survives lava / fire damage when dropped |
food.nutrition |
int | — | Hunger points restored. If food is set, item becomes edible |
food.saturation |
float | — | Saturation modifier (golden apple = 1.2) |
food.always_edible |
bool | false | Can eat at full hunger (golden apple style) |
Keys prefixed with _ are skipped — use that to "comment out" an entry
("_old_item": { ... }).
Use in recipes with the livescript: namespace:
recipe.shapedAdd({
id: 'livescript:dust_to_feather',
pattern: ['DDD', 'DDD', 'DDD'],
key: { D: 'livescript:magic_dust' },
result: 'livescript:phoenix_feather'
});Custom item textures live in data/livescript/textures/. For an item with
id magic_dust, drop a magic_dust.png file in there:
data/livescript/
├── items.json
├── scripts/
└── textures/
├── magic_dust.png
├── phoenix_feather.png
└── soul_bread.png
PNGs must be square, power-of-two size — 16x16 is the vanilla default, but 32x32, 64x64, and 128x128 all work. Minecraft scales them for inventory icons.
Items without a matching PNG show the magenta/black missing-texture pattern. The item is still functional; it just has no icon.
Live reload: press F3+T in-game after editing or adding a texture to see
the change immediately. No restart needed for texture changes — only for
items.json changes (and those need a full Minecraft restart, see above).
Every execute() call to a script with a previously-used scriptId first tears
down all listeners registered by the prior run. This works via ScriptContext,
which tracks every subscription and neutralizes them with an active flag on
unregister. Listeners themselves are NOT removed from the event bus — NeoForge's
IEventBus doesn't currently expose unsubscribe-by-lambda — but the guarded
wrapper makes them no-ops, and the JVM eventually GCs the closures when the
last script reference drops.
This is the same lesson as your perfmod debugging: leaked listeners are the silent killer of "reloadable" systems. Worth watching with a memory profiler if you start running thousands of reloads in a session.
The editor re-lints the active buffer ~500ms after each typing pause. Syntax errors are underlined with a red wavy line, and the status bar at the bottom shows the first error and an overflow count if more exist.
- JS uses Rhino's parser with multiple-error recovery — up to 10 issues per file
- Lua uses LuaJ's compile path — single error per file (the first one bails)
- Lint failures block save (Ctrl+S surfaces the message), but you can still run (Ctrl+Enter) for testing — the script just won't run if it doesn't parse
Add a @startup annotation in the first 20 lines of a script to make it run
automatically every time the server starts. Recipe edits, event listeners, and
anything else that needs to be re-applied across restarts goes here.
// @startup priority=100
recipe.removeByOutput('minecraft:torch');
recipe.shapedAdd({ /* ... */ });-- @startup priority=-5
print('low-priority startup; runs near the end')Higher priority runs first; ties broken alphabetically by script id. Output from startup scripts goes to the server log (no client is connected yet). A failing startup script logs the error but doesn't block the others.
This runs in ServerStartedEvent, AFTER the recipe manager is fully populated.
Doing it during ServerStartingEvent would race with mod recipe loading.
Three configurable layers (config/livescript-common.toml):
- Permission level — only ops can open the editor or save scripts.
- Class shutter (JS) / stripped libs (Lua) — reflection,
Runtime,ProcessBuilder,File, and network classes are blocked by default. - Hard timeout — Rhino's instruction observer and Lua's debug hook both
kill scripts that exceed
scriptTimeoutMs(5s default).
allowFilesystemAccess and allowNetworkAccess are both false by default.
Flip them only on single-player or fully-trusted servers.
Rhino and LuaJ are shaded into the final JAR to avoid classpath conflicts with
other mods that bundle their own copies (KubeJS ships Rhino, CC:Tweaked ships
its own Lua). If you want to use this mod alongside KubeJS, the shading
should make that safe; if it doesn't, we'd need to relocate the packages with
the Shadow plugin instead — build.gradle is set up so adding that is a
2-line change.
- Item registration isn't possible from scripts at all — Minecraft's RegisterEvent fires before scripts can ever run. KubeJS solves this via JSON file generation at JVM startup; we'd need a similar bootstrap layer.
- Tag mutation is not possible at runtime — datapack-only since 1.19. Lookups are available; modifications aren't.
- Smelting / brewing / smithing recipes aren't exposed yet — only crafting.
Easy enough to add: each follows the same pattern as
shapelessAdd, with a different RecipeType. - Code-folding is indent-based, not language-aware. Mis-indented code folds weirdly; the upside is one algorithm covers both JS and Lua. Switch to brace/keyword matching later if it bites.
- Find/Replace is plain text, no regex. Adding regex would be a 5-line change
in
FindState.recomputeMatches, but the bigger work is escaping for user-typed(/[/*so they search literally by default. - Autocomplete is keyword/binding-name only, not context-aware.
- Per-file undo history doesn't survive a save→close→reopen cycle — on-disk history (HistoryManager) does, but the in-memory UndoStack resets.
MIT.