Step 2: Scaffold the app
vvd create --app, and a bare draw-only surface — one pen, no toolbar yet.
Start the way every app does:
vvd create sketch --app
→ Creating app sketch in /Users/you/sketch — from the Hello World template ✦ sketch — a brand-new app, ready to come alive. Next: cd sketch vvd run # render it live in your world — hot-reloads as you edit vvd save # save a new version (a private draft) ✓ Created sketch (app) → /Users/you/sketch
An app is defineApp plus three things — a Host, a Surface, and a tools registry — and
what actually draws pixels is a tool inside that registry, exactly like every tool you've
seen elsewhere on this site:
import { ToolRegistry, defineApp, deriveCapabilities } from "@vvd/sdk"
import sketchTool from "./tool"
const tools = new ToolRegistry().register(sketchTool)
export default defineApp({
id: "sketch",
name: "Sketch",
route: "sketch",
version: "1.0.0",
engines: { host: 1, app: 1 },
capabilities: deriveCapabilities(tools),
Host: SketchHost, // the useHost() provider — see Apps → defineApp
Surface: SketchSurface, // the chrome the tool renders inside
tools,
})vvd create --app generated SketchHost and SketchSurface for you, in this same
src/app.tsx — you didn't write them, and this build never edits them. That split is
Apps → defineApp territory, and this build doesn't
repeat it. Everything from here down is sketchTool — split across src/codec.ts (the
document shape) and src/tool.tsx (everything that renders) — the part you'll spend this
whole build on, and the part the playground below can actually run.
Why a tool is running on this page
The examples on this site mount a tool against a fake host, in your browser. An app
provides a host and owns a route, so it needs the real shell — there's nothing on a docs
page for it to be a lens over. What you can honestly show is the app's screen, and the app's
screen is a tool in its registry. Run vvd run at the end of this build to see the whole
app, route and all.
The document: a growing list of strokes
export type Stroke = {
points: number[] // flattened [x0, y0, x1, y1, …] — DOCUMENT space
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>(),
})Compare that to the pin board's codec — pins: field.map<Pin>(), where a Pin is an {x, y, label}. Same CRDT shape, completely different content: a pin is a single point you place once
and can move; a stroke is a path you draw continuously, and once it exists it never moves.
A bare canvas — one pen, draw-only
No toolbar yet. Pointer down starts a stroke, pointer move extends it, and every point goes
straight into strokes — that's the whole drawing loop.
Edit and the example re-runs. Tab indents; press Escape to leave the editor.
You should see: a parchment-coloured square. Draw on it — a dark line follows your
pointer, and it's already durable: every point you dropped is sitting in data.strokes the
moment you let go.
This canvas isn't tldraw yet
vvd's real canvas tools draw with tldraw — a proper vector editor with pressure, undo,
and export built in. This page's <svg> is a hand-rolled stand-in, on purpose: the
playground on this site compiles only against @vvd/sdk, with no bundler, and tldraw (via
@vvd/editor-sdk) can't run inside that sandbox — the same reason @vvd/design-system
doesn't appear in any example on this site either. Nothing about the data is a stand-in,
though: field.map<Stroke>() is real CRDT state, and Step 4 wires it to the exact binding
tldraw itself uses. Read that as "the pixels are simplified, the collaboration is not."