Stage-10 editor-UX follow-up, second half of in-editor script authoring: - "✏ Edit" button on the Script inspector section (enabled once a source is assigned) and double-click on any script in the Project panel open the .rhai in the user's editor. Non-script assets double-click through xdg-open. - Resolution order: the new External Editor preference command (spawned detached as `<command> <file>`, flags allowed), else $VISUAL/$EDITOR in a new Terminal-panel tab (TUI editors work in-editor via the existing PTY widget), else xdg-open. - New `editor.external_editor` settings section (ExternalEditorPrefs) registered by EditorState, persisted with the preferences file, with a rich row in the Preferences window; registration covered by a unit test. Editor crate now depends on serde directly for its own sections. Saved edits flow back through the file watcher's live reload, including into a playing scene. GUI piece — needs an eye-check before promotion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
17 KiB
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 — an embeddable, sandboxed, Rust-friendly scripting
language — and plugs into the engine through the Stage-5 module system exactly
like physics: add ScriptModule to an App and
the Script 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 +
.rhailoader, the engine wrapper, the module wiring, the per-frame lifecycle (init/update(dt)) driven by theScriptHost, the engine API (Vec3+ ambient transform functions) scripts use to read/write their entity'sTransform, headless live reload (edit a.rhaifile → the running script recompiles, no restart), and editor integration (Script is addable in the inspector; Play runs scripts and live-reload reaches a playing scene), and error/output surfacing (a paused script's error and itsclaude. 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 — 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
.rhaifile, loaded as aScriptAsset(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
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>, 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.
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/debugoutput is routed to thelogcrate (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.
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
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, 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:
- find every entity with an enabled
Scriptthat names a source; - resolve each one's
.rhaitext through theAssetDatabase+AssetServer; - (re)compile +
startany script that is new or whose source text changed (this content check is the hook live reload builds on); - stage the entity's
Transforminto the engine, callupdate(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, 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.
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):
// 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_typesregistersScriptas an addable reflected component, so it appears in the inspector's Add Component menu and is rendered generically from its fields. Thesourcefield is anAssetRef<ScriptAsset>, which the inspector shows as an asset picker filtered to the project'sscripts/folder (AssetKind::Script). - New Script button. The
Scriptsection of the inspector ends with a name field + ➕ New Script: it writes a fresh.rhaifrom the built-in template intoassets/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 isoxide_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<command> <file>, flags allowed — e.g.code -g); else$VISUAL/$EDITORin a new Terminal-panel tab (TUI editors work in-editor); elsexdg-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 viaxdg-open. - Play loop. When Play starts, the editor's play
AppaddsScriptModulealongsidePhysicsModule, shares the editor'sAssetServer, and is handed a snapshot of the projectAssetDatabase. Sharing the server is what lets the file watcher's in-place reloads — which target the editor server — reach a playing scene: edit a.rhaifile (by hand, an external editor, or an AI agent) and the running script recompiles without leaving Play. - Snapshot/restore. Because
Scriptis 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.
The two pieces compose: use the Console for builds/git/log output, the Terminal for interactive sessions.
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.
// Spawn a pickup and configure it in one frame:
fn init() {
let pickup = spawn_entity("Coin");
set_component(pickup, "MeshRenderer", "(mesh: Some(\"coin\"), ...)");
}