Skip to content

Variable Server

simnaut edited this page May 6, 2026 · 3 revisions

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.

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 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 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 (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 crate bevy_var_server_trick is 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.

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>               │
   │               ▼                                                │
   │  ┌──────────────────────────┐    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:

  1. 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.
  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 (when they ship in v2) flow back through a Commands queue applied between ticks — never mid-system, never racing the snapshot.

3.5 Transport protocol

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:

  1. Reuse BRP, don't fork it. The whole point of the Option-A 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. 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.
  4. 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.

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, 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) and expose_resource::<R>(alias: &str).

  • 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) 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] } { 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.)

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

7. Snapshot consistency

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:

  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.

This rules out: torn reads, mid-system observation, racing readers, 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");

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.

11. Mutation (var/set)

Designed for v2, not shipped in v1. Sketch:

  • var/set { alias, value } returns immediately with a sequence id.
  • The IO task pushes a WriteCommand onto an MPSC queue read by a deferred system that runs between ticks (in a user-named set like VarServerSet::Apply).
  • The deferred system applies writes via Commands so they take effect at the next sync point. This means var/set never 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.

12. Discovery

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.

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

14. Future work

  • 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 Frame type 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_mcp thin wrapper that exposes var/* to LLM tools; likely a fork of bevy_brp_mcp.

15. References

Clone this wiki locally