26 KiB
AGENTS.md
项目约束
- 代码需要有完善的中文注释
- 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。
- 对工程的修改不仅要落地到代码更面,还要更改对应文档,若没有生成新的文档,文档统一存在doc目录中
- 不要擅自把现有中文文案、注释、剧情、文档改写成英文,除非用户明确要求翻译。
- 看到中文乱码时,不要直接沿用乱码文本,也不要用英文替换;先确认文件真实编码,再决定是否修改。
- 在 PowerShell 5.1 中读取或写入文本时,必须显式使用 UTF-8;如果终端输出疑似乱码,要用
Get-Content -Encoding UTF8、Python 或 Node 再次核对原文。 - 非必要不要整文件重写,尤其是包含中文的文件;优先做局部补丁,避免把未改动的中文内容重新编码。
- 修改包含中文的文件后,优先运行仓库里的编码检查,确保没有把文本写坏。
- UI面板中不要默认写一些规则描述文案,清爽一些,按照游戏UI设计规范设计即可。
- UI设计需要兼顾网页端、移动端双端的使用体验,确保在不同设备上都能正常显示和操作,移动端优先考虑。
- 不要在gitignore中添加.env.local文件。
- 严格遵循简洁的代码风格
- 前端只负责做表现,所有的逻辑、数据都放到Express后端进行运算和存储。
- 请默认保持系统的简洁性,能复用、修改、扩展现有系统、页面就不新建新系统新页面。
- 禁止将功能说明描述类的文本默认写入UI界面中。
- prd文档中每个模块的描述要落地设计到可以精准编码到位,不能出现需求落地漂移。
- 点击按钮弹出独立的面板的设计不要实现成在当前面板下面显示内容。
文档图谱
docs/
├─ README.md
├─ audits/
│ ├─ README.md
│ ├─ FUNCTION_DESIGN_AUDIT_2026-04-03.md
│ ├─ ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md
│ ├─ engineering/
│ │ ├─ README.md
│ │ ├─ ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md
│ │ ├─ ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md
│ │ ├─ ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md
│ │ └─ MONSTER_NPC_UNIFICATION_AUDIT_2026-04-06.md
│ └─ text/
│ ├─ README.md
│ ├─ CHINESE_MOJIBAKE_INVENTORY.md
│ ├─ EDITOR_GAME_PRESET_TEXT_AUDIT_2026-03-25.md
│ ├─ GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-29.md
│ ├─ GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-30.md
│ ├─ GAME_UI_PRESET_EDITOR_NPC_PROMPT_TEXT_AUDIT_2026-04-02_DEEP_SCAN.md
│ ├─ GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-30_CONTINUED.md
│ ├─ GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-31.md
│ ├─ GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-01.md
│ └─ GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-02.md
├─ design/
│ ├─ README.md
│ ├─ AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md
│ ├─ COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md
│ ├─ CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md
│ ├─ EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md
│ └─ npc-conversation-situation-draft.md
├─ experience/
│ ├─ README.md
│ ├─ ADVENTURE_RUNTIME_DEV_EXPERIENCE.md
│ ├─ AGENT_UI_CHANGELOG.md
│ ├─ CODEX_IMPLEMENTATION_EXPERIENCE_2026-03-24.md
│ ├─ CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md
│ ├─ MOBILE_UI_DEV_EXPERIENCE.md
│ ├─ PROJECT_DEVELOPMENT_EXPERIENCE.md
│ └─ PROJECT_WORK_EXPERIENCE_PLAYBOOK.md
├─ planning/
│ ├─ README.md
│ └─ CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md
├─ prd/
│ ├─ AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md
│ ├─ AI_NATIVE_CLASSIC_RPG_EXPERIENCE_BENCHMARK_PRD_2026-04-06.md
│ ├─ AI_NATIVE_CROSS_GENRE_STORY_ENGINE_PRD_2026-04-06.md
│ ├─ AI_NATIVE_CUSTOM_WORLD_CREATION_FLOW_OPTIMIZATION_PRD_2026-04-06.md
│ ├─ AI_NATIVE_NARRATIVE_THREAD_ITEM_AND_WORLD_NPC_PRD_2026-04-06.md
│ ├─ AI_NATIVE_QUEST_SYSTEM_PRD_2026-04-02.md
│ ├─ AI_NATIVE_RUNTIME_ITEM_GENERATION_DESIGN.md
│ ├─ AI_NATIVE_STORY_ENGINE_PHASE1_IMPLEMENTATION_PLAN_2026-04-06.md
│ ├─ AI_NATIVE_STORY_ENGINE_PHASE2_IMPLEMENTATION_PLAN_2026-04-06.md
│ ├─ AI_NATIVE_STORY_ENGINE_PHASE3_IMPLEMENTATION_PLAN_2026-04-06.md
│ ├─ AI_NATIVE_STORY_ENGINE_PHASE4_IMPLEMENTATION_PLAN_2026-04-06.md
│ ├─ AI_NATIVE_STORY_ENGINE_PHASE5_IMPLEMENTATION_PLAN_2026-04-06.md
│ ├─ AI_NATIVE_STORY_ENGINE_PHASE6_IMPLEMENTATION_PLAN_2026-04-06.md
│ ├─ AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md
│ ├─ BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md
│ └─ RUNTIME_ITEM_GENERATION_CURRENT_SYSTEM_DESIGN.md
├─ reference/[QwenSpriteSheetTool.tsx](src/tools/QwenSpriteSheetTool.tsx)
│ ├─ README.md
│ └─ FUNCTION_SCRIPT_CATALOG_2026-04-04.md
└─ technical/
├─ README.md
├─ AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md
├─ GO_SERVER_RUNTIME_INTEGRATION_2026-04-07.md
├─ GO_SERVER_TASKLIST_2026-04-08.md
├─ NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md
├─ PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md
└─ SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md
SpacetimeDB Rules (All Languages)
Migrating from 1.0 to 2.0?
If you are migrating existing SpacetimeDB 1.0 code to 2.0, apply spacetimedb-migration-2.0.mdc first. It documents breaking changes (reducer callbacks → event tables, name→accessor, sender() method, etc.) and should be considered before other rules.
Language-Specific Rules
| Language | Rule File |
|---|---|
| TypeScript/React | spacetimedb-typescript.mdc (MANDATORY) |
| Rust | spacetimedb-rust.mdc (MANDATORY) |
| C# | spacetimedb-csharp.mdc (MANDATORY) |
| Migrating 1.0 → 2.0 | spacetimedb-migration-2.0.mdc |
Core Concepts
- Reducers are transactional — they do not return data to callers
- Reducers must be deterministic — no filesystem, network, timers, or random
- Read data via tables/subscriptions — not reducer return values
- Auto-increment IDs are not sequential — gaps are normal, don't use for ordering
ctx.senderis the authenticated principal — never trust identity args
Feature Implementation Checklist
When implementing a feature that spans backend and client:
- Backend: Define table(s) to store the data
- Backend: Define reducer(s) to mutate the data
- Client: Subscribe to the table(s)
- Client: Call the reducer(s) from UI — don't forget this step!
- Client: Render the data from the table(s)
Common mistake: Building backend tables/reducers but forgetting to wire up the client to call them.
Index System
SpacetimeDB automatically creates indexes for:
- Primary key columns
- Columns marked as unique
You can add explicit indexes on non-unique columns for query performance.
Index names must be unique across your entire module (all tables). If two tables have indexes with the same declared name → conflict error.
Schema ↔ Code coupling:
- Your query code references indexes by name
- If you add/remove/rename an index in the schema, update all code that uses it
- Removing an index without updating queries causes runtime errors
Commands
# Login to allow remote database deployment e.g. to maincloud
spacetime login
# Start local SpacetimeDB
spacetime start
# Publish module
spacetime publish <db-name> --module-path <module-path>
# Clear and republish
spacetime publish <db-name> --clear-database -y --module-path <module-path>
# Generate client bindings
spacetime generate --lang <lang> --out-dir <out> --module-path <module-path>
# View logs
spacetime logs <db-name>
Deployment
- Maincloud is the spacetimedb hosted cloud and the default location for module publishing
- The default server marked by *** in
spacetime server listshould be used when publishing - If the default server is maincloud you should publish to maincloud
- Publishing to maincloud is free of charge
- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@/
- The database owner can view utilization and performance metrics on the dashboard
Debugging Checklist
- Is SpacetimeDB server running? (
spacetime start) - Is the module published? (
spacetime publish) - Are client bindings generated? (
spacetime generate) - Check server logs for errors (
spacetime logs <db-name>) - Is the reducer actually being called from the client?
Editing Behavior
- Make the smallest change necessary
- Do NOT touch unrelated files, configs, or dependencies
- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo
- Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users
SpacetimeDB Rust SDK
⛔ COMMON MISTAKES — LLM HALLUCINATIONS
These are actual errors observed when LLMs generate SpacetimeDB Rust code:
1. Wrong Crate for Server vs Client
// ❌ WRONG — using client crate for server module
use spacetimedb_sdk::*; // This is for CLIENTS only!
// ✅ CORRECT — use spacetimedb for server modules
use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp};
2. Wrong Table Macro Syntax
// ❌ WRONG — using attribute-style like C#
#[spacetimedb::table]
#[primary_key]
pub struct User { ... }
// ❌ WRONG — SpacetimeType on tables (causes conflicts!)
#[derive(SpacetimeType)]
#[table(accessor = my_table)]
pub struct MyTable { ... }
// ✅ CORRECT — use #[table(...)] macro with options, NO SpacetimeType
#[table(accessor = user, public)]
pub struct User {
#[primary_key]
identity: Identity,
name: Option<String>,
}
3. Wrong Table Access Pattern
// ❌ WRONG — using ctx.Db or ctx.db() method or field access
ctx.Db.user.Insert(...);
ctx.db().user().insert(...);
ctx.db.player; // Field access
// ✅ CORRECT — ctx.db is a field, table names are methods with parentheses
ctx.db.user().insert(User { ... });
ctx.db.user().identity().find(ctx.sender);
ctx.db.player().id().find(&player_id);
4. Wrong Update Pattern
// ❌ WRONG — partial update or using .update() directly on table
ctx.db.user().update(User { name: Some("new".into()), ..Default::default() });
// ✅ CORRECT — find existing, spread it, update via primary key accessor
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
ctx.db.user().identity().update(User { name: Some("new".into()), ..user });
}
5. Wrong Reducer Return Type
// ❌ WRONG — returning data from reducer
#[reducer]
pub fn get_user(ctx: &ReducerContext, id: Identity) -> Option<User> { ... }
// ❌ WRONG — mutable context
pub fn my_reducer(ctx: &mut ReducerContext, ...) { }
// ✅ CORRECT — reducers return Result<(), String> or nothing, immutable context
#[reducer]
pub fn do_something(ctx: &ReducerContext, value: String) -> Result<(), String> {
if value.is_empty() {
return Err("Value cannot be empty".to_string());
}
Ok(())
}
6. Wrong Client Connection Pattern
// ❌ WRONG — subscribing before connected
let conn = DbConnection::builder().build()?;
conn.subscription_builder().subscribe_to_all_tables(); // NOT CONNECTED YET!
// ✅ CORRECT — subscribe in on_connect callback
DbConnection::builder()
.on_connect(|conn, identity, token| {
conn.subscription_builder()
.on_applied(|ctx| println!("Ready!"))
.subscribe_to_all_tables();
})
.build()?;
7. Forgetting to Advance the Connection
// ❌ WRONG — connection never processes messages
let conn = DbConnection::builder().build()?;
// ... callbacks never fire ...
// ✅ CORRECT — must call one of these to process messages
conn.run_threaded(); // Spawn background thread
// OR
conn.run_async().await; // Async task
// OR (in game loop)
conn.frame_tick()?; // Manual polling
8. Missing Table Trait Import
// ❌ WRONG — "no method named `insert` found"
use spacetimedb::{table, reducer, ReducerContext};
ctx.db.user().insert(...); // ERROR!
// ✅ CORRECT — import Table trait for table methods
use spacetimedb::{table, reducer, Table, ReducerContext};
ctx.db.user().insert(...); // Works!
9. Wrong ScheduleAt Variant
// ❌ WRONG — At variant doesn't exist
scheduled_at: ScheduleAt::At(future_time),
// ✅ CORRECT — use Time variant
scheduled_at: ScheduleAt::Time(future_time),
10. Identity to String Conversion
// ❌ WRONG — to_hex() returns HexString<32>, not String
let id: String = identity.to_hex(); // Type mismatch!
// ✅ CORRECT — chain .to_string()
let id: String = identity.to_hex().to_string();
11. Timestamp Duration Extraction
// ❌ WRONG — returns Result, not Duration directly
let micros = ctx.timestamp.to_duration_since_unix_epoch().as_micros();
// ✅ CORRECT — unwrap the Result
let micros = ctx.timestamp.to_duration_since_unix_epoch()
.unwrap_or_default()
.as_micros();
12. Borrow After Move
// ❌ WRONG — `tool` moved into struct, then borrowed
ctx.db.stroke().insert(Stroke { tool, color, ... });
if tool == "eraser" { ... } // ERROR: value moved!
// ✅ CORRECT — check before move, or use clone
let is_eraser = tool == "eraser";
ctx.db.stroke().insert(Stroke { tool, color, ... });
if is_eraser { ... }
13. Client SDK Uses Blocking I/O
The SpacetimeDB Rust client SDK uses blocking I/O. If mixing with async runtimes (Tokio, async-std), use spawn_blocking or run the SDK on a dedicated thread to avoid blocking the async executor.
14. Wrong Schedule Syntax
// ❌ WRONG — `schedule` is not a valid table type
#[table(name = tick_timer, schedule(reducer = tick, column = scheduled_at))]
// ✅ CORRECT — `scheduled` is a valid table type
#[table(name = tick_timer, scheduled(reducer = tick, column = scheduled_at))]
1) Common Mistakes Table
Server-side errors
| Wrong | Right | Error |
|---|---|---|
#[derive(SpacetimeType)] on #[table] |
Remove it — macro handles this | Conflicting derive macros |
ctx.db.player (field access) |
ctx.db.player() (method) |
"no field player on type" |
ctx.db.player().find(id) |
ctx.db.player().id().find(&id) |
Must access via index |
&mut ReducerContext |
&ReducerContext |
Wrong context type |
Missing use spacetimedb::Table; |
Add import | "no method named insert" |
#[table(accessor = "my_table")] |
#[table(accessor = my_table)] |
String literals not allowed |
Missing public on table |
Add public flag |
Clients can't subscribe |
#[spacetimedb::reducer] |
#[reducer] after import |
Wrong attribute path |
| Network/filesystem in reducer | Use procedures instead | Sandbox violation |
| Panic for expected errors | Return Result<(), String> |
WASM instance destroyed |
2) Table Definition (CRITICAL)
Tables use #[table(...)] macro on pub struct. DO NOT derive SpacetimeType on tables!
⚠️ CRITICAL: Always import
Tabletrait — required for.insert(),.iter(),.find(), etc.
use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp};
// ❌ WRONG — DO NOT derive SpacetimeType on tables!
#[derive(SpacetimeType)] // REMOVE THIS!
#[table(accessor = task)]
pub struct Task { ... }
// ✅ CORRECT — just the #[table] attribute
#[table(accessor = user, public)]
pub struct User {
#[primary_key]
identity: Identity,
#[unique]
username: Option<String>,
online: bool,
}
#[table(accessor = message, public)]
pub struct Message {
#[primary_key]
#[auto_inc]
id: u64,
sender: Identity,
text: String,
sent: Timestamp,
}
// With multi-column index
#[table(accessor = task, public, index(name = by_owner, btree(columns = [owner_id])))]
pub struct Task {
#[primary_key]
#[auto_inc]
pub id: u64,
pub owner_id: Identity,
pub title: String,
}
Table Options
#[table(accessor = my_table)] // Private table (default)
#[table(accessor = my_table, public)] // Public table - clients can subscribe
Column Attributes
#[primary_key] // Primary key (auto-indexed, enables .find())
#[auto_inc] // Auto-increment (use with #[primary_key])
#[unique] // Unique constraint (auto-indexed)
#[index(btree)] // B-Tree index for queries
Insert returns ROW, not ID
let row = ctx.db.task().insert(Task {
id: 0, // auto-inc placeholder
owner_id: ctx.sender,
title: "New task".to_string(),
created_at: ctx.timestamp,
});
let new_id = row.id; // Get the actual ID
3) Reducers
Definition Syntax
use spacetimedb::{reducer, ReducerContext, Table};
#[reducer]
pub fn send_message(ctx: &ReducerContext, text: String) -> Result<(), String> {
// Validate input
if text.is_empty() {
return Err("Message cannot be empty".to_string());
}
// Insert returns the inserted row
let row = ctx.db.message().insert(Message {
id: 0, // auto-inc placeholder
sender: ctx.sender,
text,
sent: ctx.timestamp,
});
log::info!("Message {} sent by {:?}", row.id, ctx.sender);
Ok(())
}
Update Pattern (CRITICAL)
#[reducer]
pub fn set_name(ctx: &ReducerContext, name: String) -> Result<(), String> {
// Find existing row
let user = ctx.db.user().identity().find(ctx.sender)
.ok_or("User not found")?;
// ✅ CORRECT — spread existing row, override specific fields
ctx.db.user().identity().update(User {
name: Some(name),
..user // Preserves identity, online, etc.
});
Ok(())
}
// ❌ WRONG — partial update nulls out other fields!
// ctx.db.user().identity().update(User { identity: ctx.sender, name: Some(name), ..Default::default() });
Delete Pattern
#[reducer]
pub fn delete_message(ctx: &ReducerContext, message_id: u64) -> Result<(), String> {
ctx.db.message().id().delete(&message_id);
Ok(())
}
Lifecycle Hooks
#[reducer(init)]
pub fn init(ctx: &ReducerContext) {
// Called when module is first published
}
#[reducer(client_connected)]
pub fn client_connected(ctx: &ReducerContext) {
// ctx.sender is the connecting identity
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
ctx.db.user().identity().update(User { online: true, ..user });
} else {
ctx.db.user().insert(User {
identity: ctx.sender,
username: None,
online: true,
});
}
}
#[reducer(client_disconnected)]
pub fn client_disconnected(ctx: &ReducerContext) {
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
ctx.db.user().identity().update(User { online: false, ..user });
}
}
ReducerContext fields
ctx.sender // Identity of the caller
ctx.timestamp // Current timestamp
ctx.db // Database access
ctx.rng // Deterministic RNG (use instead of rand)
4) Index Access
Primary Key / Unique — .find() returns Option<Row>
// Primary key lookup
let user = ctx.db.user().identity().find(ctx.sender);
// Unique column lookup
let user = ctx.db.user().username().find(&"alice".to_string());
if let Some(user) = user {
// Found
}
BTree Index — .filter() returns iterator
#[table(accessor = message, public)]
pub struct Message {
#[primary_key]
#[auto_inc]
id: u64,
#[index(btree)]
room_id: u64,
text: String,
}
// Filter by indexed column
for msg in ctx.db.message().room_id().filter(&room_id) {
// Process each message in room
}
No Index — .iter() + manual filter
// Full table scan
for user in ctx.db.user().iter() {
if user.online {
// Process online users
}
}
5) Custom Types
Use #[derive(SpacetimeType)] ONLY for custom structs/enums used as fields or parameters.
use spacetimedb::SpacetimeType;
// Custom struct for table fields
#[derive(SpacetimeType, Clone, Debug, PartialEq)]
pub struct Position {
pub x: i32,
pub y: i32,
}
// Custom enum
#[derive(SpacetimeType, Clone, Debug, PartialEq)]
pub enum PlayerStatus {
Idle,
Walking(Position),
Fighting(Identity),
}
// Use in table (DO NOT derive SpacetimeType on the table!)
#[table(accessor = player, public)]
pub struct Player {
#[primary_key]
pub id: Identity,
pub position: Position,
pub status: PlayerStatus,
}
6) Scheduled Tables
use spacetimedb::{table, reducer, ReducerContext, ScheduleAt, Timestamp};
#[table(accessor = cleanup_job, scheduled(cleanup_expired))]
pub struct CleanupJob {
#[primary_key]
#[auto_inc]
scheduled_id: u64,
scheduled_at: ScheduleAt,
target_id: u64,
}
#[reducer]
pub fn cleanup_expired(ctx: &ReducerContext, job: CleanupJob) {
// Job row is auto-deleted after reducer completes
log::info!("Cleaning up: {}", job.target_id);
}
// Schedule a job
#[reducer]
pub fn schedule_cleanup(ctx: &ReducerContext, target_id: u64, delay_ms: u64) {
let future_time = ctx.timestamp + std::time::Duration::from_millis(delay_ms);
ctx.db.cleanup_job().insert(CleanupJob {
scheduled_id: 0, // auto-inc placeholder
scheduled_at: ScheduleAt::Time(future_time),
target_id,
});
}
// Cancel by deleting the row
#[reducer]
pub fn cancel_cleanup(ctx: &ReducerContext, job_id: u64) {
ctx.db.cleanup_job().scheduled_id().delete(&job_id);
}
7) Client SDK
// Connection pattern
let conn = DbConnection::builder()
.with_uri("http://localhost:3000")
.with_module_name("my-module")
.with_token(load_saved_token()) // None for first connection
.on_connect(on_connected)
.build()
.expect("Failed to connect");
// Subscribe in on_connect callback, NOT before!
fn on_connected(conn: &DbConnection, identity: Identity, token: &str) {
conn.subscription_builder()
.on_applied(|ctx| println!("Ready!"))
.subscribe_to_all_tables();
}
⚠️ CRITICAL: Advance the Connection
You MUST call one of these — without it, no callbacks fire:
conn.run_threaded(); // Background thread (simplest)
conn.run_async().await; // Async task
conn.frame_tick()?; // Manual polling (game loops)
Table Access & Callbacks
// Iterate
for user in ctx.db.user().iter() { ... }
// Find by primary key
if let Some(user) = ctx.db.user().identity().find(&identity) { ... }
// Row callbacks
ctx.db.user().on_insert(|ctx, user| { ... });
ctx.db.user().on_update(|ctx, old, new| { ... });
ctx.db.user().on_delete(|ctx, user| { ... });
// Call reducers
ctx.reducers.set_name("Alice".to_string()).unwrap();
8) Procedures (Beta)
Procedures are for side effects (HTTP, filesystem) that reducers can't do.
⚠️ Procedures are currently in beta. API may change.
use spacetimedb::{procedure, ProcedureContext};
// Simple procedure
#[procedure]
fn add_numbers(_ctx: &mut ProcedureContext, a: u32, b: u32) -> u64 {
a as u64 + b as u64
}
// Procedure with database access
#[procedure]
fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> {
// HTTP request (allowed in procedures, not reducers)
let data = fetch_from_url(&url)?;
// Database access requires explicit transaction
ctx.try_with_tx(|tx| {
tx.db.external_data().insert(ExternalData {
id: 0,
content: data,
});
Ok(())
})?;
Ok(())
}
Key differences from reducers
| Reducers | Procedures |
|---|---|
&ReducerContext (immutable) |
&mut ProcedureContext (mutable) |
Direct ctx.db access |
Must use ctx.with_tx() |
| No HTTP/network | HTTP allowed |
| No return values | Can return data |
9) Logging
use spacetimedb::log;
log::trace!("Detailed trace");
log::debug!("Debug info");
log::info!("Information");
log::warn!("Warning");
log::error!("Error occurred");
10) Commands
# Start local server
spacetime start
# Publish module
spacetime publish <module-name> --module-path <backend-dir>
# Clear database and republish
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
# Generate bindings
spacetime generate --lang rust --out-dir <client>/src/module_bindings --module-path <backend-dir>
# View logs
spacetime logs <module-name>
11) Hard Requirements
Rust-specific:
- DO NOT derive
SpacetimeTypeon#[table]structs — the macro handles this - Import
Tabletrait —use spacetimedb::Table;required for.insert(),.iter(), etc. - Use
&ReducerContext— not&mut ReducerContext - Tables are methods —
ctx.db.table()notctx.db.table - Server modules use
spacetimedbcrate — clients usespacetimedb-sdk - Reducers must be deterministic — no filesystem, network, timers, or external RNG
- Use
ctx.rng— notrandcrate for random numbers - Use
ctx.timestamp— neverstd::time::SystemTime::now()in reducers - Client MUST advance connection — call
run_threaded(),run_async(), orframe_tick() - Subscribe in
on_connectcallback — not before connection is established - Update requires full row — spread existing row with
..existing - DO NOT edit generated bindings — regenerate with
spacetime generate - Identity to String needs
.to_string()—identity.to_hex().to_string() - Client SDK is blocking — use
spawn_blockingor dedicated thread if mixing with async runtimes