A Bevy integration for SpacetimeDB.
bevy_stdb adapts SpacetimeDB's connection and callback model into Bevy-style resources, systems, plugins, and messages.
- Builder-style setup via
StdbPlugin - Connection resource access through
StdbConnection - Command interface for sending SpacetimeDB commands through
StdbCmds - Table event bridging through Bevy
MessageReaderaliases - Managed subscription intent through
StdbSubscriptions - Optional reconnect support through
StdbReconnectOptions
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>;
pub type StdbCmds<'w, 's> = StdbCommands<'w, 's, DbConnection, 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);
}
}bevy_stdb supports two connection-driving modes:
with_background_driver(...): start SpacetimeDB's background processing for the active connectionwith_frame_driver(...): drive SpacetimeDB from the Bevy schedule each frame
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.
On native targets, the typical choice is run_threaded:
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_background_driver(DbConnection::run_threaded);
}On browser targets, use the generated background task helper instead:
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_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);
}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);
}Use the StdbPlugin builder methods to register table bindings during app setup.
Each method eagerly registers the internal 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 — exposes insert, update, delete, and insert-or-update message readers |
add_table_without_pk |
Table has no primary key — exposes insert and delete message readers |
add_event_table |
Append-only log table — exposes insert message readers |
add_view |
Server-computed virtual table — exposes insert and delete message readers |
.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 event channel registration happens eagerly at startup; callback binding is deferred until a connection is active.
Depending on the table shape, systems consume database changes through MessageReader aliases:
ReadInsertMessage<T>ReadDeleteMessage<T>ReadUpdateMessage<T>ReadInsertUpdateMessage<T>
These aliases are MessageReaders backed by internal message channels. The message types themselves are not part of the public API, so application code can observe table events without writing them directly. Values yielded by .read() expose 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 { /* ... */ }
},
_ => { /* ... */ }
}
}
}By default, start a connection from a Bevy system with StdbCommands::connect. To start the initial connection during plugin setup, add with_eager_connection() to StdbPlugin.
use bevy::prelude::*;
use bevy_stdb::prelude::*;
use crate::module_bindings::{DbConnection, RemoteModule};
pub type StdbCmds<'w, 's> = StdbCommands<'w, 's, DbConnection, RemoteModule>;
// main fn...
// Use regular bevy system to request a connection via the `StdbCmds` command interface
fn request_connect(mut stdb_cmds: StdbCmds) {
stdb_cmds.connect(StdbConnectOptions::default());
}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
StdbConnectionresource is replaced - table callbacks are re-bound
- subscriptions are re-applied
Use StdbCommands<C, M> to connect or disconnect at runtime, optionally overriding the token, URI, or database 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).
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
}
}Subscriptions are required to tell Spacetime which table data you want to sync to the client. You can directly subscribe using the SDK's standard subscription_builder exposed on the connection; however this crate offers a lightweight wrapper to manage them, StdbSubscriptions. It stores your desired subscription intent separately from the live connection so they can be reapplied when connections change.
That means you can:
- enable subscription management during plugin setup using
with_subscriptions - queue subscriptions later from normal Bevy systems, typically by reading
ReadStdbConnectedMessage - automatically re-apply queued subscription intent after reconnect
Subscriptions are keyed, so you can refer to them using domain-specific identifiers to do things like resubscribe dynamically or unsubscribe.
Subscription callbacks are exposed through ReadStdbSubscriptionAppliedMessage<K> and ReadStdbSubscriptionErrorMessage<K>.
// Check the client cache once a particular subscription has been applied.
fn on_applied(mut applied_messages: ReadStdbSubscriptionAppliedMessage<SubKey>, conn: Res<StdbConn>) {
for message in applied_messages.read() {
if message.is(&SubKey::MyCharacters) {
println!("You have {} characters.", conn.db().my_characters().count());
}
}
}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());
}| bevy_stdb | bevy | spacetimedb_sdk |
|---|---|---|
| 0.1 - 0.2 | 0.18 | 2.0 |
| 0.3 - 0.8 | 0.18 | 2.1 |
| 0.9 | 0.18 | 2.2 |
This crate focuses on table-driven client workflows. Reducer and procedure access still exist through the active StdbConnection, but the primary Bevy-facing flow uses message readers for table events.
Special thanks to bevy_spacetimedb for the inspiration!