Lua 5.4
Status: Implemented. A sandboxed Lua 5.4 VM per mod with memory and time limits, hot reload, the whole IDL as
cl.<namespace>.<verb>and handle methods, the easy layer, coroutines that sleep for ticks, and mixins. API 1.20 addscl.fs(the mod’s folder, writes indata/),cl.jsonandcl.http(permission-gated HTTPS, answers on the tick). Awaitable requests, a profiler API and a debugger are Planned.
Lua mods run in a sandboxed VM per mod, hot-reload on save, and ship as plain scripts that work on every platform. The implementation is client/clight/lua (VM, sandbox, hot reload, the cl/mod prelude) and the generated client/clight/lua/generated/lua_bindings.gen.cpp. The example mod is clight-sdk/examples/ruby_lua. Every function’s Lua name is in the generated tables. The Lua course on the website teaches the language and the API from zero.
Setup
No compiler is needed. A Lua mod is a manifest plus scripts:
my_mod/
├── clight.mod.json "runtime": { "type": "lua", "entry": "scripts/main.lua" }
├── scripts/
│ ├── main.lua the entry script, runs when the mod loads
│ └── util.lua require "my_mod.util"
└── assets/ datapack/ as for every mod
clight new lua my_mod creates it; clight run starts the game with the project folder loaded and hot reload on. Editor support: clight-sdk/lua/clight.d.lua has LuaLS annotations for the whole API (generated from the IDL), so completion and hover docs work in VS Code with the Lua extension.
Names
- Every IDL function is
cl.<namespace>.<verb>(...), mirroring the C name:cl_level_set_blockiscl.level.set_block,cl_sound_playiscl.sound.play. Families with their own prefix inside a module keep it:cl.buf.*,cl.task.*,cl.widget.*,cl.player.*,cl.living.*,cl.music.*. The pre-1.1 spellings (cl.net.buf_write_i32,cl.entity.player_give,cl.item_stack, …) are the same functions and keep working;clight.d.luamarks them deprecated. - Each function is also a method on its first handle:
player:send_message("hi"),level:get_block(pos),stack:count(),g:text(...). cl.api.fns.<name>lists every generated function,cl.enums.<enum>the enum values.cl.has(name)tells whether a module ("gui"), a function ("level.set_block"), a runtime ("lua","wasm") or a feature ("hot_reload.lua","mixins") is available.cl.versionis the engine’s API version ("1.4.0"),cl.api_majorthe manifest’sapinumber.
The easy layer
-- When a player joins, give them a ruby and say hello.
cl.on("player.join", function(event)
local player = event.player
player:give("my_mod:ruby")
player:send_message("Welcome, " .. player.name .. "!")
end)
| Easy form | What it does |
|---|---|
player.name, .uuid, .pos, .health, .level / .world, .gamemode |
Properties. name is nil for non-players, uuid is the "8-4-4-4-12" string, pos a cl.vec. The shadowed getters stay as get_name(), get_uuid(), get_health(), get_level(). |
player:give(item, count) |
An id string, a stack or a registered item; count defaults to 1. An unknown id raises not_found. |
player:take(id, n), :count_item(id), :has_item(id, n) |
Inventory by id |
player:slot(n), player:held_item("main" / "off") |
A live slot: .id, .count, .empty, :shrink(n) (uses up; not in creative), :remove(n), :set(id, n), :clear(), :stack() |
player:send_message(x), :send_action_bar(x), :send_title(title, subtitle, { fade_in, stay, fade_out }), :clear_title() |
Strings, texts or any value (tostring) |
cl.client.message(x), cl.client.action_bar(x), cl.client.title(...) |
Client-only text |
cl.log(...) |
Info log; cl.log.warn("%d left", n) formats when the first argument has a %. Levels trace, debug, info, warn, error. print logs too. |
event.player, .block, .item, .world, .pos |
Friendly event fields (Events) |
| ids as strings | Wherever a block state, block id or item id is expected: level:set_block(pos, "minecraft:stone"), cl.stack("minecraft:apple", 3) |
| text styles | cl.text.literal("Hi"):color(0xFFAA00):bold():click("run_command", "/spawn"):hover("..."), cl.text.translatable(key, args...) |
Values and types
| CLight | Lua |
|---|---|
| handles | userdata with methods; one Lua value per handle, so they work as table keys. A stale handle raises an error on use (e:is_valid() is safe). |
owned values (item_stack, text, buf) |
userdata released by the GC, or at once with local s <close> = ... |
cl_str, cl_ident |
strings |
| positions and vectors | cl.pos(x, y, z) / cl.vec(x, y, z) tables with + - *, ==, :above(n), :below(n), :offset(dir, n), :center(), :length(), :normalize() |
| enums | lowercase strings ("north", "blocks", "success") |
| flags | integers, names, or { name = true } tables (registration also takes the flag names as boolean keys) |
| descriptors | tables with the C field names; missing fields keep the IDL defaults. Functions in them become callbacks. |
cl_result errors |
raised cl.Error values (err.code like "not_found", err.message); cl.try.<ns>.<fn>(...) returns true, ... or false, err |
The sandbox
- One VM per mod; its allocator enforces
limits.memory_mb(default 256, at most 4096 like WASM). An allocation over the limit raisesnot enough memoryin the current callback. - Each callback may run for 50 ms; an interrupted callback can’t be swallowed by
pcall. Ten escaped errors within a minute turn the mod off for the session. - Standard libraries:
base(nodofile/loadfile;loadonly for text),string(nodump),table,math,utf8,coroutine,os.time/clock/date/difftime,debug.traceback. Noio,packageor otheros/debugfunctions. require "my_mod.util"loadsscripts/util.lua; only your own scripts.- Globals:
cl(the API) andmod(mod.id,mod.version,mod.config,mod.persist). Each file runs in the mod’s shared environment; top-levellocals stay private to the file.
Memory and ownership
- You never release anything by hand. Use
<close>for owned values you want released now. - Handles are not owned. Keeping an entity in a table is fine, but it may go stale; check
e:is_valid(), or keepe.uuid. - Event objects are views valid only during the handler; copy the fields you need.
- Mod lifetime. Subscriptions, tasks, HUD layers, keybinds and coroutines belong to the mod and are cleared on unload.
Error handling
local ok, err = cl.try.level.set_block(level, pos, "minecraft:stone")
if not ok then cl.log.warn("set_block failed: %s (%s)", err.message, err.code) end
cl.raise(code, message) raises a cl.Error of your own. An error that escapes a handler is caught, logged with a traceback, and the call counts as “no change”.
Strings and collections
- Strings are UTF-8;
utf8.len(name)counts characters,#sbytes. - Inventory and menu slots are 0-based, matching vanilla slot numbers. Lists the API returns (
cl.entity.server_players(),ctx:entities(name)) are normal 1-based sequences.
Callbacks and events
local sub = cl.on("entity.damage", function(event)
if event.source == "minecraft:on_fire" then event.amount = event.amount * 0.5 end
end, { filter = "minecraft:zombie", priority = "late" })
sub:cancel() -- unsubscribe
cl.on(name, fn, opts)returns a subscription. Options:priority(name or number),filter,receive_cancelled,once.cl.once(name, fn)isonce = true.- Mutable fields are plain assignments.
event:cancel(),event:set_result("success"),event.cancelled. - Behaviours are functions in the descriptor’s
behaviortable; return a result ("success") to handle, nothing to let the base block or item act. - Commands:
cl.cmd.literal(...),cl.cmd.argument(name, type),:then_(child),:executes(fn),:requires_permission(name or level),cl.cmd.register(root); the context hasctx:arg,ctx:entities,ctx:player,ctx:args,ctx:success,ctx:failure,ctx:permission_level,ctx:source_name,ctx:is_player(Commands). - HUD:
cl.hud.register_layer{ id, render = function(g, partial) end, visible = function() end }. Screens:cl.screen.open{ title, init = function(s) ... end, render = ... }. - Mixins:
cl.mixin.inject(target, at, fn, opts)and the shortcutscl.mixin.head/ret/wrap/overwrite/point/value/call,h:remove(),cl.accessor(name):get(e)(Mixins).
Coroutines
cl.spawn(function()
cl.sleep_ticks(40) -- two seconds; the game keeps running
cl.entity.server_broadcast("Two seconds later")
end)
cl.spawn(fn, ...) starts a coroutine owned by the mod; cl.sleep_ticks(n) (only inside one) resumes it n ticks later, on the server tick while a server runs, otherwise on the client tick. Nothing costs a tick while nothing sleeps. After a sleep, re-check entities with is_valid().
Threading
Lua handlers run on the thread of their event, but never concurrently: each mod’s VM has a lock. Keep handlers short, filter frequent events, and split long work into steps.
Hot reload
- With
clight run(--clight-dev), changed scripts are picked up every half second. One-shot events (lifecycle.register,lifecycle.client_setup,server.command_register, …) are replayed to the handlers the reload adds again, so commands, HUD layers, keybinds and payload handlers come back with the new code. mod.persistsurvives reloads (not restarts).- New blocks and items need a restart (“restart required” is logged; the old registrations stay).
Details: Hot reload.
Performance tips
- Use filters (
filter = "minecraft:creeper") so your handler isn’t called for other entities. - Compute on the tick, draw on the frame (HUD layers, screen
render). - Avoid creating tables in per-tick handlers; reuse them.
- Cache library functions in locals (
local floor = math.floor) in hot loops.
Debugging
- Tracebacks in the log show
scripts/<file>.lua:<line>. cl.inspect(value)gives a readable dump of tables;cl.heap_bytes()the VM’s memory use.- A debugger (DAP) and a profiler API are planned.
Why Lua 5.4 (and not LuaJIT)
| Lua 5.4 (chosen) | LuaJIT 2.1 | |
|---|---|---|
| Integers | 64-bit integers next to floats, matching Java long seeds, game times and ids |
Doubles only (53-bit integers) |
| Sandboxing | Clean environments, a memory-limit allocator, instruction hooks | The FFI can’t be sandboxed |
| Determinism | Interpreted, the same on every platform | JIT behaviour differs by platform |
| Speed | Slower on heavy maths | Faster when traces compile |
Heavy maths belongs in a WebAssembly mod; for event handlers, plugins and UI glue Lua 5.4 is fast enough and much easier to make safe.