Files
Jason Larabie f8d6d76ee4 Update Unreal SDK to websocket 2.0 (#4497)
# Description of Changes

- Updated the Unreal SDK and generated Unreal bindings for the websocket
2.0 protocol/model
  - Reworked DbConnectionBase to handle the updated message shapes
- Switched subscription handling over to new message types and
QuerySetId
- Updated reducer to ReducerResult, removal of callbacks, and set
reducer flags
  - Added event table support
- Baked in multi-module support replacing [the old
PR](<https://github.com/clockworklabs/SpacetimeDB/pull/3417>)
- Added functionality to generate module support for multiple folders in
the Unreal project (add <module>.Build.cs, <module>.h, <module>.cpp)
using the --module-name
- Add new configuration option for spacetime generate to handle module
prefix
 - Regenerated Unreal Blackholio/TestClient/QuickstartChat bindings
   - Rebuilt Unreal Blackholio's consume entity to use event tables 
 - Updated migration documentation
 - Updated the version bump tool to impact C++

# API and ABI breaking changes

- Unreal websocket/message handling updated to the new protocol
- Unreal generation now expects a real .uproject target and will stop
immediately if project
    metadata is invalid instead of continuing past setup issues.

# Expected complexity level and risk

3 - A large set of changes to update the websocket/message handling
along with heavy codegen changes to handle multi-module support

# Testing

Test coverage of the Unreal SDK will need expansion in a future ticket
once our issues with flakiness on CI is resolved.

- [x] Updated Unreal Blackholio 
- [x] Ran full Unreal SDK test suite
- [x] Built new test project using the new `--module-prefix` 
- [x] Run through Unreal Blackholio (C++ and Blueprint)
- [x] Rebuilt Unreal Blackholio with multi-module, and duplicate
generated module testing side-by-side modules that would overlap

# Review Question(s)
- [x] Updates to `spacetime init` have made the tutorial a little
confusing with pathing for the Unreal Blackholio tutorial. To fix though
we'd have to update all the commands to be more explicit, or update the
tutorial `spacetime init` to use `--project-path .` to keep pathing
simpler, thoughts?

---------

Signed-off-by: Jason Larabie <jason@clockworklabs.io>
Co-authored-by: Ryan <r.ekhoff@clockworklabs.io>
2026-03-18 21:14:06 +00:00

8.8 KiB

SpacetimeDB C++ Module Quickstart

This guide will walk you through creating your first SpacetimeDB module in C++. We'll build a simple chat server to demonstrate the core concepts.

What is a SpacetimeDB Module?

A SpacetimeDB module is C++ code that gets compiled to WebAssembly and runs inside the database. Instead of the traditional architecture (database → app server → clients), SpacetimeDB lets you write your entire backend logic that runs inside the database itself, giving you microsecond latency and automatic real-time sync to clients.

Modules consist of four main components:

  • Tables: Database tables defined as C++ structs
  • Reducers: Functions that modify data and can be called by clients
  • Views: Read-only query functions that return data (std::vector or std::optional) to clients
  • Procedures: Pure functions that return values and can optionally access the database via transactions

Prerequisites

Before we begin, make sure you have:

Creating Your First Module

Step 1: Initialize the Project

Create a new C++ module using the SpacetimeDB CLI:

spacetime init --lang cpp my-chat-module
cd my-chat-module

This creates a project with the following structure:

my-chat-module/spacetimedb/
├── CMakeLists.txt
├── src/
    └── lib.cpp
└── .gitignore

Step 2: Define Your Data Structures

Open lib.cpp and replace the generated code with our chat server implementation:

#include <spacetimedb.h>

using namespace SpacetimeDB;

// Define a User table to store connected users
struct User {
    Identity identity;      // SpacetimeDB's built-in user identity type
    std::optional<std::string> name;  // User's display name (optional)
    bool online;           // Whether the user is currently connected
};

// Register the struct for BSATN serialization
SPACETIMEDB_STRUCT(User, identity, name, online)

// Register as a public table with identity as primary key
SPACETIMEDB_TABLE(User, user, Public)
FIELD_PrimaryKey(user, identity);

// Define a Message table to store chat messages
struct Message {
    Identity sender;       // Who sent the message
    Timestamp sent;        // When the message was sent
    std::string text;      // Message content
};

SPACETIMEDB_STRUCT(Message, sender, sent, text)
SPACETIMEDB_TABLE(Message, message, Public)

Step 3: Add Helper Functions and Reducers

First, add validation helper functions:

// Validate that a name is not empty, return an Outcome which houses a error as std::string
Outcome<std::string> validate_name(const std::string& name) {
    if (name.empty()) {
        return Err<std::string>("Names must not be empty");
    }
    return Ok(name);
}

// Validate that a message is not empty, return an Outcome which houses a error as std::string
Outcome<std::string> validate_message(const std::string& text) {
    if (text.empty()) {
        return Err<std::string>("Messages must not be empty");
    }
    return Ok(text);
}

Now add the reducers (functions that clients can call to modify the database):

// Called when a user sets their name
SPACETIMEDB_REDUCER(set_name, ReducerContext ctx, std::string name) {
    auto validated = validate_name(name);
    if (validated.is_err()) {
        return Err(validated.error());
    }
    
    // Find and update the user by identity (primary key)
    auto user_row = ctx.db[user_identity].find(ctx.sender());
    if (user_row.has_value()) {
        auto user = user_row.value();
        user.name = validated.value();
        ctx.db[user_identity].update(user);
        return Ok();
    }
    
    return Err("Cannot set name for unknown user");
}

// Called when a user sends a message
SPACETIMEDB_REDUCER(send_message, ReducerContext ctx, std::string text) {
    auto validated = validate_message(text);
    if (validated.is_err()) {
        return Err(validated.error());
    }
    
    Message msg{ctx.sender(), ctx.timestamp, validated.value()};
    ctx.db[message].insert(msg);
    return Ok();
}

Step 4: Add Lifecycle Reducers

Lifecycle reducers are special functions called automatically by SpacetimeDB:

// Called when a client connects
SPACETIMEDB_CLIENT_CONNECTED(client_connected, ReducerContext ctx) {
    auto user_row = ctx.db[user_identity].find(ctx.sender());
    if (user_row.has_value()) {
        auto user = user_row.value();
        user.online = true;
        ctx.db[user_identity].update(user);
    } else {
        User new_user{ctx.sender(), std::nullopt, true};
        ctx.db[user].insert(new_user);
    }
    return Ok();
}

// Called when a client disconnects  
SPACETIMEDB_CLIENT_DISCONNECTED(client_disconnected, ReducerContext ctx) {
    auto user_row = ctx.db[user_identity].find(ctx.sender());
    if (user_row.has_value()) {
        auto user = user_row.value();
        user.online = false;
        ctx.db[user_identity].update(user);
    } else {
        LOG_WARN("Disconnect event for unknown user");
    }
    return Ok();
}

Step 5: Build Your Module

Build the module using the provided CMake configuration:

spacetime build -p ./spacetimedb

This compiles your C++ code to WebAssembly, producing build/lib.wasm.

Step 6: Publish to SpacetimeDB

Start your local SpacetimeDB instance:

spacetime start

Publish your module:

spacetime publish . my-chat-db

Step 7: Test Your Module

You can test your reducers using the CLI:

# Set a user's name
spacetime call my-chat-db set_name "Alice"

# Send a message
spacetime call my-chat-db send_message "Hello, world!"

# View all users
spacetime sql my-chat-db "SELECT * FROM user"

# View all messages
spacetime sql my-chat-db "SELECT * FROM message"

Key Concepts Explained

Tables vs. Structs

  • Structs are just data types - they need SPACETIMEDB_STRUCT for serialization
  • Tables are database tables created with SPACETIMEDB_TABLE and store data persistently
  • The same struct can be used for multiple tables or just as a data type

Database Access Pattern

SpacetimeDB C++ uses a unique accessor pattern:

  • ctx.db[tableName] - Access table for iteration and basic operations, eg. ctx.db[user]
  • ctx.db[tableName_fieldName] - Access indexed fields for optimized operations, eg. ctx.db[user_id]
// Table access
ctx.db[user].insert(new_user);

// Field accessor (for indexed fields, e.g., user_identity = table 'user' + field 'identity')
ctx.db[user_identity].delete_by_key(identity);

Constraints and Indexes

Constraints are applied after table registration using FIELD_ macros:

SPACETIMEDB_TABLE(User, user, Public)
FIELD_PrimaryKey(user, identity);       // Primary key
FIELD_Unique(user, email);              // Unique constraint
FIELD_Index(user, age);                 // Index for fast queries

Public vs. Private Tables

  • Public tables: Automatically synced to subscribed clients
  • Private tables: Only accessible by reducers, not synced to clients

Next Steps

Now that you have a basic chat server:

  1. Add more features: User roles, message editing, channels
  2. Add constraints: Unique usernames, message length limits
  3. Explore indexing: For fast queries on large datasets
  4. Try scheduled reducers: For periodic cleanup or notifications
  5. Generate client code: Use spacetime generate to create TypeScript, C#, or Rust clients

Advanced Example: Adding Indexes

You can add an index to make querying messages by sender more efficient:

SPACETIMEDB_TABLE(Message, message, Public)
FIELD_Index(message, sender);  // Add index on sender for faster queries

// Now you can efficiently query by sender using the field accessor:
SPACETIMEDB_REDUCER(get_user_messages, ReducerContext ctx, Identity user_identity) {
    for (const auto& msg : ctx.db[message_sender].filter(user_identity)) {
        LOG_INFO("Message: " + msg.text);
    }
    return Ok();
}

Troubleshooting

Build errors: Ensure you have the latest Emscripten SDK and are using emcmake cmake

Module not found: Check that SpacetimeDB is running

Type errors: Remember that C++ types need exact matches - use uint32_t, not int

Constraint violations: Constraints are enforced by the database - duplicate primary keys will cause reducers to fail

Example Projects

For more complex examples, see:


Ready to learn more? Check out the C++ Reference Documentation for detailed API information.