1
Scripting
Jaroslav Beneš edited this page 2026-08-08 18:11:58 +02:00
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.

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 + .rhai loader, the engine wrapper, the module wiring, the per-frame lifecycle (init / update(dt)) driven by the ScriptHost, the engine API (Vec3 + ambient transform functions) scripts use to read/write their entity's Transform, headless live reload (edit a .rhai file → 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 its print output show in the editor Console panel), and a command terminal in that panel, plus an interactive PTY terminal 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 — 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 (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 / 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.
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:

  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, 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_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).
  • 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.

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\"), ...)");
}