Visual Scripting
Build interactive world logic with Aether's node-based visual scripting editor, featuring 33 node types, type-safe connections, and WASM compilation.
Aether provides a node-based visual scripting system that lets creators add interactivity to VR worlds without writing code. You build logic by connecting nodes in a graph, and the compiler translates that graph into WASM bytecode that runs on the standard scripting runtime.
Overview
The visual scripting system comprises three layers:
- Editor -- A node graph UI in the Creator Studio where you place and connect nodes.
- Compiler -- A pipeline that transforms the node graph into intermediate representation (IR) instructions, then into WASM bytecode.
- Runtime -- A register-based virtual machine that executes compiled IR with sandboxing limits and engine API dispatch.
The system lives in the visual_script/ module of aether-creator-studio, with the runtime in a dedicated visual_script/runtime/ submodule.
The Node Graph
A NodeGraph is the top-level container. It holds a set of nodes and the connections between them:
use aether_creator_studio::visual_script::{NodeGraph, NodeKind, DataType};
let mut graph = NodeGraph::new("door_logic");
// Add an event node that fires when a player interacts
let on_interact = graph.add_node(NodeKind::OnInteract);
// Add an action node that plays an animation
let play_anim = graph.add_node(NodeKind::PlayAnimation);
// Connect the flow output of OnInteract to the flow input of PlayAnimation
graph.connect(on_interact.flow_out(), play_anim.flow_in())?;
Each node has typed input and output ports. Connections carry data between ports. The graph enforces that connections only link compatible types.
Data Types
The type system uses eight fundamental types:
| Type | Description |
|---|---|
Flow | Execution control flow (determines the order nodes run) |
Bool | Boolean true/false |
Int | 64-bit signed integer |
Float | 64-bit floating point |
String | UTF-8 text |
Vec3 | 3D vector (x, y, z) |
Entity | Reference to an entity in the world |
Any | Wildcard type, compatible with all other types |
Connections between ports must have matching or coercible data types. The Any type acts as a wildcard and accepts any value.
Node Types
Aether ships with 33 built-in node types organized into six categories:
| Category | Nodes |
|---|---|
| Event | OnInteract, OnEnter, OnExit, OnTimer, OnStart, OnCollision |
| Flow Control | Branch, ForLoop, Sequence, Delay |
| Action | SetPosition, SetRotation, PlayAnimation, PlaySound, SpawnEntity, DestroyEntity, SendMessage, Log |
| Variable | GetVariable, SetVariable |
| Math | Add, Subtract, Multiply, Divide, Clamp, Lerp, RandomRange |
| Condition | Equal, NotEqual, Greater, Less, And, Or, Not |
Type-Safe Connections
The editor enforces type safety at connection time. Each port declares its DataType, and the validation layer checks compatibility before allowing a connection:
use aether_creator_studio::visual_script::{NodeGraph, NodeKind};
let mut graph = NodeGraph::new("example");
let add_node = graph.add_node(NodeKind::Add);
let branch_node = graph.add_node(NodeKind::Branch);
// This succeeds: Add outputs a Float, and we connect to a compatible input
graph.connect(add_node.output("result"), some_node.input("value"))?;
// This fails: connecting a Float output to a Flow input is a type mismatch
let result = graph.connect(add_node.output("result"), branch_node.flow_in());
assert!(result.is_err());
Additional validation rules:
- Connections must go from an output port to an input port (direction check).
- Each input port accepts at most one incoming connection.
- At least one event node must exist and be connected.
Graph Validation and Cycle Detection
Before compilation, the graph passes through a validation stage that checks structural correctness:
use aether_creator_studio::visual_script::validation::validate_graph;
let errors = validate_graph(&graph);
if errors.is_empty() {
println!("Graph is valid, ready to compile");
} else {
for error in &errors {
eprintln!("Validation error: {error}");
}
}
The validator performs cycle detection on execution flow connections (those with DataType::Flow). Flow connections must form a directed acyclic graph (DAG). Data connections may contain cycles because they use lazy evaluation.
The IR Compiler
The compiler transforms a validated node graph into a flat list of IR instructions through topological sorting:
Node Graph -> Topological Sort -> IR Instructions -> WASM Bytecode
The IR instruction set is register-based:
| Instruction | Description |
|---|---|
LoadConst(reg, value) | Load a constant into a register |
BinaryOp(op, dest, lhs, rhs) | Arithmetic operation |
Not(dest, src) | Boolean negation |
Branch(cond, true_label, false_label) | Conditional jump |
Jump(label) | Unconditional jump |
Label(name) | Jump target marker |
Call(function, args, result) | Invoke an engine API function |
Return | End execution |
The final output is a Vec<u8> representing a minimal WASM module that can be loaded by the scripting runtime.
The Runtime VM
The ScriptVm is a register-based virtual machine that executes compiled IR:
use aether_creator_studio::visual_script::runtime::{ScriptVm, VmConfig};
let vm_config = VmConfig::from_env();
let mut vm = ScriptVm::new(compiled_script, vm_config);
// Execute with a mock engine API for testing
let result = vm.execute(&mut engine_api)?;
Execution Model
- The VM pre-scans instructions to build a label-to-index map.
- It enters the execution loop: fetch the instruction at the program counter, increment the operations counter, and check limits.
- Each instruction is matched and executed, advancing the program counter.
- Execution stops on
Return, reaching the end of instructions, or hitting a resource limit.
Engine API Integration
The VM dispatches Call instructions through the EngineApi trait, which decouples scripts from the engine:
pub trait EngineApi {
fn call(
&mut self,
function: &str,
args: &[Value],
) -> Result<Value, RuntimeError>;
}
Built-in math functions (clamp, lerp, etc.) are handled directly by the VM. All other function calls are forwarded to the EngineApi implementation provided by the host engine.
Sandboxing Limits
The VM enforces execution limits to prevent runaway scripts:
| Limit | Environment Variable | Default |
|---|---|---|
| Max operations per execution | AETHER_SCRIPT_MAX_OPS | 10,000 |
| Max register file size | AETHER_SCRIPT_MAX_STACK | 256 |
| Max variables per script | AETHER_SCRIPT_MAX_VARS | 1,024 |
Exceeding any limit produces a typed RuntimeError (e.g., ExecutionLimitExceeded, StackOverflow, VariableLimitExceeded).
Layout Engine
The editor includes an automatic layout algorithm based on a simplified Sugiyama-style layered approach:
- Assign layers via topological order using BFS from event nodes.
- Position nodes within layers to minimize edge crossings (barycenter heuristic).
- Apply spacing constants (
DEFAULT_NODE_SPACING_X = 250.0,DEFAULT_NODE_SPACING_Y = 100.0).
Graph Limits
To maintain editor and runtime performance, graphs are bounded:
| Limit | Value |
|---|---|
| Max nodes per graph | 1,000 |
| Max connections per graph | 5,000 |
Key Types
| Type | Description |
|---|---|
NodeGraph | Top-level container holding nodes and connections |
Node | A single node with kind, position, and ports |
Port | A typed input or output on a node |
Connection | A link between an output port and an input port |
DataType | The type of data flowing through a port |
NodeKind | Enum of all 33 node types |
IrInstruction | A single instruction in the intermediate representation |
ScriptVm | Register-based VM that executes compiled IR |
EngineApi | Trait for dispatching engine calls from scripts |
RuntimeError | Typed error for VM execution failures |