Multiplayer starter kit

The Multiplayer Starter Kit demonstrates how to build self-hosted tldraw applications with real-time multiplayer collaboration using Cloudflare Durable Objects. It features production-ready backend infrastructure that handles WebSocket connections, automatic persistence, and asset management. You can use this foundation to create collaborative whiteboards, design tools, or any multi-user canvas application.

The multiplayer starter kit provides a production-ready foundation for building collaborative tldraw applications with real-time synchronization. It uses Cloudflare Durable Objects for room management, WebSocket connections for instant updates, and SQLite storage for automatic persistence.

To build with this starter kit, run this command in your terminal:

npm create tldraw@latest -- --template multiplayer

Use cases

This starter kit is perfect for building:

Collaborative whiteboards

Real-time drawing and diagramming tools where multiple users can contribute simultaneously.

Educational canvases

Shared learning environments for remote teaching, brainstorming, and visual collaboration.

Design review platforms

Interactive design collaboration spaces with persistent sessions and asset sharing.

Project planning tools

Visual project management interfaces with real-time updates across team members.

Interactive documentation

Living documents that teams can annotate and modify together in real-time.


How it works

1. Durable Objects: Room management

Each collaborative room runs in its own Cloudflare Durable Object instance. There's only ever one authoritative copy of each room's data, and all users connect to that same instance. This ensures strong consistency. Presence indicators (avatars and names) show who is currently in the room, and real-time cursors show where other users are pointing or selecting.

2. WebSocket synchronization: Real-time updates

Changes are synchronized instantly via WebSocket connections. When a user draws or modifies content, the change is sent to the Durable Object, applied to the in-memory document, and broadcast to all connected clients. The sync protocol keeps state consistent across all clients. If a connection drops, the client automatically reconnects and replays missed changes to restore consistency.

3. Persistent storage: SQLite

Room data is automatically persisted to Durable Object SQLite storage. Every change is saved immediately, so data survives restarts without manual save logic.

4. Asset management: Scalable file handling

Images, videos, and other files are uploaded directly to R2 storage and served through Cloudflare's global edge network. Shared assets are synchronized across all connected users. Pasted URLs automatically unfurl into link previews.


Customization

This starter kit is built on top of tldraw's extensible architecture, which means that everything can be customized. The canvas renders using React DOM, so you can use familiar React patterns, components, and state management. Here are some ways to customize this starter kit.

Custom shapes integration

To add custom shapes that work with multiplayer sync, you need to configure both the client-side shape utilities and the server-side schema. Both sides must know about your custom shapes so that validation, synchronization, and version compatibility work correctly.

See worker/TldrawDurableObject.ts and client/pages/Room.tsx as examples. The server file shows schema configuration while the client shows how to connect custom shapes to the sync client.

In the code example below, we add a custom sticky note shape on both client and server:

// 1. Server-side: Add custom shape schema in worker/TldrawDurableObject.ts
import { createTLSchema, defaultShapeSchemas } from '@tldraw/tlschema'

const schema = createTLSchema({
	shapes: {
		...defaultShapeSchemas,
		'sticky-note': {
			props: {
				text: { type: 'string', default: '' },
				color: { type: 'string', default: 'yellow' },
			},
			migrations: {
				currentVersion: 1,
				migrators: {},
			},
		},
	},
})

// 2. Client-side: Create ShapeUtil class in client/StickyNoteShapeUtil.tsx
import { ShapeUtil, Rectangle2d, HTMLContainer } from 'tldraw'

class StickyNoteShapeUtil extends ShapeUtil<StickyNoteShape> {
	static override type = 'sticky-note' as const

	getDefaultProps() {
		return { text: '', color: 'yellow' }
	}

	getGeometry(shape: StickyNoteShape) {
		return new Rectangle2d({ width: 200, height: 200, isFilled: true })
	}

	component(shape: StickyNoteShape) {
		return <HTMLContainer>{shape.props.text}</HTMLContainer>
	}

	indicator(shape: StickyNoteShape) {
		return <rect width={200} height={200} />
	}
}

// 3. Client-side: Pass ShapeUtil to Tldraw in client/pages/Room.tsx
;<Tldraw store={store} shapeUtils={[StickyNoteShapeUtil]} />

Asset upload customization

To customize how assets are uploaded and served, modify the asset store configuration. You can add authentication, preprocessing, or serve different asset variants based on user permissions.

See client/multiplayerAssetStore.tsx as an example. This file shows how to upload assets to your Cloudflare Worker and retrieve them for display.

In the code example below, we add authentication to asset uploads:

// Custom asset store with authentication
export const authenticatedAssetStore: TLAssetStore = {
	async upload(asset, file) {
		const id = uniqueId()
		const objectName = `${id}-${file.name}`.replace(/[^a-zA-Z0-9.]/g, '-')

		const response = await fetch(`/api/uploads/${objectName}`, {
			method: 'POST',
			body: file,
			headers: {
				Authorization: `Bearer ${getAuthToken()}`,
				'X-User-ID': getCurrentUserId(),
			},
		})

		if (!response.ok) {
			throw new Error(`Upload failed: ${response.statusText}`)
		}

		return { src: `/api/uploads/${objectName}` }
	},

	resolve(asset) {
		return `${asset.props.src}?token=${getAuthToken()}`
	},
}

Deployment configuration

Modify the Cloudflare Worker configuration to customize your deployment. You can adjust resource limits, add custom domains, and set environment-specific variables.

See wrangler.toml as an example. This configuration file controls how your worker is deployed, including bucket names, environment variables, and routing rules.

In the code example below, we configure production deployment settings:

# Production deployment configuration
name = "my-multiplayer-app"
main = "worker/worker.ts"
compatibility_date = "2024-08-01"

[env.production]
vars = { ENVIRONMENT = "production" }

[[env.production.r2_buckets]]
binding = "TLDRAW_BUCKET"
bucket_name = "my-app-production-bucket"

[[env.production.durable_objects.bindings]]
name = "TLDRAW_DURABLE_OBJECT"
class_name = "TldrawDurableObject"

[env.production.routes]
pattern = "myapp.com/api/*"
zone_name = "myapp.com"

Room persistence

Room data is automatically persisted via SQLite storage in the Durable Object. The SQLiteSyncStorage class handles all persistence logic. You don't need to implement custom save logic.

See worker/TldrawDurableObject.ts as an example. The storage is initialized in the constructor:

import { DurableObjectSqliteSyncWrapper, SQLiteSyncStorage, TLSocketRoom } from '@tldraw/sync-core'

export class TldrawDurableObject extends DurableObject {
	private room: TLSocketRoom<TLRecord, void>

	constructor(ctx: DurableObjectState, env: Env) {
		super(ctx, env)
		// SQLite storage persists automatically to Durable Object storage
		const sql = new DurableObjectSqliteSyncWrapper(ctx.storage)
		const storage = new SQLiteSyncStorage<TLRecord>({ sql })

		this.room = new TLSocketRoom<TLRecord, void>({ schema, storage })
	}
}

Further reading

  • Sync Documentation: Learn how to integrate tldraw sync into existing applications and customize the synchronization behavior.
  • Editor State Management: Learn how to work with tldraw's reactive state system, editor lifecycle, and event handling for complex canvas applications.

Building with this starter kit?

If you build something great, please share it with us in our #show-and-tell channel on Discord. We want to see what you've built!

Prev
Branching chat starter kit
Next
Shader starter kit