Learn/CLI & Headless

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.

Time17 min readLevelIntermediateUpdatedUpdated Sep 2026

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.

ProgramPackageWhat it does
eustresseustress-cliOpens, lists and closes engines, drives any engine over its bridge, and runs a Space headless in one call.
eustress-headlesseustress-engineLoads a Space and runs its simulation with no window: physics, scripts, the world database and the bridge.
eustress-engineeustress-engineThe Studio window. --space <dir>, --universe <dir> or --open <file> opens a Space directly.
eustress-mcpeustress-mcp-serverThe MCP server for AI clients; see MCP Server.
eustress-spaceeustress-spaceOpens, verifies and exports a Space's database without the engine.
convert-to-eustress, reseed-space-subtreeeustress-engineSeed a Space's database from its TOML files.
purge_tree_patheustress-worlddbRemove one entity from every store of a closed Space's database.
generate-benchmark-mapeustress-engineFill a Space with a grid of test parts.
texture-gentexture-genRegenerate the bundled material textures in a source checkout.
eustress-lspeustress-engineThe 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.

Terminal
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-space

eustress 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.

VerbWhat it doesStatus
openStart an engine on a Space, detached, and print its pid and port.Available
instancesList running engines, dropping records of engines that are gone.Available
closeShut engines down cleanly by pid, by Space or all at once.Available
bridgeSend one command to a running engine over its bridge.Available
runRun a Space in eustress-headless and wait for it to finish.Available
serverStart the multiplayer host.Not yet available
publishUpload a Space from the command line.Not yet available
simReplay simulation history.Not yet available
forkRegister 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.

Terminal
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
FlagVerbEffect
<space>openThe Space folder to open. Required.
--headlessopenStart eustress-headless instead of a Studio window.
--playopenStart the run as soon as the Space loads. Applies to headless engines; Studio opens in Edit either way.
--wait-secs <n>openHow long to wait for the bridge, default 45. The engine keeps running if this runs out, and the command exits with an error.
--jsonopen, instancesPrint only JSON, for scripts.
--pid <n>, --space <dir>, --allcloseWhich engines to close.
--forcecloseKill the process if the clean shutdown is refused or times out, and delete its record.
--workspace <dir>all threeWhere 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.

SubcommandWhat it does
pingCheck 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-stopPause the run, or stop it and restore the scene like the Stop button.
sim-set KEY=VALUE ...Write numeric simulation values.
sim-statePlay 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|findParts in the world database, by uuid or name.
call <method> [--params '{...}']Any bridge method by name, with JSON parameters.
Terminal
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.capture

entity 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.

Terminal
eustress run ~/Documents/Eustress/Lab/Spaces/Bracket-A --ticks 600
echo $?
FlagEffect
<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-autoplayStay 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

FlagEffect
--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-autoplayStay 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, --helpPrint usage.
Terminal
eustress-headless --space ~/Documents/Eustress/Lab/Spaces/Bracket-A --ticks 600
eustress-headless --universe ~/Documents/Eustress/Lab --no-autoplay

A Run, Start to Finish

  1. It loads the Space, binds the bridge and writes engine.port and its instance record.
  2. After the autoplay delay it switches to Play, and the run's watchpoint recording starts.
  3. 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 a sim_ JSON file named by time.
  4. 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 statusMeaning
0The run finished, the engine was closed cleanly, or --help was asked for.
2An 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 .glb meshes do not load.
  • No renderer: the bridge's viewport.capture and ai_camera methods produce no image.
  • No Workshop: tools.list and tools.call answer 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.

eustressopen, bridge --pidStudio, pid 18020port 52961headless, pid 18244port 53117headless, pid 18410port 53240.eustress/instances18020.json18244.json18410.jsonengine.port53117one slot per Universeowner
Every engine writes its own registry record, so the registry lists all three. The Universe's engine.port holds one port, so it reaches only the owner; the CLI reaches any engine by pid or port.

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.

.eustress/instances/18244.json
{
  "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.

Terminal
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 --all

The 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:

JSON-RPC
{"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

MethodWhat it does
pingHealth check.
ecs.queryEntity ids, names and classes, paginated.
ecs.inspectDetailed live entities and the frame rate; filters by class, name, cell or region.
scene.overviewEntity counts and class histograms per 256 m Morton cell.
scene.raycastA ray against the live Avian colliders, hits nearest first.
sim.readCurrent simulation values, all of them or by key.
sim.stepAdvance physics by N fixed 1/60 s ticks, up to 10,000.
sim.run, sim.pause, sim.stopStart, pause and stop a run; sim.run answers with the run id.
sim.set, sim.stateWrite simulation values; read play state, clock and the run ledger.
hil.ingestAppend hardware samples to a run's log. It never writes simulation values, and it rejects samples from instruments past their calibration date.
oplog.tailRecent entity creates and deletes, in order.
entity.create, .read, .update, .delete, .findParts in the world database.
entity.add_tag, entity.remove_tagCollectionService tags.
entity.promote, entity.demoteMove a part between the database and an _instance.toml folder.
db.export_tomlDump the world database to readable TOML files.
tool.equip, selection.set, state.getThe editor's active tool and selection.
action.invokeRun an editor action by name, as its shortcut would.
output.tailThe newest Output panel lines; limit, level and contains narrow them.
viewport.captureScreenshot the window to <Space>/.eustress/capture.png; the file lands a frame or two after the reply.
ai_camera.set_pose, .orbit, .frame, .captureThe off-screen AI camera; captures go to <Space>/.eustress/ai_camera.png.
data.bind, data.bindings, data.unbindDataset columns driving simulation parameters.
tools.list, tools.callThe Workshop tool registry, called with the Read and Write grant. Studio only.
engine.shutdownExit cleanly at the end of the frame.
Warning
Any local program can call these

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:

Rust
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).

CommandWhat 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

Warning
Close the Space first

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.

ProgramWhat 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.
Terminal
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 --apply

Generators 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.

Terminal
# 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.

Loading Eustress Engine...