Skip to content

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 adds cl.fs (the mod’s folder, writes in data/), cl.json and cl.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_block is cl.level.set_block, cl_sound_play is cl.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.lua marks 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.version is the engine’s API version ("1.4.0"), cl.api_major the manifest’s api number.

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 raises not enough memory in 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 (no dofile/loadfile; load only for text), string (no dump), table, math, utf8, coroutine, os.time / clock / date / difftime, debug.traceback. No io, package or other os/debug functions.
  • require "my_mod.util" loads scripts/util.lua; only your own scripts.
  • Globals: cl (the API) and mod (mod.id, mod.version, mod.config, mod.persist). Each file runs in the mod’s shared environment; top-level locals 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 keep e.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, #s bytes.
  • 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) is once = true.
  • Mutable fields are plain assignments. event:cancel(), event:set_result("success"), event.cancelled.
  • Behaviours are functions in the descriptor’s behavior table; 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 has ctx: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 shortcuts cl.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.persist survives 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.