Step 4: Make it collaborative
No new code. Watch what field.map already does with two people drawing at once.
No new code this step. The canvas from Step 3 is already collaborative — field.map merges
concurrent writes by key, and every point you draw is a write. Below is the exact same
sketch tool, mounted in two independent panes, exactly as if two people had it open on
two different machines.
import { type PointerEvent as ReactPointerEvent, type ToolRenderProps, DocumentGate, defineStateCodec, defineTool, field, useDocument } from "@vvd/sdk"
// src/codec.ts
/** One freehand stroke. Points are flattened [x0, y0, x1, y1, …]. */
export type Stroke = { points: number[]; color: string; width: number }
export const codec = defineStateCodec({
// A per-KEY map: two people drawing different strokes at once BOTH land.
strokes: field.map<Stroke>(),
})
// src/tool.tsx
function Canvas({ data, actions }: {
data: { strokes: Readonly<Record<string, Stroke>> }
actions: { map(name: "strokes"): { set(key: string, value: Stroke): void } }
}) {
const onPointerDown = (e: ReactPointerEvent<SVGSVGElement>) => {
const id = crypto.randomUUID()
actions.map("strokes").set(id, { points: [e.clientX, e.clientY], color: "#151515", width: 4 })
}
return (
<svg onPointerDown={onPointerDown} style={{ width: "100%", height: "100%" }}>
{Object.entries(data.strokes).map(([id, s]) => (
<polyline key={id} points={s.points.join(",")} stroke={s.color} strokeWidth={s.width} fill="none" />
))}
</svg>
)
}
export function SketchView({ document }: ToolRenderProps) {
const coords = { worldId: document.worldId, documentId: document.id }
const { data, status, actions, retry } = useDocument(coords, codec)
return (
<DocumentGate status={status} onRetry={retry}>
{data && actions && <Canvas data={data} actions={actions} />}
</DocumentGate>
)
}
export default defineTool({
id: "sketch",
name: "sketch",
documentTypes: ["sketch"],
needs: [],
render: SketchView,
})The fence above is abridged to the collaborative core — one point per press, no toolbar,
no drag loop — so you can see that field.map plus useDocument is the entire sync story.
Don't sync your file to it: the panes below run your full Step 3 code, unchanged.
Draw in the left pane. The stroke appears on the right — not copied over, arrived. Now draw in both panes at once, as fast as you can. You get two strokes, not one clobbering the other.
You didn't write any sync code. field.map already merges concurrent writes by key — Step 2's
one-line decision (strokes: field.map<Stroke>()) is the entire reason this works, exactly
the way it was for notes in the first-tool tutorial.
What's real here, and what tldraw's binding adds
Everything you just watched converge is a real CRDT merge — the same useDocument /
field.map machinery every tool on this site uses, offline, with no backend. It is not tldraw.
vvd's actual canvas tools (the pin board, the map, the family tree) bind a live tldraw record
store instead of reading field.map snapshots, because tldraw's own state doesn't fit a
read → snapshot → actions cycle — shapes are added, moved and resized continuously, not
replaced wholesale. The binding that does it, useTldrawYjsStore, ships in @vvd/editor-sdk,
and it's worth knowing exactly what it does — Step 7 shows what calling it looks like.
What use-tldraw-yjs-store actually doesDeep dive
It creates ONE tldraw TLStore per mount and keeps it in sync with doc.getMap<string>("tldraw-store")
on the runtime-owned Y.Doc — the same Y.Doc your useDocument(coords, codec) call already
opened, just a different key inside it than your codec's doc-meta.
Three moving parts, all inside one useEffect:
- Local → Y.Map.
store.listen({ source: "user", scope: "document" })fires on every local edit; each added/updated/removed tldraw record getsJSON.stringify'd into (or deleted from) the Y.Map inside onedoc.transact. - Y.Map → store.
yMap.observe(...)does the reverse: a remote change deserializes back intoTLRecords and callsstore.mergeRemoteChanges(() => store.put(...)), so tldraw's own validation and reactivity treat it exactly like a local edit that happened to come from somewhere else. - A flush safety net. tldraw delivers its listener on the next animation frame — which
never fires in a backgrounded tab or in the instant before unmount.
onFrameLoopMayStopreconciles the whole store against the Y.Map at exactly those moments, so "the last stroke I drew right before closing the tab" isn't silently lost.
Two isApplying* refs guard both directions from re-triggering each other — the same echo
guard every bidirectional sync in vvd uses (<CollaborativeText>'s TipTap binding has one too).
Your sketchTool's strokes: field.map<Stroke>() is the docs-sandbox stand-in for exactly
that Y.Map("tldraw-store") — same shape (a map of ids to serialized records), same merge
semantics, drawn with <polyline> instead of tldraw's shape renderer.