Step 1: What you're building
Two people, one canvas — draw and watch it appear on the other pane as you make it.
Let's say you're making a world where you're running a dungeon crawl and your co-DM just asked, mid-session, "wait, what does the room actually look like?" — and you needed to rough it out together, live, right there in the world, so the sketch becomes part of the location it depicts instead of a screenshot that gets lost in a group chat by next session. Here's how you build that.
Seven short steps. By the end you'll have built this, from nothing, by hand: two panes, one canvas, and a coloured pen that isn't yours moving across it in real time.
import { type PointerEvent as ReactPointerEvent, type ToolRenderProps, 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 { data, actions } = useDocument({ worldId: document.worldId, documentId: document.id }, codec)
return data && actions ? <Canvas data={data} actions={actions} /> : null
}
export default defineTool({
id: "sketch",
name: "sketch",
documentTypes: ["sketch"],
needs: [],
render: SketchView,
})Draw in either pane. The stroke appears on the other side as you make it, not when you let go — and the small coloured chip in the corner is the other person, live. That chip and the cursor tracing it are the same mechanism as Getting Started's presence page; a canvas is just the best possible place to show it off, because watching someone else's pen move is more visceral than a text caret.
Pins mark locations. This holds strokes.
vvd already has an infinite-board tool — pan/zoom camera, pins you drop and label, each one optionally linked to a world document. If that's what you want, see Build a pin board under Tools. This build is different on purpose: nothing here has a position on a map: it's freehand ink, and the document is a growing list of strokes, not a list of pins.
Three things make this unmistakably vvd
- Live collaborative strokes. Two people draw on the same canvas; each stroke appears as it's drawn, not on save — you just watched that happen above.
- World-linked, not export-only. A finished sketch pins to a world document — sketch the room, then link it to The Sunken Vale — so a drawing becomes a linked artifact in the world graph instead of a PNG that leaves the platform. That's Step 6.
- It's an app, not a tool. Every other canvas-family surface in vvd — the pin board, the map, the family tree — is a tool. This is the one that teaches app-level ownership on a canvas: your own route, your own screen, a tool composed inside it.