bevy_stdb 0.8.0

A Bevy-native integration for SpacetimeDB with table messages, subscriptions, and reconnect support.
Documentation

bevy_stdb

A Bevy integration for SpacetimeDB.

crates.io Dependabot docs.rs CI CodeQL

Useless AI generated image that kind of looks cool Please enjoy this useless AI generated image based on the README contents of this repo.

Overview

bevy_stdb adapts SpacetimeDB's connection and callback model into Bevy-style resources, systems, plugins, and messages.

Features

  • Builder-style setup via StdbPlugin
  • Connection resource access through StdbConnection
  • Table event bridging into normal Bevy Messages
  • Managed subscription intent through StdbSubscriptions
  • Optional reconnect support through StdbReconnectOptions

Example

use bevy::prelude::*;
use bevy_stdb::prelude::*;
use crate::module_bindings::{DbConnection, PlayerInfo, RemoteModule};

#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub enum MySubKey {
    PlayerInfo,
}

pub type StdbConn = StdbConnection<DbConnection>;
pub type StdbSubs = StdbSubscriptions<MySubKey, RemoteModule>;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(
            StdbPlugin::<DbConnection, RemoteModule>::default()
                .with_database_name("my_module")
                .with_uri("http://localhost:3000")
                .add_table::<PlayerInfo>(|reg, db| reg.bind(db.player_info()))
                .with_subscriptions::<MySubKey>()
                .with_reconnect(StdbReconnectOptions::default())
                .with_background_driver(DbConnection::run_threaded),
        )
        .add_systems(Startup, connect)
        .add_systems(Update, (subscribe_on_connect, on_player_info_insert))
        .run();
}

fn connect(mut cmds: StdbCmds) {
    cmds.connect(StdbConnectOptions::default());
}

fn subscribe_on_connect(
    mut connected: ReadStdbConnectedMessage,
    mut subs: ResMut<StdbSubs>,
) {
    if connected.read().next().is_some() {
        subs.subscribe_query(MySubKey::PlayerInfo, |q| q.from.player_info());
    }
}

fn on_player_info_insert(mut msgs: ReadInsertMessage<PlayerInfo>) {
    for msg in msgs.read() {
        info!("player inserted: {:?}", msg.row);
    }
}

Connection driving

bevy_stdb supports two connection-driving modes:

  • with_background_driver(...): start SpacetimeDB's background processing for the active connection
  • with_frame_driver(...): drive SpacetimeDB from the Bevy schedule each frame

Exactly one driver must be configured. These modes are mutually exclusive, and in most applications you'll want with_background_driver(...).

If WASM support is needed, you can enable the browser feature flag in both this crate and your spacetimedb-sdk crate using a target cfg:

# Enable browser support for wasm builds.
# Replace `*` with the versions you are using.
[target.wasm32-unknown-unknown.dependencies]
spacetimedb-sdk = { version = "*", features = ["browser"] }
bevy_stdb = { version = "*", features = ["browser"] }

I recommend checking out the bevy_cli 2d template for a good starter example using WASM + native with nice Bevy features configured.

Native background driving

On native targets, the typical choice is run_threaded:

fn main() {
    let stdb_plugin = StdbPlugin::<DbConnection, RemoteModule>::default()
        .with_database_name("my_module")
        .with_uri("http://localhost:3000")
        .with_background_driver(DbConnection::run_threaded);
}

Browser / wasm background driving (async)

On browser targets, use the generated background task helper instead:

fn main() {
    let stdb_plugin = StdbPlugin::<DbConnection, RemoteModule>::default()
        .with_database_name("my_module")
        .with_uri("http://localhost:3000")
        .with_background_driver(DbConnection::run_background_task)
}

If you target both native and browser, I recommend selecting the background driver with cfg:

fn main() {
    let mut stdb_plugin = StdbPlugin::<DbConnection, RemoteModule>::default()
        .with_database_name("my_module")
        .with_uri("http://localhost:3000");

    #[cfg(target_arch = "wasm32")]
    let driver = DbConnection::run_background_task;
    #[cfg(not(target_arch = "wasm32"))]
    let driver = DbConnection::run_threaded;
    
    stdb_plugin = stdb_plugin.with_background_driver(driver);
}

Bevy frame-tick driving

Use frame_tick when you want Bevy to drive connection progress from Bevy each frame. Internally, bevy_stdb runs this driver from PreUpdate:

use bevy::prelude::*;
use bevy_stdb::prelude::*;
use crate::module_bindings::{DbConnection, RemoteModule};

fn main() {
    let stdb_plugin = StdbPlugin::<DbConnection, RemoteModule>::default()
        .with_database_name("my_module")
        .with_uri("http://localhost:3000")
        .with_frame_driver(DbConnection::frame_tick);
}

Table registration

Use the StdbPlugin builder methods to register table bindings during app setup.

Each method eagerly registers the Bevy message channels for the row type you specify and stores a deferred binding callback that runs whenever a connection becomes active.

Method Use when
add_table Table has a primary key — emits insert, update, and delete messages
add_table_without_pk Table has no primary key — emits insert and delete messages only
add_event_table Append-only log table — emits insert messages only
add_view Server-computed virtual table — emits insert and delete messages
.add_table::<PlayerInfo>(|reg, db| reg.bind(db.player_info()))
.add_table_without_pk::<WorldClock>(|reg, db| reg.bind(db.world_clock()))
.add_event_table::<DamageEvent>(|reg, db| reg.bind(db.damage_events()))
.add_view::<NearbyMonster>(|reg, db| reg.bind(db.nearby_monsters()))

Table message registration happens eagerly at startup; callback binding is deferred until a connection is active.

Messages

Depending on the table shape, bevy_stdb forwards updates into Bevy messages such as:

  • InsertMessage<T>
  • DeleteMessage<T>
  • UpdateMessage<T>
  • InsertUpdateMessage<T>

This lets normal Bevy systems react to database changes using message readers. These messages include both the affected row data and the SpacetimeDB event that triggered the change.

use crate::module_bindings::Reducer;
use bevy_stdb::prelude::*;
use spacetimedb_sdk::Event;

fn on_person_insert(mut messages: ReadInsertMessage<PersonRow>) {
  for msg in messages.read() {
    match &msg.event {
      Event::Reducer(r) => {
        /* r.status, r.timestamp, r.reducer */ 
        if let Reducer::CreatePerson(p) = &r.reducer { /* ... */ }
      },
      _ => { /* ... */ }
    }
  }
}

Subscriptions

StdbSubscriptions stores desired subscription intent separately from the live connection. Subscriptions are keyed by a type you define, so you can refer to them by domain-specific identifiers for dynamic resubscription or unsubscription.

Enable it during plugin setup with with_subscriptions, then queue subscriptions from any Bevy system — typically in response to StdbConnectedMessage. Queued intent is automatically re-applied after a reconnect.

There are two ways to subscribe:

  • subscribe_sql(key, "SELECT * FROM my_table") — raw SQL string
  • subscribe_query(key, |q| q.from.my_table()) — generated query builder

ReadStdbSubscriptionAppliedMessage and ReadStdbSubscriptionErrorMessage are emitted for the on_applied and on_error callbacks per subscription.

fn on_applied(mut applied_msgs: ReadStdbSubscriptionAppliedMessage<SubKey>, conn: Res<StdbConn>) {
  for msg in applied_msgs.read() {
    if msg.is(&SubKey::MyCharacters) {
      println!("You have {} characters.", conn.db().my_characters().count());
    }
  }
}

Reconnects

Reconnect behavior is opt-in. Pass StdbReconnectOptions to StdbPlugin::with_reconnect to enable it.

The reconnect cycle activates when a disconnect message includes an error, or when a connection attempt fails — including a first-time failure. A clean disconnect() call does not trigger a retry. While a connection attempt is in-flight the timer is paused; it re-arms once the attempt resolves. The cycle resets fully on a successful connect so the full attempt budget is available again.

.with_reconnect(StdbReconnectOptions {
    initial_delay: Duration::from_secs(1), // delay before the first retry
    backoff_factor: 1.5,                   // multiplier applied after each failure
    max_delay: Duration::from_secs(15),    // delay is capped at this value
    max_attempts: 0,                       // 0 = retry indefinitely
})

When a reconnect succeeds:

  • the StdbConnection resource is replaced
  • table callbacks are re-bound
  • subscriptions are re-applied

Type Aliases

It is useful to define some type aliases of your own. I suggest making aliases for the connection, subscription, and commands:

#[derive(Clone, Eq, Hash, PartialEq, Debug)]
pub enum SubKeys {
    PlayerInfo,
    TimeOfDay,
}

pub type StdbConn = StdbConnection<DbConnection>;
pub type StdbSubs = StdbSubscriptions<SubKeys, RemoteModule>;
pub type StdbCmds<'w, 's> = StdbCommands<'w, 's, DbConnection, RemoteModule>;

fn example_system(conn: Res<StdbConn>, mut subs: ResMut<StdbSubs>) {
    let my_table = conn.db().player_info().id().find(&1);
    subs.subscribe_query(SubKeys::TimeOfDay, |q| q.from.world_clock());
}

Using commands

Use StdbCommands<C, M> to connect or disconnect at runtime, optionally overriding the token, URI, or module name configured on the plugin.

pub type StdbCmds<'w, 's> = StdbCommands<'w, 's, DbConnection, RemoteModule>;

// Connect with plugin defaults:
fn connect(mut cmds: StdbCmds) {
    cmds.connect(StdbConnectOptions::default());
}

// Connect with a runtime token override:
fn connect_with_token(mut cmds: StdbCmds) {
    cmds.connect(StdbConnectOptions::from_token("json.web.token"));
}

See StdbConnectOptions for all available overrides (from_token, from_uri, from_database_name, from_target).

Connection-dependent resources

bevy_stdb resources are only available while a connection is active. Guard systems with resource_exists::<StdbConnection<_>>() or accept the connection as an optional parameter. If you need to detect that a connection has been lost before the resource is cleaned up, StdbConnection::is_active() checks whether the underlying send channel is still open:

use bevy::prelude::*;
use bevy_stdb::prelude::*;
use crate::module_bindings::{DbConnection, RemoteModule};

pub type StdbConn = StdbConnection<DbConnection>;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(
            StdbPlugin::<DbConnection, RemoteModule>::default()
                .with_database_name("my_module")
                .with_uri("http://localhost:3000")
                .with_background_driver(DbConnection::run_threaded),
        )
        .add_systems(
            Update,
            my_system_active.run_if(|conn: Option<Res<StdbConn>>| conn.is_some_and(|c| c.is_active()))
        )
        .add_systems(Update, my_system_option_res)
        .run();
}

fn my_system_active(conn: Res<StdbConn>) {
    // Only runs when StdbConnection resource exists
}

fn my_system_option_res(conn: Option<Res<StdbConn>>) {
    if let Some(conn) = conn {
        // Safe to access connection
    }
}

Compatibility

bevy_stdb bevy spacetimedb_sdk
0.1 - 0.2 0.18 2.0
0.3 - 0.8 0.18 2.1

Notes

This crate focuses on table-driven client workflows. Reducer and procedure access still exist through the active StdbConnection, but the primary Bevy-facing event flow is table/message based.

Special thanks to bevy_spacetimedb for the inspiration!