Learn/Simulation

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.

Time16 min readLevelIntermediateUpdatedUpdated Sep 2026

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.

One Play frameClockframe time x scaleScripts, datawrite valuesModelspublish valuesSim valuesnamed numbersWatchpointsmin, max, averageRecordingJSON on StopTelemetry1 line per secondSnapshot4 times a secondTools read the snapshot and telemetry; Stop writes the recording to disk.
Every Play frame follows one path. Whatever reaches the sim-value map is sampled by watchpoints, added to the recording, and exposed to tools.
Info
Two clocks

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 runAdvances byFollows the time scale
Simulation clockFrame time x time scaleYes
Cell model (parts with [electrochemical])Clock time, in short stepsYes
Dataset bindings in by_time modeClock timeYes
Rigid-body physicsFixed 1/60 s steps of real timeNo
Rune on_update(dt)Frame timeNo
Heat conduction between partsFrame timeNo
Advanced
A time scale does not speed up falling parts

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.

Math
simulated time        += frame time x time scale
ticks this frame       = min(unspent time / timestep, 10)
effective compression  = simulated time / wall time
SettingDefaultMeaning
Time scale1Simulated seconds per real second
Tick rate60 HzTicks per simulated second; the timestep is its inverse
Max ticks per frame10Cap 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.

PresetTime 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
CustomThe 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.

Advanced
Bounding a compressed run by ticks

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:

KeyAction
F5Play with a character
F6Pause, or resume a paused run
F7Play without a character
F8 or EscStop

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.

MCP
{ "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 state

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

WriterWhat it writes
Cell model24 battery.* values for the [electrochemical] part with the largest capacity, every Play frame
Rune scriptsAny key passed to set_sim_value
Agentsset_sim_value, and the overrides of run_experiment
Dataset bindingsThe 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.

Advanced
Steer the cell from tools with mode and target current

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:

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

MCP
{ "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 with loop set 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:

_instance.toml
[parameters]
rated_capacity_ah = 40.0          # domain: instance
"telemetry.sample_rate" = 10      # domain: telemetry, key: sample_rate

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

Advanced
Simulation Settings applies four fields

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:

Recording path
<universe>/.eustress/knowledge/recordings/<space>/
    sim_20260922_143015_123_run3_pid4812.json

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

sim_*.json
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, data

Samples 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_telemetry returns 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_values and get_simulation_state read 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:

  1. With create_branch set, creates a git branch exp/<name>-<timestamp> in the repository that holds the Space. The Space stays on that branch afterwards.
  2. Writes each entry of sim_values as a sim value.
  3. Starts a run at time_scale (default 1) that stops itself after duration_s simulated seconds.
  4. Waits for that run to end, up to timeout_s of wall time (default 300 s).
  5. Reads the run's telemetry lines and computes min, mean, max and last for each key.
  6. Saves the result to <universe>/.eustress/experiments/<name>-<timestamp>.json and returns it.
MCP
{ "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:

MCP
{ "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

ToolWhat it does
run_simulationStart a run from Edit, or resume a paused one; optional time_scale and duration_s
pause_simulation, stop_simulationPause, or stop and restore
get_simulation_statePlay state and every sim value
await_simulationWait for a run to end and return its final values and telemetry statistics
get_sim_value, list_sim_values, set_sim_valueRead and write single values
tail_telemetryThe latest telemetry lines
sim_stepStep physics by N fixed ticks
data_bind, data_bindings, data_unbindDrive values from a Dataset
run_experiment, compare_runs, list_experimentsRun, 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 runHow it repeats
sim_stepExactly: N fixed ticks, independent of the wall clock
Physics during PlayEach step is identical, but inputs land on whichever step the frame timing gives them
Cell modelStep length follows frame time, so runs agree closely rather than bit for bit
Script on_update(dt)Receives real frame time
Random numbersSeeded 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:

Shell
eustress-headless --space <space folder> --ticks 3600

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

Loading Eustress Engine...