Skip to main content

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
HookWhen
initonce, 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_uionce a frame, immediate mode
on_free, on_hot_reloadteardown, and a state migration after a save
  • One instance per attached node: this, with the node on this.node.
  • this is mutated in place. What a script writes to it, the host reads next frame.
  • script_language in project.toml defaults to "rune". Any other value is an error.
  • See Determinism for what belongs in which hook.

The Script workspace: the file, the hooks it declares, and the events its node receives

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 props over them, then init runs.
  • 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, of is the spec a list's entries and a map's values take, and fields is one spec per record field. Each is a whole spec, so min, options and asset work at any depth.
  • A node path is resolved wherever the spec puts one, so crew arrives as the nodes it names.
  • A map's key is "string" or "int". The file spells a number key "7"; the script is handed a map keyed by numbers.
  • A struct the script declares is exported as itself. The scene writes its fields as a table, and init is 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.

ShapeExample
Named values, never spelledinput::KEY_SPACE, physics3d::BODY_DYNAMIC, ui::ANCHOR_TOP_LEFT
Node operations are methodsnode.get_node("../Target"), node.queue_free()
Several returns are a tuplelet (text, changed) = ui::text_field(...)
Vectors and strings are values; lists and tables are sharedlet 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 float1 + 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_degrees and math::to_radians convert; the inspector shows degrees.
  • A node without a transform reads its schema defaults, so it sits at its parent. Writing one adds it.
  • node.transform.translate(x, y, z) moves by an offset, and global_position(), global_rotation_euler() and global_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; loads name.rn beside the file: from disk in a dev run, from the pack when shipped. Its items are name::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 no mod or require: addons/kit/math.rn is kit::math. Its pub fns are called by path, its pub consts read by path, and a pub mod block's constants sit one segment deeper, as in kit::math::colors::RED.
  • All three hot reload. A module object held since init sees 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.

The completion popup: a module path, and the functions and constants under it

After the caretWhat 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 elsecomponent names, node's methods, Rune's own, this file's functions, its locals, keywords
KeyWhat it does
⌘BGo to definition. A project pub fn opens its file; engine API opens the Docs dock
⌥⇧FFormat the buffer with Rune's own formatter
⌘⇧RRename an identifier
⌘⇧FReplace 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_click from 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:

ShapeCall
One target, by node handlenode.call("method", args...)
A named event from an emitter to its subscribersevents
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::emit is the same without an emitter. events::emitted(name) and events::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.

A save that did not compile, reported in the Output dock

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.

ControlKey
ContinueF5
Step over, into, outF10, 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, …) is libm-backed and bit-identical everywhere.
  • rng is an engine-owned seeded stream.
  • Simulation goes in fixed_update. See Determinism for the rules.