Welcome back, intrepid SpaceTimeDB explorer! In this chapter, we’re going to put many of the concepts you’ve learned into practice by building a truly exciting project: a real-time collaborative whiteboard. Imagine multiple users drawing simultaneously on the same canvas, seeing each other’s strokes appear instantly – that’s the magic we’ll create with SpaceTimeDB.

This project will solidify your understanding of how SpaceTimeDB excels at managing dynamic, shared state for interactive applications. We’ll design a schema for drawing data, implement reducers to handle drawing actions, and conceptualize the client-side integration that brings it all to life. You’ll see firsthand how SpaceTimeDB’s built-in real-time synchronization makes building such complex features surprisingly straightforward.

Before we dive in, make sure you’re comfortable with:

  • SpaceTimeDB CLI setup and project initialization (Chapter 2)
  • Defining database schemas with tables and fields (Chapter 4)
  • Writing server-side logic using reducers (Chapter 6)
  • Connecting client applications and subscribing to table changes (Chapter 7)

Ready to sketch out some awesome real-time collaboration? Let’s get started!

Core Concepts for a Collaborative Whiteboard

Building a collaborative whiteboard requires a few key pieces of information to be managed and synchronized in real time: the strokes themselves, and potentially information about who is currently active on the board.

1. Modeling Drawing Strokes

How do we represent a drawing on a whiteboard? A drawing is typically composed of multiple “strokes.” Each stroke usually has:

  • A unique identifier.
  • The user who created it.
  • A color.
  • A thickness (or brush size).
  • A series of points that define its path.

SpaceTimeDB tables are perfect for storing this kind of structured data. We can create a Stroke table where each row represents a single continuous line drawn by a user. The series of points can be stored as a JSON array or a similar structured type within a field.

For a truly collaborative experience, it’s often helpful to know who else is currently viewing or drawing on the whiteboard. This can be modeled with a UserPresence table, tracking which users are active on which board. While we might not fully implement cursors in this chapter, having a presence table sets the stage for such features.

3. Real-time Synchronization in Action

The real power of SpaceTimeDB for this project is its automatic real-time synchronization. When one user draws a stroke, their client will call a SpaceTimeDB reducer. This reducer updates the Stroke table. Immediately, SpaceTimeDB detects this change and propagates the new stroke data to all other connected clients subscribed to the Stroke table. This happens without you writing any explicit WebSocket or pub/sub code – SpaceTimeDB handles it all!

Let’s visualize this flow:

flowchart TD ClientA[Client A] -->|Draws Stroke| Reducer_Call[Call 'add_stroke' Reducer] Reducer_Call --> SpaceTimeDB_Node[SpaceTimeDB Node] SpaceTimeDB_Node --> Update_DB[Update Stroke Table] Update_DB -->|Real-time Change Event| SpaceTimeDB_Node SpaceTimeDB_Node -->|Propagate to Subscribers| ClientA SpaceTimeDB_Node -->|Propagate to Subscribers| ClientB[Client B] SpaceTimeDB_Node -->|Propagate to Subscribers| ClientC[Client C] ClientA -.->|Receives Stroke Data| CanvasA[Render on Canvas A] ClientB -.->|Receives Stroke Data| CanvasB[Render on Canvas B] ClientC -.->|Receives Stroke Data| CanvasC[Render on Canvas C] subgraph SpaceTimeDB Backend Reducer_Call SpaceTimeDB_Node Update_DB end

Figure 13.1: Real-time Stroke Synchronization with SpaceTimeDB

In this diagram, when Client A draws, the add_stroke reducer is invoked. SpaceTimeDB processes this, updates its internal database, and then automatically pushes the new stroke data to all connected clients (A, B, and C), allowing them to render it on their respective canvases in real time.

4. Reducer Logic for Drawing Operations

Our primary reducer will be add_stroke. This reducer will accept the necessary details of a stroke (user ID, color, thickness, points array) and insert them into our Stroke table. Since SpaceTimeDB ensures deterministic execution and atomic updates, we don’t have to worry about race conditions when multiple users try to draw simultaneously. Each stroke will be processed in order.

5. Client-Side Interaction (High-Level)

While this guide focuses on SpaceTimeDB, it’s helpful to understand the client-side role. A typical frontend (e.g., a web application using HTML Canvas, React, or Vue) would:

  1. Connect to the SpaceTimeDB instance.
  2. Subscribe to changes in the Stroke table.
  3. Implement drawing logic:
    • Detect mouse/touch events (down, move, up).
    • Collect points as the user draws.
    • On mouse/touch up, package the collected points, color, and thickness into a Stroke object.
    • Call the add_stroke reducer with this Stroke object.
  4. When new stroke data arrives from SpaceTimeDB (either from the local client or another client), render it onto the HTML Canvas.

Step-by-Step Implementation

Let’s start building our SpaceTimeDB backend for the collaborative whiteboard.

Step 1: Initialize Your SpaceTimeDB Project

First, ensure you have the SpaceTimeDB CLI installed (we’ll assume v2.1.0 as of 2026-03-14). If not, refer back to Chapter 2.

Open your terminal and create a new project:

# Create a new directory for our project
mkdir collaborative-whiteboard
cd collaborative-whiteboard

# Initialize a new SpaceTimeDB project
# We'll use the 'typescript' template for type safety
spacetimedb new --template typescript whiteboard_backend

This command creates a new directory whiteboard_backend inside collaborative-whiteboard with the basic SpaceTimeDB project structure.

Now, navigate into the newly created backend directory:

cd whiteboard_backend

Step 2: Define the Schema for Strokes and Presence

We need to define our Stroke and UserPresence tables. Open the schema.st.js file located in your whiteboard_backend directory.

Initially, it might contain some example schema. Let’s replace it with our whiteboard-specific schema.

// whiteboard_backend/schema.st.js

import { Table, Reducer } from '@clockworklabs/spacetimedb-sdk';

/**
 * Represents a single drawing stroke on the whiteboard.
 */
@Table({
  table_name: 'Stroke',
})
export class Stroke {
  // A unique identifier for each stroke.
  // SpaceTimeDB automatically generates IDs for primary keys if not provided.
  @Table.PrimaryKey()
  id: string;

  // The ID of the user who created this stroke.
  user_id: string;

  // The color of the stroke (e.g., "#FF0000" for red).
  color: string;

  // The thickness of the stroke in pixels.
  thickness: number;

  // An array of points that make up the stroke.
  // Each point can be an object {x: number, y: number}.
  // We'll store this as a JSON string for simplicity within the schema,
  // but in TypeScript reducers, we'll parse/stringify it.
  points_json: string; // Storing as JSON string
}

/**
 * Tracks the presence of users on the whiteboard.
 */
@Table({
  table_name: 'UserPresence',
})
export class UserPresence {
  // The unique ID of the user. This is also the primary key.
  @Table.PrimaryKey()
  user_id: string;

  // The ID of the current whiteboard the user is on (useful for multiple boards).
  current_board_id: string;

  // Timestamp of the last activity, useful for showing "online" status.
  last_active: number;
}

// Reducer declarations will go here later.
// For now, let's just define the tables.

Explanation:

  • We import Table and Reducer from the SpaceTimeDB SDK.
  • @Table({ table_name: 'Stroke' }): This decorator declares a new table named Stroke.
  • @Table.PrimaryKey(): Marks the id field as the primary key. SpaceTimeDB will ensure its uniqueness and can auto-generate values if not explicitly set during insertion.
  • user_id, color, thickness: These are straightforward fields to store stroke properties.
  • points_json: string: This is a crucial design choice. While SpaceTimeDB’s schema definition might not directly support complex nested types like Array<{x: number, y: number}> as a native column type, you can store such data as a JSON string. Your reducers and client-side code will be responsible for serializing (converting object to JSON string) before writing to the DB and deserializing (converting JSON string back to object) after reading from the DB. This is a common and flexible pattern for complex data.
  • The UserPresence table is self-explanatory, tracking user_id, current_board_id, and last_active.

After defining your schema, compile it using the SpaceTimeDB CLI:

spacetimedb compile

This command processes your schema.st.js and generates necessary TypeScript types and client-side SDK code in the src/spacetimedb directory. This generated code will be crucial for interacting with your database from reducers and clients.

Step 3: Implement the add_stroke Reducer

Now, let’s create the reducer that will handle adding new strokes to our Stroke table.

Open the src/modules/mod.ts file. This is where your SpaceTimeDB reducers live.

Replace its content with the following:

// whiteboard_backend/src/modules/mod.ts

import { Reducer, SpacetimeDB, Identity } from '@clockworklabs/spacetimedb-sdk';
import { Stroke } from '../spacetimedb/schema'; // Import our generated Stroke type

/**
 * Adds a new drawing stroke to the whiteboard.
 * This reducer is called by clients when a user finishes drawing a stroke.
 *
 * @param identity The identity of the user calling this reducer.
 * @param user_id The ID of the user who created the stroke.
 * @param color The color of the stroke.
 * @param thickness The thickness of the stroke.
 * @param points_json A JSON string representing an array of points for the stroke.
 */
@Reducer('add_stroke')
export function add_stroke(
  identity: Identity,
  user_id: string, // In a real app, you'd likely derive this from `identity`
  color: string,
  thickness: number,
  points_json: string
) {
  // Basic validation: Ensure points_json is not empty or malformed
  if (!points_json || points_json.length < 2) {
    SpacetimeDB.log.warn("Attempted to add an empty or invalid stroke.");
    return;
  }

  // Generate a unique ID for the new stroke.
  // We can use a combination of user_id and current timestamp, or a UUID library.
  // For simplicity, let's use a basic timestamp-based ID here.
  const stroke_id = `${user_id}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;

  // Create a new Stroke object using the generated type
  const newStroke: Stroke = {
    id: stroke_id,
    user_id: user_id,
    color: color,
    thickness: thickness,
    points_json: points_json,
  };

  // Insert the new stroke into the Stroke table
  SpacetimeDB.insert(Stroke, newStroke);

  SpacetimeDB.log.info(`New stroke added by user ${user_id} with ID: ${stroke_id}`);
}

/**
 * Updates a user's presence on the whiteboard.
 * This reducer might be called periodically by clients to show active users.
 *
 * @param identity The identity of the user.
 * @param current_board_id The ID of the board the user is currently on.
 */
@Reducer('update_user_presence')
export function update_user_presence(
  identity: Identity,
  current_board_id: string
) {
  const user_id = identity.to_string(); // Use the identity as the user ID

  // Check if the user already exists in UserPresence
  const existingPresence = SpacetimeDB.filter(UserPresence, { user_id }).get_one();

  const now = Date.now();

  if (existingPresence) {
    // Update existing presence
    SpacetimeDB.update(UserPresence, { user_id }, { last_active: now, current_board_id });
    SpacetimeDB.log.info(`User ${user_id} presence updated on board ${current_board_id}.`);
  } else {
    // Insert new presence
    SpacetimeDB.insert(UserPresence, {
      user_id: user_id,
      current_board_id: current_board_id,
      last_active: now,
    });
    SpacetimeDB.log.info(`User ${user_id} joined board ${current_board_id}.`);
  }
}

Explanation:

  • import { Stroke } from '../spacetimedb/schema';: We import the Stroke class, which is a TypeScript type generated by spacetimedb compile based on our schema.st.js. This provides strong typing for our reducer logic!
  • @Reducer('add_stroke'): This decorator registers the add_stroke function as a SpaceTimeDB reducer, making it callable from clients.
  • identity: Identity: Every reducer receives the identity of the calling client. This is crucial for security and attribution. In a production app, you’d use identity.to_string() as the user_id to prevent clients from impersonating others. For simplicity here, we allow the client to pass user_id, but be aware of this security implication.
  • stroke_id: We generate a unique ID for the stroke. This is important for SpaceTimeDB’s primary key and for client-side rendering (e.g., if a client needs to update an existing stroke, though we’re only adding new ones here).
  • SpacetimeDB.insert(Stroke, newStroke);: This is the core action. It inserts our newStroke object into the Stroke table. SpaceTimeDB takes care of the rest, including real-time synchronization.
  • @Reducer('update_user_presence'): A second reducer for managing user presence. It uses SpacetimeDB.filter to check for existing presence and either SpacetimeDB.update or SpacetimeDB.insert accordingly. This demonstrates how to handle upsert-like logic.

After modifying your reducers, you need to compile them to generate the necessary bindings for the SpaceTimeDB module:

spacetimedb compile

Step 4: Run Your SpaceTimeDB Backend

Now that your schema and reducers are defined, you can start your SpaceTimeDB backend:

spacetimedb dev

You should see output indicating that SpaceTimeDB is running, typically on ws://localhost:9000. Keep this terminal window open. This command also deploys your compiled schema and reducers to the local SpaceTimeDB instance.

Step 5: Client-Side Interaction (Conceptual)

While a full frontend implementation is beyond the scope of this SpaceTimeDB guide, let’s look at the critical SpaceTimeDB interaction points that your client-side JavaScript/TypeScript application would need.

You would install the SpaceTimeDB client SDK in your frontend project (e.g., using npm):

npm install @clockworklabs/spacetimedb-sdk

Then, your client-side code would look something like this (simplified for clarity):

// frontend/src/SpacetimeDBClient.ts (Conceptual file)

import { SpacetimeDBClient, Identity } from '@clockworklabs/spacetimedb-sdk';
import { add_stroke, update_user_presence, Stroke } from './spacetimedb/client'; // Generated client SDK

const SPACETIMEDB_URI = 'ws://localhost:9000'; // Or your deployed SpaceTimeDB instance
const BOARD_ID = 'main-whiteboard'; // A fixed ID for our simple whiteboard

let client: SpacetimeDBClient;
let currentIdentity: Identity;
let currentUserID: string; // This would typically come from an auth system

export async function connectToSpacetimeDB() {
  client = new SpacetimeDBClient(SPACETIMEDB_URI);

  client.onConnect(() => {
    console.log('Connected to SpaceTimeDB!');
    currentIdentity = client.identity;
    currentUserID = currentIdentity.to_string(); // Use the SpaceTimeDB identity as the user ID

    // Subscribe to the Stroke table to receive real-time updates
    client.subscribe([
      { tableName: 'Stroke' },
      { tableName: 'UserPresence' }
    ]);

    // Send initial presence or update periodically
    update_user_presence(BOARD_ID);
  });

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

  // Listen for changes in the Stroke table
  client.on('Stroke', (strokes: Stroke[]) => {
    console.log('Received updated strokes:', strokes);
    // This is where your frontend would re-render the canvas
    // For example: `renderStrokesOnCanvas(strokes);`
  });

  // Listen for changes in UserPresence
  client.on('UserPresence', (presenceRecords) => {
    console.log('Updated user presence:', presenceRecords);
    // Update your UI to show who is online
  });

  await client.connect();
}

/**
 * Function to be called by the frontend drawing logic when a stroke is complete.
 */
export function sendStrokeToSpacetimeDB(color: string, thickness: number, points: { x: number, y: number }[]) {
  if (!client || !currentUserID) {
    console.error("SpaceTimeDB client not connected or user ID not set.");
    return;
  }

  const points_json = JSON.stringify(points);

  // Call the 'add_stroke' reducer
  add_stroke(currentUserID, color, thickness, points_json);
  console.log('Called add_stroke reducer.');
}

// Example of how you might update presence periodically
setInterval(() => {
  if (client && currentUserID) {
    update_user_presence(BOARD_ID);
  }
}, 30000); // Every 30 seconds

Key client-side takeaways:

  • SpacetimeDBClient: The main entry point for connecting.
  • client.onConnect(): Establish connection and get identity.
  • client.subscribe(): Crucially, subscribe to the Stroke and UserPresence tables to receive real-time updates.
  • client.on('Stroke', (strokes: Stroke[]) => { ... });: This event listener fires whenever the Stroke table changes. The strokes array will contain the entire current state of the Stroke table, allowing your frontend to re-render.
  • add_stroke(currentUserID, color, thickness, points_json);: This directly calls your server-side reducer. The generated client SDK (./spacetimedb/client) provides these functions.

With this setup, your frontend would handle the drawing on an HTML Canvas, collect the points, and then simply call sendStrokeToSpacetimeDB. SpaceTimeDB would then handle the persistence and real-time distribution to all other connected clients, making your whiteboard collaborative!

Mini-Challenge: Clear the Whiteboard

You’ve successfully built the core logic for adding strokes. Now, let’s add a feature to manage the whiteboard’s state: clearing all strokes.

Challenge: Create a new SpaceTimeDB reducer called clear_whiteboard. This reducer should delete all existing strokes from the Stroke table.

Hint: SpaceTimeDB’s SpacetimeDB.delete() function can accept a filter to delete multiple rows. If you want to delete all rows from a table, what would your filter look like? (Think about an empty filter or a filter that matches everything).

What to observe/learn: How to perform bulk delete operations using SpaceTimeDB reducers, which is essential for managing dynamic data sets.

Click for Solution (after you've tried it!)
// whiteboard_backend/src/modules/mod.ts (Add this to your existing file)

// ... existing imports and reducers ...

/**
 * Clears all drawing strokes from the whiteboard.
 * This reducer is typically called by an authorized user (e.g., moderator).
 *
 * @param identity The identity of the user calling this reducer.
 */
@Reducer('clear_whiteboard')
export function clear_whiteboard(identity: Identity) {
  // In a real application, you'd add authorization logic here:
  // if (!isModerator(identity)) {
  //   SpacetimeDB.log.warn(`Unauthorized attempt to clear whiteboard by ${identity.to_string()}`);
  //   return;
  // }

  // To delete all rows from a table, you can pass an empty filter object `{}`
  // or use a filter that always evaluates to true.
  // The simplest way to delete all is to provide an empty filter.
  SpacetimeDB.delete(Stroke, {}); // Deletes all rows from the Stroke table

  SpacetimeDB.log.info(`Whiteboard cleared by ${identity.to_string()}.`);
}

Explanation of Solution: By calling SpacetimeDB.delete(Stroke, {});, we tell SpaceTimeDB to delete all entries from the Stroke table that match an empty filter. An empty filter matches all entries, effectively clearing the table. Remember to re-run spacetimedb compile and spacetimedb dev after adding this reducer! On the client side, you would simply call clear_whiteboard() from the generated client SDK.

Common Pitfalls & Troubleshooting

  1. Complex points_json Handling:

    • Pitfall: Forgetting to JSON.stringify() the points array before sending it to the reducer (from the client) or before inserting it into the Stroke table (within the reducer). Similarly, forgetting to JSON.parse() the points_json string after receiving it on the client.
    • Troubleshooting: Check your client-side sendStrokeToSpacetimeDB function and your add_stroke reducer. Use console.log() to inspect the points data just before it’s sent/inserted and immediately after it’s received/read. Ensure it’s a valid JSON string when interacting with the database.
  2. Reducer Idempotency for Real-time Updates:

    • Pitfall: While add_stroke is inherently additive, if you were to design an update_stroke reducer, you’d need to ensure it can be safely called multiple times without unintended side effects. For example, if a client attempts to update a stroke that has already been deleted.
    • Troubleshooting: Always retrieve the current state of the row you intend to update or delete within your reducer. Perform checks like if (existingStroke) before attempting an update or delete. This ensures your reducer operates on the actual current state.
  3. Client-Side Rendering Performance with Many Strokes:

    • Pitfall: As the number of strokes grows, re-rendering the entire canvas every time a new stroke arrives can become slow, especially on less powerful devices.
    • Troubleshooting: This is primarily a frontend optimization, but SpaceTimeDB’s data model allows for it. Instead of clearing and redrawing everything, your client-side client.on('Stroke', ...) listener could be smarter:
      • Maintain a local cache of strokes.
      • When SpaceTimeDB sends updates, identify only the new or changed strokes.
      • Only draw the new/changed strokes, or use a double-buffering technique on the canvas.
      • Periodically “bake” older strokes into a static background layer to reduce the number of active elements to render.

Summary

Phew! You’ve just completed a significant project milestone: laying the foundation for a real-time collaborative whiteboard using SpaceTimeDB.

Here are the key takeaways from this chapter:

  • Schema Design for Dynamic Data: You learned how to model complex, dynamic data like drawing strokes, including using JSON strings for array-of-objects fields.
  • Event-Driven Reducers: You implemented add_stroke and update_user_presence reducers to handle core drawing and presence logic, demonstrating how SpaceTimeDB processes state changes.
  • Real-time Synchronization in Practice: You saw how SpaceTimeDB automatically synchronizes database changes to all connected clients, making collaborative features incredibly efficient to build.
  • Client-Side Interaction: You gained a conceptual understanding of how a frontend application connects, subscribes, and calls reducers to achieve real-time collaboration.
  • Bulk Operations: The mini-challenge introduced you to performing bulk deletes with reducers, a crucial skill for managing data.

This project highlights SpaceTimeDB’s strength in creating highly interactive and collaborative applications where shared, real-time state is paramount. You’ve now got a solid foundation for building your own multiplayer games, collaborative editors, or real-time dashboards!

In the next chapter, we’ll dive into more advanced topics like security models and authentication integration, which are critical for production-ready collaborative applications.

References

  1. SpaceTimeDB Official Documentation: https://spacetimedb.com/docs
  2. SpaceTimeDB GitHub Repository: https://github.com/clockworklabs/SpacetimeDB
  3. SpaceTimeDB CLI Releases: https://github.com/clockworklabs/SpacetimeDB/releases
  4. MDN Web Docs - JSON.stringify(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify
  5. MDN Web Docs - HTML Canvas API: https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API

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