A Bevy integration for SpacetimeDB.
bevy_stdb adapts SpacetimeDB's connection and callback model into Bevy-style resources, systems, plugins, and messages.
docs/architecture.md has diagrams of the pieces, the path one row
change takes from the module to a system, and what a capability actually binds.
- Builder-style setup via
StdbPlugin - Connection resource access through
StdbConnection - Command interface for sending SpacetimeDB commands through
StdbCmds - Table event bridging through table readers that are views over one ordered stream per row type
- Callback bridging for reducer/procedure completions through
StdbChannelsandadd_channel_message - Managed subscription intent through
StdbSubscriptions - Optional reconnect support through
StdbReconnectOptions, including connections the SDK reports as cleanly closed when the server went away
use bevy::prelude::*;
use bevy_stdb::prelude::*;
use crate::module_bindings::{DbConnection, PlayerInfo, PlayerInfoTableAccessor, 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::<PlayerInfoTableAccessor>()
.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. bevy_stdb runs this driver in PreUpdate, in StdbSet::Drive, ahead of StdbSet::Flush — so a row the driver delivers is readable in that same frame. An error from the driver other than the connection closing is reported through ReadStdbDriverErrorMessage:
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 row type's TableChange<T> message and stores a deferred binding that runs whenever a connection becomes active. Every capability feeds that one stream; which capabilities you bind decides which SDK callbacks run and therefore which readers you may use.
There is one registration API, bind, which takes a list of TableCapability values, and two layers of shorthand over it. bind_insert, bind_delete, bind_update, and bind_insert_update each bind one capability. The add_* methods bind the list that suits a kind of table — add_table is bind with insert, delete, and update. They all land in the same place, so mix them freely. Unsupported capabilities fail at compile time, with an error that names what the table lacks (PlayerTableAccessor does not report updated rows). Binding is idempotent: add_table followed by bind_insert for the same accessor binds on_insert once.
| Method | Use when |
|---|---|
add_table::<T> |
Table has a primary key — exposes unified changes plus insert, update, delete, and insert-or-update readers |
add_table_without_pk::<T> |
Table has no primary key — exposes unified insert/delete changes plus typed readers |
add_event_table::<T> |
Append-only log table — exposes unified insert changes plus typed readers |
add_view::<T> |
Server-computed virtual table — exposes unified insert/delete changes plus typed readers |
bind::<T>, bind_*::<T> |
Explicit control — exposes specific message readers |
add_*_with_ctx::<T> |
As the matching add_* method, with the SpacetimeDB context on every change — see The context behind a change |
// Semantic convenience APIs.
.add_table::<PlayerInfoTableAccessor>()
.add_table_without_pk::<WorldClockTableAccessor>()
.add_event_table::<DamageEventsTableAccessor>()
.add_view::<NearbyMonstersTableAccessor>()
// Capability-based API.
.bind::<PlayerInfoTableAccessor>([
TableCapability::insert(),
TableCapability::delete(),
TableCapability::update(),
])
.bind_insert::<DamageEventsTableAccessor>()Depending on the table shape, systems consume database changes through these readers:
ReadTableChangeMessage<T>— one stream for inserts, updates, and deletes in SDK callback orderReadInsertMessage<T>ReadDeleteMessage<T>ReadUpdateMessage<T>ReadInsertUpdateMessage<T>— inserts and updates as one stream, for mirroring a table without caring which it was
The connection and subscription readers are MessageReader aliases. The four table readers are views over the row type's single TableChange<T> stream: each filters it to the change kind it names and borrows out of it, so nothing is copied per reader and each keeps its own cursor. They have MessageReader's methods, limited to their change kind: read, read_with_id, len, is_empty, and clear. len and is_empty scan the unread changes, where MessageReader counts them in constant time, and there is no par_read. Values yielded by .read() borrow the affected row data (Inserted, Deleted, Updated, InsertedOrUpdated), and the SpacetimeDB context behind the change if the row type opted in to it.
Reading a stream whose callback was never bound — ReadUpdateMessage<T> on a table registered with add_table_without_pk, say — panics naming the missing callback, rather than yielding nothing forever. ReadInsertUpdateMessage<T> needs both inserts and updates bound, however they were bound: bind_insert_update is shorthand for bind_insert plus bind_update.
ReadTableChangeMessage<T> combines inserts, updates, and deletes for one row type:
use bevy_stdb::prelude::*;
use crate::module_bindings::PlayerRow;
fn on_player_change(mut changes: ReadTableChangeMessage<PlayerRow>) {
for change in changes.read() {
match change {
TableChange::Insert { row, .. } => { /* inserted */ }
TableChange::Update { old, new, .. } => { /* updated */ }
TableChange::Delete { row, .. } => { /* deleted */ }
}
}
}TableChange<T> is the one stream the SDK callbacks feed and the only place a row is stored. The readers above are views over it, each filtering to its own change kind and keeping its own cursor, so a row callback copies its row once no matter how many readers observe it. ReadInsertUpdateMessage<T> is one such view over Insert and Update rather than an additional TableChange variant.
Its deterministic property is limited to preserving the order in which the SDK invokes insert, delete, and update callbacks for one row type and connection driver; it does not recover server-side mutation order within a transaction, and streams for different row types are independent.
The stream is keyed by the row type, not the accessor. Binding both a table and a view over one row type merges their callbacks into it, and a change both of them see arrives twice with nothing to tell the two apart — subscribe to one accessor per row type when that matters.
What it costs: one copy of the row per change, plus one clone of the context's event for a row type that opted in to it, no matter how many readers observe it, because the readers borrow rather than receive their own copy. The SDK callback for a given change kind runs once however many capabilities asked for it.
Each change can carry the SpacetimeDB context its row callback received: ctx.event says which
reducer ran, with what arguments and for whom, or that the row arrived with the initial subscription
rather than as a live change. It is off by default. The SDK invokes a row callback once per changed
row, so carrying the context means a transaction touching 500 rows clones its event 500 times,
reducer arguments and all. Opt in for the row types whose readers need it, with an add_*_with_ctx
method or the ctx capability:
StdbPlugin::<DbConnection, RemoteModule>::default()
// ...
.add_table_with_ctx::<PersonTableAccessor>()
.bind::<DamageEventsTableAccessor>([TableCapability::insert(), TableCapability::ctx()])Then read it with ctx(), on a TableChange or on what a table reader yields:
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.ctx().event {
Event::Reducer(r) => {
/* r.status, r.timestamp, r.reducer */
if let Reducer::CreatePerson(p) = &r.reducer { /* ... */ }
},
_ => { /* ... */ }
}
}
}ctx() panics for a row type that was not bound with the ctx capability, naming it, rather than
reading as a change with no cause; try_ctx() returns an Option for code that handles both. The
setting applies to the row type rather than the accessor — a table and a view over one row share
the stream, so they share the setting — and it is order-independent: bind it before or after the
table's other capabilities.
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 connection is lost, or when a connection attempt fails — including a first-time failure. A disconnect you asked for, through StdbConnection::disconnect or StdbCommands, does not trigger a retry. Disconnect through those rather than an SDK context: a close made with ctx.disconnect() in a callback is reported as lost and retried. 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. StdbCommands::connect, reconnect, and disconnect also end the cycle: a disconnect cancels a retry that was already counting down, and a connect after the cycle gave up gets the full budget again.
"Lost" is decided by bevy_stdb, not by the SDK. The SDK reports a server that went away as a disconnect with no error — exactly what it reports for a disconnect you requested — so the absence of an error cannot mean "intentional". Each connection instead remembers whether this client asked it to close, and StdbDisconnectedMessage::result says which it was:
fn on_disconnected(mut messages: ReadStdbDisconnectedMessage) {
for msg in messages.read() {
match &msg.result {
Ok(DisconnectIntent::Requested) => { /* we closed it; nothing retries */ }
Ok(DisconnectIntent::Lost) => { /* it went away; reconnect will retry */ }
Err(err) => warn!("connection failed: {err}"),
}
}
}The backoff counts wall-clock time (Time<Real>), so a paused or sped-up game reconnects on the same schedule. When max_attempts is reached the cycle stops and says so once through ReadStdbReconnectExhaustedMessage.
.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, before the new connection can deliver a row
- subscriptions are re-applied
Each reconnect is a new SpacetimeDB connection with an empty client cache. The re-applied subscriptions deliver every matching row again as an insert, and rows deleted while the client was offline never arrive as deletes. If you mirror a table into entities or resources, clear the mirror when ReadStdbConnectedMessage reports a connection, before applying that frame's table changes.
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). An override is not one-shot: it replaces the stored setting, so later attempts and automatic reconnects keep using it.
StdbConnection only exists while there is a connection. 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
Handles belong to the connection that issued them, so StdbSubscriptions follows the StdbConnection resource: it re-applies every stored query to each new connection and drops the old handles once the connection is gone. It does not wait to be told the old one closed. Calling subscribe_sql again with a query that is already queued or live does nothing; one whose subscription ended, because it failed, is queued again, so a failed subscription can be retried as-is.
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());
}
}
}SpacetimeDB's <reducer>_then(...) (and procedure) callbacks run outside of the Bevy world, so you can't use SystemParams in them. If you need other clients to hear about the result of a reducer or procedure call, you should use EventTables; however, sometimes there are local-only changes that need a guaranteed response from the bevy world. This is where custom channel messages come in handy: .add_channel_message::<T>() lets you define a message type that can be sent from outside the Bevy world and read in a normal system.
#[derive(Message)]on your payload and register it withadd_channel_message::<T>()- In a system, clone a sender using
channels.sender::<T>(), then move it into the callback - Read it with
MessageReader<T>, allowingSystemParams to be used in response to that callback
use bevy_stdb::prelude::*;
use spacetimedb_sdk::__codegen::InternalError;
// The reducer result type isn't part of bevy_stdb, but comes in handy to define in your project
type ReducerResult = Result<Result<(), String>, InternalError>;
// The custom message you want to send from the reducer or procedure callback
#[derive(Message)]
pub struct CharacterCreated {
pub result: ReducerResult,
}
// During plugin setup, register the message type with .add_channel_message::<CharacterCreated>()
// .add_channel_message::<CharacterCreated>()
// A system that listens for some UI action for example, and sends your message when the SpacetimeDB callback finishes
fn create_character(conn: Res<StdbConn>, channels: Res<StdbChannels>) {
let tx = channels.sender::<CharacterCreated>();
let _ = conn.reducers().create_character_then("Bob".into(), move |_ctx, result| {
let _ = tx.send(CharacterCreated { result });
});
}
// Now we can use a regular system to handle the message, update UI, navigate to a new state, etc.
fn on_character_created(mut messages: MessageReader<CharacterCreated>, mut commands: Commands) {
for created in messages.read() {
match &created.result {
Ok(Ok(())) => { /* commands.spawn(...), next_state.set_state(...), etc... */ }
Ok(Err(reason)) => warn!("rejected: {reason}"),
Err(err) => error!("internal error: {err}"),
}
}
}Using this for reducers and procedures is the typical approach, but this bridge is actually completely generic. The add_channel_message method accepts any Message, so the same pattern lands the result of anything that finishes elsewhere (procedures, IoTaskPool tasks, HTTP responses) back in the Bevy world.
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 | MSRV |
|---|---|---|---|
| 0.1 - 0.8 | 0.18 | 2.0 - 2.6 | 1.93 |
| 0.12 | 0.19 | 2.0 - 2.6 | 1.95 |
| 0.13 | 0.19 | 2.7+ | 1.95 |
| 0.14 | 0.20 | 2.7+ | 1.97.1 |
An App supports one StdbPlugin, and so one module: the connection messages, reconnect state, and table reader bookkeeping are not keyed by module. Talking to a second database from one app is not supported yet.
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. When you need a reducer/procedure completion (or any off-schedule callback) back in the ECS, bridge it with add_channel_message / StdbChannels as shown in Reducer and procedure callbacks.
Special thanks to bevy_spacetimedb for the inspiration!