-
Notifications
You must be signed in to change notification settings - Fork 0
Variable Server
bevy_var_server is a sim-agnostic, Bevy-native equivalent of NASA
Trick's variable server: a remote interface that lets 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 shape — 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 (v1):
- 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.
- Loopback-only by default; binding non-loopback is opt-in.
Non-goals (v1, deferred):
- Trick wire compatibility (UDP, ASCII framing,
var_*command syntax). A separate sibling cratebevy_var_server_trickis the long-term home for that. - Authentication, TLS, multi-tenant isolation. v1 trusts the loopback boundary.
- Unit conversion à la
var_units— we report the SI unit of each variable; conversion is the client's job in v1. - High-throughput recording. A separate logger crate will reuse the same registration API.
- Mutation (
var/set). Designed but not shipped in v1; see §11.
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> │
│ ▼ │
│ ┌──────────────────────────┐ runs on IoTaskPool │
│ │ io task │ │
│ │ - bevy_remote HTTP │ │
│ │ JSON-RPC terminator │ │
│ │ - SSE writer for each │ │
│ │ var/subscribe client │ │
│ │ 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 the ECS
World. All reads come through the bounded channel from the snapshot system. This makes the entire torn-read 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 (when they ship in v2) flow back through a
Commandsqueue applied between ticks — never mid-system, never racing the snapshot.
We surveyed the candidate transports for v1:
| Protocol | Pros | Cons | Browser-reachable? | Streaming model |
|---|---|---|---|---|
| TCP raw + custom framing (Trick's mode) | 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"; matches one Trick mode | 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 in v1.
Rationale:
-
Reuse BRP, don't fork it. The whole point of the Option-A 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. -
WebSocket is the v2 escape hatch for clients that genuinely
need a single duplex socket (e.g. a 3D viewer that also writes
commands at high rate). We document the upgrade path now and add
it when a real user asks. Until then,
var/set(mutation) goes through normal HTTP/JSON-RPC. - UDP / QUIC / gRPC are explicitly rejected for v1. 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 today. A future Trick-wire-compat sibling crate may ship UDP, isolated from the main API.
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, 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)andexpose_resource::<R>(alias: &str). -
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) 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] } |
{ sim_time, values: { alias -> json } } |
var/subscribe |
{ aliases: [String], cycle_ms: u64 } |
SSE stream of { sim_time, values }
|
var/unsubscribe |
{ subscription_id } |
null |
var/pause |
{ subscription_id, paused: bool } |
null |
var/cycle |
{ subscription_id, cycle_ms } |
{ effective_cycle_ms } |
var/set |
(deferred — see §11) |
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 http://127.0.0.1:7000/sse?sub=<id>where <id> was returned by an earlier var/subscribe JSON-RPC
call. (The exact URL shape — query param vs path — is an
implementation detail to settle during the v1 build.)
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 at registration time:
server.expose_component_with::<TranslationalStateC<Earth>>(
vehicle,
"vehicle.iss.translational",
[
("position[0]", |c| c.position.raw_si().x.into()),
("position[1]", |c| c.position.raw_si().y.into()),
("position[2]", |c| c.position.raw_si().z.into()),
("velocity[0]", |c| c.velocity.raw_si().x.into()),
// ...
],
);The JEOD adapter (§13) packages this for every JEOD component so mission code never writes the closures by hand.
Trick exposes var_set_copy_mode because clients otherwise 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.
This rules out: torn reads, mid-system observation, racing readers, 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");Client-requested unit conversion (the Trick var_units analog) is
out of scope for v1. v2 will add it once usage shows demand;
likely as an optional unit field on var/get and var/subscribe.
Designed for v2, not shipped in v1. Sketch:
-
var/set { alias, value }returns immediately with a sequence id. - The IO task pushes a
WriteCommandonto an MPSC queue read by a deferred system that runs between ticks (in a user-named set likeVarServerSet::Apply). - The deferred system applies writes via
Commandsso they take effect at the next sync point. This meansvar/setnever races a running system and never tears state across a tick. - Cross-component invariants (e.g. unit-quaternion normalization, conserved quantities) are the user's responsibility to restore in a follow-up system.
- For JEOD typed components that require a witness-gated
constructor (
BodyAttitude<V>,TranslationalStateC<P>), the setter must reconstruct via the typed builder. The JEOD adapter provides a per-component setter closure analogous to the field accessors in §6.
We design v2 now to make sure the v1 surface (registration API, alias scheme, transport choice) does not foreclose it.
v1 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 future Trick-wire-compatible sibling crate
(bevy_var_server_trick) will document the Trick discovery
conventions if/when it ships.
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 bevy_jeod::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.
The JEOD adapter is the single place that knows about typed
quantities; the core bevy_var_server crate is JEOD-free.
-
bevy_var_server_trick— Trick wire-protocol compatibility. Separate crate so the main crate stays free of UDP framing, ASCII parsing, and the historical command syntax. Will get its own wiki page when it ships. -
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. -
var/set(v2) — see §11. -
Unit conversion (v2) —
var_units-style on-server conversion. - WebSocket sibling endpoint (v2) — for clients that genuinely need full duplex on one 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: 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.