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 astranslate(x, y, z)orset_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.
| Hook | When 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, oron_destroyhook. Useon_readyfor setup, and read theis_collidingglobal 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(ordefault), optionalhint, and optionaltab(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
typefield, andmin/maxkeys 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.
| Global | Type | Description |
|---|---|---|
delta | number | Seconds since the previous frame |
elapsed | number | Seconds since startup |
position_x / _y / _z | number | World position (read-only) |
rotation_x / _y / _z | number | Euler rotation in degrees (read-only) |
scale_x / _y / _z | number | World scale (read-only) |
input_x, input_y | number | Movement axis (-1..1) from the bound move action |
mouse_x, mouse_y | number | Mouse screen position |
mouse_delta_x, mouse_delta_y | number | Mouse movement since last frame |
mouse_scroll | number | Scroll delta this frame |
mouse_left / mouse_right / mouse_middle | bool | Button held |
mouse_left_just_pressed, mouse_right_just_pressed | bool | Button pressed this frame |
camera_yaw | number | Active camera yaw (radians) |
camera_ev | number | Live scene EV-100 from auto-exposure (0 if inactive) |
project_width, project_height | number | Configured game resolution, world units (1920×1080 if no project) |
gamepad_left_x / _y, gamepad_right_x / _y | number | Stick axes |
gamepad_left_trigger, gamepad_right_trigger | number | Triggers (0..1) |
gamepad_south / east / west / north | bool | Face buttons |
gamepad_l1 / r1 / l2 / r2 / l3 / r3 | bool | Shoulder / stick buttons |
gamepad_select, gamepad_start | bool | Menu buttons |
gamepad_dpad_up / down / left / right | bool | D-pad |
is_colliding | bool | True while this entity overlaps any collider |
timers_finished | table | Array of timer names that finished this frame |
self_entity_id | integer | This entity's id (bits) |
self_entity_name | string | This entity's Name |
self_health, self_max_health | number | Health component values (0 if absent) |
has_parent | bool | Whether this entity has a parent |
parent_position_x / _y / _z | number | Parent 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
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
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_translate | Same, 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
| Category | Functions |
|---|---|
| Physics | apply_force(x,y,z), apply_impulse(x,y,z), set_velocity(x,y,z), set_gravity_scale(s) |
| Audio | play_sound(path[, vol[, bus]]), play_sound_looping(path, vol), play_music(path[, vol[, fade]]), stop_music([fade]), stop_all_sounds(), play_audio([entity]) |
| Animation | play_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. |
| Timers | start_timer(name, duration[, repeat]), stop_timer(name) — finished timers appear in timers_finished |
| Spawning | spawn_entity(name), spawn_primitive(name, kind, x, y, z[, r, g, b]), despawn_self(), despawn_by_prefix(prefix), load_scene(path) |
| Rendering | set_visibility(bool), set_material_color(r, g, b[, a]), screen_shake(intensity, duration), draw_line(sx,sy,sz, ex,ey,ez[, duration]) |
| Environment | set_sun_angles(azimuth, elevation), set_fog(enabled, start, end) |
| Cursor | lock_cursor(), unlock_cursor() |
| Math | vec2(x, y), vec3(x, y, z), lerp(a, b, t), clamp(v, min, max) |
| Assets | asset_progress(), is_loading(), is_loaded(), scene_load_state() |
| Events | emit(name, args) — broadcast to every on_event (see Events) |
| Logging | print(...) (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
| Function | Description |
|---|---|
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
idargument is an entity's uniquesnake_caseid. Case matters, and it's never a component type name (component types are PascalCase) — that's what keepsget_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
| Function | Description |
|---|---|
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 })andaction("net_disconnect"). Origin peer ids are lost through server relay — a client receiving another client's RPC seesfrom = 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 —
ScriptActionis a broadcast, and a name no crate observes simply reaches nobody. The removedglobal_set/global_getverbs 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:
| Plugin | Functions |
|---|---|
renzora_physics | move_controller(...), kinematic_slide(...) |
renzora_navmesh | nav_set_destination(x, y, z), nav_clear_destination(), nav_stop() |
renzora_animation | set_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 Worldonce 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
.rhaibackend 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
- Scripting Overview — how scripts attach, run, and dispatch by extension
- Visual Blueprints — node graphs interpreted at runtime
- Input Handling — the action map and key names
- Scripting API — the full function reference