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.
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 runs | Rune | Luau |
|---|---|---|
| Play and Play Solo | Yes, every frame | Not yet (see What's Next) |
| Command bar | Yes | Yes |
| Studio plugins | Yes, .rune | Yes, .lua |
| API style | Functions and types in the eustress module | Roblox 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:
SoulService/
SoulScript/
_instance.toml marks the folder as a SoulScript
SoulScript.rune the code
SoulScript.md the summary[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.
| Class | Roblox name accepted | Holds | Runs in Play |
|---|---|---|---|
SoulScript | - | Rune code and a summary | Yes |
LuauScript | Script | Luau source | Not yet |
LuauLocalScript | LocalScript | Luau source | Not yet |
LuauModuleScript | ModuleScript | Luau source | Not 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.
| Function | Called |
|---|---|
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.
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:
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.
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.
| Type | Create | Fields and methods |
|---|---|---|
Vector3 | Vector3::new(x, y, z) | x y z, magnitude unit dot cross lerp add sub mul div neg |
CFrame | new(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 |
Color3 | new(r, g, b) (0 to 1), from_rgb (0 to 255), from_hsv | r g b, lerp to_hsv |
UDim | UDim::new(scale, offset) | scale offset, add sub |
UDim2 | new(xs, xo, ys, yo), from_scale, from_offset | x_scale x_offset y_scale y_offset, x y add sub lerp |
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.
| Property | Value | Default |
|---|---|---|
Position | Vector3, meters | 0, 0.5, 0 |
Size | Vector3, meters | 4, 1, 2 |
Orientation | Vector3, degrees | 0, 0, 0 |
Color | Color3 | Gray |
Material | Material name, such as Neon | Plastic |
Shape | Block, Ball, Cylinder, Wedge, CornerWedge, Cone | Block |
Anchored | bool | false |
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.
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
| Group | Functions | What they do |
|---|---|---|
| Sim values | get_sim_value(key), set_sim_value(key, value), list_sim_values() | Read and publish named numbers; a missing key reads as 0 |
| Output | log_info, log_warn, log_error | Write a line to the Output panel |
| UI | gui_set_text, gui_set_visible, gui_set_text_color, gui_set_bg_color, gui_set_border_color, gui_set_font_size | Change a GUI element by name; see UI Systems |
| Physics | part_apply_impulse, part_apply_angular_impulse, part_set_velocity | Push a physics body by name, in kg m/s and m/s |
| Tags | collection_add_tag, collection_has_tag, collection_get_tagged | Tag created instances; list ids of tagged objects |
| Units | units_from_meters(v, unit), units_to_meters(v, unit) | Convert for display; the unit is a symbol such as ft |
| Space files | read_space_file, write_space_file | Read and write text files by path relative to the Space; paths containing .. are refused |
| HTTP | http_get_async(url), http_post_async(url, body) | Blocking request; the frame waits for the response |
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
| Group | Globals | Notes |
|---|---|---|
| Values | Vector3, CFrame, Color3, UDim, UDim2 | Roblox constructors (Color3.fromRGB, CFrame.lookAt, UDim2.fromScale), operators and methods |
| Output | print, warn, typeof, tick | In the command bar, print and warn go to Output |
| Instances | Instance.new(class, parent) | Returns a table of properties; the parent argument is optional |
| Services | game:GetService(name) | Players, ReplicatedStorage, ServerStorage, ServerScriptService, StarterGui, StarterPlayer, StarterPack, Lighting, CollectionService |
| Tags | CollectionService | AddTag, RemoveTag, HasTag, GetTagged; GetTagged also returns ids of tagged objects already in the Space |
| Scheduling | task, wait, spawn, delay | Coroutine scheduler; waits resume only while the Play driver steps it |
| Signals | RunService, UserInputService, Touched | Present; they fire only under the Luau Play driver (see What's Next) |
| Helpers | Units, Enum | Units.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:
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")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.
- Open File > Soul Settings... and enter your Anthropic API key under API Key.
- Click Save. The key is stored on your machine in
~/.eustress_engine/soul_settings.json. - Open a SoulScript, write the summary in the Summary view, and click Build.
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().
| Luau | Rune | Purpose |
|---|---|---|
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 |
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.