# bevy_stdb
A [Bevy](https://bevy.org/) integration for [SpacetimeDB](https://spacetimedb.com).
[](https://crates.io/crates/bevy_stdb)

[](https://docs.rs/bevy_stdb)
[](https://github.com/onx2/bevy_stdb/actions/workflows/ci.yml?query=branch%3Amain)
[](https://github.com/onx2/bevy_stdb/actions/workflows/github-code-scanning/codeql)

_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 `Message`s
- **Managed subscription intent** through `StdbSubscriptions`
- **Optional reconnect support** through `StdbReconnectOptions`
## Example
```rust
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:
```toml
# 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](https://github.com/TheBevyFlock/bevy_new_2d/) 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`:
```rust
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:
```rust
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`:
```rust
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`:
```rust
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.
| `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 |
```rust
.add_table::<PlayerInfo>(|reg, db| reg.bind(db.player_info()))
.add_table_without_pk::<WorldClock>(|reg, db| reg.bind(db.world_clock()))
```
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.
```rust
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.
```rust
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.
```rust
.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:
```rust
#[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.
```rust
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:
```rust
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
| 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`](https://docs.rs/bevy_spacetimedb/) for the inspiration!