# Scripting (`oxide-script`, rhai) — Stage 10 The scripting module makes game logic live in **watched `.rhai` scripts** that hot-reload while the editor runs. It is a feature-gated module built on [`rhai`](https://rhai.rs) — an embeddable, sandboxed, Rust-friendly scripting language — and plugs into the engine through the Stage-5 module system exactly like [physics](physics.md): add [`ScriptModule`](#scriptmodule) to an `App` and the [`Script`](#the-script-component) component becomes live. > **Status.** This page tracks Stage 10 as it lands piece by piece. **Done so > far:** the component data model, the script asset + `.rhai` loader, the engine > wrapper, the module wiring, the per-frame lifecycle (`init` / `update(dt)`) > driven by the [`ScriptHost`](#the-scripthost-lifecycle), the engine API > (`Vec3` + ambient transform functions) scripts use to read/write their > entity's `Transform`, **headless [live reload](#live-reload)** (edit a > `.rhai` file → the running script recompiles, no restart), and **[editor > integration](#editor-integration)** (Script is addable in the inspector; Play > runs scripts and live-reload reaches a *playing* scene), and **[error/output > surfacing](#errors-and-output-in-the-console)** (a paused script's error and > its `print` output show in the editor Console panel), and a **[command > terminal](#the-command-terminal)** in that panel, plus an **[interactive PTY > terminal](#interactive-terminal-pty)** that runs shells / TUIs / AI-agent CLIs > like `claude`. **Remaining for the stage:** a richer script API (spawn/despawn > + component add/edit, beyond `Transform`). ## The model: ECS is the source of truth As with physics, the **ECS owns the truth**. An entity opts into scripting with one serializable, reflected component — [`Script`](#the-script-component) — that carries only *authoring* inputs (which script, enabled or not). The script's behaviour is **not** stored on the component: - The source lives on disk as a `.rhai` file, loaded as a [`ScriptAsset`](#scriptasset-the-loaded-source) (kept as plain text so a live edit just recompiles). - The compiled AST and any per-entity runtime state live in the host (a later piece), keyed by entity. Because the authored component carries no runtime state, **play-mode snapshot/restore works for free**: Stop reverts the authored `Script` components and the next Play recompiles fresh. ## The `Script` component ```rust use oxide_engine::asset::AssetRef; use oxide_script::{Script, ScriptAsset}; // Empty + disabled by default; `new` points at a source and enables it. let script = Script::new(AssetRef::new(uid)); // uid from the asset database assert!(script.enabled); ``` | Field | Type | Meaning | |-------|------|---------| | `source` | `AssetRef` | Which `.rhai` script the entity runs. Empty until assigned. | | `enabled` | `bool` | Whether the script runs; clear to suspend it without detaching. | `source` is an [`AssetRef`](assets.md), not a live `Handle`, so it is serializable and stable across runs; the inspector recognises the `AssetRef` spelling and offers a picker filtered to the `scripts/` folder (`AssetKind::Script`). The component derives `Reflect`, so it is dual-editable from the inspector, from scripts, and from external tools with no per-type editor code. ## `ScriptAsset`: the loaded source A `ScriptAsset` is just the script's source text plus a diagnostic name (the file stem). It is deliberately inert — holding *source*, not behaviour — so the same file can be recompiled on every live reload with no engine-specific data baked into the asset cache. ```rust use oxide_script::ScriptAsset; let asset = ScriptAsset::from_source("spin", "let t = 0.0;"); assert_eq!(asset.name, "spin"); ``` The `ScriptLoader` reads `.rhai` files; it is registered by `ScriptModule`, so `assets.load::("scripts/spin.rhai")` works once the module is added. `.rhai` files map to the new `AssetKind::Script` (folder `scripts/`). ## The `ScriptEngine` wrapper `ScriptEngine` owns one configured `rhai` interpreter the host reuses to compile and run every script, so sandbox configuration lives in one place: - `print` / `debug` output is routed to the `log` crate (so the editor console can surface it rather than leaking to stdout); - an operation cap (`set_max_operations`) turns a runaway loop into a **runtime error** instead of hanging the editor. ```rust use oxide_script::{ScriptAsset, ScriptEngine}; let engine = ScriptEngine::new(); let asset = ScriptAsset::from_source("ok", "let x = 1 + 2; print(x);"); let compiled = engine.compile(&asset)?; // -> CompiledScript (reusable AST) engine.run(&compiled)?; // evaluate the top level ``` Errors are a `thiserror` enum, [`ScriptError`], with two variants that both name the offending script so the console can attribute the failure: - `ScriptError::Compile` — the source failed to parse/compile; - `ScriptError::Runtime` — it compiled but raised an error (or hit the operation cap) while running. A runtime error is **returned, never panicked**, so the host can pause just that one script rather than crash the editor — the foundation for Stage 10's error-isolation goal. ## `ScriptModule` ```rust use oxide_engine::app::App; use oxide_script::ScriptModule; let mut app = App::new(); app.add_module(ScriptModule); assert!(app.has_module("script")); assert!(app.types.is_registered("Script")); ``` `ScriptModule::build` registers the `Script` component type for reflection (making it dual-editable and snapshot-captured), installs the `.rhai` `ScriptLoader`, inserts the [`ScriptHost`](#the-scripthost-lifecycle), and adds the `run_scripts` system on the `Update` schedule (after `FixedUpdate`, so scripts observe post-physics poses). Removing the module drops everything it contributed, as for any module. ## The `ScriptHost` (lifecycle) `ScriptHost` is the scripting counterpart to physics' `PhysicsWorld`: a transient `App` resource holding the **per-entity runtime state** — the compiled AST and a persistent `rhai` `Scope` — that the authored `Script` component deliberately does not. The `run_scripts` system drives it each frame: 1. find every entity with an **enabled** `Script` that names a source; 2. resolve each one's `.rhai` text through the `AssetDatabase` + `AssetServer`; 3. **(re)compile + `start`** any script that is new or whose source text changed (this content check is the hook live reload builds on); 4. stage the entity's `Transform` into the engine, call `update(dt)`, and write the (possibly mutated) transform back to the scene. Because runtime state lives in the host keyed by entity — never on the component — play-mode snapshot/restore is unaffected. A script that fails to compile or raises a runtime error is **paused** (its error remembered, reported via `ScriptHost::error_of`) rather than retried every frame or allowed to crash the host — the start of Stage 10's error-isolation goal. ## Live reload Editing a script while the app runs takes effect with **no restart**. The host keeps each running script's `Handle` alive, so the asset stays in the server's cache. When the Stage-6 file watcher sees a `.rhai` file change it calls [`reload_changed_assets`](file-watching.md), which reruns the loader **in place** on the live handle; the next frame the host reads the new source through that handle, sees it differs from what it compiled, and recompiles + restarts that one script. The entity keeps its current transform and the rest of the scene is untouched. `examples/script_spin` proves this headlessly: it spins an entity at 1 rad/s, rewrites the script to 3 rad/s, reloads it the way the watcher does, and the spin rate jumps mid-run while the orientation carries over. ```sh cargo run -p oxide-examples --bin script_spin ``` > Wiring this into the editor's **play loop** (so editing a script in an external > editor or via an AI agent updates a *playing* scene) is a following piece; the > reload mechanism itself is done and tested. ### Lifecycle hooks A script may define either or both of these functions; top-level statements run once at start (a constructor for defining functions and one-shot setup): ```rhai // scripts/spin.rhai — rotate this entity around Y at a constant rate. let speed = 1.5; // top-level state, set once at start fn init() { // optional: called once, after the top level print("spin starting"); } fn update(dt) { // optional: called every frame with the frame delta rotate_y(dt * 1.5); } ``` | Hook | When | Signature | |------|------|-----------| | top level | once, when the script (re)starts | statements at file scope | | `init()` | once, right after the top level | `fn init()` | | `update(dt)` | every frame | `fn update(dt)` — `dt` is seconds | ## Editor integration Scripting plugs into the editor the same way physics does: - **Add Component.** `register_builtin_types` registers `Script` as an *addable* reflected component, so it appears in the inspector's Add Component menu and is rendered generically from its fields. The `source` field is an `AssetRef`, which the inspector shows as an asset picker filtered to the project's `scripts/` folder (`AssetKind::Script`). - **New Script button.** The `Script` section of the inspector ends with a name field + **➕ New Script**: it writes a fresh `.rhai` from the built-in template into `assets/scripts/` (name sanitized, collisions suffixed `_2`, `_3`, …), registers it in the asset database, and assigns it to the component through the undo stack — no round-trip through an external file manager. The template is `oxide_editor::assets::script_template` (a unit test compiles it under the sandboxed engine so it can never ship a syntax error). - **Open in editor.** Next to New Script sits **✏ Edit** (enabled once a script is assigned), and double-clicking a script in the **Project panel** does the same: the file opens in your editor. Resolution order: the **External Editor** command from Preferences (`editor.external_editor`, run as ` `, flags allowed — e.g. `code -g`); else `$VISUAL`/`$EDITOR` in a new **Terminal-panel tab** (TUI editors work in-editor); else `xdg-open`. However it opens, saving feeds the file watcher's live reload — including into a playing scene. Double-clicking a non-script asset opens it via `xdg-open`. - **Play loop.** When Play starts, the editor's play `App` adds `ScriptModule` alongside `PhysicsModule`, **shares the editor's `AssetServer`**, and is handed a snapshot of the project `AssetDatabase`. Sharing the server is what lets the file watcher's in-place reloads — which target the editor server — reach a **playing** scene: edit a `.rhai` file (by hand, an external editor, or an AI agent) and the running script recompiles without leaving Play. - **Snapshot/restore.** Because `Script` is a reflected component, the play-mode snapshot captures it; a script attached or detached *during* play is reverted on Stop, like any other component. ## Errors and output in the Console A script that fails to compile or raises a runtime error is **paused** — it stops running but the editor stays alive (the error is caught, never panicked). The host logs the failure once via `log::warn!(target: "oxide_script", …)` and remembers it (`ScriptHost::error_of`), so it is not retried until the source changes. The editor's **Console panel** captures the `log` stream into a ring buffer and renders it, coloured by severity. Because script `print`/`debug` and the "script paused: …" errors all flow through `log` under the `oxide_script` target, they appear in the Console automatically — so you see a script's output and its failures without leaving the editor. ## The command terminal The Console panel doubles as a **command terminal**: a `$` prompt runs a shell command (`sh -c`) with the working directory set to the open project's root, and its stdout/stderr stream back into the same panel line by line as they arrive (stdout at info, stderr at warn, plus the echoed command and an exit-status line). A long-running command — a build, a watcher, an AI-agent CLI — streams rather than blocking the editor: reader threads push each line to the shared console buffer and the panel re-renders next frame. This is the surface for non-interactive dev tools. Running arbitrary commands from the editor is intentional (a developer tool, compiled out of an exported game). ### Interactive terminal (PTY) The command console pipes output and can't run programs that need a real terminal. The separate **Terminal panel** can: it opens a pseudo-terminal with [`portable-pty`] (Linux now, Windows later), parses the program's byte stream with [`vt100`] into a screen grid, renders that grid in egui, and routes keystrokes back — so it runs **interactive / full-screen programs**: a shell, a REPL, `vim`, or an **AI-agent CLI like `claude`**. Click *Shell* to start your `$SHELL` in the project directory, then run whatever you need inside it (`claude`, an editor, a build watcher). Tab, the arrow keys, and Escape are delivered to the program (not used for egui focus navigation) while the panel is focused, so completion, history, and full-screen apps work. Sessions are **tabbed** — `+ Shell` opens another, each tab has a close button, and a tab **auto-closes when its program exits** (type `exit` and the tab disappears). This is what hosts agents that edit the watched scripts live — their edits flow back through [live reload](#live-reload). The two pieces compose: use the **Console** for builds/git/log output, the **Terminal** for interactive sessions. [`portable-pty`]: https://docs.rs/portable-pty [`vt100`]: https://docs.rs/vt100 ## The engine API scripts call A script does not get a raw ECS pointer. The host stages the active entity's `Transform` into a shared context before each call; the script reads and mutates it through **ambient functions**, and the host writes the result back. This is the `Transform` half of dual-editability: a script and the inspector edit the **same** transform. | Function | Effect | |----------|--------| | `position() -> Vec3` | the entity's local translation | | `set_position(Vec3)` | set the translation | | `translate(Vec3)` / `translate(x, y, z)` | add to the translation | | `scale() -> Vec3` / `set_scale(Vec3)` | get/set the local scale | | `rotate_x/​rotate_y/​rotate_z(radians)` | spin about an axis (accumulates) | | `dt() -> f32` | the current frame delta (also passed to `update`) | The `Vec3` type is registered with `vec3(x, y, z)` / `vec3()`, `.x`/`.y`/`.z` get/set, `+ - *` (and scalar `*`), `length()`, `normalize()`, and `to_string()`. The `rhai` float type is configured to `f32` (the `f32_float` feature), so engine math values bridge into scripts with no casts. ### Spawning entities and editing components Beyond its own transform, a script can mutate the whole scene graph: spawn and despawn entities, and add, edit, or remove **any registered component** on any entity. The same "stage in, read back out" discipline applies — a script cannot borrow the ECS directly (the `rhai` functions must be `Send + Sync`), so each of these calls **buffers a command** that the host drains and applies against the scene + reflection registry after the script returns. Component edits go through **RON**, so a script authors data exactly like the inspector or an AI agent does — the same dual-editable representation. | Function | Effect | |----------|--------| | `entity() -> Entity` | the entity this script runs on | | `spawn_entity() -> Entity` / `spawn_entity(name)` | create a root entity, returns a handle usable immediately | | `despawn(Entity)` | remove an entity (and its subtree) | | `add_component(Entity, type_name)` | add a default-constructed component if the type is *addable* and absent | | `set_component(Entity, type_name, ron)` | insert or replace a component from its RON form | | `remove_component(Entity, type_name)` | remove the named component if present | `spawn_entity` returns a **provisional** `Entity` handle: the entity does not exist in the ECS yet, but the script can configure it in the same frame (`set_component(e, …)`, `despawn(e)`) — the host resolves the provisional id to the real entity when it applies the buffered commands, in issue order. (`spawn` is a reserved word in `rhai`, hence the longer name.) `type_name` is the name the component was registered under (e.g. `"MeshRenderer"`, `"RigidBody"`, or your own `register_type::("…")`). A command that names an unregistered type, or whose RON fails to parse, is logged to the Console and skipped — one bad call never aborts the rest or crashes the host. ```rust // Spawn a pickup and configure it in one frame: fn init() { let pickup = spawn_entity("Coin"); set_component(pickup, "MeshRenderer", "(mesh: Some(\"coin\"), ...)"); } ``` [`ScriptError`]: #the-scriptengine-wrapper