Files
Oxide/docs/scripting.md
T
Homer 4f1e9a48d7 Editor: "New Script" button on the Script inspector
Stage-10 editor-UX follow-up. The Script component's inspector section
now ends with a name field + " New Script" button (Enter in the field
also submits): it writes a fresh .rhai from the tested template into
assets/scripts/ via create_script_file (sanitized stem, collisions
suffixed), registers it in the asset database, saves the manifest, and
assigns it to the component's `source` through the normal edit path —
so the assignment is one undoable SetFieldCmd. Disabled with a hint
while no project is open; failures land in the Console via the log.

GUI piece — needs an eye-check before promotion to main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 20:33:23 +02:00

340 lines
16 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<ScriptAsset>` | 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<T>`](assets.md), not a live `Handle<T>`, so it is
serializable and stable across runs; the inspector recognises the
`AssetRef<ScriptAsset>` 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::<ScriptAsset>("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<ScriptAsset>` 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<ScriptAsset>`, 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).
- **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::<T>("…")`). 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