CLI & Headless
Eustress runs without its editor window. The eustress command opens, lists, drives and closes engines from a terminal, and eustress-headless simulates a Space with no window at all, so scripts, CI jobs and AI agents can run a Space and read the results.
01Overview
The Programs
The command-line side of Eustress is a set of programs built from the same
source as Eustress Engine. Two of them do most of the work: eustress, which manages and drives engines, and eustress-headless, which is an engine with no window.
| Program | Package | What it does |
|---|---|---|
eustress | eustress-cli | Opens, lists and closes engines, drives any engine over its bridge, and runs a Space headless in one call. |
eustress-headless | eustress-engine | Loads a Space and runs its simulation with no window: physics, scripts, the world database and the bridge. |
eustress-engine | eustress-engine | The Studio window. --space <dir>, --universe <dir> or --open <file> opens a Space directly. |
eustress-mcp | eustress-mcp-server | The MCP server for AI clients; see MCP Server. |
eustress-space | eustress-space | Opens, verifies and exports a Space's database without the engine. |
convert-to-eustress, reseed-space-subtree | eustress-engine | Seed a Space's database from its TOML files. |
purge_tree_path | eustress-worlddb | Remove one entity from every store of a closed Space's database. |
generate-benchmark-map | eustress-engine | Fill a Space with a grid of test parts. |
texture-gen | texture-gen | Regenerate the bundled material textures in a source checkout. |
eustress-lsp | eustress-engine | The Rune language server; see Rune LSP. |
Build Them
The Windows installer installs Eustress Engine and, when they were built
beside it, eustress-lsp and eustress-mcp. Build the other programs in the eustress folder of a source checkout; each lands in target/release.
cargo build --release -p eustress-cli # eustress
cargo build --release -p eustress-engine # eustress-engine, eustress-headless and the engine's tools
cargo build --release -p eustress-space # eustress-spaceeustress open and eustress run look for eustress-engine and eustress-headless next to
their own executable first and on your PATH second, so
building everything into the same target folder is enough.
02The eustress Command
Verbs
eustress is one program with a verb per job. -v or --verbose turns on debug logging for
any verb, and --help prints each verb's flags.
| Verb | What it does | Status |
|---|---|---|
open | Start an engine on a Space, detached, and print its pid and port. | Available |
instances | List running engines, dropping records of engines that are gone. | Available |
close | Shut engines down cleanly by pid, by Space or all at once. | Available |
bridge | Send one command to a running engine over its bridge. | Available |
run | Run a Space in eustress-headless and wait for it to finish. | Available |
server | Start the multiplayer host. | Not yet available |
publish | Upload a Space from the command line. | Not yet available |
sim | Replay simulation history. | Not yet available |
fork | Register a fork with the Trust Registry. | Not yet available |
Why the last four are not usable yet is in Remaining Verbs.
Open, List, Close
eustress open starts a new engine on a Space and returns as soon
as its bridge is up, printing the engine's instance record with its pid and
port. The engine runs detached, so closing the terminal leaves it running, and
capturing the output with $(...) returns as soon as the record
is printed.
cd ~/Documents/Eustress/Lab
eustress open Spaces/Bracket-A --headless --json
eustress open Spaces/Bracket-B
eustress instances
eustress close --pid 18244
eustress close --all| Flag | Verb | Effect |
|---|---|---|
<space> | open | The Space folder to open. Required. |
--headless | open | Start eustress-headless instead of a Studio window. |
--play | open | Start the run as soon as the Space loads. Applies to headless engines; Studio opens in Edit either way. |
--wait-secs <n> | open | How long to wait for the bridge, default 45. The engine keeps running if this runs out, and the command exits with an error. |
--json | open, instances | Print only JSON, for scripts. |
--pid <n>, --space <dir>, --all | close | Which engines to close. |
--force | close | Kill the process if the clean shutdown is refused or times out, and delete its record. |
--workspace <dir> | all three | Where the instance registry lives. Default: EUSTRESS_WORKSPACE, else Documents/Eustress. |
Without --play, a headless engine opens in Edit and waits for a
run command, so you can set its values first. close sends the engine.shutdown bridge method, which exits the way closing the
window does: the port file and instance record are removed and the world
database is released.
Drive an Engine
eustress bridge sends one request to a running engine, Studio or
headless, and prints a one-line summary followed by the full JSON result, so its
output pipes into jq. Choose the engine before the subcommand:
with no flag it reaches the engine whose port is in .eustress/engine.port under --universe <dir> (the
current folder by default); --port <n> and --pid <n> reach one engine directly.
| Subcommand | What it does |
|---|---|
ping | Check that the engine answers. |
inspect [--class] [--name-contains] [--limit 50] | Entities with mesh, material, transform and physics flags, plus the frame rate. |
ecs-query [--class] [--offset] [--limit 100] | Entity ids, names and classes, a page at a time. |
sim-read [--keys a,b] | Simulation values, all of them or the listed keys. |
sim-step [--ticks 1] | Advance physics by exact 1/60 s ticks. |
sim-run [--time-scale] [--duration] [--wait] | Start or resume a run, or retune a running one. --wait prints the run's final values when it ends, giving up after --timeout seconds (300). |
sim-pause, sim-stop | Pause the run, or stop it and restore the scene like the Stop button. |
sim-set KEY=VALUE ... | Write numeric simulation values. |
sim-state | Play state, simulation clock and the run ledger. |
sim-await [--run-id] [--timeout 300] | Wait for a run to end and print its outcome. |
raycast [--origin X Y Z] [--direction X Y Z] | Cast a ray against the live colliders; --max-distance and --max-hits bound it. |
oplog [--limit 50] | Recent entity creates and deletes. |
entity create|read|update|delete|find | Parts in the world database, by uuid or name. |
call <method> [--params '{...}'] | Any bridge method by name, with JSON parameters. |
eustress bridge ping
eustress bridge --pid 18244 inspect --class Part --limit 20
eustress bridge --port 53117 entity create --name Crate --position 0 2 0 --color 0.8 0.5 0.2
eustress bridge --pid 18244 sim-run --duration 60 --time-scale 10 --wait
eustress bridge call viewport.captureentity create makes a 1 m anchored Plastic block unless you say
otherwise; --shape takes block, ball, cylinder, wedge,
cornerwedge or cone, and colors are red, green and blue from 0 to 1.
One-Shot Runs
eustress run starts eustress-headless on a Space in the
foreground and waits for it. With --ticks it is a batch job:
run that many simulation ticks, export the recording, exit. It succeeds when
eustress-headless exits with status 0 and fails otherwise, so a CI step can
check it directly.
eustress run ~/Documents/Eustress/Lab/Spaces/Bracket-A --ticks 600
echo $?| Flag | Effect |
|---|---|
<space> | The Space folder to simulate. Required. |
--ticks <n> | Stop after N ticks and export the recording. Without it, the run lasts until the process is closed. |
--tick-rate <hz> | Main loop rate, default 60. |
--no-autoplay | Stay in Edit and wait for a run command over the bridge. |
03Headless
What It Runs
eustress-headless is the simulation half of Eustress Engine without the window. It adds the same core set of plugins Studio does (the Space loader, the world database, Avian physics, the Realism and simulation systems, the Rune script runtime, and the Engine Bridge) on a plain run loop, with no window, no GPU and no editor interface. Luau scripts start on Play once Studio does; see Scripting.
Because it hosts the same bridge, every eustress bridge subcommand and every live tool of the MCP server works against it, apart from the few listed under Limits. It registers itself with kind headless, so eustress instances can tell it
from a Studio window.
Flags
| Flag | Effect |
|---|---|
--space <dir> | The Space to open: the folder that holds Workspace and world.fjalldb. |
--universe <dir> | Open the first Space inside a Universe instead. |
--ticks <n> | Stop after N simulation ticks, export the recording and exit. |
--tick-rate <hz> | Main loop rate, default 60. The simulation step stays fixed at 60 Hz. |
--no-autoplay | Stay in Edit and wait for a run command over the bridge or MCP. |
--autoplay-delay-frames <n> | Frames to let the Space load before entering Play, default 120, about 2 s at 60 Hz. |
-h, --help | Print usage. |
eustress-headless --space ~/Documents/Eustress/Lab/Spaces/Bracket-A --ticks 600
eustress-headless --universe ~/Documents/Eustress/Lab --no-autoplayA Run, Start to Finish
- It loads the Space, binds the bridge and writes
engine.portand its instance record. - After the autoplay delay it switches to Play, and the run's watchpoint recording starts.
- With
--ticks, it returns to Edit on the first frame its simulation clock has reached N ticks, so the recording can count slightly more than N. Leaving Play exports the recording to<Universe>/.eustress/knowledge/recordings/<Space>/as asim_JSON file named by time. - It gives that work four frames to finish, then exits.
Without --ticks it runs until it is closed, by eustress close, by the engine.shutdown bridge
method or by ending the process.
| Exit status | Meaning |
|---|---|
0 | The run finished, the engine was closed cleanly, or --help was asked for. |
2 | An argument was wrong: an unknown flag, a missing value, or a Space folder that does not exist. The reason goes to stderr. |
Limits
- No glTF loader: parts built from primitive shapes, physics, scripts, simulation values and the bridge all work, but custom
.glbmeshes do not load. - No renderer: the bridge's
viewport.captureandai_cameramethods produce no image. - No Workshop:
tools.listandtools.callanswer that the tool registry is not available. - No input: with no window, scripts that poll input see nothing pressed.
04Many Engines
One Port File, One Owner
A Universe has one .eustress/engine.port file, so it names one
engine: the Universe's owner, whichever engine wrote it last. An engine removes
the file on exit only while it still holds its own port, and a surviving engine
takes a released slot back within a second, including one left by an engine
that crashed.
Anything that addresses the Universe, such as eustress bridge --universe or the MCP server's live tools,
reaches the owner. To reach one engine among several, address it by port or
pid.
The Instance Registry
Every engine, Studio or headless, also writes a record of its own once its
bridge is up, at <workspace>/.eustress/instances/<pid>.json,
where the workspace is the folder that holds its Universe. The record is
rewritten when the engine switches Space and removed when it exits cleanly. The
eustress command reads records from --workspace, else EUSTRESS_WORKSPACE, else Documents/Eustress (on
Windows, the Documents folder in your user profile even when OneDrive redirects
it), so pass --workspace when your Universes live elsewhere.
{
"pid": 18244,
"port": 53117,
"kind": "headless",
"space": "C:\\Users\\you\\Documents\\Eustress\\Lab\\Spaces\\Bracket-A",
"universe": "C:\\Users\\you\\Documents\\Eustress\\Lab",
"started_at": "2026-09-22T14:03:11.482917300+00:00"
}eustress instances reads these records, checks that each port
still answers, and deletes the records of engines that are gone, so what it
lists is what you can drive. --json adds an owns_universe field that marks each Universe's owner.
Simulations per Engine
Beside its record, each engine keeps a private folder, instances/<pid>/, with its simulation command queue (sim-commands.jsonl) and its runtime snapshot (snapshot.json). Only that engine reads the queue and writes the
snapshot, so engines on one Universe run their own simulations side by side.
The Universe's own queue and runtime-snapshot.json belong to
the owner, and every engine appends to the shared .eustress/telemetry.jsonl, tagging each line with its pid,
Space and run id.
cd ~/Documents/Eustress/Lab
A=$(eustress open Spaces/Bracket-A --headless --json | jq .pid)
B=$(eustress open Spaces/Bracket-B --headless --json | jq .pid)
eustress bridge --pid $A sim-set load.current_a=2.5
eustress bridge --pid $A sim-run --duration 60 --time-scale 100
eustress bridge --pid $B sim-run --duration 60 --time-scale 100
eustress bridge --pid $A sim-await # run id, end reason, final values
eustress bridge --pid $B sim-await
eustress close --allThe MCP server's simulation tools take a pid the same way;
without one they reach the owner and name the other engines that share the
Universe.
05The Bridge
Wire Format
The Engine Bridge speaks JSON-RPC 2.0 over TCP on 127.0.0.1,
one newline-terminated JSON object per request and per reply. Connect, write a
line, read a line:
{"jsonrpc":"2.0","id":1,"method":"ecs.inspect","params":{"limit":10}}
{"jsonrpc":"2.0","id":1,"result":{ ... }}
{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found: ecs.inspekt"}}Requests run on the engine's main thread, up to 64 per frame. sim.step is spread across frames, at most 100 ms of stepping per
frame, and db.export_toml writes its files on a worker thread,
so the engine keeps rendering and answering while they work; only one sim.step runs at a time. Error codes follow JSON-RPC: -32601 for an unknown method, -32602 for bad
parameters, -32603 for a failure inside the engine.
Methods
| Method | What it does |
|---|---|
ping | Health check. |
ecs.query | Entity ids, names and classes, paginated. |
ecs.inspect | Detailed live entities and the frame rate; filters by class, name, cell or region. |
scene.overview | Entity counts and class histograms per 256 m Morton cell. |
scene.raycast | A ray against the live Avian colliders, hits nearest first. |
sim.read | Current simulation values, all of them or by key. |
sim.step | Advance physics by N fixed 1/60 s ticks, up to 10,000. |
sim.run, sim.pause, sim.stop | Start, pause and stop a run; sim.run answers with the run id. |
sim.set, sim.state | Write simulation values; read play state, clock and the run ledger. |
hil.ingest | Append hardware samples to a run's log. It never writes simulation values, and it rejects samples from instruments past their calibration date. |
oplog.tail | Recent entity creates and deletes, in order. |
entity.create, .read, .update, .delete, .find | Parts in the world database. |
entity.add_tag, entity.remove_tag | CollectionService tags. |
entity.promote, entity.demote | Move a part between the database and an _instance.toml folder. |
db.export_toml | Dump the world database to readable TOML files. |
tool.equip, selection.set, state.get | The editor's active tool and selection. |
action.invoke | Run an editor action by name, as its shortcut would. |
output.tail | The newest Output panel lines; limit, level and contains narrow them. |
viewport.capture | Screenshot the window to <Space>/.eustress/capture.png; the file lands a frame or two after the reply. |
ai_camera.set_pose, .orbit, .frame, .capture | The off-screen AI camera; captures go to <Space>/.eustress/ai_camera.png. |
data.bind, data.bindings, data.unbind | Dataset columns driving simulation parameters. |
tools.list, tools.call | The Workshop tool registry, called with the Read and Write grant. Studio only. |
engine.shutdown | Exit cleanly at the end of the frame. |
The bridge listens only on 127.0.0.1 and checks no
identity. Every method above, entity.delete and engine.shutdown included, is open to any program on your
computer that reads the port file.
The Shared Client
The eustress command and the MCP server share one client crate, eustress-bridge-client: synchronous TCP from the standard
library, a 2 second connect limit and friendly errors when no engine answers. A
Rust tool inside the Eustress workspace can depend on it by path:
use eustress_bridge_client::{call_engine, call_port, default_workspace_root, list_instances};
use serde_json::json;
use std::path::Path;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// The Universe's owner, found through its engine.port file.
let universe = Path::new("C:/Users/you/Documents/Eustress/Lab");
let scene = call_engine(universe, "ecs.inspect", json!({ "limit": 10 }))?;
println!("{scene}");
// Every live engine in the workspace, each by its own port.
for rec in list_instances(&default_workspace_root()) {
let pong = call_port(rec.port, "ping", json!({}))?;
println!("{} {} {:?} {}", rec.pid, rec.kind.as_str(), rec.space, pong);
}
Ok(())
}06Utility Programs
eustress-space
eustress-space reads a Space's world database with only the storage crate
linked, no engine, which makes it the quickest way to see what a Space holds.
Its path is the Space folder or its world.fjalldb folder. It
opens the same database the engine writes, so point it at a Space no engine
has open (see Database Tools).
| Command | What it does |
|---|---|
eustress-space open <path> | Entity count, class histogram, world bounds and the database header. |
eustress-space verify <path> | Validates every stored entity and exits non-zero if any fails. |
eustress-space export <path> [--out <dir>] | Writes each entity as a readable .instance.toml, grouped by class, to <path>/export_toml by default. |
Database Tools
Fjall, the database under a Space, takes no lock between processes, so
nothing stops two programs from writing one Space's world.fjalldb at once, and the result is a corrupted
database. Run the tools below only on a Space that no engine has open.
| Program | What it does |
|---|---|
convert-to-eustress [--space <dir>] [--eustress-root <dir>] [--dry-run] | Seed each Space's database from its TOML files, keeping the files. Every Space under Documents/Eustress by default. The engine also does this for a Space when it opens it; see Importing. |
reseed-space-subtree --space <dir> [--subdir Workspace] | Re-read one folder's TOML files into the database after edits made while the engine was closed. Always pass --space: the default is Universe1/Spaces/Space1. |
purge_tree_path --space <dir> --match <text> [--apply] | Remove every stored record whose path contains the text. A dry run unless --apply is given, which first copies each value to <Space>/.eustress/trash/db-purge-<seconds>/, then deletes and re-reads to prove it. |
SPACE=~/Documents/Eustress/Lab/Spaces/Bracket-A
cargo run --release -p eustress-worlddb --bin purge_tree_path -- --space "$SPACE" --match Crate
cargo run --release -p eustress-worlddb --bin purge_tree_path -- --space "$SPACE" --match Crate --applyGenerators and the LSP
generate-benchmark-map fills a Space with an N by N grid of test
parts, written straight into the Space's database, so the same closed Space rule applies. In that default mode, --output must be a path inside the Space's Workspace folder; --disk writes one folder per
part instead, and --binary-ecs writes binary parts.
# 316 x 316 = 99,856 parts, 4 m apart
cargo run --release -p eustress-engine --bin generate-benchmark-map -- \
--grid-size 316 --spacing 4 --seed 42 --output "$SPACE/Workspace/BenchmarkGrid"Its other flags are --active-pct (the share of parts given a
velocity, default 0.10) and the defaults shown above for --grid-size (100), --spacing (4) and --seed (42).
texture-gen regenerates the bundled PBR material library (base
color, normal and ORM maps for 18 materials) into the source tree: run cargo run -p texture-gen, add --only brick,grass for a subset or --size for another resolution (2048 by
default), and use its check subcommand to report seams. eustress-lsp serves Rune language intelligence over stdio or TCP;
see Rune LSP.
07What's Next
Headless Capture
A GPU tier for eustress-headless will keep the renderer while still opening no
window, so viewport.capture and the AI camera will produce
images on a machine with no desktop, such as a CI runner with a graphics
adapter.
Remaining Verbs
Four verbs are defined but not usable yet, and will be documented here once they
are. server starts eustress-server, whose multiplayer loop is
still empty; see Networking. publish calls Wrangler with an upload command that names no
file; see Publishing for how Spaces are
published. sim reads a history stream that starts empty in its
own process, so it has no records to show. fork posts to
registry endpoints that no Eustress service implements yet.
Open a Space from a script, run it, read the numbers, close it.