Aesthetic — Lua Scripting
Aesthetic lets you write your own modules in Lua — no client rebuild required. A script declares a module, its settings and event handlers; the module shows up in the GUI next to the built-in ones and its settings are saved to the config.
Script location
<Minecraft config folder>/aesthetic/scripts/*.lua
Every .lua file in this folder is a separate script. The folder is created on first
launch together with a sample example.lua.
Hot reload
Scripts reload automatically:
- save a file — the script reloads instantly, keeping setting values and the module's enabled state;
- create a file — the script loads on the fly;
- delete a file — its modules are unloaded.
Every reload is announced in chat with the [lua] prefix.
Errors
A load error prints to chat and the script is skipped. An error inside a callback prints to chat and disables the module so it doesn't spam every tick. Fix the code and save — the script reloads.
Minimal script
local module = ui.category("scripts"):module("code", "Hello")
module:event("tick", function()
-- runs 20 times a second while the module is enabled
end)
Next: Getting Started.
Getting Started
1. Create a file and declare a module
Create my-module.lua in <config>/aesthetic/scripts/ — the game can be running:
local module = ui.category("scripts"):module("code", "My Module")
ui.category(name)— GUI category:"combat","movement","visuals","player","misc"or"scripts".:module(icon, title[, description])— icon name (e.g."code","bolt","eye"— the editor autocompletes the full set) and the module title. The title must be unique across all modules.
2. Add settings
local delay = module:number("Delay", 20, 1, 100, 1, "t")
local text = module:string("Text", "hello")
local on = module:boolean("Enabled part", true)
Settings are read with :get() and show up in the module's GUI automatically.
All types are listed in Modules & Settings.
3. Subscribe to events
local ticks = 0
module:callback("enable", function()
ticks = 0
end)
module:event("tick", function()
ticks = ticks + 1
if on:get() and ticks % delay:get() == 0 then
client.print_chat(text:get())
end
end)
callback handles the lifecycle ("enable", "disable"), event handles game events
(see Events). Event names ignore case and underscores: render_2d and
render2d are the same.
4. Save and enable
Save the file — chat shows [lua] Loaded my-module.lua and the module appears in the
GUI. Enable it and keep editing: every save reloads the script while keeping your
setting values and the enabled state.
Editor support (autocomplete)
The mod generates language-server annotations for the whole API on every launch:
<config>/aesthetic/scripts/library/*.lua— LuaCATS stubs for all globals, events, settings and render canvases;<config>/aesthetic/scripts/.luarc.json— configuration pointing lua-language-server at them.
Open the scripts folder in an editor with lua-language-server — the
Lua extension in
VS Code, the SumnekoLua plugin in IntelliJ, or lua_ls in Neovim — and you get
completion, hovers, signature help and type checking out of the box, including event
names with typed callback arguments (module:event("render_2d", function(render) ...),
packet names and the full icon-name set.
The library/ folder is regenerated on every launch to match the installed mod version —
don't edit it. .luarc.json is only written when missing, so your own tweaks survive.
Debugging
print("a", value)andclient.print_chat(text)— output to chat ([lua]prefix);aesthetic.log(msg)— output to the game log;- callback errors print to chat and disable the module — fix and re-enable.
Shared libraries
require resolves files relative to the scripts folder. Only top-level .lua files
auto-load as scripts, so keep shared code in a subfolder:
local util = require "lib/util" -- <scripts>/lib/util.lua
Modules & Settings
Creating a module
local module = ui.category("visuals"):module("draw-square", "My ESP")
| Method | What it does |
|---|---|
ui.category(name) | picks a GUI category: combat, movement, visuals, player, misc, scripts |
category:module(icon, title[, description]) | registers a new module and returns it; description is readable via modules |
module:callback(name, fn) | lifecycle hooks: "enable", "disable" |
module:event(name, fn) | subscribes to a game event (list) |
module:toggle() | flips the module on/off |
module:enabled() | returns true while the module is enabled |
module:set_enabled(bool) | enables or disables the module |
module.name | the module's name |
One callback per event: a repeated module:event("tick", ...) replaces the previous
one.
Icon names autocomplete in the editor — the full set lives in the generated
library/icons.lua stub (see autocomplete).
Settings
Settings are created with module methods, appear in the GUI automatically and are saved to the config. The first argument is the name (also the GUI label).
local b = module:boolean("Flag", true)
local n = module:number("Speed", 1.0, 0.1, 10.0, 0.1, "x")
local r = module:range("Bounds", 20, 80, 0, 100, 1, "%")
local s = module:string("Message", "hello")
local k = module:key("Boost", "R")
local c = module:color("Color", 0xFF4FF2A6)
local sel = module:selection("Mode", nil, { "A", "B", "C" })
local multi = module:multi_selection("Targets", { "Players" }, { "Players", "Mobs" })
| Signature | Notes |
|---|---|
boolean(name, default) | on/off switch |
number(name, default, min, max[, step[, unit]]) | a slider; step defaults to 1, unit is a label like "ms" |
range(name, default_min, default_max, min, max[, step[, unit]]) | two-handle slider; the value is a {min, max} table |
string(name, default) | text field |
key(name[, default]) | key bind (like Item Helper); default is a key name or a table of up to 3 names |
color(name, argb[, alpha]) | color picker, 0xAARRGGBB; alpha = false hides the alpha slider |
selection(name, default, options) | pick one option; default = nil means the first one |
multi_selection(name, defaults, options[, allow_empty]) | pick several options; defaults = nil means the first one; the value is an array of strings |
Working with values
n:get() -- current value
n:set(5) -- set (numbers are clamped to [min, max])
n:reset() -- back to default
n.value -- sugar for get/set
sel:set("B") -- selection: by option name
r:get().min -- range returns {min=, max=}
r:set({ min = 10, max = 90 })
Colors come back as unsigned numbers, so comparing against 0xAARRGGBB literals works
directly.
Key binds
local k = module:key("Boost", "R") -- or { "LCtrl", "R" }
module:event("tick", function()
if k:pressed() then
-- held right now (always false while a screen is open)
end
end)
k:get() -- bound key names, e.g. { "R" }
k:set({ "LCtrl", "R" }) -- rebind from code
Key names match the GUI labels: letters/digits ("R"), "Space", "LShift", "LCtrl",
"LAlt", "Enter", "Tab", "F1"–"F25", mouse buttons "MMB", "M4", "M5", etc.
The bind is editable in the module's GUI panel like any other setting.
Other modules — the modules global
The modules global reads and controls every registered module — built-in ones
included, not just those created by scripts.
modules.list() -- all module names
modules.active() -- names of the currently enabled modules
modules.binds() -- all binds: { { module = "Flight", keys = { "R" }, type = "toggle" }, ... }
local flight = modules.get("Flight") -- case-insensitive; nil when there is no such module
flight:enabled() -- true/false
flight:set_enabled(true)
flight:toggle()
flight:binds() -- { { keys = { "R" }, type = "toggle" }, ... }
flight.name, .title, .description and .category describe the module. Bind
type is "toggle" or "hold"; key names match the GUI labels (see
key binds).
Reading and overriding settings
ref:settings() lists the setting names in GUI order; ref:setting(name) (also
case-insensitive) returns a handle with the usual :get() / :set(v) / :reset()
and the .value sugar. Its type field says which value shape applies — the same
shapes as the setting builders above:
type | value |
|---|---|
boolean | boolean |
number | number, clamped to the setting's range |
range | { min = , max = } table |
string | string |
key | array of key names; also has :pressed() |
color | 0xAARRGGBB number |
selection | option name |
multi_selection | array of option names |
order | permutation of the current key array |
local speed = modules.get("Speed")
for _, name in ipairs(speed:settings()) do
aesthetic.log(name .. " = " .. tostring(speed:setting(name):get()))
end
speed:setting("Mode"):set("Strafe") -- unknown options/keys raise an error
Overrides go through the same path as the GUI: the module reacts immediately and the new value persists to the config.
Persistence
Setting values and the enabled state survive game restarts and script hot-reloads. They are keyed by the module and setting names — renaming loses the stored values.
Events
Subscribe with module:event(name, fn). The callback receives one table with the
event's fields (or nothing if the event has none). Names ignore case and underscores:
render_2d == render2d == Render_2D.
Cancellable events have a :cancel() method — call it to stop the event:
module:event("chat", function(e)
if e.text:find("spam") then e:cancel() end
end)
Writable fields are read back after your callback — assign a new value and the game uses it:
module:event("input", function(e)
e.jump = true -- player movement is controlled through input
end)
Callbacks only run while the module is enabled. The event table is only valid inside the callback — don't store it for later.
Ticks
| Event | Fields | Notes |
|---|---|---|
tick | — | before the player tick; cancellable |
post_tick | — | after the player tick |
pre_interaction | — | before attacks and item use are processed |
Input
| Event | Fields | Notes |
|---|---|---|
input | forward, backward, left, right, jump, sneak, sprint — all writable | cancellable; the way to control player movement |
key | key, action, mods | cancellable; GLFW key codes |
char | char, codepoint, mods | cancellable; a typed character |
mouse_move | x, y | cancellable |
mouse_scroll | horizontal, vertical | cancellable |
mouse_look | delta_x, delta_y | cancellable; camera rotation by mouse |
cursor_lock | state = "lock" / "unlock" | cancellable |
slot_drag | slot, button, shift | cancellable; dragging across container slots |
Chat & text
| Event | Fields | Notes |
|---|---|---|
chat | text | cancellable; a message arrived in chat |
chat_send | text | cancellable; you are about to send a message |
title | text, kind = "title"/"subtitle"/"actionbar" | a title appeared on screen |
boss_bar | text | the boss bar text changed |
Combat & interaction
| Event | Fields | Notes |
|---|---|---|
attack | target — entity table | cancellable; before you hit |
post_attack | target | after the hit |
item_use | cooldown — writable | cancellable |
block_interact | hand, x, y, z, side | cancellable; hand = "main"/"off" |
block_place | hand, x, y, z, side, item | item is an item table |
block_breaking | state = "start"/"stop", block, x, y, z |
Movement
| Event | Fields | Notes |
|---|---|---|
jump | yaw, cooldown — writable | cancellable |
land | x, y, z, fall_distance | you touched the ground |
slowdown | multiplier — writable | cancellable; slowdown while using an item |
Network
Note:
packet_sendandpacket_receivefire on network threads — don't use rendering orprojectioninside them.
| Event | Fields | Notes |
|---|---|---|
packet_send | name, decoded fields | cancellable |
packet_receive | name, decoded fields | cancellable |
packet_process | — | received packets are applied to the world |
movement_packets | — | after movement packets are sent |
player_packets | — | after player packets are sent |
name is the mod's own stable snake_case packet name — the same one packets.send
accepts (Minecraft class names are obfuscated at runtime, so packets are never matched
by class name). Common packets also carry decoded payload fields; the full list is on
the Packets page.
module:event("packet_receive", function(e)
if e.name == "entity_velocity" and e.entity_id == minecraft.player().id then
e:cancel() -- ignore knockback
end
end)
World & modules
| Event | Fields |
|---|---|
chunk_load | x, z |
chunk_unload | x, z |
module_state | module — module name, enabled |
Rendering
render_2d, render_gui and render_3d receive a canvas / renderer object instead of
a field table.
| Event | Fields | Notes |
|---|---|---|
frame | — | every frame |
render_2d | 2D canvas | draw over the game (HUD) |
render_gui | 2D canvas | draw during GUI screens |
render_3d | 3D renderer | draw in the world |
camera_ratio | shrink — writable | |
sky_color | color — writable, original | |
time_of_day | time — writable, original | visual time of day |
skybox | rendered — writable (true hides the vanilla sky) | |
fog | red, green, blue, alpha, environmental_start, environmental_end, render_distance_start, render_distance_end — all writable | color channels are 0..1 |
window_resize | width, height |
Game API
minecraft — game state
| Function | What it does |
|---|---|
minecraft.player() | returns your player as a table, or nil when not in a world |
minecraft.player(name) | finds a player by name (case doesn't matter), or nil |
minecraft.players() | returns every player in the world as an array; you are in the list too — check is_self |
minecraft.entities([type_id]) | returns every entity; pass a type id like "minecraft:item" to keep only that type |
minecraft.block(x, y, z) | returns the block id at that position, like "minecraft:stone", or nil outside a world |
minecraft.block_state(x, y, z) | returns the full block state table at that position, or nil outside a world |
minecraft.item(id) | returns the item table for one default item, like minecraft.item("minecraft:netherite_sword"), or nil for an unknown id |
minecraft.time() | returns the world's time of day |
minecraft.dimension() | returns the dimension id, like "minecraft:overworld" |
minecraft.in_game() | returns true while a world is loaded |
minecraft.effects() | returns your active potion effects as an array of {id, amplifier, duration} |
minecraft.armor() | returns your worn armor as {head, chest, legs, feet} item tables |
local p = minecraft.player()
if p and p.health < 6 then
client.print_chat("low hp!")
end
local_player — your player
Reads and controls the local player in one place. Every function returns nil
(or does nothing) while no player exists, so no guards are needed.
| Function | What it does |
|---|---|
local_player.entity() | the full entity table, same as minecraft.player() |
local_player.position() | returns x, y, z |
local_player.velocity() | returns x, y, z velocity, in blocks per tick |
local_player.rotation() | returns yaw, pitch |
local_player.health() | current health |
local_player.food() | food level (0–20) |
local_player.saturation() | saturation level |
local_player.on_ground() | true while standing on the ground |
local_player.exists() | true while a local player exists |
local_player.set_velocity(x, y, z) | sets the velocity directly, in blocks per tick |
local_player.jump() | jumps, even mid-air |
local_player.set_position(x, y, z) | teleports client-side to that position |
module:event("tick", function()
if local_player.on_ground() then
local vx, _, vz = local_player.velocity()
local_player.set_velocity(vx * 1.1, 0.42, vz * 1.1)
end
end)
Entity table
Every function that returns an entity (player, players, entities, attack.target)
gives you a plain table. It is a snapshot: it shows the moment it was created and does
not update by itself.
Fields every entity has:
| Field | Meaning |
|---|---|
id, uuid, name, type | entity id, UUID, display name and type ("minecraft:player") |
is_self | true when this entity is your own player |
alive, age | whether it is alive; how many ticks it has existed |
pose | current pose: "standing", "crouching", "swimming", "gliding", "sleeping", ... |
x, y, z, eye_y | position; eye_y is the eye height |
prev_x/y/z, delta_x/y/z | position on the previous tick and how far it moved this tick |
vel_x/y/z | current velocity |
yaw, pitch | where the entity is looking |
width, height | hitbox size |
distance | distance from your player to this entity |
on_ground, sneaking, sprinting, swimming, crawling | movement state |
spectator, invisible, glowing, silent, no_gravity | status flags |
on_fire, fire_ticks, fire_immune | burning state |
in_water, wet, submerged, in_lava | fluid state; wet also counts rain |
frozen_ticks, frozen | powder snow freezing |
air, max_air | remaining air (drowning) |
fall_distance | how far it has been falling |
vehicle | the id of the entity it rides, or nil |
passengers | array of rider entity ids, or nil when empty |
Living entities (mobs, players) also have:
| Field | Meaning |
|---|---|
health, max_health, absorption | hearts, including the yellow absorption ones |
armor | armor points (what the armor bar shows) |
hurt_time, death_time, dead | red-flash ticks after a hit; death animation ticks |
baby, scale | whether it is a baby and its size multiplier |
body_yaw, head_yaw | body and head rotation (the entity's yaw is the head look) |
movement_speed | movement speed attribute |
blocking, climbing, gliding, sleeping, jumping | action state; gliding means elytra flight |
using_item, using_riptide | whether it is using an item / riptide-spinning |
item_use_time, item_use_time_left | ticks the current item has been / still needs to be used |
held_item, offhand_item | item tables of the hands |
active_item | the item being used right now (eating, blocking, ...), or nil |
equipment | worn armor as {head, chest, legs, feet} item tables |
effects | active potion effects: array of {id, amplifier, duration, infinite, ambient, visible} |
Players also have ping and gamemode. Your own player additionally has:
| Field | Meaning |
|---|---|
food, saturation | hunger bar and hidden saturation |
xp_level, xp_progress, total_xp | experience level, progress to the next level (0..1) and total points |
attack_cooldown | attack charge 0..1 — 1 means the next hit is full strength |
flying, allow_flying, creative | ability flags |
walk_speed, fly_speed | movement ability speeds |
Item table
Describes one item stack. Returned by held_item, equipment, inventory.item,
minecraft.item(id) and others.
| Field | Meaning |
|---|---|
id, name, count | item id ("minecraft:diamond_sword"), display name and stack size |
empty | true for an empty slot |
max_count, stackable | how many fit in one stack |
rarity | "common", "uncommon", "rare" or "epic" |
use_action | what using it does: "none", "eat", "drink", "block", "bow", "spear", ... |
damage, max_damage | durability used so far and the maximum (0 when not damageable) |
damageable, damaged, unbreakable | durability flags |
enchanted | true when the item has any enchantment |
enchantments | table of id -> level, e.g. enchantments["minecraft:sharpness"] == 5 |
custom_name | the anvil-given name, or nil |
food | {nutrition, saturation, can_always_eat} for eatable items, or nil |
tags | array of item tag ids, like "minecraft:swords" |
local held = minecraft.player().held_item
if held.enchantments["minecraft:sharpness"] then
client.print_chat(held.name .. " has Sharpness")
end
Block state table
Returned by minecraft.block_state(x, y, z). Describes the exact block at a position,
including its state properties.
| Field | Meaning |
|---|---|
id, name | block id ("minecraft:chest") and display name |
x, y, z | the queried block position (floored) |
air, liquid, solid, replaceable | basic kind flags; replaceable means placing over it works (grass, water, ...) |
burnable | fire can consume it |
tool_required | drops only when mined with the right tool |
opaque, full_cube, has_collision | shape flags |
has_block_entity | has extra data like a chest or furnace |
random_ticks | receives random ticks (crops grow, ice melts, ...) |
emits_redstone | emits redstone power |
piston_behavior | "normal", "destroy", "block", "push_only", ... |
hardness, blast_resistance | mining and explosion resistance |
slipperiness | ice-like sliding (0.6 is normal, 0.98 is ice) |
velocity_multiplier, jump_velocity_multiplier | movement slowdown on/in the block (soul sand, honey) |
luminance | light it emits, 0–15 |
opacity | how much light it blocks |
map_color | the color it has on maps, as a number |
properties | table of state properties as strings, e.g. properties.facing == "north", properties.lit == "true" |
fluid | {id, level, still} when the block contains a fluid, or nil |
tags | array of block tag ids, like "minecraft:mineable/pickaxe" |
local b = minecraft.block_state(x, y, z)
if b and b.properties.lit == "true" then
client.print_chat(b.name .. " is lit")
end
client — client & chat
| Function | What it does |
|---|---|
client.fps() | returns the current FPS |
client.ping() | returns your ping in milliseconds |
client.server() | returns the server address, or nil in singleplayer |
client.has_screen() | returns true while any screen (inventory, chat, menu) is open |
client.millis() | returns the current time in milliseconds |
client.tick_delta() | returns the progress between two ticks (0..1) — useful for smooth movement |
client.say_chat(text) | sends text to the server chat; a leading / sends it as a command |
client.command(cmd) | sends a command to the server (the leading / is optional) |
client.print_chat(text) | shows a message in your chat only — nothing is sent to the server |
client.key_down(code) | returns true while a key is held down (GLFW key code, e.g. 32 is space) |
print(...) — the standard Lua print, shown in your chat with the [lua] prefix.
aesthetic
| Function | What it does |
|---|---|
aesthetic.log(msg) | writes a line to the game log file |
aesthetic.script | the file name of the current script |
Standard libraries
math, string, table, bit32, coroutine, a limited os (os.time, os.clock,
os.date) and require (see Getting Started) are available.
Rendering
Colors are 0xAARRGGBB (alpha in the high byte). 2D coordinates are logical pixels;
draw calls and projection results are pixel-aligned automatically.
2D canvas
The argument of render_2d (HUD over the game) and render_gui (during screens):
module:event("render_2d", function(render)
render.rect(8, 8, 120, 24, 0x90101010, 7)
render.text("Hello", 16, 14, 9, 0xFFFFFFFF)
end)
| Function | What it does |
|---|---|
width() / height() | screen size in logical pixels |
rect(x, y, w, h, color[, radius]) | draws a filled rectangle; radius rounds the corners |
outline(x, y, w, h, color[, radius[, stroke]]) | draws a border (stroked inwards) |
line(x1, y1, x2, y2, color[, stroke]) | draws a line between two points |
circle(cx, cy, r, color) | draws a filled circle |
text(str, x, y[, size[, color[, font]]]) | draws text and returns its width |
text_width(str[, size[, font]]) | returns the width the text would take, without drawing it |
font_height([size[, font]]) | returns the line height of a font |
px(v) | aligns a logical coordinate to a physical pixel |
Default text size is 9, default font is "regular". Built-in fonts: "regular",
"medium", "consolas", "inter", "small", "icon" (icon glyphs by name —
render.text("bolt", x, y, 9, color, "icon")).
The canvas is only valid inside the callback — don't store it.
Custom fonts — fonts
| Function | What it does |
|---|---|
fonts.register(name, path[, antialias]) | loads a TTF/OTF file; the path is relative to the scripts folder (absolute works too) |
fonts.exists(name) | returns true if a font with this name is registered |
fonts.register("pixel", "fonts/pixel.ttf")
-- ...
render.text("Hi", 8, 8, 12, 0xFFFFFFFF, "pixel")
Registering the same name again replaces the font (hot-reload friendly); built-in fonts can't be replaced.
3D renderer
The argument of render_3d. Coordinates are world coordinates — the camera offset
is handled for you.
| Function | What it does |
|---|---|
camera() | returns the camera position as x, y, z |
line(x1, y1, z1, x2, y2, z2, color[, width[, through_walls]]) | draws a line between two world points |
box(min_x, min_y, min_z, max_x, max_y, max_z, color[, width[, through_walls]]) | draws a box outline |
tracer(x, y, z, color[, width[, through_walls]]) | draws a line from the camera to a point |
through_walls = true makes the shape visible through blocks.
module:event("render_3d", function(render)
for _, p in ipairs(minecraft.players()) do
if not p.is_self then
local half = p.width / 2
render.box(p.x - half, p.y, p.z - half,
p.x + half, p.y + p.height, p.z + half,
0xFF4FF2A6, 2)
end
end
end)
3D → 2D projection — projection
Converts world coordinates to screen coordinates (the same space the 2D canvas draws
in). Call from render callbacks (render_2d, render_gui, frame).
| Function | Returns |
|---|---|
projection.to_screen(x, y, z[, ignore_frustum]) | screen position sx, sy, or nil when the point is behind the camera or out of view |
projection.box(min_x, min_y, min_z, max_x, max_y, max_z) | the box on screen as x, y, w, h, or nil |
projection.entity_box(entity_id) | the entity's hitbox on screen as x, y, w, h (smoothly interpolated), or nil |
ignore_frustum = true also projects points outside the view (it only fails behind
the camera) — useful for edge-of-screen indicators.
module:event("render_2d", function(render)
for _, p in ipairs(minecraft.players()) do
if not p.is_self then
local x, y, w, h = projection.entity_box(p.id)
if x then render.outline(x, y, w, h, 0xFF4FF2A6, 0, 1) end
end
end
end)
Services
Bridges to the client's built-in services: smooth rotations, freelook, inventory, the vanilla interaction manager and movement prediction.
rotation
Server-side rotations with smoothing, priorities and automatic freelook (your camera stays put while the "server-side" facing rotates).
| Function | What it does |
|---|---|
rotation.set(yaw, pitch[, priority]) | asks for a rotation; returns true if it was accepted |
rotation.look_at(x, y, z[, priority]) | rotates to face a point (measured from the player's eyes) |
rotation.camera() | rotates towards where the camera looks (priority 100) |
rotation.active() | returns true while a managed rotation is running |
rotation.lock_item_use() | blocks new rotations until the end of the tick |
A request lasts one tick — to keep facing a direction, request it every tick (usually
from pre_interaction or tick). A request with a lower priority than the active one
is rejected.
module:event("pre_interaction", function()
local target = minecraft.player("Notch")
if target then
rotation.look_at(target.x, target.y + target.height / 2, target.z, 10)
end
end)
freelook
| Function | What it does |
|---|---|
freelook.active() | returns true while freelook is on |
freelook.activate() / deactivate() | turns freelook on / off |
freelook.lock() / unlock() | freezes the camera (the mouse stops rotating it) |
freelook.locked() | returns true while the camera is frozen |
freelook.yaw() / pitch() | current camera angles |
inventory
Actions are scheduled to a safe point of the tick and play nicely with movement and packets.
| Function | What it does |
|---|---|
inventory.swap(a, b) | swaps the items in two slots |
inventory.drop(slot[, all]) | drops from a slot (all defaults to true — the whole stack) |
inventory.merge(from, to) | moves a stack onto another one |
inventory.use(slot[, yaw, pitch]) | uses the item in a slot, optionally facing a direction first |
inventory.item(slot) | returns the item table {id, count, name}, or nil |
inventory.find(item_id) | returns the first slot holding that item, or nil |
inventory.count(item_id) | returns how many of that item you carry in total |
inventory.slot() | returns the selected hotbar slot (0–8) |
inventory.set_slot(i) | selects a hotbar slot (0–8) |
Slot numbering
| Range | Meaning |
|---|---|
0 | crafting result |
1–4 | crafting grid |
5–8 | armor (helmet → boots) |
9–35 | main inventory |
36–44 | hotbar |
45 | offhand |
module:event("tick", function()
local totem = inventory.find("minecraft:totem_of_undying")
if totem and inventory.item(45).id ~= "minecraft:totem_of_undying" then
inventory.swap(totem, 45)
end
end)
interaction
The vanilla interaction manager: attacks, block/entity/item interactions, block breaking and screen-handler clicks. Everything goes through the vanilla client paths — correct packets, swing timers and sequence numbers. For raw packet control see Packets.
Hands are "main" / "off" (default "main"); sides are
"up" | "down" | "north" | "south" | "east" | "west" (default "up"). Interactions
return "success" | "fail" | "pass".
| Function | What it does |
|---|---|
interaction.gamemode() | current game mode, e.g. "survival" |
interaction.attack(entity_id) | attacks an entity (with a main-hand swing) |
interaction.interact(entity_id[, hand]) | right-clicks an entity |
interaction.use_item([hand]) | uses the held item |
interaction.use_block(x, y, z[, side[, hand]]) | right-clicks a block (aims at its center) |
interaction.attack_block(x, y, z[, side]) | starts breaking a block (the first hit) |
interaction.update_breaking(x, y, z[, side]) | continues breaking; call every tick while "holding the button" |
interaction.cancel_breaking() | aborts the current breaking |
interaction.break_block(x, y, z) | finishes breaking instantly (creative / fully cracked) |
interaction.breaking() | true while a block is being broken |
interaction.breaking_progress() | breaking stage 0–10, -1 when idle |
interaction.stop_using() | releases bow / stops eating / lowers shield |
interaction.swing([hand]) | swings an arm (animation + packet) |
interaction.sync_id() | sync id of the open screen handler (0 = inventory) |
interaction.click_slot(sync_id, slot, button[, action]) | clicks a slot like a mouse click |
interaction.click_button(sync_id, button) | clicks a screen-handler button (stonecutter, loom, ...) |
interaction.pick_block(x, y, z[, include_data]) | pick-block |
interaction.pick_entity(entity_id[, include_data]) | pick-block on an entity |
interaction.close_screen() | closes the open screen (and tells the server) |
click_slot actions:
"pickup" (default), "quick_move" (shift-click), "swap" (button = hotbar slot
0–8, 40 = offhand), "clone", "throw", "quick_craft", "pickup_all".
module:event("pre_interaction", function()
for _, p in ipairs(minecraft.players() or {}) do
if not p.is_self and p.distance < 3 then
interaction.attack(p.id)
break
end
end
end)
prediction
Vanilla-accurate movement physics simulation: "where will this player be in N ticks?". The local player is simulated from the real input; other players from input guessed from their observed motion. Simulations are cached per client tick and stepped lazily — asking for tick 5 and then tick 8 only simulates three more ticks. Max 1200 ticks.
| Function | What it does |
|---|---|
prediction.self(ticks) | your snapshot ticks ticks ahead (0 = now) |
prediction.self_path(from, to) | array of your snapshots for [from, to] |
prediction.self_with_input(input, ticks) | one-off simulation with a custom held input |
prediction.player(entity_id, ticks) | another player's snapshot |
prediction.player_path(entity_id, from, to) | array of another player's snapshots |
prediction.velocity(entity_id) | de-smeared per-tick velocity → vx, vy, vz |
A snapshot is a table: {x, y, z, vel_x, vel_y, vel_z, min_x, min_y, min_z, max_x, max_y, max_z, on_ground, fall_distance, horizontal_collision} (min_*/max_* are the
bounding box). input for self_with_input is a table of booleans:
{forward, backward, left, right, jump, sneak, sprint} — omitted keys are false.
Remote players' network positions are interpolated, so their raw per-tick delta is
unreliable — prediction.velocity returns the recovered true motion and is what the
simulation itself seeds from.
module:event("render_3d", function(render)
for _, p in ipairs(minecraft.players() or {}) do
if not p.is_self and p.distance < 20 then
local s = prediction.player(p.id, 10)
if s then
render.box(s.min_x, s.min_y, s.min_z, s.max_x, s.max_y, s.max_z, 0x80FF4040, 2)
end
end
end
end)
Packets
Raw network control through the packets global and the packet_send /
packet_receive events.
Minecraft's class names are obfuscated at production runtime, so scripts can't match
packets by Java class name. The mod maintains its own stable snake_case mapping: the
same names appear as e.name in the packet events and are accepted by packets.send.
| Function | What it does |
|---|---|
packets.list() | sorted array of names packets.send can build |
packets.send(name, fields) | builds a C2S packet and sends it; returns true on success |
packets.send returns false for an unknown name or when a required game object is
missing (no world, unknown entity id). The send goes through the normal client pipeline,
so it fires packet_send — a module that cancels its own scripted packets will swallow
them too.
packets.send("move_position", { x = p.x, y = p.y + 0.05, z = p.z, on_ground = false })
packets.send("client_command", { mode = "start_sprinting" })
packets.send("player_action", { action = "swap_item_with_offhand", x = 0, y = 0, z = 0 })
packets.send("attack_entity", { entity_id = target.id })
For attacking, block breaking and container clicks prefer the
interaction service — it goes through the vanilla client
paths with correct swing timers and sequence numbers. Raw packets are for when you need
to lie to the server on purpose.
Sendable packets (C2S)
Optional fields show their defaults. hand is "main" / "off" (default "main"),
side is "up" | "down" | "north" | "south" | "east" | "west" (default "up"),
sequence defaults to 0.
| Name | Fields |
|---|---|
move_full | x, y, z, yaw, pitch, on_ground = true, horizontal_collision = false |
move_position | x, y, z, on_ground = true, horizontal_collision = false |
move_look | yaw, pitch, on_ground = true, horizontal_collision = false |
move_on_ground | on_ground = true, horizontal_collision = false |
client_command | mode — "stop_sleeping" | "start_sprinting" | "stop_sprinting" | "start_riding_jump" | "stop_riding_jump" | "open_inventory" | "start_fall_flying", jump_height = 0 |
client_status | mode — "perform_respawn" | "request_stats" |
hand_swing | hand |
player_action | action — "start_destroy_block" | "abort_destroy_block" | "stop_destroy_block" | "drop_all_items" | "drop_item" | "release_use_item" | "swap_item_with_offhand", x, y, z, side, sequence |
attack_entity | entity_id, sneaking = false |
interact_entity | entity_id, hand, sneaking = false |
interact_entity_at | entity_id, x, y, z (hit point), hand, sneaking = false |
interact_block | x, y, z, side, hit_x/hit_y/hit_z (default block center), inside = false, hand, sequence |
interact_item | hand, yaw, pitch (default current rotation), sequence |
select_slot | slot — hotbar 0–8 |
close_screen | sync_id |
teleport_confirm | id |
keep_alive | id |
command | command — without the leading / |
player_input | forward, backward, left, right, jump, sneak, sprint — all default false |
pick_item_from_block | x, y, z, include_data = false |
pick_item_from_entity | entity_id, include_data = false |
Decoded event fields
packet_send and packet_receive tables always carry name; for the packets below
the payload is decoded too. Everything else still fires — just without extra fields.
Outgoing (packet_send) — all the sendable packets above are decoded back with the
same fields (move_* carry x/y/z only when the packet changes position, yaw/pitch
only when it changes look).
Incoming (packet_receive):
| Name | Fields |
|---|---|
keep_alive | id |
disconnect | reason |
player_position_look | teleport_id, x, y, z, vel_x, vel_y, vel_z, yaw, pitch |
entity_velocity | entity_id, vel_x, vel_y, vel_z |
entity_position | entity_id, x, y, z, yaw, pitch, on_ground |
entity_position_sync | entity_id, x, y, z, yaw, pitch, on_ground |
entity_damage | entity_id, source_cause_id, source_direct_id |
damage_tilt | entity_id, yaw |
health_update | health, food, saturation |
explosion | x, y, z, radius, knockback_x/y/z? (present when you were pushed) |
block_update | x, y, z, block — block id |
select_slot | slot |
Note: packet events fire on network threads — collect data and act in
tick, don't call rendering orprojectionfrom them.
Performance & @jit
-- @jit — bytecode compilation
A pragma line anywhere in the file:
-- @jit
compiles the script (and everything it requires) to JVM bytecode instead of
interpreting it. Hot code gets JIT-compiled by the JVM and runs several times faster —
worth it for heavy logic in tick, frame and render callbacks.
If compilation fails, the script automatically loads interpreted and chat shows
@jit compilation failed, running interpreted — remove the pragma or simplify the
construct the compiler choked on.
Tips
-
Read settings once. Every
setting:get()crosses into Java. In a hot callback, read values into locals at the top.module:event("render_2d", function(render) local argb = color:get() -- once for i = 1, #list do ... end -- not color:get() inside the loop end) -
Collect in
tick, draw in render.minecraft.players()/entities()build snapshot tables — too expensive per frame. Collect once per tick (20/s) and only project and draw inrender_2d.local tracked = {} module:event("tick", function() local list = {} for _, e in ipairs(minecraft.entities()) do if not e.is_self and e.distance < 64 then list[#list + 1] = e end end tracked = list end) module:event("render_2d", function(render) for i = 1, #tracked do local x, y, w, h = projection.entity_box(tracked[i].id) if x then render.outline(x, y, w, h, 0xFF4FF2A6, 0, 1) end end end) -
projection.entity_box(id)instead of rebuilding tables — it projects the live entity by id with interpolation, allocating nothing on the script side. -
Localize hot functions:
local entity_box = projection.entity_boxbefore a loop avoids repeated table lookups. -
Don't subscribe for nothing. A
frame/packet_sendcallback fires very often; if once per tick is enough — usetick. -
Coroutines are expensive — don't create them in a loop.
Examples
Spammer with HUD text
Settings, tick logic and 2D rendering in one module.
local module = ui.category("scripts"):module("code", "Lua Example")
local interval = module:number("Interval", 40, 5, 200, 1, "t")
local message = module:string("Message", "Hello from Lua!")
local mode = module:selection("Mode", nil, { "Client", "Chat" })
local hud = module:boolean("HUD text", true)
local color = module:color("Color", 0xFF4FF2A6)
local ticks = 0
module:callback("enable", function()
ticks = 0
end)
module:event("tick", function()
ticks = ticks + 1
if ticks % interval:get() == 0 then
if mode:get() == "Chat" then
client.say_chat(message:get())
else
client.print_chat(message:get())
end
end
end)
module:event("render_2d", function(render)
if not hud:get() or not minecraft.player() then return end
local label = message:get() .. " | " .. client.fps() .. " fps"
local w = render.text_width(label, 9) + 16
render.rect(8, render.height() - 40, w, 22, 0x90101010, 7)
render.text(label, 16, render.height() - 34, 9, color:get())
end)
2D ESP with JIT compilation
A full configurable ESP: target filtering, Box/Corner styles, health bar, names and distance. Data is collected once per tick; rendering only projects and draws.
-- @jit
local module = ui.category("visuals"):module("draw-square", "Lua ESP 2D")
local targets = module:multi_selection(
"Targets",
{ "Players" },
{ "Players", "Mobs", "Items", "Other" }
)
local style = module:selection("Style", nil, { "Box", "Corner" })
local color = module:color("Color", 0xFF4FF2A6)
local thickness = module:number("Thickness", 1, 1, 5, 1)
local fill = module:boolean("Fill", false)
local names = module:boolean("Names", true)
local healthBar = module:boolean("Health bar", true)
local distanceTag = module:boolean("Distance", false)
local maxDistance = module:number("Max distance", 128, 16, 512, 8, "m")
local tracked = {}
local function classify(e)
if e.type == "minecraft:player" then
return "Players"
elseif e.type == "minecraft:item" then
return "Items"
elseif e.health then
return "Mobs"
end
return "Other"
end
module:callback("enable", function()
tracked = {}
end)
module:callback("disable", function()
tracked = {}
end)
module:event("tick", function()
local selected = {}
for _, t in ipairs(targets:get()) do
selected[t] = true
end
local limit = maxDistance:get()
local list = {}
for _, e in ipairs(minecraft.entities()) do
if not e.is_self and e.distance <= limit and selected[classify(e)] then
list[#list + 1] = e
end
end
tracked = list
end)
local function healthColor(frac)
local r = math.floor(255 * (1 - frac))
local g = math.floor(255 * frac)
return 0xFF000000 + r * 0x10000 + g * 0x100
end
local function drawCorners(render, x, y, w, h, argb, stroke)
local len = math.min(w, h) * 0.3
render.line(x, y, x + len, y, argb, stroke)
render.line(x, y, x, y + len, argb, stroke)
render.line(x + w - len, y, x + w, y, argb, stroke)
render.line(x + w, y, x + w, y + len, argb, stroke)
render.line(x, y + h - len, x, y + h, argb, stroke)
render.line(x, y + h, x + len, y + h, argb, stroke)
render.line(x + w, y + h - len, x + w, y + h, argb, stroke)
render.line(x + w - len, y + h, x + w, y + h, argb, stroke)
end
module:event("render_2d", function(render)
local count = #tracked
if count == 0 or not minecraft.in_game() then return end
local argb = color:get()
local stroke = thickness:get()
local corner = style:get() == "Corner"
local drawFill = fill:get()
local drawNames = names:get()
local drawHealth = healthBar:get()
local drawDistance = distanceTag:get()
local fillColor = (argb % 0x1000000) + 0x28000000
for i = 1, count do
local e = tracked[i]
local x, y, w, h = projection.entity_box(e.id)
if x then
if drawFill then
render.rect(x, y, w, h, fillColor)
end
if corner then
drawCorners(render, x, y, w, h, argb, stroke)
else
render.outline(x - 1, y - 1, w + 2, h + 2, 0x80000000, 0, stroke + 2)
render.outline(x, y, w, h, argb, 0, stroke)
end
if drawHealth and e.health then
local frac = e.health / e.max_health
if frac > 1 then frac = 1 end
render.rect(x - 5, y, 2, h, 0xA0101010)
render.rect(x - 5, y + h * (1 - frac), 2, h * frac, healthColor(frac))
end
if drawNames then
local tw = render.text_width(e.name, 9)
render.text(e.name, x + (w - tw) / 2, y - 13, 9, argb)
end
if drawDistance then
local tag = string.format("%dm", math.floor(e.distance + 0.5))
local tw = render.text_width(tag, 8)
render.text(tag, x + (w - tw) / 2, y + h + 3, 8, 0xFFFFFFFF)
end
end
end
end)
Auto totem via services
local module = ui.category("combat"):module("shield-heart", "Lua Auto Totem")
module:event("tick", function()
if inventory.item(45).id ~= "minecraft:totem_of_undying" then
local totem = inventory.find("minecraft:totem_of_undying")
if totem then inventory.swap(totem, 45) end
end
end)