Learn/Scripting

Scripting

Scripts are source files inside a Space that Eustress compiles and runs. Rune scripts run frame by frame while you play; a Roblox-compatible Luau runtime powers the command bar and Studio plugins; and a SoulScript can start as a plain-language summary that Claude turns into Rune.

Time14 min readLevelIntermediateUpdatedUpdated Sep 2026

01Overview

What a Script Is

A script is a file of code that lives in a Space next to the parts it works on. Eustress reads it when the Space opens, shows it in the Explorer, and runs Rune scripts when you press Play. The file extension picks the language: .rune for Rune, .luau or .lua for Luau.

Scripts you create are SoulScripts: a folder holding the code, an optional Markdown summary of what the code should do, and a small _instance.toml that marks the folder as a script. Because every piece is a plain file, scripts travel with the Space through git, diffs and external editors.

Rune and Luau

Rune is the language Eustress runs during Play. Luau is available as a Roblox-compatible runtime with Roblox-style globals, and today it runs from the command bar and from Studio plugins.

Where code runsRuneLuau
Play and Play SoloYes, every frameNot yet (see What's Next)
Command barYesYes
Studio pluginsYes, .runeYes, .lua
API styleFunctions and types in the eustress moduleRoblox globals such as Instance and game

02Scripts on Disk

The Script Folder

The Script button (in the ribbon of Modes that offer it) creates a script folder in SoulService, or inside the Explorer item you have selected. The code and summary files take the folder's name:

Space folder
SoulService/
  SoulScript/
    _instance.toml     marks the folder as a SoulScript
    SoulScript.rune    the code
    SoulScript.md      the summary
SoulService/SoulScript/_instance.toml
[metadata]
class_name = "SoulScript"
archivable = true

[script]
source = "SoulScript.rune"

The loader opens the file named by [script] source. Without that key it takes the first .rune, .luau, .soul or .lua file in the folder. Everything else in the folder is left alone, so the summary and any notes never show up as objects of their own. The MCP server's execute_rune and execute_luau tools write scripts in this same layout.

Loose Files

A bare .rune, .luau or .lua file dropped into any service folder also loads as a script, so ServerScriptService/spawner.rune works without a folder. Loose script files that sit directly in SoulService are moved into their own folders the next time the Space opens, with an _instance.toml and an empty summary. If a folder with that name already exists, the file is left where it is and a warning is logged.

Script Classes

The Luau classes carry Roblox's script types. The Roblox importer brings Script, LocalScript and ModuleScript in as these, each with a script.luau source file; see Importing.

ClassRoblox name acceptedHoldsRuns in Play
SoulScript-Rune code and a summaryYes
LuauScriptScriptLuau sourceNot yet
LuauLocalScriptLocalScriptLuau sourceNot yet
LuauModuleScriptModuleScriptLuau sourceNot yet

03When Scripts Run

The Play Lifecycle

Scripts do nothing while you edit. When you press Play (F5) or Play Solo (F7), Studio compiles every Rune script in the Space and then calls these functions in each script that defines them. A function a script leaves out is simply skipped.

Playcompileon_initon_readyon_update(dt)every frameStopon_exit
One Play session for a Rune script. on_init and on_ready run once in the first frame, on_update runs every frame after them, and Stop calls on_exit.
FunctionCalled
on_init()Once, in the first Play frame, and again after the script is recompiled
on_ready()Once, right after the first on_init
on_update(dt)Every frame while playing; dt is the frame time in seconds
on_exit()When you stop (F8)

Pause (F6) stops the calls until you resume.

Advanced
A script with only main() does nothing in Play

main() is the entry point for one-shot runs: the command bar and the MCP tools. Play only calls the lifecycle functions above. The starter file the Script button writes uses main, so move its body into on_init when you want it to run on Play.

State Between Frames

Every call starts a fresh Rune VM over the compiled script, and Rune has no mutable globals, so a local variable does not survive from one frame to the next. Keep running values in sim values, which persist across frames and are what watchpoints, recordings and the MCP sim tools read:

SoulService/Counter/Counter.rune
use eustress::{log_info, get_sim_value, set_sim_value};

pub fn on_init() {
    set_sim_value("demo.elapsed", 0.0);
    log_info("Counter started");
}

pub fn on_update(dt) {
    let t = get_sim_value("demo.elapsed") + dt;
    set_sim_value("demo.elapsed", t);
}

pub fn on_exit() {
    log_info("Counter stopped");
}

Parts a script creates during Play exist only for that session: they are marked as script-spawned and removed when you stop, and nothing is written to disk. See Simulation for watchpoints and recordings.

Editing While Playing

Save a .rune file while the Space is playing, in Studio or in an external editor, and the file watcher hands the new source to the running session. The script recompiles in place on the next frame and on_init runs again for the new code. If the new version fails to compile, the last good version keeps running and the errors go to Output.

Errors and Output

Compile errors appear in the Output panel one per line, in the form script:line:col: error: message. Errors thrown while on_init, on_ready or on_update runs are reported there too, under the script's name. To print from a script, use log_info, log_warn or log_error; they write to Output and to the engine log. Rune's own println is captured into Output only in the command bar.

04The Rune API

Imports

Every Eustress function and type lives in the eustress module, so a script file names what it uses at the top. The same module set compiles your scripts, the command bar, the script editor's checks and the Rune language server, so they agree on what exists.

Rune
use eustress::{log_info, Vector3, Instance};
// or everything at once:
use eustress::*;

// Physical-law libraries live one level down:
use eustress::realism::electrical;

The realism libraries are covered on the Realism page.

Value Types

The value types follow Roblox naming, with snake_case methods. Positions and sizes are in meters; angles are in radians.

TypeCreateFields and methods
Vector3Vector3::new(x, y, z)x y z, magnitude unit dot cross lerp add sub mul div neg
CFramenew(x, y, z), from_position, angles(rx, ry, rz), look_at(from, to)position, x y z look_vector right_vector up_vector inverse point_to_world_space point_to_object_space lerp mul add sub
Color3new(r, g, b) (0 to 1), from_rgb (0 to 255), from_hsvr g b, lerp to_hsv
UDimUDim::new(scale, offset)scale offset, add sub
UDim2new(xs, xo, ys, yo), from_scale, from_offsetx_scale x_offset y_scale y_offset, x y add sub lerp
Rune
use eustress::{log_info, Vector3, CFrame, Color3};

pub fn on_init() {
    let target = Vector3::new(3.0, 0.0, 4.0);
    log_info(`distance ${target.magnitude()} m`);   // 5 m

    let eye = CFrame::look_at(Vector3::new(0.0, 2.0, 10.0), target);
    let ahead = eye.point_to_world_space(Vector3::new(0.0, 0.0, -5.0));
    log_info(`five meters ahead: ${ahead.x}, ${ahead.y}, ${ahead.z}`);

    let tint = Color3::from_hsv(0.6, 0.8, 1.0);
    log_info(`blue channel ${tint.b}`);
}

Instances and Raycasts

Instance::new(class) returns an instance handle that you name and set properties on. During Play, Part-family classes (Part, MeshPart, SpherePart, CylinderPart, WedgePart, CornerWedgePart) appear in the world at the end of the frame; other classes are skipped with one warning per class.

PropertyValueDefault
PositionVector3, meters0, 0.5, 0
SizeVector3, meters4, 1, 2
OrientationVector3, degrees0, 0, 0
ColorColor3Gray
MaterialMaterial name, such as NeonPlastic
ShapeBlock, Ball, Cylinder, Wedge, CornerWedge, ConeBlock
Anchoredboolfalse

workspace_raycast(origin, direction, None) casts a ray from origin along direction for up to 1,000 m and returns the first hit, if any. The hit has instance (the part's name), position, normal, distance and material. Raycasts answer one frame late: each call returns the result of the same call on the previous frame, so the first frame returns None.

SoulService/Probe/Probe.rune
use eustress::{set_sim_value, workspace_raycast, Instance, Vector3, Color3};

pub fn on_init() {
    if let Some(ball) = Instance::new("Part") {
        ball.set_name("Probe");
        ball.set("Shape", "Ball");
        ball.set("Size", Vector3::new(1.0, 1.0, 1.0));
        ball.set("Position", Vector3::new(0.0, 12.0, 0.0));
        ball.set("Color", Color3::new(1.0, 0.4, 0.1));
        ball.set("Anchored", true);
    }
}

pub fn on_update(dt) {
    let down = Vector3::new(0.0, -1.0, 0.0);
    if let Some(hit) = workspace_raycast(Vector3::new(0.0, 20.0, 0.0), down, None) {
        set_sim_value("probe.drop", hit.distance);
    }
}

Simulation, UI and Files

GroupFunctionsWhat they do
Sim valuesget_sim_value(key), set_sim_value(key, value), list_sim_values()Read and publish named numbers; a missing key reads as 0
Outputlog_info, log_warn, log_errorWrite a line to the Output panel
UIgui_set_text, gui_set_visible, gui_set_text_color, gui_set_bg_color, gui_set_border_color, gui_set_font_sizeChange a GUI element by name; see UI Systems
Physicspart_apply_impulse, part_apply_angular_impulse, part_set_velocityPush a physics body by name, in kg m/s and m/s
Tagscollection_add_tag, collection_has_tag, collection_get_taggedTag created instances; list ids of tagged objects
Unitsunits_from_meters(v, unit), units_to_meters(v, unit)Convert for display; the unit is a symbol such as ft
Space filesread_space_file, write_space_fileRead and write text files by path relative to the Space; paths containing .. are refused
HTTPhttp_get_async(url), http_post_async(url, body)Blocking request; the frame waits for the response
Advanced
write_space_file changes the Space for good

Stop restores the scene, but it does not restore files. A file a script writes during Play is still there after you stop, and it is part of the Space from then on.

05Luau

The Luau Runtime

The Luau runtime is a sandboxed Luau VM with a Roblox-style API installed as globals, so ported Roblox code reads the way it did. It runs today in two places: the command bar and Studio plugins. Luau files in a Space load into the Explorer, but Play does not start them yet.

In the command bar a script runs once from top to bottom on a fresh VM. The coroutine scheduler is present, so task.spawn runs its function right away. A task.wait at the top level returns at once, and a function that waits inside task.spawn never resumes, because nothing advances the scheduler outside Play.

Globals

GroupGlobalsNotes
ValuesVector3, CFrame, Color3, UDim, UDim2Roblox constructors (Color3.fromRGB, CFrame.lookAt, UDim2.fromScale), operators and methods
Outputprint, warn, typeof, tickIn the command bar, print and warn go to Output
InstancesInstance.new(class, parent)Returns a table of properties; the parent argument is optional
Servicesgame:GetService(name)Players, ReplicatedStorage, ServerStorage, ServerScriptService, StarterGui, StarterPlayer, StarterPack, Lighting, CollectionService
TagsCollectionServiceAddTag, RemoveTag, HasTag, GetTagged; GetTagged also returns ids of tagged objects already in the Space
Schedulingtask, wait, spawn, delayCoroutine scheduler; waits resume only while the Play driver steps it
SignalsRunService, UserInputService, TouchedPresent; they fire only under the Luau Play driver (see What's Next)
HelpersUnits, EnumUnits.from_meters, Units.to_meters; Enum.KeyCode.Space reads as the string KeyCode.Space

Other Roblox services such as RunService and TweenService are plain globals rather than GetService results.

Building from the Command Bar

When a command-bar run ends, every Part it created becomes a real part: it is saved into the Space under Workspace and spawned, so it stays after the run. BillboardGui and TextLabel instances are materialized the same way. This builds a tagged staircase:

Luau (command bar)
for i = 1, 10 do
    local step = Instance.new("Part")
    step.Name = "Step" .. i
    step.Size = Vector3.new(4, 0.4, 1.2)
    step.Position = Vector3.new(0, i * 0.4, i * 1.2)
    step.Color = Color3.fromHSV(i / 10, 0.6, 0.9)
    step.Anchored = true
    CollectionService:AddTag(step, "Stairs")
end
print("Built 10 steps")
Advanced
Stick to parts and labels

The command bar saves every created instance other than BillboardGui and TextLabel with a block mesh, whatever its class. Create folders, models and other classes from the Insert menu instead.

06Tools

The Command Bar

The command bar runs code against the open Space, in Edit or in Play. Click the language label at its left to switch between Rune and Luau. Enter runs, Shift+Enter adds a new line, and pasted multi-line code works as typed. Each run echoes the code and its output to Output.

Rune in the command bar takes plain statements: a snippet that declares no function is wrapped in main and gets use eustress::*; added, so log_info(`hi`) works on its own. A snippet that declares items runs as written, starting at main or, if there is none, on_init.

The Script Editor

Double-click a script in the Explorer to open it in a Studio tab. A SoulScript tab switches between two views, Summary and Code. Summary edits are saved to the .md file as you type; in the Code view, Save writes the code file.

Rune code is checked as you type, 80 ms after you stop. Errors and warnings show in the editor and in the Problems panel, and the same checks run over every script when a Space opens. For editing in VS Code or another editor, see IDE Integration and Rune LSP.

From Summary to Code

A SoulScript's summary is a Markdown description of what the script should do. In the Summary view, Build sends it to Claude together with the names, classes, positions, sizes and colors of the objects in the scene. Claude writes Rune, and the result is saved into the folder's code file. Summarize goes the other way: it writes a summary of the code into <name>.md.

  1. Open File > Soul Settings... and enter your Anthropic API key under API Key.
  2. Click Save. The key is stored on your machine in ~/.eustress_engine/soul_settings.json.
  3. Open a SoulScript, write the summary in the Summary view, and click Build.
Info
Builds need your own key

Without an API key, Build stops with an error in the script's status. Everything else on this page works without one: plain Rune and Luau never call a model.

Studio Plugins

A Studio plugin is a script that adds buttons to the ribbon's Plugins tab. Put a .lua file, a folder with an init.lua, or a .rune file in the Eustress/Plugins folder under your local data folder: %LOCALAPPDATA% on Windows, ~/Library/Application Support on macOS, ~/.local/share on Linux. Studio loads each one once per session; Reload Plugins on the Plugins tab reloads them all. A Luau plugin runs top to bottom; a Rune plugin must define pub fn register().

LuauRunePurpose
plugin:AddSection(tab, id, label)plugin_add_section(id, label)Add a group to the Plugins tab
plugin:AddButton(...)plugin_add_button(section, id, label, tooltip, callback)Add a button that calls a function
plugin:Notify(level, message)plugin_notify(level, message)Show a notification: info, success, warning or error
plugin:GetSelection()plugin_get_selection()Ids of the selected objects
Plugins/selection_counter.lua
plugin:AddSection("plugins", "selection-counter", "Selection Counter")

plugin:AddButton(
    "plugins",                  -- tab id (always "plugins")
    "selection-counter",        -- section id
    "count-selection",          -- button id
    "Count Selected",           -- label
    nil,                        -- icon
    "Show how many entities are selected",
    "selection_counter:count",  -- action id, unique across plugins
    "normal",                   -- "small", "normal" or "large"
    function()
        local selected = plugin:GetSelection()
        plugin:Notify("info", #selected .. " selected")
    end
)

07What's Next

Luau in Play

The Luau Play driver is written and tested: a coroutine scheduler inside the VM that makes task.wait and Signal:Wait yield, RunService Heartbeat, Stepped and RenderStepped every frame, live UserInputService state and input events, and Touched events from Avian collisions on scene parts reached as workspace.Name. Studio will switch it on so Luau scripts start on Play and stop cleanly on Stop, and workspace:Raycast will be connected to the engine's raycaster.

A Wider Rune API

Rune will gain handles to objects already in the scene, so a script can find an existing part and move or recolor it during Play, and ScreenGui button clicks will call an on_button_click(name) function (see UI Systems). Keyboard and mouse queries, raycast filters, tweens and data stores are declared in the module and will be connected to the engine's input, physics, animation and storage.

Write it in a file. Press Play. Watch it run.

Loading Eustress Engine...