-
Notifications
You must be signed in to change notification settings - Fork 0
Variable Server
Status: design-only, not implemented (as of May 2026). No
bevy_var_servercrate exists in the workspace and no tracking issue is open. This page is preserved as an aspirational design contract; it is the spec a future implementation should hold itself to, not a description of shipped code.
bevy_var_server is the proposed sim-agnostic, Bevy-native equivalent
of NASA Trick's variable server: a remote interface that would let
external tools list, read, write, and subscribe to runtime simulation
state, with sim-time-stamped frames at a per-client cadence. This page
is the design contract for the crate before any code lands. See also
the Strategy and Type-System wiki pages for
project-wide architecture context.
NASA Trick exposes simulation state to outside processes through a variable server: a TCP socket on which clients can request the current value of any registered variable, subscribe at a chosen cadence, and inject writes between integration steps. Trick GUIs, recorders, and analysis tools all funnel through it. When we drop Trick in favor of Bevy ECS as the orchestrator, we have to provide the same capability — otherwise we lose every external tool the JEOD community already uses, and we forfeit the much larger ecosystem of 3D viewers, dashboards, and MCP debugging tools that already speak the modern equivalents.
The intended consumers are:
- 3D visualization (browser-based or native) plotting trajectories, attitudes, and surface footprints in real time.
- Operator GUIs that read mode flags and command setpoints between ticks.
- CLI debugging via
curland shell pipelines. - Recording / playback tools that snapshot a configurable variable set at a fixed cadence.
- LLM/MCP tooling (e.g.
bevy_brp_mcp) that introspects runtime state to answer "what is the current ISS altitude" without bespoke glue per question.
The crate is not JEOD-specific. JEOD is one user; a bouncing-ball demo is another. JEOD-specific behavior lives in a thin adapter (see §13).
Goals:
- Sim-agnostic: works against any Bevy
App, no JEOD dependency. - Reuses
bevy_remote(BRP) for transport and reflection plumbing. - Snapshot-consistent reads: a frame's values are all sampled in the same tick, never torn across two ticks.
- Per-client cadence: a 100 Hz subscriber and a 1 Hz subscriber cohabit one server without coupling.
- Sim-time stamping: every push frame carries the simulation's own clock, not wall-clock.
- Mutation (
var/set) applied between ticks viaCommands— never mid-system, never racing the snapshot. - Per-variable SI unit reporting and on-server unit conversion when the client requests a compatible target unit.
- Loopback-only by default; binding non-loopback is opt-in.
Non-goals:
- Authentication, TLS, multi-tenant isolation. The crate trusts the loopback boundary and any reverse proxy a deployment puts in front of it.
- High-throughput recording. A separate logger crate will reuse the
same registration API and
Frametype.
Bevy App (one process, one schedule)
┌──────────────────────────────────────────────────────────────┐
│ ...physics systems... │
│ │ │
│ ▼ │
│ ┌──────────────────────────┐ user-named SystemSet │
│ │ snapshot_system │ ◄ runs after VarServerConfig:: │
│ │ - reads exposed │ sample_after │
│ │ components/resources │ │
│ │ - serializes one Frame │ │
│ └────────────┬─────────────┘ │
│ │ bounded crossbeam_channel<Frame> │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ apply_writes_system │ ◄ runs in VarServerSet::Apply │
│ │ - drains write queue │ between ticks │
│ │ - issues Commands │ │
│ └──────────────────────────┘ │
│ ▲ MPSC<WriteCommand> │
│ │ │
│ ┌────────────┴─────────────┐ runs on IoTaskPool │
│ │ io task │ │
│ │ - bevy_remote HTTP │ │
│ │ JSON-RPC terminator │ │
│ │ - SSE writer for each │ │
│ │ var/subscribe client │ │
│ │ - enqueues var/set │ │
│ │ NEVER touches `World` │ │
│ └────────────┬─────────────┘ │
└───────────────┼────────────────────────────────────────────────┘
│ HTTP / SSE
▼
external clients
(curl, browser, MCP, recorder, GUI)
Three structural rules fall out of this layout:
- The IO task never reads or writes the ECS
Worlddirectly. Reads come through the bounded snapshot channel; writes are queued onto an MPSC and applied byapply_writes_system. This makes the entire torn-read / mid-system-stomp class of bug unrepresentable. - Snapshot serialization happens at one well-known schedule point
(
VarServerConfig::sample_after). Users place this set after their integration set so observed values reflect a completed tick. - Mutations flow back through
apply_writes_systembetween ticks, which issuesCommands. Cross-component invariants (unit quaternion normalization, conserved quantities) are the user's responsibility to restore in a follow-up system.
We surveyed the candidate transports:
| Protocol | Pros | Cons | Browser-reachable? | Streaming model |
|---|---|---|---|---|
| TCP raw + custom framing | Lowest overhead; full duplex; multiplexes many subscriptions on one socket | Custom framing means every client implements a parser; no curl story; no browser support |
No | Server pushes frames continuously |
| HTTP/JSON-RPC over TCP (BRP's existing transport) | Free curl/httpie ergonomics; identical method shape to the rest of BRP and bevy_brp_mcp; trivial to test by hand |
Request/response is awkward for high-cadence push without SSE/long-poll | Yes (fetch) |
Long-poll or SSE |
| HTTP + Server-Sent Events (SSE) | Reuses HTTP/JSON-RPC for control; SSE is a well-supported unidirectional push channel; works in browsers without extra libraries | Unidirectional (server→client) only; SSE carries text — binary needs base64 | Yes (EventSource) |
Native: text/event-stream
|
| WebSocket | Full duplex over a single connection; framed messages; binary or text; broad client support | Not native to BRP — we'd own the route or pull axum/tokio-tungstenite; harder to script with curl
|
Yes (native API) | Native: bidirectional frames |
| UDP | Lowest latency; lossy semantics fit "newest sample wins" | No reliability/ordering/congestion; MTU forces fragmentation; firewall-hostile; zero browser support | No | Datagrams |
| QUIC / HTTP/3 | Multiplexed streams, built-in TLS, no head-of-line blocking | Adds dep weight; browser support requires HTTP/3 specifically; debug ergonomics still poor today | Indirect (HTTP/3) | Bidirectional streams |
| gRPC (HTTP/2) | Schema-driven; great codegen; bidirectional streams | Protobuf tax on every client; browsers need grpc-web; debug ergonomics worse than JSON |
Indirect (grpc-web) |
Bidirectional streams |
Decision: HTTP/JSON-RPC for control + SSE for subscription streams.
Rationale:
-
Reuse BRP, don't fork it. The whole point of the design is
"JSON-RPC on top of
bevy_remote".RemoteHttpPluginalready terminates HTTP/JSON-RPC, already speaks the reflection registry, and already has companion tooling (bevy_brp_mcp,bevy_brp_cli,bevy-inspector-egui). A different transport throws that integration away. -
SSE is the cheapest streaming primitive that works everywhere
we care about.
var/subscribereplies withContent-Type: text/event-stream; the server emits onedata: { sim_time, values }\n\nper cadence tick. Browsers consume it withnew EventSource(url). CLI tools consume it withcurl -N. No new framing, no new dep, no new schema layer. - UDP / QUIC / gRPC are explicitly rejected. UDP can't reach browsers and forces our own reliability layer for non-lossy queries. QUIC and gRPC each add a transport stack, codegen story, and debug-ergonomics regression that buys us nothing.
-
WebSocket is documented as a possible additional transport.
It is not part of the default surface — the recommended client
path is HTTP+SSE. A
WebSocketTransportadd-on plugin can be built later without changing the core registration API or snapshot pipeline; see Future work (§14).
SSE frame format:
event: var
data: {"sim_time":{"sec_si":3600.0,"label":"TAI"},"values":{"vehicle.iss.translational.position[0]":-3741233.4, "...": ...}}
Each event is one cadence tick. The blank line terminates the event per the SSE spec.
The crate exposes one plugin and one extension trait. A user-facing sketch:
use bevy::prelude::*;
use bevy_var_server::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(VarServerPlugin {
config: VarServerConfig {
bind: "127.0.0.1:7000".parse().unwrap(),
sample_after: SystemSetBox::of(MyPhysicsSet::Integrate),
default_cycle_ms: 100,
sim_clock: Arc::new(WallClock::default()),
},
})
.add_systems(Startup, register_vars)
.run();
}
fn register_vars(mut commands: Commands, mut server: VarServer) {
let ball = commands.spawn((Position(Vec3::ZERO), Velocity(Vec3::ZERO))).id();
server.expose_component::<Position>(ball, "ball.position");
server.expose_component::<Velocity>(ball, "ball.velocity");
}Concretely:
-
VarServerPlugin— addsbevy_remote::RemotePluginandRemoteHttpPlugin, registers the snapshot system in the user-suppliedsample_afterset, registersapply_writes_systeminVarServerSet::Apply, spawns the IO task. -
VarServerConfig:-
bind: SocketAddr(default127.0.0.1:7000). -
sample_after: SystemSetBox— the set the snapshot system runs after. -
default_cycle_ms: u64— default cadence when a client doesn't specify. -
sim_clock: Arc<dyn SimClock>— sim-time provider (§9).
-
-
VarServer— system param (or a small extension trait onApp) withexpose_component::<C>(entity, alias: &str),expose_resource::<R>(alias: &str),expose_component_with::<C>(entity, alias, accessors), andexpose_component_with_unit::<C>(entity, alias, unit). -
SimClocktrait:pub trait SimClock: Send + Sync + 'static { fn now(&self, world: &World) -> SimInstant; } pub struct SimInstant { pub sec_si: f64, pub label: &'static str, }
Default impl
WallClockreadsTime<Fixed>::elapsed. JEOD ships aJeodTaiClockwrapper that readsSimulationTimeR.tai_tjt.
The compiler-visible API is small on purpose: everything else (method handlers, snapshot serializers, write appliers) is internal.
These extend stock BRP methods. Names are namespaced under var/
so they don't collide with BRP's bevy/* calls.
| Method | Params | Returns |
|---|---|---|
var/list |
none | [{ alias, type_path, unit, dim }] |
var/exists |
{ alias } |
bool |
var/get |
{ aliases: [String], unit?: String } |
{ sim_time, values: { alias -> json } } |
var/set |
{ writes: [{ alias, value, unit?: String }] } |
{ accepted: u64 } (sequence id) |
var/subscribe |
{ aliases: [String], cycle_ms: u64, unit?: String } |
{ subscription_id, effective_cycle_ms, sse_url } |
var/unsubscribe |
{ subscription_id } |
null |
var/pause |
{ subscription_id, paused: bool } |
null |
var/cycle |
{ subscription_id, cycle_ms } |
{ effective_cycle_ms } |
The optional unit parameter on var/get, var/set, and
var/subscribe requests on-server conversion to/from a target unit
that is dimensionally compatible with the variable's declared unit
(see §10).
Worked curl example:
curl -s http://127.0.0.1:7000/jsonrpc -d '{
"jsonrpc": "2.0", "id": 1,
"method": "var/get",
"params": {"aliases": ["ball.position", "ball.velocity"]}
}'For subscriptions:
curl -N "$(curl -s http://127.0.0.1:7000/jsonrpc -d '{...}' | jq -r .result.sse_url)"Aliases are dotted paths assigned at registration:
ball.position
ball.position[0]
vehicle.iss.translational.position[0]
env.gravity.earth.mu
Internally an alias resolves to (Entity, ComponentId, ParsedPath)
where ParsedPath comes from bevy_reflect. The path supports
struct-field access (.position), tuple-index access (.0), and
sequence indexing ([0]).
Components that use #[reflect(opaque)] (most JEOD typed quantities
do — Position<F> wraps a DVec3 behind a phantom and isn't
field-reflectable in the bevy_reflect sense) need an explicit
field-accessor closure pair (read + optional write) at registration
time:
server.expose_component_with::<TranslationalStateC<Earth>>(
vehicle,
"vehicle.iss.translational",
[
Accessor::ro("position[0]", |c| c.position.raw_si().x.into()),
Accessor::ro("position[1]", |c| c.position.raw_si().y.into()),
Accessor::ro("position[2]", |c| c.position.raw_si().z.into()),
Accessor::rw(
"velocity[0]",
|c| c.velocity.raw_si().x.into(),
|c, v| c.velocity = Velocity::new_si(v.as_f64()?, c.velocity.raw_si().y, c.velocity.raw_si().z),
),
// ...
],
);Accessor::ro declares a read-only field; Accessor::rw is needed
for any field that participates in var/set. The JEOD adapter
(§13) packages all of this for every JEOD component so mission code
never writes the closures by hand.
Without care, clients see torn reads — half a state vector from tick N and half from tick N+1. We avoid the entire failure mode by construction:
- The snapshot system is the only code that touches the live
Worldfor serving. It runs inside the schedule at the user'ssample_afterset, so it sees a consistent post-integration snapshot every tick. - The snapshot system serializes one
Frameper tick into a small pre-allocated buffer keyed by alias and pushes it to the IO task over a boundedcrossbeam_channel. Bounded channel + drop-oldest policy means a stalled client cannot back-pressure the simulation. - The IO task answers
var/getfrom the most-recent frame and feedsvar/subscribestreams from successive frames. It does not have aWorldhandle at all. -
var/setwrites flow the opposite direction — IO task → MPSC queue →apply_writes_systembetween ticks →Commands. Writes never race a running system, never partially apply, and never see an intermediate snapshot.
This rules out: torn reads, mid-system observation, racing readers, mid-tick stomps, and "client crashed → simulation blocks".
var/subscribe takes cycle_ms. The schedule has its own minimum
sample period (the run frequency of the sample_after set,
typically Time<Fixed>'s timestep). We:
- Round the requested
cycle_msup to the nearest integer multiple of the schedule period. We always downsample, never upsample — the server cannot produce data more often than the simulation generates ticks. - Return the rounded value as
effective_cycle_msin thevar/subscriberesponse so clients know the truth. - For each subscription, the IO task counts ticks and emits one SSE event every N frames where N = effective_cycle_ms / schedule_period_ms. No timer thread; no drift.
A 100 Hz sim with a client requesting cycle_ms = 33 gets
effective_cycle_ms = 40 (one event every 4 ticks, 25 Hz).
Every frame carries:
{ "sim_time": { "sec_si": 3600.0, "label": "TAI" }, "values": { ... } }label is a free-form string identifying the timescale. Conventions:
-
"sim_elapsed"—Time<Fixed>::elapsed_seconds_f64(). The default for sims that don't overridesim_clock. -
"TAI"— JEOD'sSimulationTimeR.tai_tjt. Used by the JEOD adapter. -
"UTC","TT", etc. — available if a sim wants to expose a different scale.
SimClock implementations are free to do whatever they like;
clients should treat label as opaque and trust the sec_si value
plus the label as a pair.
var/list reports a SI unit string per alias: "m", "m/s",
"rad", "kg", etc. The JEOD adapter populates this automatically
from each typed component's Quantity phantom — Position<F> is
"m", Velocity<F> is "m/s", Acceleration<F> is "m/s^2",
and so on.
For untyped f64 fields (a bouncing-ball demo, a game), the unit is
supplied at registration:
server.expose_component_with_unit::<Velocity>(ball, "ball.velocity", "m/s");Conversion. var/get, var/set, and var/subscribe take an
optional unit parameter. The server checks dimensional
compatibility against the variable's declared unit and applies a
multiplicative factor (and offset, for temperature-like scales)
before reporting or applying. Compatibility is checked at request
time; an incompatible request fails with a structured error naming
the source unit, target unit, and dimensional mismatch — never
silently coerces.
The supported unit set covers the SI base units and named derived
units used in JEOD ("m", "km", "m/s", "km/s", "rad",
"deg", "kg", "s", "N", "Pa", …). Custom units are
registrable via a small UnitRegistry resource.
var/set posts a batch of writes as a single JSON-RPC call:
{"jsonrpc":"2.0","id":1,"method":"var/set","params":{
"writes":[
{"alias":"vehicle.iss.thrust.command","value":1500.0,"unit":"N"},
{"alias":"vehicle.iss.mode","value":"orbit_hold"}
]
}}Behavior:
- The IO task validates each write against the registry (alias
exists, value matches the field's reflected type, unit is
dimensionally compatible) and pushes a
WriteCommandonto an MPSC queue read byapply_writes_system. - The IO task returns immediately with an
acceptedsequence id — the write has been queued, not necessarily applied. -
apply_writes_systemruns inVarServerSet::Apply, between ticks, and drains the queue, issuingCommands. Writes take effect at the next sync point — never mid-system, never racing the snapshot. - A failed validation rejects the entire batch and returns a structured error pointing at the offending entry.
- Cross-component invariants (unit-quaternion normalization, conserved quantities, JEOD typed-frame discipline) are the user's responsibility to restore in a follow-up system. The server does not attempt to repair them.
- For JEOD typed components that require a witness-gated
constructor (
BodyAttitude<V>,TranslationalStateC<P>), the setter closure insideAccessor::rwreconstructs via the typed builder. The JEOD adapter provides this for every JEOD component.
The server binds an explicit SocketAddr (loopback by default).
Clients discover the server out-of-band — typically via a config
flag, an environment variable, or knowing they're on the same host.
There is no announce/multicast.
A ~50-line Bevy app showing the crate works without any JEOD code:
use bevy::prelude::*;
use bevy_var_server::prelude::*;
#[derive(Component, Reflect, Default)] #[reflect(Component)]
struct Position(Vec3);
#[derive(Component, Reflect, Default)] #[reflect(Component)]
struct Velocity(Vec3);
#[derive(SystemSet, Hash, PartialEq, Eq, Clone, Debug)]
struct Step;
fn step(mut q: Query<(&mut Position, &mut Velocity)>, t: Res<Time<Fixed>>) {
for (mut p, mut v) in &mut q {
v.0.y -= 9.81 * t.delta_seconds();
p.0 += v.0 * t.delta_seconds();
if p.0.y < 0.0 { p.0.y = 0.0; v.0.y *= -0.8; }
}
}
fn main() {
App::new()
.add_plugins(MinimalPlugins)
.register_type::<Position>().register_type::<Velocity>()
.add_plugins(VarServerPlugin::default()
.after(Step))
.add_systems(FixedUpdate, step.in_set(Step))
.add_systems(Startup, |mut c: Commands, mut s: VarServer| {
let e = c.spawn((Position(Vec3::Y * 10.0), Velocity::default())).id();
s.expose_component_with_unit::<Position>(e, "ball.position", "m");
s.expose_component_with_unit::<Velocity>(e, "ball.velocity", "m/s");
})
.run();
}Then:
curl -s http://127.0.0.1:7000/jsonrpc -d '{
"jsonrpc":"2.0","id":1,"method":"var/list","params":null}' | jq
# => [{"alias":"ball.position","unit":"m",...},
# {"alias":"ball.velocity","unit":"m/s",...}]JEOD ships a thin adapter:
use astrodyn_bevy::prelude::*;
use bevy_jeod_var_server::register_jeod_with_var_server;
let vehicle = /* spawn via VehicleBuilder, see typed_mission.rs */;
register_jeod_with_var_server(
&mut server,
vehicle,
"vehicle.iss",
);This produces aliases of the form:
vehicle.iss.translational.position[0..2]
vehicle.iss.translational.velocity[0..2]
vehicle.iss.attitude.quaternion[0..3]
vehicle.iss.angular_velocity[0..2]
vehicle.iss.gravity.acceleration[0..2]
vehicle.iss.mass.total
with units pulled from each typed quantity's Quantity phantom and
read+write accessors that respect the typed-frame and
witness-constructor invariants. The JEOD adapter is the single
place that knows about typed quantities; the core
bevy_var_server crate is JEOD-free.
-
Recording / logging crate — reuses the registration API and
the snapshot frame format; writes to disk instead of an HTTP
socket. The same
Frametype flows into both consumers. -
WebSocket transport add-on — a
WebSocketTransportplugin layered on the same registration API and snapshot pipeline, for clients that want a single duplex socket. - AuthN/AuthZ + non-loopback bind — required before any multi-host deployment.
-
MCP companion — a
bevy_var_server_mcpthin wrapper that exposesvar/*to LLM tools; likely a fork ofbevy_brp_mcp.
- NASA Trick variable server documentation (conceptual reference): https://nasa.github.io/trick/documentation/simulation_capabilities/Variable-Server
-
bevy_remote(BRP) docs: https://docs.rs/bevy_remote -
bevy_brp_mcp: https://github.com/natepiano/bevy_brp_mcp - Project Strategy wiki page.
- Project Type-System wiki page.
- Project Tier3-Regeneration wiki page.