Skip to content

Scripting with Rhai

Scripts add behaviour to individual objects. They are written in Rhai, a small embedded language with Rust-like syntax, and run the same way in the editor’s Play mode and in an exported player.

An object can have both a script and a Blueprint. They run as independent programs in the same session, with the script running first.

  1. Select the object in the viewport or the Outliner.
  2. Open Tools → Script editor (the object must be selected; the editor opens as the Code tab).
  3. Write your script, or start from one of the gameplay templates.
  4. Press Cmd/Ctrl+Enter to apply it. Applying goes through the project’s undo history.
  5. Press Play to run it. Runtime messages and errors appear in the Console.

The editor checks syntax as you type, and validates the final source again when you apply, save or press Play. It colours engine functions, hooks such as start and update, and your own functions differently, so a misspelt engine call stands out.

You can also write a short script straight in the object’s Details panel:

  1. Select the object and scroll to the Script section.
  2. Click a template (Rotating object, Keyboard movement, Stateful rising object, Player trigger zone, Dynamic collision bounce or Keyboard physics movement), or Add empty script.
  3. Edit the code. The field is highlighted and runs the same strict check as world scripts: unknown functions, wrong argument counts and misspelt hooks show as an error with the line number, and Ready to run appears when the script passes.
  4. Click Apply script. Open in Code workspace opens the full editor, with completion, hover docs, find and replace, recipes and the API browser.

You will put a gold coin on the jetty in the Ocean Voyage example: a script turns it and makes it float, and the built-in collectible behaviour adds 10 to the score when the player walks into it.

  1. Choose File → Open example → Ocean Voyage, then File → Save project to keep your own copy.
  2. Click Add → Sphere in the toolbar. In the Details panel, rename it Coin.
  3. Under Transform, set Parent to Jetty, then set Local location to 0, 1, 5 and Scale to 0.6, 0.6, 0.6. The coin now hovers over the jetty, 3 m in front of the player start.
  4. Under Appearance, set Base colour to a gold such as #FFCC33.
  5. Under Physics, keep Body on Static and turn on Sensor, so the player walks through the coin instead of bumping into it.
  6. Under Behaviours, turn on Collectible and set Score per item to 10.
  7. With the coin selected, open Tools → Script editor, paste the script below and press Cmd/Ctrl+Enter.
// A coin that turns and floats. Collecting it is handled by Behaviours → Collectible.
let spin = 3.0; // radians per second
let base_y = 0.0;
fn start() {
base_y = position_y();
print("Coin ready at height " + base_y);
}
fn update(dt) {
rotate(0.0, spin * dt, 0.0);
set_position(position_x(), base_y + 0.2 * (elapsed * 2.0).sin(), position_z());
}

Do the same from the command line, on a copy of the example. Save the script above as coin.rhai in the workspace folder. scene.add returns the new object’s ID in data.object.id; in a fresh copy of Ocean Voyage it is 36.

Terminal window
cp -R examples/ocean-voyage /tmp/demo
cd /tmp/demo # put coin.rhai here
feather-cli --workspace /tmp/demo --project project.feather.json command scene.add \
--json '{"kind":"Sphere","name":"Coin","parent_id":3,"position":[0,1,5]}' --save project.feather.json
feather-cli --workspace /tmp/demo --project project.feather.json command scene.update \
--json '{"id":36,"patch":{"scale":[0.6,0.6,0.6],"color":[1.0,0.8,0.2],"physics":{"sensor":true},"gameplay":{"collectible":true,"score":10}}}' \
--save project.feather.json
# Check the script, then attach it (jq turns the file into a JSON string).
jq -n --rawfile src coin.rhai '{kind:"object",source:$src}' | \
feather-cli --workspace /tmp/demo --project project.feather.json command script.check --json -
jq -n --rawfile src coin.rhai '{id:36,patch:{script:{source:$src}}}' | \
feather-cli --workspace /tmp/demo --project project.feather.json command scene.update \
--json - --save project.feather.json

Check it worked. Press Play: the coin turns and bobs. Walk forward with W; the coin disappears, the Console shows Collected Coin (+10). and the score next to the Console tab reads Score 10. To check it without the editor, walk the player forward for two seconds:

Terminal window
feather-cli --workspace /tmp/demo --project project.feather.json command game.simulate \
--json '{"frames":120,"inputs":[{"frame":0,"key":"W","pressed":true}]}'

In data.report, score is 10, logs contains Coin [Script]: Coin ready at height … and Collected Coin (+10)., and the Coin entry in objects has final.visible: false and a max_height about 0.13 m above its start height.

Each script keeps its own global variables for the whole Play session and can define two optional lifecycle functions:

Function When it runs
start() Once, when Play begins
update(dt) Every frame; dt is the frame time in seconds

The global elapsed holds the time since Play started, in seconds.

let speed = 1.0;
fn start() {
print("My object is ready");
}
fn update(dt) {
rotate(0.0, speed * dt, 0.0);
if key_down("E") {
translate(0.0, dt, 0.0);
}
}
Function Description
translate(x, y, z) Moves the object in world space, in metres
rotate(x, y, z) Rotates the object, in radians
set_position(x, y, z) Sets an absolute world position
position() Returns [x, y, z]; position_x(), position_y() and position_z() read one axis
self_name(), self_id() The object’s name and its ID as a string

Reads reflect earlier writes in the same callback. Transform writes teleport the object; to move a physics body, use velocity and impulses instead.

Four optional callbacks react to physics contacts. Each receives the other object’s ID as a string — a positive number, or the reserved values player and world.

Callback Runs when
trigger_enter(other) Another collider starts overlapping this object’s sensor
trigger_exit(other) That overlap ends
collision_start(other) This solid body starts touching another solid collider
collision_end(other) That contact ends

Trigger callbacks need Physics → Sensor enabled on the object, along with a collider. Inside any contact callback you can inspect the other object with other_id(), other_name(), other_kind(), other_is_player(), other_is_sensor(), other_kind_is("Cube") and other_id_is("player").

// Attach to a sensor object with a collider.
fn trigger_enter(other) {
if other_is_player() {
print("Welcome, " + other_name());
}
}

For objects with a Dynamic body:

Function Description
velocity() Linear velocity [x, y, z] in metres per second
set_velocity(x, y, z) Replaces the linear velocity
apply_impulse(x, y, z) Adds an impulse in newton-seconds; the body’s mass determines the change in speed

Queries ask the live physics world what is where: scene objects, runtime copies, the terrain and the player. They answer at once, inside the callback. Sensors are ignored.

Function Returns
raycast(x, y, z, dx, dy, dz, max) The first hit along a ray from a point in a direction, up to max metres, or ()
raycast(from, dir, max) The same with vec3 or [x, y, z] values for the start and direction
raycast(…, ignore) Either form with one more argument: an object ID to skip, such as self_id()
spherecast(x, y, z, dx, dy, dz, radius, max) The first hit for a ball of radius moved along the ray, or ()
overlap_sphere(x, y, z, radius) IDs of everything inside a ball; the player is "player"
overlap_box(x, y, z, hx, hy, hz) IDs of everything inside a box with half extents hx, hy, hz
overlap_box(x, y, z, hx, hy, hz, yaw) The same box turned by yaw degrees
line_of_sight(from, to) true when nothing blocks the line between two points

A hit is a map:

Field Value
id The object’s ID, "player", or () for the terrain
player true when the hit is the player
ground true when the hit is the terrain or anything that is not a scene object
point, normal Where it hit and the surface direction there, as [x, y, z]
distance Metres from the start

The direction does not need to be normalised. Distances are greater than 0 and up to 2,000 m; a spherecast radius is up to 50 m; overlap radii and half extents are up to 200 m. The overlap lists leave out the terrain. A ray starting inside this object hits it, so pass self_id() as ignore.

// Report what is in front of the camera, up to 30 m away.
fn update(dt) {
if action_pressed("fire") {
let hit = raycast(camera_position(), camera_forward(), 30.0, self_id());
if hit != () && !hit.ground {
print("Looking at " + hit.id + ", " + hit.distance + " m away");
}
}
}

These take an object ID, so a script can change itself (self_id()) or any other object. Colour and light changes last until Stop.

Function Description
exists(id) Whether the object is still in the game; false once it is despawned
set_rotation(id, x, y, z) Turns the object to absolute angles, in degrees
set_color(id, r, g, b) Gives every mesh of the object one colour; each channel is 0–1 (sRGB)
set_color(id, r, g, b, glow) The same, glowing in that colour; glow is 0 (none) to 50
set_light(id, brightness) Scales a light object’s authored intensity; 0 is off, up to 20
set_light(id, brightness, r, g, b) The same with a new light colour, each channel 0–1
// A warning lamp that glows red while the player is within 5 m.
fn update(dt) {
let near = distance(self_id(), "player") < 5.0;
if near {
set_color(self_id(), 1.0, 0.1, 0.1, 8.0);
} else {
set_color(self_id(), 0.2, 0.2, 0.2);
}
}

Joints, force fields, thrusters, spawners, gravity, wind and explosions have their own script functions, listed in Physics → Extend it.

get_var(name) and set_var(name, value) share numeric values between the script and the Blueprint on the same object. Missing values read as zero, and all values reset when Play starts again.

Game variables are the project’s tunable values, edited in Window → Game variables (see World logic to create them). Any world or object script reads them live:

Function Description
tune("id") The variable’s current value; fails at runtime if it does not exist
tune("id", fallback) The current value, or fallback when the variable does not exist
tune_number("id") The value as a Number; fails if it is missing or another kind
tune_bool("id") The value as a Boolean
tune_text("id") The value as text

An edit applies at once, even during Play. tune_number, tune_bool and tune_text are the typed reads Blueprint Call nodes use. The one-argument tune("id") is the game variable; the two-argument tune("domain", patch) that changes system settings is separate (see Player & traversal).

// Read a tunable value and a toggle, with a fallback for the toggle.
fn start() {
let cash = tune_number("starting_cash");
if tune("shop_open", false) {
toast("Shop is open, cash " + cash);
}
}

key_down(key) returns true while a key is held, for example key_down("E") or key_down("Space"). For rebindable controls, use input actions instead; see Player & traversal.

A held tool is an imported model drawn in the player’s hands in first person. hold puts it there; offsets are in camera space (x right, y up, and -z ahead, so a negative z is in front of the camera) and rotation is in degrees (pitch, yaw, roll). The model bobs as the player walks and lags behind quick turns.

Function Description
hold(model, x, y, z, scale) Holds an imported model at that offset; scale is a multiplier
hold(model, x, y, z, scale, pitch, yaw, roll) The same, with the model’s rotation in degrees
hold_none() Removes the held model
hold_kick(amount) Kicks the tool back; the amount is 0–2 and the kick fades out
hold_shake(amount) Rumbles the held model; the amount is 0–1
camera_position() The camera’s world position [x, y, z]
camera_forward() The camera’s facing direction [x, y, z], pitch included
// A vacuum tool that kicks and rumbles when it is fired, aimed where the camera looks.
fn update(dt) {
hold("Vacuum", 0.35, -0.25, -0.6, 0.5);
if action_pressed("fire") {
let aim = camera_forward();
let at = camera_position();
push_area(at[0], at[1], at[2], 3.0, aim[0] * 8.0, 0.0, aim[2] * 8.0, "litter");
hold_kick(0.8);
hold_shake(0.3);
}
}

Scripts are bounded so a mistake cannot freeze the editor:

  • Source up to 16 KiB per script.
  • 200,000 operations per callback, 32 nested calls and 128 variables.
  • Arrays up to 256 entries, maps up to 128 entries and strings up to 16 KiB.

A runtime error disables only the failing script and reports its source line in the Console. Press Stop to restore the scene; the next Play starts fresh.

  • trigger_enter never runs. The object needs Physics → Sensor and a collider. On a Collectible object the player collects it within 1.6 m and its collider turns off, so the player’s trigger callbacks do not fire; react to the score instead, or drop Collectible and handle the pickup in the script.
  • despawn(self_id()) leaves the object in place. despawn removes runtime copies made with spawn_copy. To hide an authored object, use set_visible(id, false).
  • A physics body jumps or ignores collisions. translate, rotate and set_position teleport the object. Move Dynamic bodies with set_velocity or apply_impulse.
  • Nothing happens and the Console shows an error. The script stopped at that line; fix it, press Stop and Play again.

Agents can set a script through the same command the editor uses:

Terminal window
feather-cli --connect .feather-session.json command scene.update \
--json '{"id":1,"patch":{"script":{"source":"fn update(dt) { rotate(0.0, dt, 0.0); }"}}}'

See Agents & automation for connecting a live session.