How to add the workflow engine to your SpacetimeDB game.
The workflow engine integrates into your game via Cargo dependencies. You add two crates, write workflows as sequential code, and register them with one macro. That's it.
[dependencies]
workflow-core = { git = "https://github.com/perplexes/spacetime-workflow-engine" }
workflow-macros = { git = "https://github.com/perplexes/spacetime-workflow-engine" }
spacetimedb = "1.11"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"Create workflow files using the #[workflow] macro:
// src/workflows/buff.rs
use workflow_core::prelude::*;
use workflow_macros::{workflow, Timer, Signal};
#[derive(Timer)]
pub enum BuffTimer { Expire }
#[derive(Signal)]
pub enum BuffSignal {
Dispel,
Stack(u32),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuffInit {
pub effect_type: String,
pub magnitude: i32,
pub duration_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuffResult {
pub effect_type: String,
pub final_stacks: u32,
}
#[workflow]
fn buff(init: BuffInit) -> Result<BuffResult> {
let mut stacks: u32 = 1;
loop {
select! {
timer!(BuffTimer::Expire, init.duration_secs.secs()) => break,
signal!(BuffSignal::Dispel) => break,
signal!(BuffSignal::Stack(n)) => {
stacks += n;
continue
},
}.await;
}
Ok(BuffResult {
effect_type: init.effect_type.clone(),
final_stacks: stacks,
})
}In your module's lib.rs, call the install! macro with your workflow registrations:
// src/lib.rs
use spacetimedb::{reducer, ReducerContext};
use workflow_core::prelude::*;
mod workflows;
// Generate tables, reducers, and register workflows
workflow_macros::install! {
"buff" => workflows::BuffWorkflow,
"countdown" => workflows::CountdownWorkflow,
"patrol" => workflows::PatrolWorkflow,
}The macro generates:
Workflowtable (stores workflow state)WorkflowTimertable (scheduled timers)LastWorkflowIdtable (for retrieving created workflow IDs)WorkflowStatusenumworkflow_startreducerworkflow_signalreducerworkflow_cancelreducerworkflow_timer_firereducer (called automatically by SpacetimeDB)- Automatic workflow registration that survives module updates
Add game-specific reducers that start workflows and send signals:
// src/lib.rs (continued)
#[reducer]
pub fn apply_buff(
ctx: &ReducerContext,
entity_id: u64,
effect_type: String,
magnitude: i32,
duration_secs: u64,
) -> Result<(), String> {
let init = workflows::BuffInit {
effect_type,
magnitude,
duration_secs,
};
let data = serde_json::to_vec(&init).map_err(|e| e.to_string())?;
workflow_start(ctx, "buff".to_string(), Some(entity_id), None, data)
}
#[reducer]
pub fn dispel_buff(ctx: &ReducerContext, workflow_id: u64) -> Result<(), String> {
workflow_signal(ctx, workflow_id, "dispel".to_string(), vec![])
}
#[reducer]
pub fn stack_buff(ctx: &ReducerContext, workflow_id: u64, amount: u32) -> Result<(), String> {
let payload = serde_json::to_vec(&amount).map_err(|e| e.to_string())?;
workflow_signal(ctx, workflow_id, "stack".to_string(), payload)
}Workflows are stored in the workflow table. Query them from your code:
use spacetimedb::Table;
// Find all active workflows for an entity
pub fn get_entity_workflows(ctx: &ReducerContext, entity_id: u64) -> Vec<Workflow> {
ctx.db.workflow().iter()
.filter(|w| w.entity_id == Some(entity_id) && w.is_active())
.collect()
}
// Find a specific workflow
pub fn get_workflow(ctx: &ReducerContext, id: u64) -> Option<Workflow> {
ctx.db.workflow().id().find(&id)
}Clients can subscribe to workflow tables:
import { DbConnection } from './bindings';
const conn = await DbConnection.builder()
.withUri('ws://localhost:3000')
.withModuleName('my-game')
.build();
// Subscribe to workflows for a specific entity
conn.subscriptionBuilder()
.subscribe(`SELECT * FROM workflow WHERE entity_id = ${playerId}`);
// React to workflow changes
conn.db.workflow.onInsert((workflow) => {
console.log(`New workflow: ${workflow.workflowType} - ${workflow.status}`);
});
conn.db.workflow.onUpdate((oldWorkflow, newWorkflow) => {
console.log(`Workflow ${newWorkflow.id} status: ${newWorkflow.status}`);
});
// Start a workflow via reducer
await conn.reducers.applyBuff(entityId, "strength", 10, 60);
// Send a signal
await conn.reducers.dispelBuff(workflowId);Here's a full module with the workflow engine:
// src/lib.rs
use spacetimedb::{reducer, ReducerContext};
use workflow_core::prelude::*;
use workflow_macros::{workflow, Timer, Signal};
// ============================================
// BUFF WORKFLOW
// ============================================
#[derive(Timer)]
enum BuffTimer { Expire }
#[derive(Signal)]
enum BuffSignal { Dispel, Stack(u32) }
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BuffInit {
effect_type: String,
magnitude: i32,
duration_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BuffResult {
effect_type: String,
final_stacks: u32,
}
#[workflow]
fn buff(init: BuffInit) -> Result<BuffResult> {
let mut stacks: u32 = 1;
loop {
select! {
timer!(BuffTimer::Expire, init.duration_secs.secs()) => break,
signal!(BuffSignal::Dispel) => break,
signal!(BuffSignal::Stack(n)) => {
stacks += n;
continue
},
}.await;
}
Ok(BuffResult {
effect_type: init.effect_type.clone(),
final_stacks: stacks,
})
}
// ============================================
// INSTALL WORKFLOW ENGINE
// ============================================
workflow_macros::install! {
"buff" => BuffWorkflow,
}
// ============================================
// GAME REDUCERS
// ============================================
#[reducer]
pub fn apply_buff(
ctx: &ReducerContext,
entity_id: u64,
effect_type: String,
magnitude: i32,
duration_secs: u64,
) -> Result<(), String> {
let init = BuffInit { effect_type, magnitude, duration_secs };
let data = serde_json::to_vec(&init).map_err(|e| e.to_string())?;
workflow_start(ctx, "buff".to_string(), Some(entity_id), None, data)
}
#[reducer]
pub fn dispel_buff(ctx: &ReducerContext, workflow_id: u64) -> Result<(), String> {
workflow_signal(ctx, workflow_id, "dispel".to_string(), vec![])
}
#[reducer]
pub fn stack_buff(ctx: &ReducerContext, workflow_id: u64, amount: u32) -> Result<(), String> {
let payload = serde_json::to_vec(&amount).map_err(|e| e.to_string())?;
workflow_signal(ctx, workflow_id, "stack".to_string(), payload)
}-
Keep workflows focused — Each workflow handles one behavior. Don't combine unrelated logic.
-
Use entity_id — Always pass the entity ID for workflows that affect game entities. Makes querying easy.
-
Use correlation_id for grouping — Related workflows (e.g., all effects from one spell) can share a correlation ID.
-
Workflows are for durable state — Use workflows for things that need to survive restarts. Don't use them for frame-by-frame logic.
-
Transaction safety — Workflow handlers run in the same transaction as the trigger. If your handler fails, everything rolls back.
-
Mutable variables need explicit types —
let mut count: u32 = 0;notlet mut count = 0;
Recommended layout:
my-game/
├── src/
│ ├── lib.rs # Main module, install! macro, game reducers
│ └── workflows/
│ ├── mod.rs # Re-exports
│ ├── buff.rs # Buff workflow
│ ├── patrol.rs # Patrol workflow
│ └── combat.rs # Combat workflow
├── Cargo.toml
└── tests/
└── integration.ts # TypeScript integration tests
→ API Reference — Complete API documentation → Examples — Real-world workflow patterns