Simulation
A simulation in Eustress is a Play session measured by a simulation clock. The clock can run faster than real time for the models that integrate against it, every named value a model or script publishes is recorded, and each run is saved as a file you can compare with the next one.
01Overview
What a Run Is
A simulation run is one Play session. Press Play and the run starts; press Stop
and Studio restores the world and saves the run. In between, every frame follows
the same path: the simulation clock advances, scripts, Dataset bindings and models
read and write sim values (named numbers such as battery.soc), and the engine records those values.
Rigid-body physics (Avian) steps at a fixed 60 Hz of real time. The simulation clock is a second clock that can run faster than real time for the models that integrate against it. Play starts both and Pause stops both.
What Speeds Up
The time scale multiplies the simulation clock. Only systems that integrate against that clock run faster; the rest of a run keeps real time:
| Part of a run | Advances by | Follows the time scale |
|---|---|---|
| Simulation clock | Frame time x time scale | Yes |
Cell model (parts with [electrochemical]) | Clock time, in short steps | Yes |
Dataset bindings in by_time mode | Clock time | Yes |
| Rigid-body physics | Fixed 1/60 s steps of real time | No |
Rune on_update(dt) | Frame time | No |
| Heat conduction between parts | Frame time | No |
Avian never reads the simulation clock, so at 3,600x a dropped part still
takes the same wall-clock time to land. Use the time scale for models such
as a battery cell over hundreds of hours, and step mechanics with sim_step when you need exact control.
02The Clock
Time and Ticks
The simulation clock holds simulated time, wall time, the time scale and a tick count. Each Play frame it adds the frame's duration times the time scale to simulated time, and counts fixed ticks of one timestep, at most 10 per frame. Pause freezes the clock and Stop resets it to zero.
simulated time += frame time x time scale
ticks this frame = min(unspent time / timestep, 10)
effective compression = simulated time / wall time| Setting | Default | Meaning |
|---|---|---|
| Time scale | 1 | Simulated seconds per real second |
| Tick rate | 60 Hz | Ticks per simulated second; the timestep is its inverse |
| Max ticks per frame | 10 | Cap on the ticks counted in one frame |
While a run is playing or paused, a badge at the top of the viewport shows the
state and the clock, for example 1.5m | Tick 5400 | 1x:
simulated time, ticks and the time scale.
Setting the Time Scale
Open Simulation Settings with the button beside Stop, at the left end of the ribbon's tab row. Choose a preset, or Custom and a value in Custom Time Scale, and press Save. The live clock takes the new scale at once, mid-run included, and keeps it for later runs in the session.
| Preset | Time scale |
|---|---|
| Realtime (1×) | 1 |
| 1 min / sec (60×) | 60 |
| 1 hr / sec (3,600×) | 3,600 |
| 1 day / sec (86,400×) | 86,400 |
| 1 week / sec (604,800×) | 604,800 |
| 1 month / sec (2.63M×) | 2,630,000 |
| 1 year / sec (31.5M×) | 31,536,000 |
| Custom | The Custom Time Scale field |
The same dialog sets Tick Rate and Max Ticks / Frame. Tick Rate changes the
clock's timestep and the step length of the cell model; physics stays at 60 Hz.
Agents choose the scale per run instead: run_simulation takes time_scale, and a run it starts from Edit without one runs at 1x.
Limits of Compression
How much faster than real time a run actually goes depends on three limits:
- Slow frames: the engine's virtual clock adds at most 33 ms per frame, so below about 30 frames per second simulated time falls behind the requested scale.
- Model resolution: the cell model cuts each frame into steps of one timestep, up to 4,096 of them. At 60 frames per second that holds 1/60 s steps up to 4,096x; above that the steps lengthen (about 0.35 s at 86,400x) and the engine logs a warning.
- Tick cap: once a frame would need more than 10 ticks, the tick count stops tracking simulated time.
Each recording stores the ratio it achieved as compression_ratio, simulated time divided by wall time.
At 3,600x each frame advances the clock by a minute but adds only 10
ticks, so a tick budget ends a run far later in simulated time than it
looks. Bound compressed runs with duration_s, which stops
on simulated seconds.
03Running
Play, Pause, Stop
The run controls sit at the left end of the ribbon's tab row: Play, Play with Character, Pause, Stop and Simulation Settings. The keys trigger the same actions and can be rebound in Keyboard Shortcuts:
| Key | Action |
|---|---|
F5 | Play with a character |
F6 | Pause, or resume a paused run |
F7 | Play without a character |
F8 or Esc | Stop |
The Roblox keymap preset maps Play to F8 and Stop to Shift+F5; Esc always stops. Pause freezes
physics, the simulation clock, scripts and the cell model, and F6 resumes the same run.
What Stop Restores
Stop returns Studio to Edit mode and puts back what the run changed.
Transforms, part properties and humanoid values come back from the snapshot
taken at Play, and so do whole [electrochemical] and [thermodynamic] states, so a battery's charge, temperature and
cycle count rewind together. Parts deleted during the run come back, and parts
created during it are removed.
The simulation then exports the run's recording and resets: the clock returns
to zero, sim values clear and watchpoint statistics reset. A run that stops itself
after duration_s goes through the same Stop.
Stepping
Stepping advances rigid-body physics by an exact number of fixed ticks,
independent of the wall clock. Agents and the CLI step through the sim_step tool (the engine bridge's sim.step
method): 1 to 10,000 ticks of 1/60 s, after which physics is left paused so the
world holds still until the next step.
{ "tool": "pause_simulation", "arguments": {} }
{ "tool": "sim_step", "arguments": { "ticks": 120 } }
// reply: stepped 120 fixed tick(s) (2.000s sim time); read inspect_scene for the new statePause the run first so only the steps move the world. A step runs the fixed physics schedule alone; the simulation clock, scripts and the cell model advance only in Play frames. Long steps are spread across frames so the engine keeps drawing, and one step can be in flight at a time.
04Sim Values
One Map of Numbers
Sim values are one flat map from names to numbers. Models publish into it, scripts, agents and Dataset bindings write into it, and everything that observes a run reads it.
| Writer | What it writes |
|---|---|
| Cell model | 24 battery.* values for the [electrochemical] part with the largest capacity, every Play frame |
| Rune scripts | Any key passed to set_sim_value |
| Agents | set_sim_value, and the overrides of run_experiment |
| Dataset bindings | The key a column is bound to |
The cell model reads three keys back. battery.mode selects idle
(0), charge (1) or discharge (2), and is 2 when unset. battery.target_current sets the current in amperes; without it the
model charges at 1C and discharges at 0.5C. A battery.current
written by a script sets the current for that frame directly, which is how a
controller holds a cell at zero. The model republishes every other battery.* key each frame, so writing one does not change the cell.
Stop clears the map.
A battery.current written by set_sim_value, run_experiment or a Dataset binding lands before the
script frame, which replaces the frame's explicit writes, so the cell model
never sees it. battery.mode and battery.target_current stay set until changed, so set those
from outside a script.
From Rune
Rune scripts use three functions from the eustress module: get_sim_value(key) (0.0 for a missing key), set_sim_value(key, value) and list_sim_values(). Import them at the top of a .rune file:
use eustress::{get_sim_value, set_sim_value};
pub fn on_update(dt) {
// What the cell model published last frame (0.0 if absent).
let soc = get_sim_value("battery.soc");
// Publish a value of your own; tools and recordings see it.
set_sim_value("pack.soc_percent", soc * 100.0);
// Below 20 %, hold the cell idle. An explicit write wins this frame.
if soc < 0.2 {
set_sim_value("battery.current", 0.0);
}
}A script's on_update receives the frame's real duration in
seconds, not simulated time, so state a script integrates itself advances at real
time. See Scripting for the rest of the script API.
Driven by Data
A Dataset column can drive a sim value during a run, so a model runs against
measured numbers instead of its defaults. The data_bind tool binds
one numeric column of a Dataset's CSV to one key:
{ "tool": "data_bind", "arguments": {
"dataset": "DriveCycle",
"column": "current_a",
"target": "battery.target_current",
"mode": "by_time",
"time_column": "time_s"
} }With battery.mode at its default of 2, that column sets the
discharge current second by second of simulated time.
by_row: one row per frame, holding the last row at the end, or starting over withloopset to true.by_time: interpolates the column linearly against the simulation clock, and holds the first or last value outside the recorded span.
Binding a key that is already bound replaces the old binding. data_bindings lists bindings with the value each last wrote, and data_unbind returns a key to the model.
Parameters
Parameters are a separate store: typed values attached to an instance and shown in
Properties. A part's [parameters] table fills them. A plain key
lands in the instance domain, and a quoted dotted key names its
own domain:
[parameters]
rated_capacity_ah = 40.0 # domain: instance
"telemetry.sample_rate" = 10 # domain: telemetry, key: sample_rateQuote the dotted key: unquoted, TOML reads it as a nested table and the loader stores the whole table as one JSON value. The simulation clock and the models do not read parameters. To feed a number into a running model, write a sim value or bind a Dataset column.
05Watch and Record
Watchpoints
A watchpoint is a sim value the engine keeps statistics for: the current value, minimum, maximum, running average and a history of up to 10,000 samples, oldest dropped first. During a run, each value with a watchpoint is sampled once per frame at the current simulated time.
The models register them. Entering Play registers nine for the cell: battery.voltage, battery.current, battery.soc, battery.temperature_c, battery.power, battery.c_rate, battery.dendrite_risk, battery.capacity_retention and battery.cycle_count. The other sim values carry no
statistics but are still recorded. Stop resets every watchpoint's statistics.
Save applies the preset, Custom Time Scale, Tick Rate and Max Ticks /
Frame. The dialog's Startup, Manual Step, Auto-Stop Conditions, Output and
Watchpoints sections and its binding list are not read by the engine, and
its Rune API Reference shows a sim.* script API that the
runtime does not install. Publish values with set_sim_value and control runs with the tools.
Recordings
Every run is recorded. Entering Play starts a recording, and each frame adds one sample, at the current simulated time, for every sim value, not only those with watchpoints. Stop computes statistics for each series, writes the recording as JSON and prints its path to Output:
<universe>/.eustress/knowledge/recordings/<space>/
sim_20260922_143015_123_run3_pid4812.jsonThe name carries the UTC date and time to the millisecond, the run number and the engine's process id, so engines running variants side by side never overwrite each other. The file holds:
metadata name, simulation_duration_s, wall_duration_s,
total_ticks, compression_ratio, tags
series one entry per sim value key:
name, label, unit,
times[] (simulated seconds), values[],
stats { min, max, mean, std_dev, first, last }
events time_s, tick, event_type, description, dataSamples follow rendered frames, so a recording grows with the run's wall-clock length: a minute at 60 frames per second is 3,600 samples per value.
Telemetry and Snapshots
Two lighter feeds serve tools while a run is live:
- Telemetry: once a second the engine appends one JSON line, a timestamp and every sim value, to
<universe>/.eustress/telemetry.jsonl.tail_telemetryreturns the latest lines (20 by default, up to 100), optionally filtered to a list of keys. - Runtime snapshot: four times a second the engine writes the play state and the current sim values, watchpoints included, to a snapshot file.
get_sim_value,list_sim_valuesandget_simulation_stateread it, so what they return can trail the engine by up to 250 ms.
06Experiments
The Experiment Loop
An experiment is a named run with fixed inputs and a saved result. The run_experiment tool performs the whole loop in one call:
- With
create_branchset, creates a git branchexp/<name>-<timestamp>in the repository that holds the Space. The Space stays on that branch afterwards. - Writes each entry of
sim_valuesas a sim value. - Starts a run at
time_scale(default 1) that stops itself afterduration_ssimulated seconds. - Waits for that run to end, up to
timeout_sof wall time (default 300 s). - Reads the run's telemetry lines and computes min, mean, max and last for each key.
- Saves the result to
<universe>/.eustress/experiments/<name>-<timestamp>.jsonand returns it.
{ "tool": "run_experiment", "arguments": {
"name": "charge_1c",
"description": "Does a 1C charge stay under 45 C?",
"sim_values": { "battery.mode": 1 },
"duration_s": 3600,
"time_scale": 60,
"create_branch": true
} }In the Workshop, run_experiment and run_simulation stop for your approval unless auto mode is on.
Comparing Runs
compare_runs lines two saved experiments up key by key: baseline,
candidate and the difference. Pass file names, or latest and latest-1. A decrease counts as an improvement unless the key is
listed in higher_is_better:
{ "tool": "compare_runs", "arguments": {
"run_a": "latest-1",
"run_b": "latest",
"higher_is_better": ["battery.capacity_retention", "battery.soc"]
} }list_experiments lists saved results newest first (20 by default)
with duration, time scale and final values. Because an experiment can run on its
own branch, a design change and the result it produced can be committed together;
see Universes for branching.
The Agent Surface
| Tool | What it does |
|---|---|
run_simulation | Start a run from Edit, or resume a paused one; optional time_scale and duration_s |
pause_simulation, stop_simulation | Pause, or stop and restore |
get_simulation_state | Play state and every sim value |
await_simulation | Wait for a run to end and return its final values and telemetry statistics |
get_sim_value, list_sim_values, set_sim_value | Read and write single values |
tail_telemetry | The latest telemetry lines |
sim_step | Step physics by N fixed ticks |
data_bind, data_bindings, data_unbind | Drive values from a Dataset |
run_experiment, compare_runs, list_experiments | Run, save and compare experiments |
Each is a tool of the MCP server. The eustress CLI talks to the same engine bridge; see CLI & Headless.
07Determinism & Headless
What Repeats Exactly
The physics step is pinned so the same inputs give the same world: a fixed 60 Hz
timestep, 6 solver substeps and the default solver configuration. The engine also
keeps a global random seed, a fixed constant by default, and NumberRange random draws in scripts come from a generator seeded
with it. A determinism test in the repository drops 16 seeded cubes, steps 120
ticks twice and requires identical end states.
| Part of a run | How it repeats |
|---|---|
sim_step | Exactly: N fixed ticks, independent of the wall clock |
| Physics during Play | Each step is identical, but inputs land on whichever step the frame timing gives them |
| Cell model | Step length follows frame time, so runs agree closely rather than bit for bit |
Script on_update(dt) | Receives real frame time |
| Random numbers | Seeded from a fixed constant |
For bit-for-bit repeats of mechanics, pause and drive the world with sim_step.
Headless Batch Runs
eustress-headless runs a Space's simulation with no window. It is
built from the engine crate and loads the same core plugins as Studio (physics,
realism, scripts, sim values, recordings and the engine bridge) on a plain frame
loop:
eustress-headless --space <space folder> --ticks 3600It waits 120 frames for the Space to load, enters Play, stops when the clock
reaches the tick count, exports the recording and exits with code 0. Without --ticks it runs until stopped, a windowless engine you drive over
the bridge; --no-autoplay makes it wait for run_simulation. --tick-rate sets how often frames
run, not how fast simulated time passes, so a headless run takes as long as the
same run in Studio. The eustress run command wraps it; CLI & Headless lists every flag.
08What's Next
Breakpoints
The engine already checks a breakpoint registry every Play frame. A breakpoint
compares one sim value with a threshold (<, <=, ==, >=, >, !=), can fire once or with a cooldown, and on a hit stops the
simulation clock and writes a breakpoint event into the recording. Next come ways
to declare breakpoints from Studio, scripts and tools, and a hit that pauses the
whole run rather than only the clock.
A Simulation File
The engine carries a TOML schema for run settings (tick rate, time scale,
auto-start, time and tick limits, recording format, watchpoints, breakpoints,
test expectations and named parameters) and a CSV writer for recordings. Neither is
wired to a Space yet. Loading that file from a Space, the dialog's remaining
sections, and a size cap for telemetry.jsonl, which grows without
limit today, are the next steps.
Compress the model, not the physics, and keep every run.