Skip to content
Apps— browse docs
On this page

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.

src/codec.ts + src/tool.tsx (abridged)
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.

One canvas, two windows
Draw in either pane.
Starting the example…

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.

Tip:

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 gets JSON.stringify'd into (or deleted from) the Y.Map inside one doc.transact.
  • Y.Map → store. yMap.observe(...) does the reverse: a remote change deserializes back into TLRecords and calls store.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. onFrameLoopMayStop reconciles 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.

Next steps