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.
2. User Presence (Optional but Recommended)
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:
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:
- Connect to the SpaceTimeDB instance.
- Subscribe to changes in the
Stroketable. - 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 aStrokeobject. - Call the
add_strokereducer with thisStrokeobject.
- 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
TableandReducerfrom the SpaceTimeDB SDK. @Table({ table_name: 'Stroke' }): This decorator declares a new table namedStroke.@Table.PrimaryKey(): Marks theidfield 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 likeArray<{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
UserPresencetable is self-explanatory, trackinguser_id,current_board_id, andlast_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 theStrokeclass, which is a TypeScript type generated byspacetimedb compilebased on ourschema.st.js. This provides strong typing for our reducer logic!@Reducer('add_stroke'): This decorator registers theadd_strokefunction as a SpaceTimeDB reducer, making it callable from clients.identity: Identity: Every reducer receives theidentityof the calling client. This is crucial for security and attribution. In a production app, you’d useidentity.to_string()as theuser_idto prevent clients from impersonating others. For simplicity here, we allow the client to passuser_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 ournewStrokeobject into theStroketable. SpaceTimeDB takes care of the rest, including real-time synchronization.@Reducer('update_user_presence'): A second reducer for managing user presence. It usesSpacetimeDB.filterto check for existing presence and eitherSpacetimeDB.updateorSpacetimeDB.insertaccordingly. 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 getidentity.client.subscribe(): Crucially, subscribe to theStrokeandUserPresencetables to receive real-time updates.client.on('Stroke', (strokes: Stroke[]) => { ... });: This event listener fires whenever theStroketable changes. Thestrokesarray will contain the entire current state of theStroketable, 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
Complex
points_jsonHandling:- Pitfall: Forgetting to
JSON.stringify()thepointsarray before sending it to the reducer (from the client) or before inserting it into theStroketable (within the reducer). Similarly, forgetting toJSON.parse()thepoints_jsonstring after receiving it on the client. - Troubleshooting: Check your client-side
sendStrokeToSpacetimeDBfunction and youradd_strokereducer. Useconsole.log()to inspect thepointsdata 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.
- Pitfall: Forgetting to
Reducer Idempotency for Real-time Updates:
- Pitfall: While
add_strokeis inherently additive, if you were to design anupdate_strokereducer, 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 anupdateordelete. This ensures your reducer operates on the actual current state.
- Pitfall: While
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_strokeandupdate_user_presencereducers 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
- SpaceTimeDB Official Documentation: https://spacetimedb.com/docs
- SpaceTimeDB GitHub Repository: https://github.com/clockworklabs/SpacetimeDB
- SpaceTimeDB CLI Releases: https://github.com/clockworklabs/SpacetimeDB/releases
- MDN Web Docs - JSON.stringify(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify
- 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.