Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
255 lines
9.0 KiB
Plaintext
255 lines
9.0 KiB
Plaintext
---
|
|
title: Multiplayer starter kit
|
|
sidebarTitle: Multiplayer
|
|
status: published
|
|
author: tldraw
|
|
date: 9/16/2024
|
|
order: 7
|
|
description: 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.
|
|
embed: multiplayer
|
|
githubLink: https://github.com/tldraw/tldraw/tree/main/templates/sync-cloudflare
|
|
keywords:
|
|
- multiplayer
|
|
- sync
|
|
- collaboration
|
|
- real-time
|
|
readability: 8
|
|
voice: 7
|
|
completeness: 8
|
|
accuracy: 9
|
|
notes: 'Revised to reflect current SQLite DO storage architecture. Code examples updated to show correct ShapeUtil patterns.'
|
|
---
|
|
|
|
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:
|
|
|
|
```bash
|
|
npm create tldraw@latest -- --template multiplayer
|
|
```
|
|
|
|
---
|
|
|
|
## Use cases
|
|
|
|
This starter kit is perfect for building:
|
|
|
|
<StarterKitBento type="multiplayer" />
|
|
|
|
---
|
|
|
|
## 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:
|
|
|
|
```tsx
|
|
// 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>
|
|
}
|
|
|
|
getIndicatorPath(shape: StickyNoteShape) {
|
|
const path = new Path2D()
|
|
path.rect(0, 0, 200, 200)
|
|
return path
|
|
}
|
|
}
|
|
|
|
// 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:
|
|
|
|
```tsx
|
|
// 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:
|
|
|
|
```toml
|
|
# 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 room is created lazily and supports WebSocket hibernation:
|
|
|
|
```tsx
|
|
import {
|
|
DurableObjectSqliteSyncWrapper,
|
|
type SessionStateSnapshot,
|
|
SQLiteSyncStorage,
|
|
TLSocketRoom,
|
|
} from '@tldraw/sync-core'
|
|
import { DurableObject } from 'cloudflare:workers'
|
|
|
|
export class TldrawDurableObject extends DurableObject {
|
|
// Room is created lazily on first connection
|
|
private room: TLSocketRoom<TLRecord, void> | null = null
|
|
|
|
private getOrCreateRoom(): TLSocketRoom<TLRecord, void> {
|
|
if (!this.room) {
|
|
const sql = new DurableObjectSqliteSyncWrapper(this.ctx.storage)
|
|
const storage = new SQLiteSyncStorage<TLRecord>({ sql })
|
|
|
|
this.room = new TLSocketRoom<TLRecord, void>({
|
|
schema,
|
|
storage,
|
|
// Disable idle timeout — Cloudflare handles keep-alive via auto-response
|
|
clientTimeout: Infinity,
|
|
// Persist session state to WebSocket attachments for hibernation recovery
|
|
onSessionSnapshot: (sessionId, snapshot) => {
|
|
const ws = this.sessionIdToWs.get(sessionId)
|
|
if (ws) ws.serializeAttachment({ sessionId, snapshot })
|
|
},
|
|
})
|
|
|
|
// Resume any sessions that survived hibernation
|
|
for (const ws of this.ctx.getWebSockets()) {
|
|
const attachment = ws.deserializeAttachment() as SocketAttachment | null
|
|
if (!attachment?.sessionId) continue
|
|
if (attachment.snapshot) {
|
|
this.room.handleSocketResume({
|
|
sessionId: attachment.sessionId,
|
|
socket: ws,
|
|
snapshot: attachment.snapshot,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return this.room
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Further reading
|
|
|
|
- **[Sync documentation](/docs/sync)**: Learn how to integrate tldraw sync into existing applications and customize the synchronization behavior.
|
|
- **[Editor state management](/docs/editor)**: 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](https://discord.tldraw.com/?utm_source=docs&utm_medium=organic&utm_campaign=sociallink) channel on Discord. We want to see what you've built!
|