Skip to content

A temporal-like workflow engine for SpacetimeDB, for durable execution of e.g. MOB behaviors, spell buffs, anything that is a persistent background job

Notifications You must be signed in to change notification settings

perplexes/spacetime-workflow-engine

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

42 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SpacetimeDB Workflow Engine

Durable state machines for games that never forget.

The Problem

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.

The Solution

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.

Quick Start

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.

Macro Features

The #[workflow] macro supports:

Suspending (await points)

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

Non-suspending (immediate)

Feature Syntax Description
Reducer call reducer!(my_reducer(args)) Fire-and-forget reducer call
Subscribe subscribe!(Signal::Variant(value)) Register for broadcast signals

Control flow

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

State tracking

Feature Syntax Description
Mutable vars let mut x: Type = val; Tracked across await points (explicit type required)

State query (auto-generated)

Feature Description
{Workflow}View struct Contains init + all mutable variables
{Workflow}::view(state_data) Parse state_data into typed view

Examples

Buff/Debuff (Timed Effect)

#[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 })
}

Auction (Timed Bidding)

#[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 })
}

Trade (Multi-Party Coordination)

#[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 })
}

Quest (Progress Tracking)

#[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 })
}

Parent-Child (Hierarchical)

#[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.

Querying Workflow State

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}`);

How It Works

  1. #[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 WorkflowHandler implementation with event dispatch
  2. install! { ... } generates SpacetimeDB infrastructure:

    • Workflow table for workflow state
    • WorkflowTimer scheduled table for timers
    • WorkflowSubscription table for broadcast signal filtering
    • workflow_start, workflow_signal, workflow_cancel reducers
    • workflow_timer_fire scheduled reducer (auto-called by SpacetimeDB)
    • Automatic workflow registration that survives module updates
  3. Runtime handles persistence:

    • State serialized/deserialized at each await point
    • Timers scheduled via SpacetimeDB's scheduled reducers
    • Signals delivered via workflow_signal reducer
    • Mutable variables tracked and restored across restarts

Type-Safe Events

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,
}

Documentation

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

Project Structure

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

Requirements

Quick Setup

# 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 setup

mise automatically installs the correct versions of Rust and Node.js.

Running Tests

# Unit tests
mise run test

# Integration tests (requires SpacetimeDB server)
mise run integration

License

MIT

About

A temporal-like workflow engine for SpacetimeDB, for durable execution of e.g. MOB behaviors, spell buffs, anything that is a persistent background job

Resources

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 2

  •  
  •