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:

TypeDescription
FlowExecution control flow (determines the order nodes run)
BoolBoolean true/false
Int64-bit signed integer
Float64-bit floating point
StringUTF-8 text
Vec33D vector (x, y, z)
EntityReference to an entity in the world
AnyWildcard 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:

CategoryNodes
EventOnInteract, OnEnter, OnExit, OnTimer, OnStart, OnCollision
Flow ControlBranch, ForLoop, Sequence, Delay
ActionSetPosition, SetRotation, PlayAnimation, PlaySound, SpawnEntity, DestroyEntity, SendMessage, Log
VariableGetVariable, SetVariable
MathAdd, Subtract, Multiply, Divide, Clamp, Lerp, RandomRange
ConditionEqual, 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:

InstructionDescription
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
ReturnEnd 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

  1. The VM pre-scans instructions to build a label-to-index map.
  2. It enters the execution loop: fetch the instruction at the program counter, increment the operations counter, and check limits.
  3. Each instruction is matched and executed, advancing the program counter.
  4. 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:

LimitEnvironment VariableDefault
Max operations per executionAETHER_SCRIPT_MAX_OPS10,000
Max register file sizeAETHER_SCRIPT_MAX_STACK256
Max variables per scriptAETHER_SCRIPT_MAX_VARS1,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:

  1. Assign layers via topological order using BFS from event nodes.
  2. Position nodes within layers to minimize edge crossings (barycenter heuristic).
  3. 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:

LimitValue
Max nodes per graph1,000
Max connections per graph5,000

Key Types

TypeDescription
NodeGraphTop-level container holding nodes and connections
NodeA single node with kind, position, and ports
PortA typed input or output on a node
ConnectionA link between an output port and an input port
DataTypeThe type of data flowing through a port
NodeKindEnum of all 33 node types
IrInstructionA single instruction in the intermediate representation
ScriptVmRegister-based VM that executes compiled IR
EngineApiTrait for dispatching engine calls from scripts
RuntimeErrorTyped error for VM execution failures