Scripting
Scripts are Rune: .rn files attached to nodes
in a scene. The Rune book covers the
language.
The model
// scripts/spinner.rn
pub fn init(this) {
this.angle = 0.0;
}
pub fn update(this, dt) {
this.angle += dt;
this.node.transform.rotation_euler = [0.0, this.angle, 0.0];
}
pub fn fixed_update(this, dt) {} // the fixed 60 Hz step, before physics
pub fn draw_ui(this) {} // immediate-mode UI, once per frame
| Hook | When |
|---|---|
init | once, after the properties are written |
update(dt) | once a frame, with the measured frame time |
fixed_update(dt) | on the fixed 60 Hz step, before physics. Simulation belongs here |
draw_ui | once a frame, immediate mode |
on_free, on_hot_reload | teardown, and a state migration after a save |
- One instance per attached node:
this, with the node onthis.node. thisis mutated in place. What a script writes to it, the host reads next frame.script_languageinproject.tomldefaults to"rune". Any other value is an error.- See Determinism for what belongs in which hook.

Exported properties
pub fn exports() {
#{ speed: 2.0, clockwise: true }
}
[nodes.script]
source = "scripts/spinner.rn"
[nodes.script.props]
speed = 3.5
- Defaults are written onto the instance, the node's
propsover them, theninitruns. - The inspector writes an override only where the value differs from the default.
node.attach_script(path, props)does the same at run time.
A property can hold other properties. A bare list or object says what it is:
waves: [2, 4, 8] is a list of whole numbers, and wave: #{ hp: 3 } is a
record whose fields keep their own types. A list holds one type.
pub fn exports() {
#{
waves: [2, 4, 8],
wave: #{ hp: 3, name: "grunt" },
crew: #{ "type": "list", "of": #{ "type": "node" }, "default": [] },
}
}
- Written out,
ofis the spec alist's entries and amap's values take, andfieldsis one spec perrecordfield. Each is a whole spec, somin,optionsandassetwork at any depth. - A node path is resolved wherever the spec puts one, so
crewarrives as the nodes it names. - A
map'skeyis"string"or"int". The file spells a number key"7"; the script is handed a map keyed by numbers. - A
structthe script declares is exported as itself. The scene writes its fields as a table, andinitis handed that class back, methods and all.
struct Wave { hp, name }
impl Wave {
fn power(self) { self.hp * 2 }
}
pub fn exports() {
#{ wave: Wave { hp: 3, name: "grunt" } }
}
Modules
Subsystems declare their bindings against balaur_script. Scripts reach them
with ::.
let t = engine::time();
let ball = scene::get_node("World/Ball");
this.node.body3d.set(#{ kind: physics3d::BODY_DYNAMIC });
engine, scene, log, input, rng, math, physics, physics2d,
physics3d, render, audio, ui, animation, skeleton, assets,
http, websocket, gamend, events, task, debugger, fs, toml,
json, script. Every function and constant is in the
script modules reference, generated from a booted
engine.
| Shape | Example |
|---|---|
| Named values, never spelled | input::KEY_SPACE, physics3d::BODY_DYNAMIC, ui::ANCHOR_TOP_LEFT |
| Node operations are methods | node.get_node("../Target"), node.queue_free() |
| Several returns are a tuple | let (text, changed) = ui::text_field(...) |
| Vectors and strings are values; lists and tables are shared | let b = a copies a; this.pos.x = 1.0 writes this.pos |
| Tables, lists, nothing | #{ kind: "sphere" }, [1, 2], () |
| An integer meets a float as a float | 1 + 0.5 is 1.5; a native float argument is still written 0.0 |
Each component is a field on the node, and that field is a handle carrying
every function that drives the component: this.node.body2d.apply_impulse(x, y).
A module keeps what belongs to no node, such as physics2d::raycast. Every
handle also has get(), set(table), patch(table), has() and remove().
The
component reference lists the methods per
component.
let body = this.node.body2d;
body.apply_impulse(0.0, 12.0);
if body.linear_velocity().0 > 5.0 {
body.set_linear_velocity(5.0, 0.0);
}
A handle's properties are fields. A script moves a node by writing its
transform:
let p = this.node.transform.position; // p.x, p.y, p.z
this.node.transform.position = [p.x, p.y + 1.0, p.z];
this.node.transform.rotation_euler = [0.0, math::to_radians(90.0), 0.0];
- Rotations are euler radians, x then y then z.
math::to_degreesandmath::to_radiansconvert; the inspector shows degrees. - A node without a
transformreads its schema defaults, so it sits at its parent. Writing one adds it. node.transform.translate(x, y, z)moves by an offset, andglobal_position(),global_rotation_euler()andglobal_scale()on the same handle answer in world space.
Script files as modules
mod util; // scripts/util.rn
pub fn update(this, dt) {
this.speed = util::clamp(this.speed + dt, 0.0, 10.0);
}
mod name;loadsname.rnbeside the file: from disk in a dev run, from the pack when shipped. Its items arename::item.script::require("lib.rn")loads a file at run time and returns its functions in an object.- A file directly under
addons/<name>/is a module every script reaches with nomodorrequire:addons/kit/math.rniskit::math. Itspub fns are called by path, itspub consts read by path, and apub modblock's constants sit one segment deeper, as inkit::math::colors::RED. - All three hot reload. A module object held since
initsees the new code after a save, and a saved addon reaches every caller.
Errors
A failing binding raises and ends the call. script::attempt returns
(ok, value): the result, or the error message.
let (ok, clip) = script::attempt(|| assets::load("animations/missing.toml"));
if !ok {
log::warn(format!("no clip: {}", clip));
}
Logs
log::info, log::warn and log::error write a line. The engine keeps
the last 500 in memory, and the Output dock shows them.
Each run also writes its log to logs/run.log in the game's user data
folder, and keeps the last five runs as run.1.log and on. [log] to_file
turns it off and [log] keep sets how many runs stay. A panic is written
there before the process dies. A browser keeps the file in the page's
storage, so the next visit can read the run that crashed.
log::since(cursor) answers each line once, for a script that sends the
log somewhere:
let read = log::since(this.cursor);
for entry in read.entries {
send(entry.level, entry.message);
}
this.cursor = read.cursor;
read.missed counts the lines dropped before they were read.
Tooling
script::complete, hover, signature, definition, symbols,
references, find, rename, replace and format answer from the running
engine. Two clients read them: the editor's Script workspace, and balaur lsp,
a Language Server Protocol server on stdin and stdout.

| After the caret | What completes |
|---|---|
physics2d:: | that module's functions and constants, each with the line the reference prints |
node.body2d. | what acts on that component, plus get, set, has, add, remove, props |
this. | the script's own exports() properties and its pub fns |
| anything else | component names, node's methods, Rune's own, this file's functions, its locals, keywords |
| Key | What it does |
|---|---|
⌘B | Go to definition. A project pub fn opens its file; engine API opens the Docs dock |
⌥⇧F | Format the buffer with Rune's own formatter |
⌘⇧R | Rename an identifier |
⌘⇧F | Replace text |
Ctrl works in place of ⌘ in every chord here.
Rename and replace are textual. Both walk the files the script's mod
declarations reach and list every match before writing. A rename matches the
identifier as a token; a replace matches text.
balaur fmt . # lay every script out
balaur fmt . --check # name the files that would change; exits 1 if any do
balaur lsp <project> serves diagnostics, completion, hover, signature help,
go-to-definition, document and workspace symbols, references, rename and
formatting. The engine repository ships a VS Code extension in editors/code;
its README has the Neovim and Zed recipes.
Events
The engine calls methods a script declares by name.
on_animation_finished(name)from an animation,on_clickfrom a widget,on_response(r)from an HTTP request.- Most events have a polling twin (
input::key_just_pressed,animation::just_finished). An event is a frame-scoped snapshot.
An async entry point suspends on task::wait and resumes at the start of
the tick the result enters the simulation. See
Networking for the delivery model.
pub async fn init(this) {
let r = task::wait(http::request("https://example.com")).await;
log::info(format!("{} {}", r["status"], r["body"]));
}
Two ways for scripts to reach each other:
| Shape | Call |
|---|---|
| One target, by node handle | node.call("method", args...) |
| A named event from an emitter to its subscribers | events |
this.node.emit("hit", #{ damage: 3 }); // from this node
events::listen(this.node, "hit", enemy); // only that enemy's
events::listen(this.node, "hit"); // every emitter's
pub fn on_hit(this, payload) {} // on_<name>, whichever it was
- Delivery is at the top of the next frame's update, in emission then subscription order.
- The emitter is part of the key. Delivery is a lookup.
events::emitis the same without an emitter.events::emitted(name)andevents::emitted_from(node, name)are the polling twins.
Hot reload
- The host watches the project directory and recompiles a file on save.
- The new unit replaces the old. Instances keep their state objects, and the next call resolves against the new code.
- Save-to-live latency is a few milliseconds.
on_hot_reload(this)migrates the shape of a script's state.- A save that does not compile puts the diagnostic (file, line, column, caret) in the Output dock. The previous code keeps running.
- Module paths resolve at compile time. A misspelled engine call is caught on save.

Debugging
Click a line number in the Script workspace to set a breakpoint and run the scene. The Debugger dock shows call frames and their locals.
| Control | Key |
|---|---|
| Continue | F5 |
| Step over, into, out | F10, F11, Shift+F11 |
The same controls are the debugger module:
debugger::set_breakpoints("scripts/ball.rn", [12, 30]);
let pause = debugger::stop_location(); // (), or the pause: reason, line, frames
debugger::resume(debugger::STEP_OVER); // CONTINUE | STEP_OVER | STEP_INTO | STEP_OUT
debugger::set_break_on_error(true);
- A script that throws logs the error and the engine continues. Break on error stops at the line that threw, with frames and locals readable.
- Off by default. On, every synchronous call goes through the stepping executor. The Debugger dock has the same switch.
- An error pause cannot be continued. Any step button drops the call and finishes the tick.
- An async entry point cannot be paused. A breakpoint inside one is let through.
- Locals are a frame's named arguments.
Determinism in scripts
math(math::sin,math::pow,math::PI, …) islibm-backed and bit-identical everywhere.rngis an engine-owned seeded stream.- Simulation goes in
fixed_update. See Determinism for the rules.