Skip to content

Variable Server

Test User edited this page May 9, 2026 · 3 revisions

Variable Server

Status: design-only, not implemented (as of May 2026). No bevy_var_server crate 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.

1. Overview & motivation

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 curl and 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).

2. Goals & non-goals

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 via Commands — 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 Frame type.

3. Architecture

                    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:

  1. The IO task never reads or writes the ECS World directly. Reads come through the bounded snapshot channel; writes are queued onto an MPSC and applied by apply_writes_system. This makes the entire torn-read / mid-system-stomp class of bug unrepresentable.
  2. 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.
  3. Mutations flow back through apply_writes_system between ticks, which issues Commands. Cross-component invariants (unit quaternion normalization, conserved quantities) are the user's responsibility to restore in a follow-up system.

3.5 Transport protocol

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:

  1. Reuse BRP, don't fork it. The whole point of the design is "JSON-RPC on top of bevy_remote". RemoteHttpPlugin already 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.
  2. SSE is the cheapest streaming primitive that works everywhere we care about. var/subscribe replies with Content-Type: text/event-stream; the server emits one data: { sim_time, values }\n\n per cadence tick. Browsers consume it with new EventSource(url). CLI tools consume it with curl -N. No new framing, no new dep, no new schema layer.
  3. 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.
  4. WebSocket is documented as a possible additional transport. It is not part of the default surface — the recommended client path is HTTP+SSE. A WebSocketTransport add-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.

4. Public API surface

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 — adds bevy_remote::RemotePlugin and RemoteHttpPlugin, registers the snapshot system in the user-supplied sample_after set, registers apply_writes_system in VarServerSet::Apply, spawns the IO task.

  • VarServerConfig:

    • bind: SocketAddr (default 127.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 on App) with expose_component::<C>(entity, alias: &str), expose_resource::<R>(alias: &str), expose_component_with::<C>(entity, alias, accessors), and expose_component_with_unit::<C>(entity, alias, unit).

  • SimClock trait:

    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 WallClock reads Time<Fixed>::elapsed. JEOD ships a JeodTaiClock wrapper that reads SimulationTimeR.tai_tjt.

The compiler-visible API is small on purpose: everything else (method handlers, snapshot serializers, write appliers) is internal.

5. JSON-RPC method extensions

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)"

6. Variable addressing

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.

7. Snapshot consistency

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:

  1. The snapshot system is the only code that touches the live World for serving. It runs inside the schedule at the user's sample_after set, so it sees a consistent post-integration snapshot every tick.
  2. The snapshot system serializes one Frame per tick into a small pre-allocated buffer keyed by alias and pushes it to the IO task over a bounded crossbeam_channel. Bounded channel + drop-oldest policy means a stalled client cannot back-pressure the simulation.
  3. The IO task answers var/get from the most-recent frame and feeds var/subscribe streams from successive frames. It does not have a World handle at all.
  4. var/set writes flow the opposite direction — IO task → MPSC queue → apply_writes_system between 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".

8. Cadence semantics

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_ms up 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_ms in the var/subscribe response 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).

9. Sim-time stamping

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 override sim_clock.
  • "TAI" — JEOD's SimulationTimeR.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.

10. Units

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.

11. Mutation (var/set)

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 WriteCommand onto an MPSC queue read by apply_writes_system.
  • The IO task returns immediately with an accepted sequence id — the write has been queued, not necessarily applied.
  • apply_writes_system runs in VarServerSet::Apply, between ticks, and drains the queue, issuing Commands. 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 inside Accessor::rw reconstructs via the typed builder. The JEOD adapter provides this for every JEOD component.

12. Discovery

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.

13. Reference: integration patterns

13a. Bouncing ball (sim-agnostic example)

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",...}]

13b. JEOD vehicle

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.

14. Future work

  • Recording / logging crate — reuses the registration API and the snapshot frame format; writes to disk instead of an HTTP socket. The same Frame type flows into both consumers.
  • WebSocket transport add-on — a WebSocketTransport plugin 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_mcp thin wrapper that exposes var/* to LLM tools; likely a fork of bevy_brp_mcp.

15. References

Clone this wiki locally