Hazel

Lua

Write per-entity gameplay logic in Lua 5.4 — the full-featured scripting backend for native desktop and mobile builds.

Renzora's scripting core is language-agnostic: the backend is chosen by file extension, and a .lua file runs on the Lua backend (mlua 0.10, Lua 5.4, vendored). Lua ships as a standalone plugin on every native build; it is not available on the web (wasm) target. It exposes the full API (~70 functions and all eight lifecycle hooks).

Scripts live on a ScriptComponent. The inspector shows a Scripts section on every entity, so there is never an "add the component first" step — drop a .lua file on its drop zone, or pick one from the + menu, and the component is created for you. You can also drag a .lua straight onto the entity's row in the hierarchy. Removing the last script takes the component off again.

That section is inherent to the entity rather than something you add, so Scripts does not appear in the Add Component list and has no header trash button. The component itself is still absent until a script is on it — the always-visible section is UI over that absence, which is what keeps it free (see Profiling). Authored game UI is the one thing that gets a component up front: renzora_ember::game_ui inserts it on every UiWidget/UiCanvas so <input bind="Entity.var"> resolves. One Lua VM is cached per (entity, script) and persists across frames.

Your first script

Create scripts/player.lua:

function on_ready()
    print("Player spawned!")
end

function on_update()
    local speed = 5.0
    translate(input_x * speed * delta, 0, input_y * speed * delta)
end

Drag player.lua onto an entity in the hierarchy (or add the Scripts component in the Inspector and pick the file there), then hit play — the entity moves with the movement input axis (WASD / left stick).

The transform globals (position_x, rotation_y, …) are read-only inputs refreshed each frame. Assigning to them does nothing. To move an entity, call a transform function such as translate(x, y, z) or set_position(x, y, z), or drive it through physics (set_velocity, apply_impulse).

Lifecycle hooks

Define any of these free functions; the engine calls the ones that exist. None are required.

HookWhen it fires
props()Once on load — returns a table of editable properties (see below)
on_ready()Once, the first frame the script runs
on_update()Every frame
on_rpc(name, args, from)A networked RPC arrived. from is the sender's peer id (0 for relayed messages)
on_ui(name, args, entity)A markup UI event fired (on_press, on_change, …). entity is the firing node's id as a u64 integer (Entity::to_bits()), not a handle
on_http(callback, status, body)An HTTP response came back. status is the HTTP code; status == 0 means the request failed and body holds the error text
on_player_joined(id)A player connected (fires on the server/host only)
on_player_left(id)A player disconnected (server/host only)
on_scene_loaded(path)A scene finished loading. Reaches only scripts that survived the load — i.e. Persistent ones on a global scene
on_scene_load_failed(path, error)A scene load failed; error is the reason. Without this a loading screen cannot tell "still working" from "never arriving"
on_event(name, args)A broadcast game event was emitted (see Events)

There is no on_start, on_collision, or on_destroy hook. Use on_ready for setup, and read the is_colliding global for overlap state. Collision events are available only as Blueprint nodes.

Script properties

props() returns a table of variables that appear as editable fields in the Inspector, so designers can tune values without touching code:

function props()
    return {
        speed     = { value = 5.0, hint = "Movement speed in m/s" },
        jump      = { default = 10.0, tab = "Movement" },
        can_fly   = false,
        team_name = "Red",
    }
end

function on_update()
    translate(input_x * speed * delta, 0, input_y * speed * delta)
end
  • Each property is either a bare value or a table with value (or default), optional hint, and optional tab (groups fields under a named tab in the Inspector).
  • The widget type is inferred from the value — number, bool, string, entity, vec2/vec3, or color. There is no type field, and min/max keys are not read.
  • Declared properties become readable/writable globals inside every hook. After each hook the engine reads the globals back, so changes persist and can be bound into UI with {{ Entity.speed }}.

Reading the world

These globals are set fresh before each hook. They are inputs — read them, don't assign to them.

GlobalTypeDescription
deltanumberSeconds since the previous frame
elapsednumberSeconds since startup
position_x / _y / _znumberWorld position (read-only)
rotation_x / _y / _znumberEuler rotation in degrees (read-only)
scale_x / _y / _znumberWorld scale (read-only)
input_x, input_ynumberMovement axis (-1..1) from the bound move action
mouse_x, mouse_ynumberMouse screen position
mouse_delta_x, mouse_delta_ynumberMouse movement since last frame
mouse_scrollnumberScroll delta this frame
mouse_left / mouse_right / mouse_middleboolButton held
mouse_left_just_pressed, mouse_right_just_pressedboolButton pressed this frame
camera_yawnumberActive camera yaw (radians)
camera_evnumberLive scene EV-100 from auto-exposure (0 if inactive)
project_width, project_heightnumberConfigured game resolution, world units (1920×1080 if no project)
gamepad_left_x / _y, gamepad_right_x / _ynumberStick axes
gamepad_left_trigger, gamepad_right_triggernumberTriggers (0..1)
gamepad_south / east / west / northboolFace buttons
gamepad_l1 / r1 / l2 / r2 / l3 / r3boolShoulder / stick buttons
gamepad_select, gamepad_startboolMenu buttons
gamepad_dpad_up / down / left / rightboolD-pad
is_collidingboolTrue while this entity overlaps any collider
timers_finishedtableArray of timer names that finished this frame
self_entity_idintegerThis entity's id (bits)
self_entity_namestringThis entity's Name
self_health, self_max_healthnumberHealth component values (0 if absent)
has_parentboolWhether this entity has a parent
parent_position_x / _y / _znumberParent world position

Input

Two input styles are available. The input_x/input_y globals and gamepad_* globals above are the quickest; for named actions and raw keys, use these functions:

function on_update()
    if is_key_just_pressed("Space") then
        apply_impulse(0, 8, 0)            -- jump
    end

    -- Action map: same code works on keyboard and gamepad
    if input_button_pressed("fire") then
        action("spawn_bullet", {})
    end

    local mx, my = input_axis_2d("move")  -- returns two values
    translate(mx * 5 * delta, 0, my * 5 * delta)
end
FunctionDescription
is_key_pressed(key)True while a key is held (Bevy key name, e.g. "Space", "KeyW")
is_key_just_pressed(key)True the frame the key goes down
is_key_just_released(key)True the frame the key goes up
input_button_pressed(action)True while a mapped action is held
input_button_just_pressed(action)True the frame the action fires
input_button_just_released(action)True the frame the action releases
input_axis_1d(action)1D axis value for a mapped action
input_axis_2d(action)2D axis — returns x, y

Moving and transforming

Transform writes are queued as commands and applied after the hook returns.

function on_update()
    translate(0, 0, -5 * delta)   -- move forward in local space
    rotate(0, 90 * delta, 0)      -- spin 90°/s around Y
    look_at(0, 1, 0)              -- face a world point
end
FunctionDescription
set_position(x, y, z)Set world position
set_rotation(x, y, z)Set Euler rotation (degrees)
set_scale(x, y, z) / set_scale_uniform(s)Set scale
translate(x, y, z)Move by an offset
rotate(x, y, z)Rotate by Euler degrees
look_at(x, y, z)Orient toward a world point
parent_set_position / parent_set_rotation / parent_translateSame, applied to the parent
set_child_position(name, x, y, z) / set_child_rotation(...) / child_translate(...)Apply to a named child

Physics, audio, animation, environment

function on_ready()
    play_music("audio/theme.ogg", 0.6)
    play_animation("idle", true, 1.0)   -- name, looping, speed
end
CategoryFunctions
Physicsapply_force(x,y,z), apply_impulse(x,y,z), set_velocity(x,y,z), set_gravity_scale(s)
Audioplay_sound(path[, vol[, bus]]), play_sound_looping(path, vol), play_music(path[, vol[, fade]]), stop_music([fade]), stop_all_sounds(), play_audio([entity])
Animationplay_animation(name[, looping[, speed]]), stop_animation(), pause_animation(), resume_animation(), set_animation_speed(s), seek_animation(t), get_animation_time(), is_animation_playing(), crossfade_animation(name, dur[, looping]), set_anim_param(name, v), set_anim_bool(name, v), trigger_anim(name), set_layer_weight(layer, w). Hook: on_animation_event(name, entity) on clip markers.
Timersstart_timer(name, duration[, repeat]), stop_timer(name) — finished timers appear in timers_finished
Spawningspawn_entity(name), spawn_primitive(name, kind, x, y, z[, r, g, b]), despawn_self(), despawn_by_prefix(prefix), load_scene(path)
Renderingset_visibility(bool), set_material_color(r, g, b[, a]), screen_shake(intensity, duration), draw_line(sx,sy,sz, ex,ey,ez[, duration])
Environmentset_sun_angles(azimuth, elevation), set_fog(enabled, start, end)
Cursorlock_cursor(), unlock_cursor()
Mathvec2(x, y), vec3(x, y, z), lerp(a, b, t), clamp(v, min, max)
Assetsasset_progress(), is_loading(), is_loaded(), scene_load_state()
Eventsemit(name, args) — broadcast to every on_event (see Events)
Loggingprint(...) (stdlib), print_log(msg) (engine console)

Reading and writing components

The reflection functions read or write any registered component field by a "Component.field" path. To reach another entity you pass its id — the unique, lowercase snake_case identifier shown in the hierarchy and the inspector's ID field (every entity has exactly one; there are no separate names or tags):

function on_update()
    -- Read another entity's health (by its id, e.g. an entity whose id is "boss")
    local hp = get_on("boss", "Health.current")

    -- Write a field on this entity
    set("Health.current", hp - 1)

    -- Read engine subsystem state mirrored onto this entity
    if get("PhysicsReadState.grounded") then
        apply_impulse(0, 6, 0)
    end
end
FunctionDescription
get(path) / get_on(id, path)Read a field on this / another entity (by id)
set(path, value) / set_on(id, path, value)Write a field
get_component(type) / get_component_on(id, type)Read all fields of a component as a table
get_components(...) / get_components_on(...)Read multiple components at once
has_component(type) / has_component_on(id, type)Test for a component

The id argument is an entity's unique snake_case id. Case matters, and it's never a component type name (component types are PascalCase) — that's what keeps get_on("boss", "Health.current") unambiguous.

Engine subsystems mirror read-only state through this same mechanism: get("PhysicsReadState.grounded"), get("NavReadState.*"), and get("AnimatorReadState.*").

Networking

Multiplayer scripting is native-only and built on the engine's Lightyear layer. Hooks on_rpc, on_player_joined, and on_player_left deliver events; these functions query and send:

function on_update()
    if net_is_server() then
        -- server-authoritative logic
    end
end

function on_player_joined(id)
    rpc("welcome", { player = id })   -- broadcast an RPC
end

function on_rpc(name, args, from)
    if name == "welcome" then
        print("welcomed " .. tostring(args.player))
    end
end
FunctionDescription
net_is_server()True on the dedicated/host server
net_is_client()True when connected and not the server
net_is_connected()True when networking is active
net_player_count()Connected client count (server only; 0 elsewhere)
rpc(name, args)Fire a networked RPC (reliable channel)

Connecting is done through action(), not a bare function: action("net_connect", { address = "127.0.0.1", port = 7636 }) and action("net_disconnect"). Origin peer ids are lost through server relay — a client receiving another client's RPC sees from = 0. See Multiplayer.

HTTP

Requests are asynchronous (native only). Responses are delivered to on_http on a later frame, tagged by the callback name you pass:

function on_ready()
    http_get("https://example.com/score", "score")
end

function on_http(callback, status, body)
    if callback == "score" and status == 200 then
        local data = json_parse(body)
        print(data.high)
    elseif status == 0 then
        print("request failed: " .. body)
    end
end

http_get(url[, callback]), http_post(url, body[, callback]), and json_parse(str) are the full surface. The callback defaults to "get" / "post".

Events

emit(name, args) broadcasts an event. Every script's on_event(name, args) hears it, as does any Rust system observing renzora::GameEvent.

-- the boss, on death
emit("boss_died", { room = "crypt", xp = 500 })

-- anywhere else — a quest tracker, an achievement check, a save trigger
function on_event(name, args)
    if name == "boss_died" then
        print_log("cleared " .. args.room)
    end
end

Use an event when the sender shouldn't have to know who is listening. Use set_on("music_player", …) when you know exactly what you're addressing. The boss shouldn't need to learn that three unrelated systems care that it died; that coupling is what events remove.

Two properties worth knowing:

  • Delivery is next-frame. An emit is queued and dispatched on the following frame's pass, so a script never observes its own emit inside the same hook. This is deliberate: dispatching inline would re-enter the script VM mid-hook, and a handler that emits could recurse without bound.
  • Nobody listening is normal. An event with no handlers is not an error — unlike action(), where an unclaimed name means a missing observer.

Events are also available visually: the On Event and Emit Event blueprint nodes compile to exactly these calls.

The action() escape hatch

action(name, args) fires a generic ScriptAction event that domain crates observe. It's how scripts reach a large catalog of verbs that have no dedicated function — UI widgets, markup, audio players, networking, and more:

action("ui_set_text", { name = "score_label", text = "Score: 100" })
action("hui_spawn", { template = "ui/hud.html" })
action("net_connect", { address = "127.0.0.1", port = 7636 })

action_on(target, name, args) targets a named entity instead of self. Common families include 40+ ui_* widget verbs, hui_* markup verbs, net_connect/net_disconnect, and play_audio_player.

An unrecognised verb is silently ignored — ScriptAction is a broadcast, and a name no crate observes simply reaches nobody. The removed global_set / global_get verbs were in exactly that state before being deleted, so treat a "working" action call that does nothing as a missing observer.

Extension functions

Some domain crates inject extra functions into the Lua VM when their plugin is active. They are available exactly like the built-ins:

PluginFunctions
renzora_physicsmove_controller(...), kinematic_slide(...)
renzora_navmeshnav_set_destination(x, y, z), nav_clear_destination(), nav_stop()
renzora_animationset_anim_param, set_anim_bool, set_anim_trigger, get_animation_length

Lua and the other backends

The backend is picked per file by extension, so a project can hold more than one language at once. Today that means .lua and .rs:

  • .lua — this page. The full scripting surface, interpreted, hot-reloaded on save.
  • .rs — a Rust script, compiled into a native plugin and handed &mut World once per frame per entity. No command vocabulary, because the reason to write Rust is the access no vocabulary can stand in for.

Lua comes from plugins/lua, a standalone C-ABI plugin. Which languages a project can use is decided by which backend plugins are present, not by how the engine was compiled — see Script Backends to add one.

The .rhai backend has been removed. If you have Rhai scripts, port them to Lua; the function names are nearly all the same, and the syntax table covers the rest.

What's next