SpacetimeDB Workflow Engine
Durable state machines for games that never forget.
You're building an online game. An NPC starts patrolling between waypoints. A player casts a 60-second buff. A factory begins producing iron ingots.
Then the server restarts.
Where was the NPC? How much time was left on the buff? Did the factory finish its cycle? Traditional code loses all of this. You're left writing complex recovery logic, checkpoint systems, or accepting that restarts break your game.
Workflows are durable state machines that survive anything:
- Timers that persist — Schedule an event for 5 minutes from now. Server restarts after 3 minutes. Timer still fires 2 minutes later.
- Signals for events — Send "threat detected" to an NPC's patrol workflow. It transitions to alert state.
- Queryable state — Clients subscribe to workflow tables. UI updates automatically when NPC state changes.
- Hierarchical — A patrol workflow spawns a combat workflow. When combat ends, patrol resumes.
- Side effects — Call reducers synchronously within workflows for game state updates.
- Broadcast signals — Subscribe to global events and receive only matching signals.
Add the workflow engine to your SpacetimeDB module:
[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"Then write your workflow as simple sequential code:
use workflow_core::prelude::*;
use workflow_macros::{workflow, Timer, Signal};
#[derive(Timer)]
enum BuffTimer { Expire }
#[derive(Signal)]
enum BuffSignal {
Dispel,
Stack(u32), // Payload auto-deserialized
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BuffInit { duration_secs: u64 }
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BuffResult { final_stacks: u32 }
// Write sequential code - the macro transforms it into a state machine
#[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 { final_stacks: stacks })
}
// Generate tables and reducers
workflow_macros::install! {
"buff" => BuffWorkflow,
}That's it. The buff survives restarts and module updates. Clients can query its state. Timer/signal names are compile-time checked.
The #[workflow] macro supports:
| Feature | Syntax | Description |
|---|---|---|
| Timer await | timer!(Timer::Variant, duration).await |
Suspend until timer fires |
| Signal in select | signal!(Signal::Variant) |
Wait for signal in select! |
| Payload binding | signal!(Signal::Stack(n)) |
Extract signal payload |
| Procedure call | procedure!("name", args).await |
Call procedure, get result |
| Spawn child | spawn!("workflow", init).await |
Spawn child, get result |
| Select | select! { ... }.await |
Wait for first matching event |
| Feature | Syntax | Description |
|---|---|---|
| Reducer call | reducer!(my_reducer(args)) |
Fire-and-forget reducer call |
| Subscribe | subscribe!(Signal::Variant(value)) |
Register for broadcast signals |
| Feature | Syntax | Description |
|---|---|---|
| For loops | for i in 0..n { ... .await } |
Loop with await inside |
| Conditionals | if cond { ... .await } else { ... } |
Branch with await |
| Loop/break/continue | loop { select! { ... }.await } |
Loop control flow |
| Early return | return Ok(...) / return Err(...) |
Exit workflow early |
| Feature | Syntax | Description |
|---|---|---|
| Mutable vars | let mut x: Type = val; |
Tracked across await points (explicit type required) |
| Feature | Description |
|---|---|
{Workflow}View struct |
Contains init + all mutable variables |
{Workflow}::view(state_data) |
Parse state_data into typed view |
#[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 { final_stacks: stacks })
}#[workflow]
fn auction(init: AuctionInit) -> Result<AuctionResult> {
let mut current_bid: u64 = init.starting_price;
let mut high_bidder: u64 = 0;
loop {
select! {
timer!(AuctionTimer::End, init.duration_secs.secs()) => break,
signal!(AuctionSignal::Bid(bid)) => {
if bid.amount > current_bid {
current_bid = bid.amount;
high_bidder = bid.bidder_id;
}
continue
},
signal!(AuctionSignal::Buyout(bidder_id)) => {
if let Some(price) = init.buyout_price {
current_bid = price;
high_bidder = bidder_id;
break
}
continue
},
}.await;
}
Ok(AuctionResult { sold: high_bidder > 0, final_price: current_bid })
}#[workflow]
fn trade(init: TradeInit) -> Result<TradeResult> {
let mut player1_accepted: bool = false;
let mut player2_accepted: bool = false;
loop {
select! {
timer!(TradeTimer::Expire, init.timeout_secs.secs()) => break,
signal!(TradeSignal::Accept(player_id)) => {
if player_id == init.player1_id { player1_accepted = true; }
else if player_id == init.player2_id { player2_accepted = true; }
if player1_accepted && player2_accepted { break }
continue
},
signal!(TradeSignal::Cancel(_)) => break,
}.await;
}
Ok(TradeResult { completed: player1_accepted && player2_accepted })
}#[workflow]
fn quest(init: QuestInit) -> Result<QuestResult> {
let mut objectives_done: u32 = 0;
loop {
select! {
timer!(QuestTimer::Expire, init.time_limit_secs.secs()) => break,
signal!(QuestSignal::ObjectiveComplete(index)) => {
if index < init.objective_count { objectives_done += 1; }
if objectives_done >= init.objective_count { break }
continue
},
signal!(QuestSignal::Abandon) => break,
}.await;
}
Ok(QuestResult { completed: objectives_done >= init.objective_count })
}#[workflow]
fn parent(init: ParentInit) -> Result<ParentResult> {
let mut total: i32 = 0;
for i in 0..init.task_count {
let result: ChildResult = spawn!("child", ChildInit { task_id: i }).await;
total += result.value;
}
Ok(ParentResult { total })
}See EXAMPLES.md for complete implementations including Combat, Respawn, Day/Night cycles, Production, and more.
The #[workflow] macro automatically generates a View struct for each workflow, allowing you to query internal state (e.g., quest progress for NPC dialogs):
// Auto-generated for each workflow:
pub struct QuestWorkflowView {
pub init: QuestInit,
pub objectives_done: u32,
pub abandoned: bool,
// ... all mutable variables
}
impl QuestWorkflow {
pub fn view(state_data: &[u8]) -> Result<QuestWorkflowView, serde_json::Error>;
}
// Usage in game code:
let workflow = Workflow::filter_by_entity_id(&player_id).next()?;
let view = QuestWorkflow::view(&workflow.state_data)?;
if view.objectives_done < view.init.objective_count {
npc.say("Bring me more goblin ears!");
}From TypeScript, parse stateData as JSON:
const workflow = conn.db.workflow.id.find(questId);
const state = JSON.parse(new TextDecoder().decode(workflow.stateData));
console.log(`Progress: ${state.objectives_done}/${state.init.objective_count}`);-
#[workflow]transforms your sequential code into a state machine:- Identifies await points (
timer!,signal!,procedure!,spawn!,select!) - Handles non-suspending calls (
reducer!,subscribe!) - Generates state struct with phase tracking and variable persistence
- Creates
WorkflowHandlerimplementation with event dispatch
- Identifies await points (
-
install! { ... }generates SpacetimeDB infrastructure:Workflowtable for workflow stateWorkflowTimerscheduled table for timersWorkflowSubscriptiontable for broadcast signal filteringworkflow_start,workflow_signal,workflow_cancelreducersworkflow_timer_firescheduled reducer (auto-called by SpacetimeDB)- Automatic workflow registration that survives module updates
-
Runtime handles persistence:
- State serialized/deserialized at each await point
- Timers scheduled via SpacetimeDB's scheduled reducers
- Signals delivered via
workflow_signalreducer - Mutable variables tracked and restored across restarts
Use #[derive(Timer)] and #[derive(Signal)] for compile-time checked event names:
#[derive(Timer)]
enum PatrolTimer {
StartPatrol,
Arrival,
Wait,
}
#[derive(Signal)]
enum PatrolSignal {
ThreatDetected(u64), // Payload auto-deserialized
StandDown,
}| Guide | Description |
|---|---|
| Concepts | Understand the mental model |
| Tutorial | Build your first workflow |
| Examples | Real-world patterns explained |
| Integration | Add workflows to your game |
| API Reference | Complete reference |
spacetime-workflow-engine/
├── core/ # workflow-core: Pure Rust library
├── macros/ # workflow-macros: Proc macros (#[workflow], Timer, Signal, install!)
├── example/ # Example SpacetimeDB module
└── tests/ # TypeScript integration tests
- mise - Development tool manager
- SpacetimeDB CLI - Install guide
# Install mise (if not already installed)
curl https://mise.run | sh
eval "$(~/.local/bin/mise activate bash)" # or zsh/fish
# Clone and setup
git clone https://github.com/perplexes/spacetime-workflow-engine
cd spacetime-workflow-engine
mise trust && mise install
mise run setupmise automatically installs the correct versions of Rust and Node.js.
# Unit tests
mise run test
# Integration tests (requires SpacetimeDB server)
mise run integrationMIT