Welcome back, intrepid developer! In our journey through SpaceTimeDB, we’ve covered the basics of setting up your database, defining schemas, and even writing server-side logic with reducers. But where SpaceTimeDB truly shines is in its ability to power real-time, collaborative applications. This is where the magic of shared state and instant synchronization comes alive!

In this chapter, we’re going to dive deep into building collaborative features. We’ll explore the patterns and techniques that allow multiple users to interact with the same data simultaneously, seeing updates happen in real-time across all connected clients. Think multiplayer games, shared whiteboards, collaborative document editors, or live dashboards – SpaceTimeDB makes these complex scenarios surprisingly approachable. Get ready to build applications that feel alive and responsive!

To make the most of this chapter, you should be comfortable with:

  • SpaceTimeDB installation and basic project setup (Chapter 2)
  • Schema definition with tables and fields (Chapter 3)
  • Writing and deploying SpaceTimeDB reducers (Chapter 5)
  • Basic client-side interaction with SpaceTimeDB, including subscriptions (Chapter 6)

The Heart of Collaboration: Shared State and Real-time Sync

At its core, a collaborative application is all about shared state. It’s about ensuring that everyone involved sees and interacts with the same, consistent version of reality. If one user draws a line, every other user should see that line appear instantly. If a player moves their character, all other players should witness that movement without delay.

Traditional backend architectures often struggle with this. You might have to poll the server constantly, manage complex WebSocket connections, or build elaborate pub/sub systems. SpaceTimeDB, however, is designed from the ground up to handle this automatically:

  1. Centralized, Deterministic State: Your entire application state lives in SpaceTimeDB tables. All changes to this state happen through deterministic reducers. This means that for a given starting state and a sequence of reducer calls, the final state will always be the same, regardless of when or where the reducer was called. This determinism is crucial for consistency.
  2. Event-Driven Updates: Whenever a reducer successfully modifies the database, SpaceTimeDB treats this as an “event.” It then efficiently propagates these changes to all subscribed clients. You don’t need to manually push updates; SpaceTimeDB handles the “when” and “how.”
  3. Client-Side Reactivity: The client SDKs (like TypeScript/JavaScript) automatically update their local views of the subscribed tables. This reactive nature means your frontend code can simply render the current state, and it will automatically reflect any changes from other users.

Let’s visualize this flow:

flowchart TD ClientA[Client A] --> CallReducerA(1. Calls Reducer) CallReducerA --> SpaceTimeDB_Server[2. SpaceTimeDB Server] SpaceTimeDB_Server --> ApplyReducer[3. Applies Reducer to State] ApplyReducer --> PropagateChanges[4. Propagates Changes to Subscribed Clients] PropagateChanges --> ClientA_Update(5. Client A Updates) PropagateChanges --> ClientB_Update(5. Client B Updates) PropagateChanges --> ClientC_Update(5. Client C Updates) ClientB[Client B] -.-> CallReducerB(Other clients can also initiate changes) ClientC[Client C] -.-> CallReducerC(Other clients can also initiate changes)

Figure 7.1: SpaceTimeDB’s Shared State Synchronization Flow

As you can see, when Client A calls a reducer, SpaceTimeDB processes it, updates its internal state, and then broadcasts those changes to all clients that are subscribed to the affected tables, including Client A itself. This ensures everyone is always in sync.

Optimistic vs. Pessimistic Concurrency

When multiple users try to modify the same data, concurrency becomes a concern.

  • Pessimistic concurrency involves locking data before modifying it, preventing others from accessing it until the lock is released. This ensures consistency but can hurt responsiveness and scalability.
  • Optimistic concurrency assumes conflicts are rare. Users modify data, and conflicts are detected and resolved after the fact. This is generally more scalable and responsive.

SpaceTimeDB leans towards an optimistic model, but with a twist. Its deterministic reducers guarantee that operations are applied in a strict, consistent order on the server. If two clients call the same reducer “simultaneously,” SpaceTimeDB will process them one after another. Clients will then receive the updates in the order they were processed, ensuring everyone ends up with the same final state. Conflicts are implicitly resolved by the server’s single-threaded, deterministic execution model for reducers.

Common Collaborative Patterns

Let’s look at some patterns you’ll encounter when building collaborative features:

  1. Presence: Knowing who is online and what they’re doing. This is fundamental for many collaborative apps (e.g., “3 users are viewing this document”).
  2. Shared Cursors/Selections: In a text editor, seeing where others are typing or what they have selected.
  3. Shared Canvas/Whiteboard: Multiple users drawing on the same digital canvas. This is a fantastic way to demonstrate real-time interaction.
  4. Multiplayer Game State: Synchronizing player positions, scores, game events, and item interactions.

We’ll focus on building a simple Shared Canvas/Whiteboard example to illustrate these concepts.

Step-by-Step Implementation: A Collaborative Whiteboard

Let’s build a minimalist collaborative whiteboard where multiple users can draw points, and everyone sees them appear instantly.

Step 1: Define the Schema for Drawing Points

First, we need to define how we’ll store individual points drawn on our whiteboard.

Open your spacetime-module/src/lib.rs file (or create one if you’re starting a new module) and add the following table definition.

// spacetime-module/src/lib.rs

use spacetimedb::{spacetimedb, Table, Reducer, Identity, Timestamp};

// Our Point table will store each individual dot drawn on the canvas.
#[spacetimedb(table)]
pub struct Point {
    #[spacetimedb(primarykey)]
    pub id: u64, // A unique ID for each point
    pub x: f32,  // X coordinate
    pub y: f32,  // Y coordinate
    pub color: String, // Color of the point (e.g., "#FF0000")
    pub user_id: Identity, // The identity of the user who drew this point
    pub timestamp: Timestamp, // When the point was drawn
}

// We'll add our reducer here next!

Explanation:

  • Point struct: Represents a single point drawn on the canvas.
  • id: u64: A unique identifier for each point. We’ll generate this on the server to ensure uniqueness.
  • x: f32, y: f32: Floating-point coordinates for precision.
  • color: String: Stores the color, perhaps as a hex string (e.g., #RRGGBB).
  • user_id: Identity: This is crucial for collaboration! SpaceTimeDB’s Identity type automatically tracks the unique identifier of the client calling the reducer. This allows us to attribute points to specific users.
  • timestamp: Timestamp: Records when the point was created. Timestamp is another built-in SpaceTimeDB type.

Step 2: Create a Reducer to Add Points

Now, let’s create a reducer that allows clients to add new points to our Point table.

Add this reducer to your spacetime-module/src/lib.rs file, right after the Point struct.

// spacetime-module/src/lib.rs
// ... (Point struct definition) ...

#[spacetimedb(reducer)]
pub fn add_point(ctx: ReducerContext, x: f32, y: f32, color: String) {
    // Generate a unique ID for the new point.
    // In a real app, you might use a more robust ID generation strategy
    // or SpaceTimeDB's built-in sequence generator if available.
    // For now, we'll use a simple timestamp-based ID.
    let id = ctx.timestamp.as_micros();

    // Insert the new point into the Point table.
    Point::insert(Point {
        id,
        x,
        y,
        color,
        user_id: ctx.sender, // The user who called this reducer
        timestamp: ctx.timestamp,
    }).expect("Failed to insert point");

    // No explicit return needed; the side effect of inserting the row
    // is what matters and will be propagated.
}

Explanation:

  • #[spacetimedb(reducer)]: Marks this function as a SpaceTimeDB reducer.
  • ctx: ReducerContext: This special parameter provides useful context about the reducer call, including:
    • ctx.sender: The Identity of the client that invoked this reducer. Perfect for attributing actions!
    • ctx.timestamp: The server’s current timestamp when the reducer is executed. Great for unique IDs and ordering.
  • x: f32, y: f32, color: String: These are the parameters passed from the client, representing the details of the point to be drawn.
  • id = ctx.timestamp.as_micros(): We’re using the reducer’s execution timestamp (in microseconds) as a simple unique ID. While usually effective, for highly concurrent systems, a dedicated counter or UUID generation might be considered if the timestamp resolution isn’t sufficient for uniqueness.
  • Point::insert(...): This is how we add a new row to the Point table. We construct a Point instance using the provided arguments and the ctx.sender and ctx.timestamp.
  • expect("Failed to insert point"): Basic error handling. In production, you’d want more graceful error management.

Step 3: Deploy the Module

Save your lib.rs file. Now, navigate to your spacetime-module directory in your terminal and deploy your changes:

spacetime deploy

This will compile your Rust module and push it to your SpaceTimeDB instance. If you’re running a local spacetime-dev instance, it will be updated instantly.

Step 4: Client-Side: Connecting and Drawing

Now for the fun part! Let’s build a simple web client using TypeScript to connect, draw, and see updates.

Setup your client project: If you don’t have a client project set up, create a simple index.html and script.ts (or script.js) file. You’ll need to compile TypeScript if you use .ts.

client/index.html (minimal HTML):

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SpaceTimeDB Whiteboard</title>
    <style>
        body { font-family: sans-serif; display: flex; flex-direction: column; align-items: center; margin: 20px; }
        canvas { border: 1px solid #ccc; background-color: #f9f9f9; cursor: crosshair; }
        .controls { margin-top: 10px; }
        .color-picker { width: 40px; height: 40px; border: none; cursor: pointer; }
        .user-info { margin-bottom: 15px; font-size: 0.9em; color: #555; }
    </style>
</head>
<body>
    <h1>Collaborative Whiteboard</h1>
    <div class="user-info" id="user-info">Connecting...</div>
    <div class="controls">
        Choose Color: <input type="color" id="colorPicker" class="color-picker" value="#FF0000">
    </div>
    <canvas id="whiteboardCanvas" width="800" height="600"></canvas>

    <script src="./script.js"></script> <!-- Ensure this matches your compiled JS file -->
</body>
</html>

client/script.ts (Client-side logic):

// client/script.ts

import { SpacetimeDBClient, Identity } from "@clockworklabs/spacetimedb-sdk";
import { Point } from "./types"; // We'll generate this soon!
import { add_point } from "./modules/spacetime"; // And this!

// --- Configuration ---
const SPACETIMEDB_URI = "ws://localhost:3000"; // Your SpaceTimeDB instance URI
const DB_NAME = "my_whiteboard_db"; // Replace with your actual DB name if different

// --- DOM Elements ---
const canvas = document.getElementById("whiteboardCanvas") as HTMLCanvasElement;
const ctx = canvas.getContext("2d")!;
const colorPicker = document.getElementById("colorPicker") as HTMLInputElement;
const userInfo = document.getElementById("user-info") as HTMLDivElement;

let currentColor: string = colorPicker.value;
let currentIdentity: Identity | null = null; // Store the current user's identity

// --- SpaceTimeDB Client Setup ---
const client = new SpacetimeDBClient(SPACETIMEDB_URI);

client.onConnect(() => {
    console.log("Connected to SpaceTimeDB!");
    client.enter(DB_NAME);
});

client.onDisconnect(() => {
    console.log("Disconnected from SpaceTimeDB.");
});

client.onInitialSync(() => {
    currentIdentity = client.identity;
    console.log("Initial sync complete. My identity:", currentIdentity?.to_string());
    userInfo.textContent = `Connected as: ${currentIdentity?.to_string().substring(0, 8)}...`;

    // Subscribe to the 'Point' table to receive all drawing updates
    client.subscribe([`Point`]);
});

// --- Client-side Type Definitions (for generated types) ---
// For now, we manually define Point. Later, we'll use generated types.
// Create a file: `client/types.ts`
/*
export interface Point {
    id: bigint; // u64 in Rust becomes bigint in TypeScript
    x: number;
    y: number;
    color: string;
    user_id: Identity;
    timestamp: bigint; // Timestamp in Rust becomes bigint
}
*/
// You'll need to generate these types from your SpaceTimeDB module.
// Run `spacetime client-sdk generate --lang ts --out-dir client`
// This will create `client/types.ts` and `client/modules/spacetime.ts`

// --- Drawing Logic ---
let isDrawing = false;

canvas.addEventListener("mousedown", (e) => {
    isDrawing = true;
    draw(e); // Draw the first point immediately
});

canvas.addEventListener("mousemove", (e) => {
    if (!isDrawing) return;
    draw(e);
});

canvas.addEventListener("mouseup", () => {
    isDrawing = false;
    ctx.beginPath(); // Reset path for next drawing stroke
});

canvas.addEventListener("mouseout", () => {
    isDrawing = false;
    ctx.beginPath();
});

colorPicker.addEventListener("change", (e) => {
    currentColor = (e.target as HTMLInputElement).value;
});

function draw(e: MouseEvent) {
    if (!currentIdentity) {
        console.warn("Not connected yet, cannot draw.");
        return;
    }

    const rect = canvas.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;

    // Call the SpaceTimeDB reducer!
    add_point(x, y, currentColor);

    // Locally draw the point immediately for responsiveness
    // (This point will be redrawn when the server sends the update,
    // but drawing it locally prevents perceived lag)
    drawSinglePoint(x, y, currentColor);
}

function drawSinglePoint(x: number, y: number, color: string) {
    ctx.lineTo(x, y);
    ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(x, y);

    ctx.strokeStyle = color; // Set stroke color
    ctx.lineWidth = 2; // Set line width
    ctx.lineCap = "round"; // Round line ends
}

// --- SpaceTimeDB Table Listeners ---

// This function clears the canvas and redraws all points.
// We'll call this whenever the 'Point' table changes.
function redrawAllPoints() {
    ctx.clearRect(0, 0, canvas.width, canvas.height); // Clear entire canvas
    ctx.beginPath(); // Reset path

    // Get all points from the client's local cache of the 'Point' table
    const allPoints = client.get_all("Point") as Point[];

    // Sort points by timestamp to ensure drawing order is consistent
    allPoints.sort((a, b) => Number(a.timestamp - b.timestamp));

    allPoints.forEach((point, index) => {
        ctx.strokeStyle = point.color;
        ctx.lineWidth = 2;
        ctx.lineCap = "round";

        // For simplicity, we're drawing points as individual strokes.
        // A more advanced drawing app would group points into strokes.
        if (index === 0 || point.id !== allPoints[index - 1].id) { // Simple check for new stroke
            ctx.beginPath();
            ctx.moveTo(point.x, point.y);
        } else {
            ctx.lineTo(point.x, point.y);
            ctx.stroke();
            ctx.beginPath();
            ctx.moveTo(point.x, point.y);
        }
    });
}

// Register a listener for changes to the 'Point' table
client.on("Point:insert", redrawAllPoints);
client.on("Point:update", redrawAllPoints); // Not strictly needed for this example, but good practice
client.on("Point:delete", redrawAllPoints); // If we add a 'clear' reducer later

// Initial draw when client connects and syncs
client.onInitialSync(redrawAllPoints);

// Connect to SpaceTimeDB
client.connect();

Explanation of Client-Side Code:

  1. Imports: We import SpacetimeDBClient and Identity from the SDK. We also anticipate importing Point and add_point from generated files.
  2. Configuration: Set your SpaceTimeDB URI and database name.
  3. DOM Elements: Get references to your canvas and color picker.
  4. SpacetimeDBClient Setup:
    • client.onConnect, client.onDisconnect: Basic connection logging.
    • client.onInitialSync: This is crucial! Once the client has received the initial snapshot of the database, we can:
      • Get the client’s Identity (SpaceTimeDB assigns one automatically if not authenticated).
      • client.subscribe([Point]): Tell SpaceTimeDB we want to receive all updates for the Point table. This is how real-time synchronization happens!
  5. Drawing Logic:
    • mousedown, mousemove, mouseup event listeners handle user interaction on the canvas.
    • add_point(x, y, currentColor): When a user draws, we directly call our SpaceTimeDB reducer from the client! This sends the drawing action to the server.
    • drawSinglePoint: This function draws a point directly on the local canvas. We do this optimistically for immediate visual feedback. The “true” state will come from the server, but this makes the app feel snappier.
  6. redrawAllPoints(): This is the core of our collaborative rendering.
    • It clears the entire canvas.
    • It fetches all points currently in the client’s local cache of the Point table using client.get_all("Point").
    • It then iterates through these points and redraws them. Sorting by timestamp ensures a consistent drawing order across clients.
  7. client.on("Point:insert", redrawAllPoints): This is the magic! Whenever a new Point is inserted (by any client, including our own after the reducer call), this listener fires, and we redraw the entire canvas. This ensures all clients see the same, up-to-date drawing.

Step 5: Generate Client-Side Types and Reducer Stubs

Before running the client, you need to generate the TypeScript types for your tables and the stub functions for your reducers. This makes client-side development much easier and type-safe.

Navigate to your project’s root (or wherever you want the client folder to be) and run:

spacetime client-sdk generate --lang ts --out-dir client

This command will create:

  • client/types.ts: Contains TypeScript interfaces matching your Rust #[spacetimedb(table)] structs.
  • client/modules/spacetime.ts: Contains TypeScript functions that wrap your Rust #[spacetimedb(reducer)] functions, allowing you to call them directly from your client-side code.

Verify that client/types.ts contains an interface for Point and client/modules/spacetime.ts contains an add_point function. If you’re using a bundler like Webpack or Parcel, you might need to adjust paths slightly. For a simple setup, ensure your script.ts can import from these generated files.

Step 6: Run Your Client

You’ll need a way to serve your index.html and script.js (compiled from script.ts). A simple HTTP server will do:

# If you have Node.js and npm installed:
npm install -g http-server
cd client
http-server . -p 8080

Now, open your browser to http://localhost:8080. Open a second browser tab (or even a different browser!) to the same URL. Draw in one window, and watch it appear in the other! You’ve just built a real-time collaborative application with SpaceTimeDB!

Mini-Challenge: Clear My Drawings

Let’s enhance our whiteboard. It’s great to draw, but sometimes you want a fresh start, or maybe just want to erase your own contributions.

Challenge: Add a new reducer called clear_my_points that, when called, deletes all Point entries that belong to the Identity of the caller. Then, add a button to your HTML and connect it to this new reducer in your client-side TypeScript.

Hint:

  • You’ll need to add a #[spacetimedb(reducer)] function to lib.rs.
  • Inside the reducer, you can use Point::filter(|point| point.user_id == ctx.sender).delete_all();
  • Remember to spacetime deploy after modifying lib.rs.
  • After deployment, regenerate your client SDK types (spacetime client-sdk generate ...) so the clear_my_points function is available in your client.
  • In your client script.ts, import the new reducer and add an event listener to a button.

What do you observe when you clear your points? Do other users see your points disappear? Why or why not?

Common Pitfalls & Troubleshooting

  1. Forgetting to spacetime deploy: Any changes to your Rust module (lib.rs) require a spacetime deploy to take effect on the server. Always remember this step!
  2. Not regenerating client SDK: If you add or change tables/reducers in your Rust module, your client-side TypeScript definitions will be out of date. Always run spacetime client-sdk generate --lang ts --out-dir client after a spacetime deploy if your client code relies on those changes.
  3. Incorrect Subscriptions: If your client isn’t receiving updates, double-check that you’ve correctly called client.subscribe([TableName]) for all relevant tables. No subscription, no real-time updates!
  4. Client-side Redrawing Logic: For complex collaborative apps, simply redrawAllPoints() on every update might be inefficient. For simple cases like our whiteboard, it’s fine. For a game, you might update specific entities. Consider if you need to optimize rendering by only redrawing changed elements, though SpaceTimeDB’s reactivity often makes full redraws surprisingly performant for many applications.
  5. Race Conditions in Client-Side Logic: While SpaceTimeDB’s reducers are deterministic, your client-side code might still have race conditions if it relies on local state that hasn’t been confirmed by the server. Always strive to make your client-side rendering a pure function of the SpaceTimeDB state. If you draw optimistically (like we did), ensure the server’s update eventually reconciles that state.

Summary

Phew! You’ve just built a truly collaborative application. In this chapter, we covered:

  • The fundamental principles of shared state and real-time synchronization in SpaceTimeDB.
  • How SpaceTimeDB’s deterministic reducers and event-driven propagation simplify building collaborative features.
  • Common collaborative patterns like presence and shared canvases.
  • A hands-on example of building a real-time collaborative whiteboard using SpaceTimeDB tables and reducers.
  • The importance of client.subscribe() for receiving real-time updates.
  • Key steps for client-side development, including generating SDK types.
  • Practical tips and troubleshooting for common issues.

You now have a solid understanding of how to leverage SpaceTimeDB for real-time, multi-user applications. This opens up a world of possibilities, from multiplayer games to live data dashboards and interactive productivity tools.

What’s Next?

In the next chapter, we’ll delve into more advanced topics like managing user authentication and authorization, ensuring that only the right users can perform specific actions in your collaborative applications.


References


This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.