spacetimedb-csharp
π―Skillfrom douglance/spacetimedb
Enables C# developers to build server-side modules and Unity clients for seamlessly integrating with SpacetimeDB database runtime.
Part of
douglance/spacetimedb(5 items)
Installation
curl -sSf https://install.spacetimedb.com | shcurl https://sh.rustup.rs -sSf | shgit clone https://github.com/clockworklabs/SpacetimeDBdocker run --rm --pull always -p 3000:3000 clockworklabs/spacetime startSkill Details
Build C# modules and Unity clients for SpacetimeDB. Covers server-side module development and client SDK integration.
Overview
# SpacetimeDB C# SDK
This skill provides comprehensive guidance for building C# server-side modules and Unity/C# clients that connect to SpacetimeDB.
---
HALLUCINATED APIs β DO NOT USE
These APIs DO NOT EXIST. LLMs frequently hallucinate them.
```csharp
// WRONG β these do not exist
[SpacetimeDB.Procedure] // C# does NOT support procedures yet!
ctx.db.tableName // Wrong casing, should be PascalCase
ctx.Db.tableName.Get(id) // Use Find, not Get
ctx.Db.TableName.FindById(id) // Use index accessor: ctx.Db.TableName.Id.Find(id)
ctx.Db.table.field_name.Find(x) // Wrong! Use PascalCase: ctx.Db.Table.FieldName.Find(x)
Optional
// WRONG β missing partial keyword
public struct MyTable { } // Must be "partial struct"
public class Module { } // Must be "static partial class"
// WRONG β non-partial types
[SpacetimeDB.Table(Name = "player")]
public struct Player { } // WRONG β missing partial!
// WRONG β sum type syntax (VERY COMMON MISTAKE)
public partial struct Shape : TaggedEnum<(Circle, Rectangle)> { } // WRONG: struct, missing names
public partial record Shape : TaggedEnum<(Circle, Rectangle)> { } // WRONG: missing variant names
public partial class Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { } // WRONG: class
// WRONG β Index attribute without full qualification
[Index.BTree(Name = "idx", Columns = new[] { "Col" })] // Ambiguous with System.Index!
[Index.BTree(Name = "idx", Columns = ["Col"])] // Collection expressions don't work in attributes!
```
CORRECT PATTERNS
```csharp
// CORRECT IMPORTS
using SpacetimeDB;
// CORRECT TABLE β must be partial struct
[SpacetimeDB.Table(Name = "player", Public = true)]
public partial struct Player
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
public Identity OwnerId;
public string Name;
}
// CORRECT MODULE β must be static partial class
public static partial class Module
{
[SpacetimeDB.Reducer]
public static void CreatePlayer(ReducerContext ctx, string name)
{
ctx.Db.Player.Insert(new Player { Id = 0, OwnerId = ctx.Sender, Name = name });
}
}
// CORRECT DATABASE ACCESS β PascalCase, index-based lookups
var player = ctx.Db.Player.Id.Find(playerId);
var player = ctx.Db.Player.OwnerId.Find(ctx.Sender);
// CORRECT SUM TYPE β partial record with named tuple elements
[SpacetimeDB.Type]
public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { }
```
DO NOT
- Forget
partialkeyword β required on all tables and Module class - Use lowercase table access β
ctx.Db.Playernotctx.Db.player - Try to use procedures β C# does not support procedures yet
- Use
Optionalβ use C# nullable syntaxT?instead - Use struct for sum types β must be
partial record
---
Common Mistakes Table
Server-side errors
| Wrong | Right | Error |
|-------|-------|-------|
| Missing partial keyword | public partial struct Table | Generated code won't compile |
| ctx.Db.player (lowercase) | ctx.Db.Player (PascalCase) | Property not found |
| Optional | string? | Type not found |
| ctx.Db.Table.Get(id) | ctx.Db.Table.Id.Find(id) | Method not found |
| Wrong .csproj name | StdbModule.csproj | Publish fails silently |
| .NET 9 SDK | .NET 8 SDK only | WASI compilation fails |
| Missing WASI workload | dotnet workload install wasi-experimental | Build fails |
| [Procedure] attribute | Reducers only | Procedures not supported in C# |
| Missing Public = true | Add to [Table] attribute | Clients can't subscribe |
| Using Random | Avoid non-deterministic code | Sandbox violation |
| async/await in reducers | Synchronous only | Not supported |
| [Index.BTree(...)] | [SpacetimeDB.Index.BTree(...)] | Ambiguous with System.Index |
| Columns = ["A", "B"] | Columns = new[] { "A", "B" } | Collection expressions invalid in attributes |
| partial struct : TaggedEnum | partial record : TaggedEnum | Sum types must be record |
| TaggedEnum<(A, B)> | TaggedEnum<(A A, B B)> | Tuple must include variant names |
Client-side errors
| Wrong | Right | Error |
|-------|-------|-------|
| Wrong namespace | using SpacetimeDB.ClientApi; | Types not found |
| Not calling FrameTick() | conn.FrameTick() in Update loop | No callbacks fire |
| Accessing conn.Db from background thread | Copy data in callback, process elsewhere | Data races |
---
Hard Requirements
- Tables and Module MUST be
partialβ required for code generation - Use PascalCase for table access β
ctx.Db.TableName, notctx.Db.tableName - Project file MUST be named
StdbModule.csprojβ CLI requirement - Requires .NET 8 SDK β .NET 9 and newer not yet supported
- Install WASI workload β
dotnet workload install wasi-experimental - C# does NOT support procedures β use reducers only
- Reducers must be deterministic β no filesystem, network, timers, or
Random - Add
Public = trueβ if clients need to subscribe to a table - Use
T?for nullable fields β notOptional - Pass
0for auto-increment β to trigger ID generation on insert - Sum types must be
partial recordβ not struct or class - Fully qualify Index attribute β
[SpacetimeDB.Index.BTree]to avoid System.Index ambiguity
---
Server-Side Module Development
Table Definition (CRITICAL)
Tables MUST use partial struct or partial class for code generation.
```csharp
using SpacetimeDB;
// WRONG β missing partial!
[SpacetimeDB.Table(Name = "player")]
public struct Player { } // Will not generate properly!
// RIGHT β with partial keyword
[SpacetimeDB.Table(Name = "player", Public = true)]
public partial struct Player
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
public Identity OwnerId;
public string Name;
public Timestamp CreatedAt;
}
// With indexes
[SpacetimeDB.Table(Name = "task", Public = true)]
public partial struct Task
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
[SpacetimeDB.Index.BTree]
public Identity OwnerId;
public string Title;
public bool Completed;
}
// Multi-column index
[SpacetimeDB.Table(Name = "score", Public = true)]
[SpacetimeDB.Index.BTree(Name = "by_player_game", Columns = new[] { "PlayerId", "GameId" })]
public partial struct Score
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
public Identity PlayerId;
public string GameId;
public int Points;
}
```
Field Attributes
```csharp
[SpacetimeDB.PrimaryKey] // Exactly one per table (required)
[SpacetimeDB.AutoInc] // Auto-increment (integer fields only)
[SpacetimeDB.Unique] // Unique constraint
[SpacetimeDB.Index.BTree] // Single-column B-tree index
[SpacetimeDB.Default(value)] // Default value for new columns
```
Column Types
```csharp
byte, sbyte, short, ushort // 8/16-bit integers
int, uint, long, ulong // 32/64-bit integers
float, double // Floats
bool // Boolean
string // Text
Identity // User identity
Timestamp // Timestamp
ScheduleAt // For scheduled tables
T? // Nullable (e.g., string?)
List
```
Insert with Auto-increment
```csharp
// Insert returns the row with generated ID
var player = ctx.Db.Player.Insert(new Player
{
Id = 0, // Pass 0 to trigger auto-increment
OwnerId = ctx.Sender,
Name = name,
CreatedAt = ctx.Timestamp
});
ulong newId = player.Id; // Get actual generated ID
```
Module and Reducers
The Module class MUST be public static partial class.
```csharp
using SpacetimeDB;
public static partial class Module
{
[SpacetimeDB.Reducer]
public static void CreateTask(ReducerContext ctx, string title)
{
// Validate
if (string.IsNullOrEmpty(title))
{
throw new Exception("Title cannot be empty"); // Rolls back transaction
}
// Insert
ctx.Db.Task.Insert(new Task
{
Id = 0,
OwnerId = ctx.Sender,
Title = title,
Completed = false
});
}
[SpacetimeDB.Reducer]
public static void CompleteTask(ReducerContext ctx, ulong taskId)
{
var task = ctx.Db.Task.Id.Find(taskId);
if (task is null)
{
throw new Exception("Task not found");
}
if (task.Value.OwnerId != ctx.Sender)
{
throw new Exception("Not authorized");
}
ctx.Db.Task.Id.Update(task.Value with { Completed = true });
}
[SpacetimeDB.Reducer]
public static void DeleteTask(ReducerContext ctx, ulong taskId)
{
ctx.Db.Task.Id.Delete(taskId);
}
}
```
Lifecycle Reducers
```csharp
public static partial class Module
{
[SpacetimeDB.Reducer(ReducerKind.Init)]
public static void Init(ReducerContext ctx)
{
// Called once when module is first published
Log.Info("Module initialized");
}
[SpacetimeDB.Reducer(ReducerKind.ClientConnected)]
public static void OnConnect(ReducerContext ctx)
{
// ctx.Sender is the connecting client
Log.Info($"Client connected: {ctx.Sender}");
}
[SpacetimeDB.Reducer(ReducerKind.ClientDisconnected)]
public static void OnDisconnect(ReducerContext ctx)
{
// Clean up client state
Log.Info($"Client disconnected: {ctx.Sender}");
}
}
```
ReducerContext API
```csharp
ctx.Sender // Identity of the caller
ctx.Timestamp // Current timestamp
ctx.Db // Database access
ctx.Identity // Module's own identity
ctx.ConnectionId // Connection ID (nullable)
```
Database Access
#### Naming Convention
- Tables: Use PascalCase singular names in the
Nameattribute
- [Table(Name = "User")] β ctx.Db.User
- [Table(Name = "PlayerStats")] β ctx.Db.PlayerStats
- Indexes: PascalCase, match field name
- Field OwnerId with [Index.BTree] β ctx.Db.User.OwnerId
#### Primary Key Operations
```csharp
// Find by primary key β returns nullable
if (ctx.Db.Task.Id.Find(taskId) is Task task)
{
// Use task
}
// Update by primary key
ctx.Db.Task.Id.Update(updatedTask);
// Delete by primary key
ctx.Db.Task.Id.Delete(taskId);
```
#### Index Operations
```csharp
// Find by unique index β returns nullable
if (ctx.Db.Player.Username.Find("alice") is Player player)
{
// Found player
}
// Filter by B-tree index β returns iterator
foreach (var task in ctx.Db.Task.OwnerId.Filter(ctx.Sender))
{
// Process each task
}
```
#### Iterate All Rows
```csharp
// Full table scan
foreach (var task in ctx.Db.Task.Iter())
{
// Process each task
}
```
Custom Types
Use [SpacetimeDB.Type] for custom structs/enums. Must be partial.
```csharp
using SpacetimeDB;
[SpacetimeDB.Type]
public partial struct Position
{
public int X;
public int Y;
}
[SpacetimeDB.Type]
public partial struct PlayerStats
{
public int Health;
public int Mana;
public Position Location;
}
// Use in table
[SpacetimeDB.Table(Name = "player", Public = true)]
public partial struct Player
{
[SpacetimeDB.PrimaryKey]
public Identity Id;
public string Name;
public PlayerStats Stats;
}
```
Sum Types / Tagged Enums (CRITICAL)
Sum types MUST use partial record and inherit from TaggedEnum.
```csharp
using SpacetimeDB;
// Step 1: Define variant types as partial structs with [Type]
[SpacetimeDB.Type]
public partial struct Circle { public int Radius; }
[SpacetimeDB.Type]
public partial struct Rectangle { public int Width; public int Height; }
// Step 2: Define sum type as partial RECORD (not struct!) inheriting TaggedEnum
// The tuple MUST include both the type AND a name for each variant
[SpacetimeDB.Type]
public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { }
// Step 3: Use in a table
[SpacetimeDB.Table(Name = "drawings", Public = true)]
public partial struct Drawing
{
[SpacetimeDB.PrimaryKey]
public int Id;
public Shape ShapeA;
public Shape ShapeB;
}
```
#### Creating Sum Type Values
```csharp
// Create variant instances using the generated nested types
var circle = new Shape.Circle(new Circle { Radius = 10 });
var rect = new Shape.Rectangle(new Rectangle { Width = 4, Height = 6 });
// Insert into table
ctx.Db.Drawing.Insert(new Drawing { Id = 1, ShapeA = circle, ShapeB = rect });
```
#### COMMON SUM TYPE MISTAKES
| Wrong | Right | Why |
|-------|-------|-----|
| partial struct Shape : TaggedEnum<...> | partial record Shape : TaggedEnum<...> | Must be record, not struct |
| TaggedEnum<(Circle, Rectangle)> | TaggedEnum<(Circle Circle, Rectangle Rectangle)> | Tuple must have names |
| new Shape { ... } | new Shape.Circle(new Circle { ... }) | Use nested variant constructor |
Scheduled Tables
```csharp
using SpacetimeDB;
[SpacetimeDB.Table(Name = "reminder", Scheduled = nameof(Module.SendReminder))]
public partial struct Reminder
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
public string Message;
public ScheduleAt ScheduledAt;
}
public static partial class Module
{
// Scheduled reducer receives the full row
[SpacetimeDB.Reducer]
public static void SendReminder(ReducerContext ctx, Reminder reminder)
{
Log.Info($"Reminder: {reminder.Message}");
// Row is automatically deleted after reducer completes
}
[SpacetimeDB.Reducer]
public static void CreateReminder(ReducerContext ctx, string message, ulong delaySecs)
{
var futureTime = ctx.Timestamp + TimeSpan.FromSeconds(delaySecs);
ctx.Db.Reminder.Insert(new Reminder
{
Id = 0,
Message = message,
ScheduledAt = ScheduleAt.Time(futureTime)
});
}
[SpacetimeDB.Reducer]
public static void CancelReminder(ReducerContext ctx, ulong reminderId)
{
ctx.Db.Reminder.Id.Delete(reminderId);
}
}
```
Logging
```csharp
using SpacetimeDB;
Log.Debug("Debug message");
Log.Info("Information");
Log.Warn("Warning");
Log.Error("Error occurred");
Log.Panic("Critical failure"); // Terminates execution
```
Data Visibility
Public = true exposes ALL rows to ALL clients.
| Scenario | Pattern |
|----------|---------|
| Everyone sees all rows | [Table(Name = "x", Public = true)] |
| Server-only data | [Table(Name = "x")] (private by default) |
Project Setup
#### Required .csproj (MUST be named StdbModule.csproj)
```xml
```
#### Prerequisites
```bash
# Install .NET 8 SDK (required, not .NET 9)
# Download from https://dotnet.microsoft.com/download/dotnet/8.0
# Install WASI workload
dotnet workload install wasi-experimental
```
Commands
```bash
# Start local server
spacetime start
# Publish module
spacetime publish
# Clear database and republish
spacetime publish
# Generate bindings
spacetime generate --lang csharp --out-dir
# View logs
spacetime logs
```
---
Client-Side SDK
Overview
The SpacetimeDB C# SDK enables .NET applications and Unity games to:
- Connect to SpacetimeDB databases over WebSocket
- Subscribe to real-time table updates
- Invoke reducers (server-side functions)
- Maintain a local cache of subscribed data
- Handle authentication via Identity tokens
Critical Requirement: The C# SDK requires manual connection advancement. You must call FrameTick() regularly to process messages.
Installation
#### .NET Console/Library Applications
Add the NuGet package:
```bash
dotnet add package SpacetimeDB.ClientSDK
```
#### Unity Applications
Add via Unity Package Manager using the git URL:
```
https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk.git
```
Steps:
- Open Window > Package Manager
- Click the + button in top-left
- Select "Add package from git URL"
- Paste the URL above and click Add
Generate Module Bindings
Before using the SDK, generate type-safe bindings from your module:
```bash
mkdir -p module_bindings
spacetime generate --lang cs --out-dir module_bindings --project-path PATH_TO_MODULE
```
This creates:
SpacetimeDBClient.g.cs- Main client types (DbConnection, contexts, builders)Tables/*.g.cs- Table handle classes with typed accessReducers/*.g.cs- Reducer invocation methodsTypes/*.g.cs- Row types and custom types from the module
Connection Setup
#### Basic Connection Pattern
```csharp
using SpacetimeDB;
using SpacetimeDB.Types;
DbConnection? conn = null;
conn = DbConnection.Builder()
.WithUri("http://localhost:3000") // SpacetimeDB server URL
.WithModuleName("my-database") // Database name or Identity
.OnConnect(OnConnected) // Connection success callback
.OnConnectError((err) => { // Connection failure callback
Console.Error.WriteLine($"Connection failed: {err}");
})
.OnDisconnect((conn, err) => { // Disconnection callback
if (err != null) {
Console.Error.WriteLine($"Disconnected with error: {err}");
}
})
.Build();
void OnConnected(DbConnection conn, Identity identity, string authToken)
{
Console.WriteLine($"Connected with Identity: {identity}");
// Save authToken for reconnection
// Set up subscriptions here
}
```
#### Connection Builder Methods
| Method | Description |
|--------|-------------|
| WithUri(string uri) | SpacetimeDB server URI (required) |
| WithModuleName(string name) | Database name or Identity (required) |
| WithToken(string token) | Auth token for reconnection |
| WithConfirmedReads(bool) | Wait for durable writes before returning |
| OnConnect(callback) | Called on successful connection |
| OnConnectError(callback) | Called if connection fails |
| OnDisconnect(callback) | Called when disconnected |
| Build() | Create and open the connection |
Critical: Advancing the Connection
The SDK does NOT automatically process messages. You must call FrameTick() regularly.
#### Console Application Loop
```csharp
while (true)
{
conn.FrameTick();
Thread.Sleep(16); // ~60 FPS
}
```
#### Unity MonoBehaviour Pattern
```csharp
public class SpacetimeManager : MonoBehaviour
{
private DbConnection conn;
void Update()
{
conn?.FrameTick();
}
}
```
Warning: Do NOT call FrameTick() from a background thread. It modifies conn.Db and can cause data races with main thread access.
Subscribing to Tables
#### Using SQL Queries
```csharp
void OnConnected(DbConnection conn, Identity identity, string authToken)
{
conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.OnError((ctx, err) => {
Console.Error.WriteLine($"Subscription failed: {err}");
})
.Subscribe(new[] {
"SELECT * FROM player",
"SELECT * FROM message WHERE sender = :sender"
});
}
void OnSubscriptionApplied(SubscriptionEventContext ctx)
{
Console.WriteLine("Subscription ready - data available");
// Access ctx.Db to read subscribed rows
}
```
#### Using Typed Query Builder
```csharp
conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.OnError((ctx, err) => Console.Error.WriteLine(err))
.AddQuery(qb => qb.From.Player().Build())
.AddQuery(qb => qb.From.Message().Where(c => c.Sender.Eq(identity)).Build())
.Subscribe();
```
#### Subscribe to All Tables (Development Only)
```csharp
conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.SubscribeToAllTables();
```
Warning: SubscribeToAllTables() cannot be mixed with Subscribe() on the same connection.
#### Subscription Handle
```csharp
SubscriptionHandle handle = conn.SubscriptionBuilder()
.OnApplied(ctx => Console.WriteLine("Applied"))
.Subscribe(new[] { "SELECT * FROM player" });
// Later: unsubscribe
handle.UnsubscribeThen(ctx => {
Console.WriteLine("Unsubscribed");
});
// Check status
bool isActive = handle.IsActive;
bool isEnded = handle.IsEnded;
```
Accessing the Client Cache
Subscribed data is stored in conn.Db (or ctx.Db in callbacks).
#### Iterating All Rows
```csharp
foreach (var player in ctx.Db.Player.Iter())
{
Console.WriteLine($"Player: {player.Name}");
}
```
#### Count Rows
```csharp
int playerCount = ctx.Db.Player.Count;
```
#### Find by Unique/Primary Key
For columns marked [Unique] or [PrimaryKey] on the server:
```csharp
// Find by unique column
Player? player = ctx.Db.Player.Identity.Find(someIdentity);
// Returns null if not found
if (player != null)
{
Console.WriteLine($"Found: {player.Name}");
}
```
#### Filter by BTree Index
For columns with [Index.BTree] on the server:
```csharp
// Filter returns IEnumerable
IEnumerable
int count = levelOnePlayers.Count();
```
#### Remote Query (Ad-hoc SQL)
```csharp
var result = ctx.Db.Player.RemoteQuery("WHERE level > 10");
Player[] highLevelPlayers = result.Result;
```
Row Event Callbacks
Register callbacks to react to table changes:
#### OnInsert
```csharp
ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => {
Console.WriteLine($"Player joined: {player.Name}");
};
```
#### OnDelete
```csharp
ctx.Db.Player.OnDelete += (EventContext ctx, Player player) => {
Console.WriteLine($"Player left: {player.Name}");
};
```
#### OnUpdate
Fires when a row with a primary key is replaced:
```csharp
ctx.Db.Player.OnUpdate += (EventContext ctx, Player oldRow, Player newRow) => {
Console.WriteLine($"Player {oldRow.Name} renamed to {newRow.Name}");
};
```
#### Checking Event Source
```csharp
ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => {
switch (ctx.Event)
{
case Event
// Initial subscription data
break;
case Event
// Change from a reducer
Console.WriteLine($"Reducer: {reducerEvent.Reducer}");
break;
}
};
```
Calling Reducers
Reducers are server-side functions that modify the database.
#### Invoke a Reducer
```csharp
// Reducers are methods on ctx.Reducers or conn.Reducers
ctx.Reducers.SendMessage("Hello, world!");
ctx.Reducers.CreatePlayer("NewPlayer");
ctx.Reducers.UpdateScore(playerId, 100);
```
#### Reducer Callbacks
React when a reducer completes (success or failure):
```csharp
conn.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => {
if (ctx.Event.Status is Status.Committed)
{
Console.WriteLine($"Message sent: {text}");
}
else if (ctx.Event.Status is Status.Failed(var reason))
{
Console.Error.WriteLine($"Send failed: {reason}");
}
};
```
#### Unhandled Reducer Errors
Catch reducer errors without specific handlers:
```csharp
conn.OnUnhandledReducerError += (ReducerEventContext ctx, Exception ex) => {
Console.Error.WriteLine($"Reducer error: {ex.Message}");
};
```
#### Reducer Event Properties
```csharp
conn.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => {
ReducerEvent
Timestamp when = evt.Timestamp;
Status status = evt.Status;
Identity caller = evt.CallerIdentity;
ConnectionId? callerId = evt.CallerConnectionId;
U128? energy = evt.EnergyConsumed;
};
```
Identity and Authentication
#### Getting Current Identity
```csharp
// In OnConnect callback
void OnConnected(DbConnection conn, Identity identity, string authToken)
{
// identity - your unique identifier
// authToken - save this for reconnection
PlayerPrefs.SetString("SpacetimeToken", authToken);
}
// From any context
Identity? myIdentity = ctx.Identity;
ConnectionId myConnectionId = ctx.ConnectionId;
```
#### Reconnecting with Token
```csharp
string savedToken = PlayerPrefs.GetString("SpacetimeToken", null);
DbConnection.Builder()
.WithUri("http://localhost:3000")
.WithModuleName("my-database")
.WithToken(savedToken) // Reconnect as same identity
.OnConnect(OnConnected)
.Build();
```
#### Anonymous Connection
Pass null to WithToken or omit it entirely for a new anonymous identity.
BSATN Serialization
SpacetimeDB uses BSATN (Binary SpacetimeDB Algebraic Type Notation) for serialization. The SDK handles this automatically for generated types.
#### Supported Types
| C# Type | SpacetimeDB Type |
|---------|------------------|
| bool | Bool |
| byte, sbyte | U8, I8 |
| ushort, short | U16, I16 |
| uint, int | U32, I32 |
| ulong, long | U64, I64 |
| U128, I128 | U128, I128 |
| U256, I256 | U256, I256 |
| float, double | F32, F64 |
| string | String |
| List | Array |
| T? (nullable) | Option |
| Identity | Identity |
| ConnectionId | ConnectionId |
| Timestamp | Timestamp |
| Uuid | Uuid |
#### Custom Types
Types marked with [SpacetimeDB.Type] on the server are generated as C# types:
```csharp
// Server-side (Rust or C#)
[SpacetimeDB.Type]
public partial struct Vector3
{
public float X;
public float Y;
public float Z;
}
// Client-side (auto-generated)
public partial struct Vector3 : IEquatable
{
public float X;
public float Y;
public float Z;
// BSATN serialization methods included
}
```
#### TaggedEnum (Sum Types) on Client
```csharp
// Server
[SpacetimeDB.Type]
public partial record GameEvent : TaggedEnum<(
string PlayerJoined,
string PlayerLeft,
(string player, int score) ScoreUpdate
)>;
// Client usage
switch (gameEvent)
{
case GameEvent.PlayerJoined(var name):
Console.WriteLine($"{name} joined");
break;
case GameEvent.ScoreUpdate((var player, var score)):
Console.WriteLine($"{player} scored {score}");
break;
}
```
#### Result Type
```csharp
// Result
Result
if (result is Result
{
Console.WriteLine($"Success: {player.Name}");
}
else if (result is Result
{
Console.Error.WriteLine($"Error: {error}");
}
```
Unity Integration
#### Project Setup
- Add the SpacetimeDB package via Package Manager
- Generate bindings and add to your Unity project
- Create a manager MonoBehaviour
#### SpacetimeManager Pattern
```csharp
using UnityEngine;
using SpacetimeDB;
using SpacetimeDB.Types;
public class SpacetimeManager : MonoBehaviour
{
public static SpacetimeManager Instance { get; private set; }
[SerializeField] private string serverUri = "http://localhost:3000";
[SerializeField] private string moduleName = "my-game";
private DbConnection conn;
public DbConnection Connection => conn;
void Awake()
{
if (Instance != null)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
void Start()
{
Connect();
}
void Update()
{
// CRITICAL: Must call every frame
conn?.FrameTick();
}
void OnDestroy()
{
conn?.Disconnect();
}
public void Connect()
{
string token = PlayerPrefs.GetString("SpacetimeToken", null);
conn = DbConnection.Builder()
.WithUri(serverUri)
.WithModuleName(moduleName)
.WithToken(token)
.OnConnect(OnConnected)
.OnConnectError(OnConnectError)
.OnDisconnect(OnDisconnect)
.Build();
}
private void OnConnected(DbConnection conn, Identity identity, string authToken)
{
Debug.Log($"Connected as {identity}");
PlayerPrefs.SetString("SpacetimeToken", authToken);
conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.OnError((ctx, err) => Debug.LogError($"Subscription error: {err}"))
.SubscribeToAllTables();
}
private void OnConnectError(Exception err)
{
Debug.LogError($"Connection failed: {err}");
}
private void OnDisconnect(DbConnection conn, Exception err)
{
if (err != null)
{
Debug.LogError($"Disconnected: {err}");
}
}
private void OnSubscriptionApplied(SubscriptionEventContext ctx)
{
Debug.Log("Subscription ready");
// Initialize game state from ctx.Db
}
}
```
#### Unity-Specific Considerations
- Main Thread Only: All SpacetimeDB callbacks run on the main thread (during
FrameTick())
- Scene Loading: Use
DontDestroyOnLoadfor the connection manager
- Reconnection: Handle disconnects gracefully for mobile/poor connectivity
- PlayerPrefs: Use for token persistence (or use a more secure method for production)
#### Spawning GameObjects from Table Data
```csharp
public class PlayerSpawner : MonoBehaviour
{
[SerializeField] private GameObject playerPrefab;
private Dictionary
void Start()
{
var conn = SpacetimeManager.Instance.Connection;
conn.Db.Player.OnInsert += OnPlayerInsert;
conn.Db.Player.OnDelete += OnPlayerDelete;
conn.Db.Player.OnUpdate += OnPlayerUpdate;
// Spawn existing players
foreach (var player in conn.Db.Player.Iter())
{
SpawnPlayer(player);
}
}
void OnPlayerInsert(EventContext ctx, Player player)
{
// Skip if this is initial subscription data we already handled
if (ctx.Event is Event
SpawnPlayer(player);
}
void OnPlayerDelete(EventContext ctx, Player player)
{
if (playerObjects.TryGetValue(player.Identity, out var go))
{
Destroy(go);
playerObjects.Remove(player.Identity);
}
}
void OnPlayerUpdate(EventContext ctx, Player oldPlayer, Player newPlayer)
{
if (playerObjects.TryGetValue(newPlayer.Identity, out var go))
{
// Update position, etc.
go.transform.position = new Vector3(newPlayer.X, newPlayer.Y, newPlayer.Z);
}
}
void SpawnPlayer(Player player)
{
var go = Instantiate(playerPrefab);
go.transform.position = new Vector3(player.X, player.Y, player.Z);
playerObjects[player.Identity] = go;
}
}
```
Thread Safety
The C# SDK is NOT thread-safe. Follow these rules:
- Call
FrameTick()from ONE thread only (main thread recommended)
- All callbacks run during
FrameTick()on the calling thread
- Do NOT access
conn.Dbfrom other threads whileFrameTick()may be running
- Background work: Copy data out of callbacks, process on background threads
```csharp
// Safe pattern for background processing
conn.Db.Player.OnInsert += (ctx, player) => {
// Copy the data
var playerData = new PlayerDTO {
Id = player.Id,
Name = player.Name
};
// Process on background thread
Task.Run(() => ProcessPlayerAsync(playerData));
};
```
Error Handling
#### Connection Errors
```csharp
.OnConnectError((err) => {
// Network errors, invalid module name, etc.
Debug.LogError($"Connect error: {err}");
})
```
#### Subscription Errors
```csharp
.OnError((ctx, err) => {
// Invalid SQL, schema changes, etc.
Debug.LogError($"Subscription error: {err}");
})
```
#### Reducer Errors
```csharp
conn.Reducers.OnMyReducer += (ctx, args) => {
if (ctx.Event.Status is Status.Failed(var reason))
{
Debug.LogError($"Reducer failed: {reason}");
}
};
// Catch-all for unhandled reducer errors
conn.OnUnhandledReducerError += (ctx, ex) => {
Debug.LogError($"Unhandled: {ex}");
};
```
Complete Console Example
```csharp
using System;
using SpacetimeDB;
using SpacetimeDB.Types;
class Program
{
static DbConnection? conn;
static bool running = true;
static void Main()
{
conn = DbConnection.Builder()
.WithUri("http://localhost:3000")
.WithModuleName("chat")
.OnConnect(OnConnect)
.OnConnectError(err => Console.Error.WriteLine($"Failed: {err}"))
.OnDisconnect((c, err) => running = false)
.Build();
while (running)
{
conn.FrameTick();
Thread.Sleep(16);
}
}
static void OnConnect(DbConnection conn, Identity id, string token)
{
Console.WriteLine($"Connected as {id}");
// Set up callbacks
conn.Db.Message.OnInsert += (ctx, msg) => {
Console.WriteLine($"[{msg.Sender}]: {msg.Text}");
};
conn.Reducers.OnSendMessage += (ctx, text) => {
if (ctx.Event.Status is Status.Failed(var reason))
{
Console.Error.WriteLine($"Send failed: {reason}");
}
};
// Subscribe
conn.SubscriptionBuilder()
.OnApplied(ctx => {
Console.WriteLine("Ready! Type messages:");
StartInputLoop(ctx);
})
.Subscribe(new[] { "SELECT * FROM message" });
}
static void StartInputLoop(SubscriptionEventContext ctx)
{
Task.Run(() => {
while (running)
{
var input = Console.ReadLine();
if (!string.IsNullOrEmpty(input))
{
ctx.Reducers.SendMessage(input);
}
}
});
}
}
```
Common Patterns
#### Optimistic Updates
```csharp
// Show immediate feedback, correct on server response
void SendMessage(string text)
{
// Optimistic: show immediately
AddMessageToUI(myIdentity, text, isPending: true);
// Send to server
conn.Reducers.SendMessage(text);
}
conn.Reducers.OnSendMessage += (ctx, text) => {
if (ctx.Event.CallerIdentity == conn.Identity)
{
if (ctx.Event.Status is Status.Committed)
{
// Confirm the pending message
ConfirmPendingMessage(text);
}
else
{
// Remove failed message
RemovePendingMessage(text);
}
}
};
```
#### Local Player Detection
```csharp
conn.Db.Player.OnInsert += (ctx, player) => {
bool isLocalPlayer = player.Identity == ctx.Identity;
if (isLocalPlayer)
{
// This is our player
SetupLocalPlayerController(player);
}
else
{
// Remote player
SpawnRemotePlayer(player);
}
};
```
#### Waiting for Specific Data
```csharp
async Task
{
var tcs = new TaskCompletionSource
void Handler(EventContext ctx, Player player)
{
if (player.Identity == playerId)
{
tcs.TrySetResult(player);
conn.Db.Player.OnInsert -= Handler;
}
}
// Check if already exists
var existing = conn.Db.Player.Identity.Find(playerId);
if (existing != null) return existing;
conn.Db.Player.OnInsert += Handler;
return await tcs.Task;
}
```
---
Troubleshooting
Connection Issues
- "Connection refused": Check server is running at the specified URI
- "Module not found": Verify module name matches published database
- Timeout: Check firewall/network, ensure
FrameTick()is being called
No Callbacks Firing
- Check
FrameTick(): Must be called regularly - Check subscription: Ensure
OnAppliedfired successfully - Check callback registration: Register before subscribing
Data Not Appearing
- Check SQL syntax: Invalid queries fail silently
- Check table visibility: Tables must be
Public = truein the module - Check subscription scope: Only subscribed rows are cached
Unity-Specific
- NullReferenceException in Update: Guard with
conn?.FrameTick() - Missing types: Regenerate bindings after module changes
- Assembly errors: Ensure SpacetimeDB assemblies are in correct folder
Build Issues
- WASI compilation fails: Ensure .NET 8 SDK (not 9+), install WASI workload
- Publish fails silently: Ensure project is named
StdbModule.csproj - Generated code errors: Ensure all tables/types have
partialkeyword
---
References
- [C# SDK Reference](https://spacetimedb.com/docs/sdks/c-sharp)
- [Unity Tutorial](https://spacetimedb.com/docs/unity/part-1)
- [SpacetimeDB SQL Reference](https://spacetimedb.com/docs/sql)
- [GitHub: Unity Demo (Blackholio)](https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio)
More from this repository4
Generates TypeScript client bindings and types for SpacetimeDB, enabling seamless database interaction in TypeScript projects.
Explains SpacetimeDB's unique architecture, core concepts, and best practices for building WebAssembly-powered, transactional database applications.
Manages and interacts with SpacetimeDB databases through a command-line interface for efficient database operations and development.
Develops SpacetimeDB server modules in Rust, enabling WebAssembly database applications with tables, reducers, and direct client-database interactions.