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.
Add a script to an object
Section titled “Add a script to an object”- Select the object in the viewport or the Outliner.
- Open Tools → Script editor (the object must be selected; the editor opens as the Code tab).
- Write your script, or start from one of the gameplay templates.
- Press Cmd/Ctrl+Enter to apply it. Applying goes through the project’s undo history.
- 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:
- Select the object and scroll to the Script section.
- Click a template (Rotating object, Keyboard movement, Stateful rising object, Player trigger zone, Dynamic collision bounce or Keyboard physics movement), or Add empty script.
- 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.
- Click Apply script. Open in Code workspace opens the full editor, with completion, hover docs, find and replace, recipes and the API browser.
Example: a spinning coin worth 10 points
Section titled “Example: a spinning coin worth 10 points”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.
- Choose File → Open example → Ocean Voyage, then File → Save project to keep your own copy.
- Click Add → Sphere in the toolbar. In the Details panel, rename it
Coin. - Under Transform, set Parent to Jetty, then set Local location to
0,1,5and Scale to0.6,0.6,0.6. The coin now hovers over the jetty, 3 m in front of the player start. - Under Appearance, set Base colour to a gold such as
#FFCC33. - Under Physics, keep Body on Static and turn on Sensor, so the player walks through the coin instead of bumping into it.
- Under Behaviours, turn on Collectible and set Score per item to
10. - 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 secondlet 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.
cp -R examples/ocean-voyage /tmp/democd /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.jsonCheck 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:
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.
Script structure
Section titled “Script structure”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); }}Moving objects
Section titled “Moving objects”| 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.
Contact events
Section titled “Contact events”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()); }}Physics bodies
Section titled “Physics bodies”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 |
Physics queries
Section titled “Physics queries”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"); } }}Changing objects at runtime
Section titled “Changing objects at runtime”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); }}More physics functions
Section titled “More physics functions”Joints, force fields, thrusters, spawners, gravity, wind and explosions have their own script functions, listed in Physics → Extend it.
Sharing state between programs
Section titled “Sharing state between programs”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
Section titled “Game variables”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.
Held tools and camera aim
Section titled “Held tools and camera aim”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); }}Limits
Section titled “Limits”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.
Common problems
Section titled “Common problems”trigger_enternever 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.despawnremoves runtime copies made withspawn_copy. To hide an authored object, useset_visible(id, false).- A physics body jumps or ignores collisions.
translate,rotateandset_positionteleport the object. Move Dynamic bodies withset_velocityorapply_impulse. - Nothing happens and the Console shows an error. The script stopped at that line; fix it, press Stop and Play again.
Automate it
Section titled “Automate it”Agents can set a script through the same command the editor uses:
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.