Imported from OohBen/swarm-arena (
server/AGENTS.md). Install upstream withnpx skills add OohBen/swarm-arena --skill server. Copyright stays with the author.
SpacetimeDB Core Concepts
SpacetimeDB is a relational database that is also a server. It lets you upload application logic directly into the database via WebAssembly modules, eliminating the traditional web/game server layer entirely.
Critical Rules
- Reducers are transactional. They do not return data to callers. Use subscriptions to read data.
- Reducers must be deterministic. No filesystem, network, timers, or random. All state must come from tables.
- Read data via tables/subscriptions, not reducer return values. Clients get data through subscribed queries.
- Auto-increment IDs are not sequential. Gaps are normal, do not use for ordering. Use timestamps or explicit sequence columns.
ctx.senderis the authenticated principal. Never trust identity passed as arguments.
Feature Implementation Checklist
- 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
- Client: Render the data from the table(s)
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?
Tables
- Private tables (default): Only accessible by reducers and the database owner.
- Public tables: Exposed for client read access through subscriptions. Writes still require reducers.
Organize data by access pattern, not by entity:
Player PlayerState PlayerStats
id <-- player_id player_id
name position_x total_kills
position_y total_deaths
velocity_x play_time
Reducers
Reducers are transactional functions that modify database state. They run atomically, cannot interact with the outside world, and do not return data to callers. See the language-specific server skills for syntax.
Event Tables
Event tables broadcast reducer-specific data to clients. Rows are never stored in the client cache (count() returns 0, iter() yields nothing); only onInsert callbacks fire.
Subscriptions
Subscriptions replicate database rows to clients in real-time.
- Subscribe: Register SQL queries describing needed data
- Receive initial data: All matching rows are sent immediately
- Receive updates: Real-time updates when subscribed rows change
- React to changes: Use callbacks (
onInsert,onDelete,onUpdate)
Best practices:
- Group subscriptions by lifetime
- Subscribe before unsubscribing when updating subscriptions
- Avoid overlapping queries
- Use indexes for efficient queries
Modules
Modules are WebAssembly bundles containing application logic that runs inside the database.
- Tables: Define the data schema
- Reducers: Define callable functions that modify state
- Event Tables: Broadcast reducer-specific data to clients
- Views: Read-only functions that expose computed subsets of data to clients
- Procedures: (Unstable) Functions that can have side effects (HTTP requests,
ctx.withTx)
Server-side modules can be written in: Rust, C#, TypeScript, C++
Lifecycle: Write → Compile → Publish (spacetime publish) → Hot-swap (republish without disconnecting clients)
Identity
- Identity: A long-lived, globally unique identifier for a user.
- ConnectionId: Identifies a specific client connection.
- Always use
ctx.sender/ctx.Sender/ctx.sender()for authorization.
SpacetimeDB works with many OIDC providers, including SpacetimeAuth (built-in), Auth0, Clerk, Keycloak, Google, and GitHub.
SpacetimeDB CLI
Use this skill when the user needs help with the spacetime CLI tool - initializing projects, building modules, publishing databases, querying data, managing servers, or troubleshooting CLI issues.
Quick Reference
Project Initialization & Development
# Initialize new project
spacetime init my-project --lang rust|csharp|typescript|cpp
spacetime init my-project --template <template-id>
# Build module
spacetime build # release build
spacetime build --debug # faster iteration, slower runtime
# Dev mode (auto-rebuild, auto-publish, generates bindings)
spacetime dev
spacetime dev --client-lang typescript --module-bindings-path ./client/src/module_bindings
# Generate client bindings
spacetime generate --lang typescript|csharp|rust|unrealcpp --out-dir ./bindings --module-path ./server
Publishing & Deployment
# Publish to Maincloud (default)
spacetime publish my-database --yes
# Publish to local server
spacetime publish my-database --server local --yes
# Clear database and republish
spacetime publish my-database --delete-data always --yes
Database Interaction
# SQL queries
spacetime sql my-database "SELECT * FROM users"
spacetime sql my-database --interactive # REPL mode
# Call reducers (each argument is a separate positional arg)
spacetime call my-database my_reducer '"value"' '123'
# Subscribe to changes
spacetime subscribe my-database "SELECT * FROM users" --num-updates 10
# View logs
spacetime logs my-database -f # follow logs
spacetime logs my-database -n 100 # up to 100 log lines
# Describe schema
spacetime describe my-database --json
spacetime describe my-database table users --json
spacetime describe my-database reducer my_reducer --json
Database Management
# List databases
spacetime list
# Delete database
spacetime delete my-database
# Rename database
spacetime rename <database-identity> --to new-name
Server Management
# List configured servers
spacetime server list
# Add server
spacetime server add local --url http://localhost:3000 --default
spacetime server add myserver --url https://my-spacetime.example.com
# Set default server
spacetime server set-default local
# Test connectivity
spacetime server ping local
# Start local instance
spacetime start
# Clear local data
spacetime server clear
Authentication
# Login (opens browser)
spacetime login
# Login with token
spacetime login --token <token>
# Show login status
spacetime login show
# Logout
spacetime logout
Default Servers
| Name | URL | Description |
|---|---|---|
maincloud |
https://maincloud.spacetimedb.com |
Production cloud (default) |
local |
http://127.0.0.1:3000 |
Local development server |
Common Flags
| Flag | Short | Description |
|---|---|---|
--server |
-s |
Target server (nickname, hostname, or URL) |
--yes |
-y |
Non-interactive mode (skip confirmations) |
--anonymous |
Use anonymous identity | |
--module-path |
-p |
Path to module project |
Troubleshooting
"Not logged in"
spacetime login
# Or use --anonymous for public operations
"Server not responding"
spacetime server ping <server>
# For local: ensure spacetime start is running
"Schema conflict"
# Clear data and republish
spacetime publish my-db --delete-data always --yes
"Build failed"
# Check Rust/C# toolchain
rustup show
# For Rust modules, ensure wasm32-unknown-unknown target
rustup target add wasm32-unknown-unknown
Module Languages
Server-side (modules): Rust, C#, TypeScript, C++
Client SDKs: TypeScript, C#, Rust, Unreal Engine
CLI generate targets: TypeScript, C#, Rust, Unreal C++
SpacetimeDB TypeScript SDK Reference
Imports
import { schema, table, t } from 'spacetimedb/server';
import { SenderError } from 'spacetimedb/server';
import { ScheduleAt } from 'spacetimedb'; // for scheduled tables only
Tables
table(OPTIONS, COLUMNS) takes two arguments. The name field MUST be snake_case:
const entity = table(
{ name: 'entity', public: true },
{
identity: t.identity().primaryKey(),
name: t.string(),
active: t.bool(),
}
);
Options: name (snake_case, recommended), public: true, event: true, scheduled: (): any => reducerRef, indexes: [...]
ctx.db accessors are the camelCase form of the table's name field.
Column Types
| Builder | JS type | Notes |
|---|---|---|
t.u64() |
bigint | Use 0n literals |
t.i64() |
bigint | Use 0n literals |
t.u32() / t.i32() |
number | |
t.f64() / t.f32() |
number | |
t.bool() |
boolean | |
t.string() |
string | |
t.identity() |
Identity | |
t.connectionId() |
ConnectionId | |
t.timestamp() |
Timestamp | |
t.timeDuration() |
TimeDuration | |
t.scheduleAt() |
ScheduleAt |
Modifiers: .primaryKey(), .autoInc(), .unique(), .index('btree')
Optional columns: nickname: t.option(t.string())
Indexes
Prefer inline .index('btree') for single-column. Use named indexes only for multi-column:
// Inline (preferred for single-column):
authorId: t.u64().index('btree'),
// Access: ctx.db.post.authorId.filter(authorId);
// Multi-column (named):
indexes: [{ accessor: 'by_group_user', algorithm: 'btree', columns: ['groupId', 'userId'] }]
// Access: ctx.db.membership.by_group_user.filter([groupId, userId]);
When you frequently look up rows by multiple columns, prefer a multi-column index over filtering by one column and looping over the results. Multi-column filter takes an array matching the index column order. You can omit trailing columns to do a prefix scan.
Schema Export
const spacetimedb = schema({ entity, record }); // ONE object, not spread args
export default spacetimedb;
Reducers
Export name becomes the reducer name:
export const createEntity = spacetimedb.reducer(
{ name: t.string(), age: t.i32() },
(ctx, { name, age }) => {
ctx.db.entity.insert({ identity: ctx.sender, name, age, active: true });
}
);
// No arguments, just the callback:
export const doReset = spacetimedb.reducer((ctx) => { ... });
DB Operations
ctx.db.entity.insert({ id: 0n, name: 'Sample' }); // Insert (0n for autoInc)
ctx.db.entity.id.find(entityId); // Find by PK → row | null
ctx.db.entity.identity.find(ctx.sender); // Find by unique column
[...ctx.db.item.authorId.filter(authorId)]; // Filter → spread to Array
[...ctx.db.entity.iter()]; // All rows → Array
ctx.db.entity.id.update({ ...existing, name: newName }); // Update (spread + override)
ctx.db.entity.id.delete(entityId); // Delete by PK
Note: iter() and filter() return iterators. Spread to Array for .sort(), .filter(), .map().
Lifecycle Hooks
MUST be export const. Bare calls are silently ignored:
export const init = spacetimedb.init((ctx) => { ... });
export const onConnect = spacetimedb.clientConnected((ctx) => { ... });
export const onDisconnect = spacetimedb.clientDisconnected((ctx) => { ... });
Reducer Context API
ReducerContext is the single source of sender identity, deterministic time, and deterministic randomness inside a reducer. Always go through ctx for these. Standard library clocks and random sources are not available in modules.
// Auth: ctx.sender is the caller's Identity
if (!row.owner.equals(ctx.sender)) throw new SenderError('unauthorized');
// Server timestamp (deterministic per reducer call)
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
// Deterministic RNG
const f: number = ctx.random(); // [0.0, 1.0)
const roll: number = ctx.random.integerInRange(1, 6); // inclusive
const bytes: Uint8Array = ctx.random.fill(new Uint8Array(16));
// Client: Timestamp → Date
new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
Scheduled Tables
const tickTimer = table({
name: 'tick_timer',
scheduled: (): any => tick, // (): any => breaks circular dep
}, {
scheduled_id: t.u64().primaryKey().autoInc(),
scheduled_at: t.scheduleAt(),
});
export const tick = spacetimedb.reducer(
{ timer: tickTimer.rowType },
(ctx, { timer }) => { /* timer row auto-deleted after this runs */ }
);
// One-time: ScheduleAt.time(ctx.timestamp.microsSinceUnixEpoch + delayMicros)
// Repeating: ScheduleAt.interval(60_000_000n)
Custom Types
// Product type (struct):
const Position = t.object('Position', { x: t.i32(), y: t.i32() });
const entity = table({ name: 'entity' }, {
id: t.u64().primaryKey().autoInc(),
pos: Position,
});
// Sum type (tagged union):
const Shape = t.enum('Shape', {
circle: t.i32(),
rectangle: t.object('Rect', { w: t.i32(), h: t.i32() }),
});
// Values: { tag: 'circle', value: 10 }
Views
// Anonymous view (same for all clients):
export const activeUsers = spacetimedb.anonymousView(
{ name: 'active_users', public: true },
t.array(entity.rowType),
(ctx) => [...ctx.db.entity.iter()].filter(e => e.active)
);
// Per-user view (varies by ctx.sender):
export const myProfile = spacetimedb.view(
{ name: 'my_profile', public: true },
t.option(entity.rowType),
(ctx) => ctx.db.entity.identity.find(ctx.sender) ?? undefined
);
Complete Example
import { schema, table, t } from 'spacetimedb/server';
const entity = table(
{ name: 'entity', public: true },
{
identity: t.identity().primaryKey(),
name: t.string(),
active: t.bool(),
}
);
const record = table(
{
name: 'record',
public: true,
indexes: [{ accessor: 'by_owner', algorithm: 'btree', columns: ['owner'] }],
},
{
id: t.u64().primaryKey().autoInc(),
owner: t.identity(),
value: t.u32(),
}
);
const spacetimedb = schema({ entity, record });
export default spacetimedb;
export const onConnect = spacetimedb.clientConnected((ctx) => {
const existing = ctx.db.entity.identity.find(ctx.sender);
if (existing) ctx.db.entity.identity.update({ ...existing, active: true });
});
export const onDisconnect = spacetimedb.clientDisconnected((ctx) => {
const existing = ctx.db.entity.identity.find(ctx.sender);
if (existing) ctx.db.entity.identity.update({ ...existing, active: false });
});
export const createEntity = spacetimedb.reducer(
{ name: t.string() },
(ctx, { name }) => {
if (ctx.db.entity.identity.find(ctx.sender)) throw new Error('already exists');
ctx.db.entity.insert({ identity: ctx.sender, name, active: true });
}
);
export const addRecord = spacetimedb.reducer(
{ value: t.u32() },
(ctx, { value }) => {
if (!ctx.db.entity.identity.find(ctx.sender)) throw new Error('not found');
ctx.db.record.insert({ id: 0n, owner: ctx.sender, value });
}
);