--- name: spacetimedb-csharp description: Build C# modules and clients for SpacetimeDB. Covers server-side module development and client SDK integration. license: Apache-2.0 metadata: author: clockworklabs version: "2.0" tested_with: "SpacetimeDB 2.0, .NET 8 SDK" --- # SpacetimeDB C# SDK This skill provides guidance for building C# server-side modules and C# clients that connect to SpacetimeDB 2.0. --- ## HALLUCINATED APIs — DO NOT USE **These APIs DO NOT EXIST. LLMs frequently hallucinate them.** ```csharp // WRONG — these table access patterns do not exist ctx.db.tableName // Wrong casing — use ctx.Db ctx.Db.tableName // Wrong casing — accessor must match exactly 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 field; // Use C# nullable: string? field // 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(Accessor = "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(Accessor = "idx", Columns = new[] { "Col" })] // Ambiguous with System.Index! [SpacetimeDB.Index.BTree(Accessor = "idx", Columns = ["Col"])] // Valid with modern C# collection expressions // WRONG — old 1.0 patterns [SpacetimeDB.Table(Name = "Player")] // Use Accessor, not Name (2.0) // Use SpacetimeDB.Runtime .WithModuleName("my-db") // Use .WithDatabaseName() (2.0) ScheduleAt.Time(futureTime) // Use new ScheduleAt.Time(futureTime) // WRONG — lifecycle hooks starting with "On" [SpacetimeDB.Reducer(ReducerKind.ClientConnected)] public static void OnClientConnected(ReducerContext ctx) { } // STDB0010 error! // WRONG — non-deterministic code in reducers var random = new Random(); // Use ctx.Rng var guid = Guid.NewGuid(); // Not allowed var now = DateTime.Now; // Use ctx.Timestamp // WRONG — collection parameters int[] itemIds = { 1, 2, 3 }; _conn.Reducers.ProcessItems(itemIds); // Generated code expects List! ``` ### CORRECT PATTERNS ```csharp using SpacetimeDB; // CORRECT TABLE — must be partial struct, use Accessor [SpacetimeDB.Table(Accessor = "Player", Public = true)] public partial struct Player { [SpacetimeDB.PrimaryKey] [SpacetimeDB.AutoInc] public ulong Id; [SpacetimeDB.Index.BTree] 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); // Unique/PK: returns nullable foreach (var p in ctx.Db.Player.OwnerId.Filter(ctx.Sender)) { } // BTree: returns IEnumerable // CORRECT SUM TYPE — partial record with named tuple elements [SpacetimeDB.Type] public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { } // CORRECT — collection parameters use List _conn.Reducers.ProcessItems(new List { 1, 2, 3 }); ``` --- ## Common Mistakes Table | Wrong | Right | Error | |-------|-------|-------| | 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 | | async/await in reducers | Synchronous only | Not supported | | `table.Name.Update(...)` | `table.Id.Update(...)` | Update only via primary key (2.0) | | Not calling `FrameTick()` | `conn.FrameTick()` in Update loop | No callbacks fire | | Accessing `conn.Db` from background thread | Copy data in callback | Data races | --- ## Hard Requirements 1. **Tables and Module MUST be `partial`** — required for code generation 2. **Use `Accessor =` in table attributes** — `Name =` is only for SQL compatibility (2.0) 3. **Project file MUST be named `StdbModule.csproj`** — CLI requirement 4. **Requires .NET 8 SDK** — .NET 9 and newer not yet supported 5. **Install WASI workload** — `dotnet workload install wasi-experimental` 6. **Procedures are supported** — use `[SpacetimeDB.Procedure]` with `ProcedureContext` when needed 7. **Reducers must be deterministic** — no filesystem, network, timers, or `Random` 8. **Add `Public = true`** — if clients need to subscribe to a table 9. **Use `T?` for nullable fields** — not `Optional` 10. **Pass `0` for auto-increment** — to trigger ID generation on insert 11. **Sum types must be `partial record`** — not struct or class 12. **Fully qualify Index attribute** — `[SpacetimeDB.Index.BTree]` to avoid System.Index ambiguity 13. **Update only via primary key** — use delete+insert for non-PK changes (2.0) 14. **Use `SpacetimeDB.Runtime` package** — not `ServerSdk` (2.0) 15. **Use `List` for collection parameters** — not arrays 16. **`Identity` is in `SpacetimeDB` namespace** — not `SpacetimeDB.Types` --- ## Server-Side Module Development ### Table Definition ```csharp using SpacetimeDB; [SpacetimeDB.Table(Accessor = "Player", Public = true)] public partial struct Player { [SpacetimeDB.PrimaryKey] [SpacetimeDB.AutoInc] public ulong Id; [SpacetimeDB.Index.BTree] public Identity OwnerId; public string Name; public Timestamp CreatedAt; } // Multi-column index (use fully-qualified attribute!) [SpacetimeDB.Table(Accessor = "Score", Public = true)] [SpacetimeDB.Index.BTree(Accessor = "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 ``` ### SpacetimeDB Column Types ```csharp Identity // User identity (SpacetimeDB namespace, not SpacetimeDB.Types) Timestamp // Timestamp (use ctx.Timestamp server-side, never DateTime.Now) ScheduleAt // For scheduled tables T? // Nullable (e.g., string?) List // Collections (use List, not arrays) ``` Standard C# primitives (`bool`, `byte`..`ulong`, `float`, `double`, `string`) are all supported. ### Insert with Auto-Increment ```csharp 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; // Insert returns the row with generated ID ``` ### Module and Reducers ```csharp using SpacetimeDB; public static partial class Module { [SpacetimeDB.Reducer] public static void CreateTask(ReducerContext ctx, string title) { if (string.IsNullOrEmpty(title)) throw new Exception("Title cannot be empty"); 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) { if (ctx.Db.Task.Id.Find(taskId) is not Task task) throw new Exception("Task not found"); if (task.OwnerId != ctx.Sender) throw new Exception("Not authorized"); ctx.Db.Task.Id.Update(task 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) { Log.Info("Module initialized"); } // CRITICAL: no "On" prefix! [SpacetimeDB.Reducer(ReducerKind.ClientConnected)] public static void ClientConnected(ReducerContext ctx) { Log.Info($"Client connected: {ctx.Sender}"); if (ctx.Db.User.Identity.Find(ctx.Sender) is User user) { ctx.Db.User.Identity.Update(user with { Online = true }); } else { ctx.Db.User.Insert(new User { Identity = ctx.Sender, Online = true }); } } [SpacetimeDB.Reducer(ReducerKind.ClientDisconnected)] public static void ClientDisconnected(ReducerContext ctx) { if (ctx.Db.User.Identity.Find(ctx.Sender) is User user) { ctx.Db.User.Identity.Update(user with { Online = false }); } } } ``` ### Event Tables (2.0) Reducer callbacks are removed in 2.0. Use event tables + `OnInsert` instead. ```csharp [SpacetimeDB.Table(Accessor = "DamageEvent", Public = true, Event = true)] public partial struct DamageEvent { public Identity Target; public uint Amount; } [SpacetimeDB.Reducer] public static void DealDamage(ReducerContext ctx, Identity target, uint amount) { ctx.Db.DamageEvent.Insert(new DamageEvent { Target = target, Amount = amount }); } ``` Client subscribes and uses `OnInsert`: ```csharp conn.Db.DamageEvent.OnInsert += (ctx, evt) => { PlayDamageAnimation(evt.Target, evt.Amount); }; ``` Event tables must be subscribed explicitly — they are excluded from `SubscribeToAllTables()`. ### Database Access ```csharp // Find by primary key — returns nullable, use pattern matching if (ctx.Db.Task.Id.Find(taskId) is Task task) { /* use task */ } // Update by primary key (2.0: only primary key has .Update) ctx.Db.Task.Id.Update(task with { Title = newTitle }); // Delete by primary key ctx.Db.Task.Id.Delete(taskId); // Find by unique index — returns nullable if (ctx.Db.Player.Username.Find("alice") is Player player) { } // Filter by B-tree index — returns iterator foreach (var task in ctx.Db.Task.OwnerId.Filter(ctx.Sender)) { } // Full table scan — avoid for large tables foreach (var task in ctx.Db.Task.Iter()) { } var count = ctx.Db.Task.Count; ``` ### Custom Types and Sum Types ```csharp [SpacetimeDB.Type] public partial struct Position { public int X; public int Y; } // Sum types MUST be partial record with named tuple [SpacetimeDB.Type] public partial struct Circle { public int Radius; } [SpacetimeDB.Type] public partial struct Rectangle { public int Width; public int Height; } [SpacetimeDB.Type] public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { } // Creating sum type values var circle = new Shape.Circle(new Circle { Radius = 10 }); ``` ### Scheduled Tables ```csharp [SpacetimeDB.Table(Accessor = "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 { [SpacetimeDB.Reducer] public static void SendReminder(ReducerContext ctx, Reminder reminder) { Log.Info($"Reminder: {reminder.Message}"); } [SpacetimeDB.Reducer] public static void CreateReminder(ReducerContext ctx, string message, ulong delaySecs) { ctx.Db.Reminder.Insert(new Reminder { Id = 0, Message = message, ScheduledAt = new ScheduleAt.Time(ctx.Timestamp + TimeSpan.FromSeconds(delaySecs)) }); } } ``` ### Logging ```csharp Log.Debug("Debug message"); Log.Info("Information"); Log.Warn("Warning"); Log.Error("Error occurred"); Log.Exception("Critical failure"); // Logs at error level ``` ### 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) ctx.SenderAuth // Authorization context (JWT claims, internal call detection) ctx.Rng // Deterministic random number generator ``` ### Error Handling Throwing an exception in a reducer rolls back the entire transaction: ```csharp [SpacetimeDB.Reducer] public static void TransferCredits(ReducerContext ctx, Identity toUser, uint amount) { if (ctx.Db.User.Identity.Find(ctx.Sender) is not User sender) throw new Exception("Sender not found"); if (sender.Credits < amount) throw new Exception("Insufficient credits"); ctx.Db.User.Identity.Update(sender with { Credits = sender.Credits - amount }); if (ctx.Db.User.Identity.Find(toUser) is User receiver) ctx.Db.User.Identity.Update(receiver with { Credits = receiver.Credits + amount }); } ``` --- ## Project Setup ### Required .csproj (MUST be named `StdbModule.csproj`) ```xml net8.0 wasi-wasm Exe enable enable ``` ### Prerequisites ```bash # Install .NET 8 SDK (required, not .NET 9) # Install WASI workload dotnet workload install wasi-experimental ``` --- ## Client SDK ### Installation ```bash dotnet add package SpacetimeDB.ClientSDK ``` ### Generate Module Bindings ```bash spacetime generate --lang csharp --out-dir module_bindings --module-path PATH_TO_MODULE ``` This creates `SpacetimeDBClient.g.cs`, `Tables/*.g.cs`, `Reducers/*.g.cs`, and `Types/*.g.cs`. ### Connection Setup ```csharp using SpacetimeDB; using SpacetimeDB.Types; var conn = DbConnection.Builder() .WithUri("http://localhost:3000") .WithDatabaseName("my-database") .WithToken(savedToken) .OnConnect(OnConnected) .OnConnectError(err => Console.Error.WriteLine($"Failed: {err}")) .OnDisconnect((conn, err) => { if (err != null) Console.Error.WriteLine(err); }) .Build(); void OnConnected(DbConnection conn, Identity identity, string authToken) { // Save authToken to persistent storage for reconnection Console.WriteLine($"Connected: {identity}"); conn.SubscriptionBuilder() .OnApplied(OnSubscriptionApplied) .SubscribeToAllTables(); } ``` ### Critical: FrameTick **The SDK does NOT automatically process messages.** You must call `FrameTick()` regularly. ```csharp // Console application while (running) { conn.FrameTick(); Thread.Sleep(16); } // Unity: call conn?.FrameTick() in Update() ``` **Warning**: Do NOT call `FrameTick()` from a background thread. It modifies `conn.Db` and can cause data races. ### Subscribing to Tables ```csharp // SQL queries conn.SubscriptionBuilder() .OnApplied(OnSubscriptionApplied) .OnError((ctx, err) => Console.Error.WriteLine($"Subscription failed: {err}")) .Subscribe(new[] { "SELECT * FROM player", "SELECT * FROM message WHERE sender = :sender" }); // Subscribe to all tables (development only) conn.SubscriptionBuilder() .OnApplied(OnSubscriptionApplied) .SubscribeToAllTables(); // Subscription handle for later unsubscribe SubscriptionHandle handle = conn.SubscriptionBuilder() .OnApplied(ctx => Console.WriteLine("Applied")) .Subscribe(new[] { "SELECT * FROM player" }); handle.UnsubscribeThen(ctx => Console.WriteLine("Unsubscribed")); ``` **Warning**: `SubscribeToAllTables()` cannot be mixed with `Subscribe()` on the same connection. ### Accessing the Client Cache ```csharp // Iterate all rows foreach (var player in ctx.Db.Player.Iter()) { Console.WriteLine(player.Name); } // Count rows int playerCount = ctx.Db.Player.Count; // Find by unique/primary key — returns nullable Player? player = ctx.Db.Player.Identity.Find(someIdentity); if (player != null) { Console.WriteLine(player.Name); } // Filter by BTree index — returns IEnumerable foreach (var p in ctx.Db.Player.Level.Filter(1)) { } ``` ### Row Event Callbacks ```csharp ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => { Console.WriteLine($"Player joined: {player.Name}"); }; ctx.Db.Player.OnDelete += (EventContext ctx, Player player) => { Console.WriteLine($"Player left: {player.Name}"); }; ctx.Db.Player.OnUpdate += (EventContext ctx, Player oldRow, Player newRow) => { Console.WriteLine($"Player {oldRow.Name} renamed to {newRow.Name}"); }; // Checking event source ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => { switch (ctx.Event) { case Event.SubscribeApplied: break; // Initial subscription data case Event.Reducer(var reducerEvent): Console.WriteLine($"Reducer: {reducerEvent.Reducer}"); break; } }; ``` ### Calling Reducers ```csharp ctx.Reducers.SendMessage("Hello, world!"); ctx.Reducers.CreatePlayer("NewPlayer"); // Reducer completion callbacks 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 conn.OnUnhandledReducerError += (ReducerEventContext ctx, Exception ex) => { Console.Error.WriteLine($"Reducer error: {ex.Message}"); }; ``` ### Identity and Authentication ```csharp // In OnConnect callback — save token for reconnection void OnConnected(DbConnection conn, Identity identity, string authToken) { // Save authToken to persistent storage (file, config, PlayerPrefs, etc.) SaveToken(authToken); } // Reconnect with saved token string savedToken = LoadToken(); DbConnection.Builder() .WithUri("http://localhost:3000") .WithDatabaseName("my-database") .WithToken(savedToken) .OnConnect(OnConnected) .Build(); // Pass null or omit WithToken for anonymous connection ``` --- ## Commands ```bash spacetime start spacetime publish --module-path spacetime publish --clear-database -y --module-path spacetime generate --lang csharp --out-dir /SpacetimeDB --module-path spacetime logs ```