Skip to content
Apps— browse docs
On this page

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.

src/codec.ts + src/tool.tsx
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,
})
sketch — where you're headed
Move your pointer inside a pane
Starting the example…

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.

Note:

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

  1. 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.
  2. 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.
  3. 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.

Build it