# Scripting with Rhai

> Attach Rhai scripts to objects to add gameplay. Scripts run identically in the editor's Play mode and in exported games.

Source: https://feather-engine.com/docs/logic/scripting/

Scripts add behaviour to individual objects. They are written in [Rhai](https://rhai.rs), 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](/docs/logic/blueprints/). They run as independent
programs in the same session, with the script running first.

## Add a script to an object

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.

## 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.

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**.

```rust
// 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.

```bash
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:

```bash
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

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.

```rust
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);
    }
}
```

:::tip
Object scripts can also call the full world API — finding objects, playing sounds, timers, events,
items and more. See [World logic](/docs/logic/world-logic/) for the shared functions.
:::

## 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

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")`.

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

## 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

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`.

```rust
// 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

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 |

```rust
// 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

Joints, force fields, thrusters, spawners, gravity, wind and explosions have their own script functions,
listed in [Physics → Extend it](/docs/gameplay/physics/#extend-it).

## 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

Game variables are the project's tunable values, edited in **Window → Game variables** (see
[World logic](/docs/logic/world-logic/#game-variables) 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](/docs/gameplay/player-and-traversal/)).

```rust
// 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);
    }
}
```

## Input

`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](/docs/gameplay/player-and-traversal/).

## 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 |

```rust
// 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

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

- **`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.

## Automate it

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

```bash
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](/docs/agents/overview/) for connecting a live session.
