Skip to content
Starter Kits— browse docs
On this page

Blocks (app)

The wooden-letters toy, app-sized — draggable, rotatable, tumbling letter blocks that everyone shares.

Not every app is a dashboard. This one is a pile of wooden letter blocks spelling your project's name, which you can drag, spin, scale and tumble — and so can everyone else in the world, at the same time, watching your hands move.

It's here because it's the shortest possible answer to "is the collaboration actually real, or is it a demo?" There's nothing in this kit but shared state, awareness and CSS.

You will learn

  • How a continuous gesture stays smooth while still being shared state
  • Why per-block positions are a map and not a list (again)
  • How the platform's audio engine gets a sound out of a creation
  • That an app doesn't have to be serious to be a good example

Try it

Blocks, running (the toy, without the app shell)
Starting the example…

Drag the letters around. Their positions are shared state, so a second person sees your blocks move. The clacking sound needs the platform's audio engine, which a docs page doesn't run.

Drag a block. Grab a handle to rotate or scale it. Tap one to tumble it to a new face. Every position is shared state, so a second person watches your blocks move as you move them.

Create it

vvd create letter-blocks --app --template=blocks
Expected output:
→ Creating app letter-blocks in /Users/you/dev/letter-blocks — from the Blocks template

✦  letter-blocks — a brand-new app, ready to come alive.

Next:
cd letter-blocks
vvd run    # render it live in your world — hot-reloads as you edit
vvd save   # save a new version (a private draft)
✓ Created letter-blocks (app) → /Users/you/dev/letter-blocks

The blocks spell your project's name, so vvd create tempest --app --template=blocks gives you seven of them.

What you'd build with it

Less obvious than the other kits, and more fun than most:

  • A world's title card — the thing on the wall when nobody's working, that anyone can rearrange.
  • A toy or a game board — pieces on a surface, positions shared, no server.
  • A physical-feeling planner — cards you arrange by hand, where the arrangement is the meaning.
  • A shared mood or tone board — drag things into piles together, in real time.
  • An icebreaker for a collaborative session — genuinely useful for a group that hasn't worked together yet.
  • A test bed for gestures — if you're building something drag-heavy, this is the smallest correct example of a gesture that's both smooth and shared.

What's in it

src/app.tsx233 lines

The app itself: a `defineApp` with the route it owns, the surface it draws, and the tools it hosts inside it.

src/app.tsx
import { type PointerEvent, useEffect, useRef, useState } from "react"

import { ToolRegistry, defineApp, defineTool, field, useCollabState, useDocumentAwareness, useSound } from "@vvd/sdk"

const NAME = "letter-blocks"
const FILE = "src/app.tsx"

// Each letter of letter-blocks is a little wooden block on a canvas. Drag them around,
// rotate + scale them (grab a handle), and tap one to tumble it to a new face —
// all live, for everyone. Nothing here is faked; it's all real @vvd/sdk.
type Block = { x: number; y: number; rot: number; scale: number; rx: number; ry: number }
type Peer = { id: number; color: string; name: string; x: number; y: number }
type Drag = { mode: "move" | "rotate" | "scale"; i: number; sx: number; sy: number; b0: Block; downX: number; downY: number; moved: boolean; startDist: number }

const SIZE = 82
const FACES = [
  { t: "rotateY(0deg) translateZ(41px)", c: "#c0392b" },
  { t: "rotateY(90deg) translateZ(41px)", c: "#2e86c1" },
  { t: "rotateY(180deg) translateZ(41px)", c: "#2e8b57" },
  { t: "rotateY(-90deg) translateZ(41px)", c: "#d68910" },
  { t: "rotateX(90deg) translateZ(41px)", c: "#8e44ad" },
  { t: "rotateX(-90deg) translateZ(41px)", c: "#16a085" },
]
const WOOD = "repeating-linear-gradient(91deg, rgba(120,82,45,0.10) 0px, rgba(120,82,45,0.10) 1px, transparent 1px, transparent 5px), repeating-linear-gradient(87deg, rgba(80,52,25,0.06) 0px, rgba(80,52,25,0.06) 2px, transparent 2px, transparent 11px), radial-gradient(130% 110% at 28% 18%, #e9cfa0 0%, #d8b881 45%, #bf9860 100%)"

const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v)
const noise = (seed: number) => { const x = Math.sin(seed * 127.1 + 13.37) * 43758.5453; return x - Math.floor(x) }
function defaultBlock(i: number, n: number): Block {
  const gap = Math.min(0.13, 0.74 / Math.max(1, n - 1))
  return {
    x: clamp01(0.5 + (i - (n - 1) / 2) * gap + (noise(i) - 0.5) * 0.03),
    y: 0.44 + (noise(i + 90) - 0.5) * 0.13,
    rot: (noise(i + 7) - 0.5) * 14,
    scale: 0.9 + noise(i + 33) * 0.2,
    rx: 0,
    ry: 0,
  }
}

function makeClack(): string {
  if (typeof btoa === "undefined") return ""
  const sr = 11025
  const n = Math.floor(sr * 0.16)
  const buf = new Uint8Array(44 + n * 2)
  const dv = new DataView(buf.buffer)
  const put = (o: number, s: string) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)) }
  put(0, "RIFF"); dv.setUint32(4, 36 + n * 2, true); put(8, "WAVE"); put(12, "fmt ")
  dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true)
  dv.setUint32(24, sr, true); dv.setUint32(28, sr * 2, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true)
  put(36, "data"); dv.setUint32(40, n * 2, true)
  for (let i = 0; i < n; i++) {
    const t = i / sr
    const env = Math.exp(-t * 34)
    const body = Math.sin(2 * Math.PI * 523 * t) * 0.6 + Math.sin(2 * Math.PI * 784 * t) * 0.3 + Math.sin(2 * Math.PI * 1318 * t) * 0.15
    dv.setInt16(44 + i * 2, Math.max(-1, Math.min(1, body * env)) * 0.7 * 32767, true)
  }
  let bin = ""
  for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i])
  return "data:audio/wav;base64," + btoa(bin)
}
const CLACK = makeClack()

function usePeers(awareness: ReturnType<typeof useDocumentAwareness>): Peer[] {
  const [list, setList] = useState<Peer[]>([])
  useEffect(() => {
    const a = awareness
    if (!a) { setList([]); return }
    const read = () => {
      const out: Peer[] = []
      a.getStates().forEach((raw, id) => {
        const s = raw as { user?: { color?: string; name?: string }; cursor?: { x: number; y: number } }
        if (id === a.clientID || !s.cursor) return
        out.push({ id, color: s.user?.color || "#7c8cff", name: s.user?.name || "", x: s.cursor.x, y: s.cursor.y })
      })
      setList(out)
    }
    read()
    a.on("change", read)
    return () => a.off("change", read)
  }, [awareness])
  return list
}

function BlockCanvas({ data, actions, handle, canEdit, clack }: {
  data: { blocks: Readonly<Record<string, Block>> }
  actions: { map(name: "blocks"): { set(key: string, value: Block): void } }
  handle: Parameters<typeof useDocumentAwareness>[0]
  canEdit: boolean
  clack: { play: () => void }
}) {
  const awareness = useDocumentAwareness(handle)
  const peers = usePeers(awareness)
  const areaRef = useRef<HTMLDivElement | null>(null)
  const dragRef = useRef<Drag | null>(null)
  const rafRef = useRef<number>(0)
  const pendingRef = useRef<{ key: string; block: Block } | null>(null)
  const [hover, setHover] = useState<number | null>(null)

  const letters = NAME.split("")
  const n = letters.length
  const at = (i: number): Block => data.blocks[String(i)] || defaultBlock(i, n)

  const commit = (key: string, block: Block) => {
    pendingRef.current = { key, block }
    if (!rafRef.current) {
      rafRef.current = requestAnimationFrame(() => {
        rafRef.current = 0
        const p = pendingRef.current
        if (p) actions.map("blocks").set(p.key, p.block)
      })
    }
  }

  const begin = (mode: Drag["mode"], i: number, e: PointerEvent) => {
    if (!canEdit) return
    const b0 = at(i)
    const rect = areaRef.current?.getBoundingClientRect()
    const cx = rect ? rect.left + b0.x * rect.width : e.clientX
    const cy = rect ? rect.top + b0.y * rect.height : e.clientY
    dragRef.current = { mode, i, sx: e.clientX, sy: e.clientY, b0, downX: e.clientX, downY: e.clientY, moved: false, startDist: Math.hypot(e.clientX - cx, e.clientY - cy) || 1 }
    areaRef.current?.setPointerCapture?.(e.pointerId)
  }

  const onMove = (e: PointerEvent) => {
    const rect = areaRef.current?.getBoundingClientRect()
    if (rect) awareness?.setLocalStateField("cursor", { x: (e.clientX - rect.left) / rect.width, y: (e.clientY - rect.top) / rect.height })
    const d = dragRef.current
    if (!d || !rect) return
    const cx = rect.left + d.b0.x * rect.width
    const cy = rect.top + d.b0.y * rect.height
    if (Math.hypot(e.clientX - d.downX, e.clientY - d.downY) > 4) d.moved = true
    if (d.mode === "move") {
      commit(String(d.i), { ...d.b0, x: clamp01(d.b0.x + (e.clientX - d.sx) / rect.width), y: clamp01(d.b0.y + (e.clientY - d.sy) / rect.height) })
    } else if (d.mode === "rotate") {
      commit(String(d.i), { ...d.b0, rot: (Math.atan2(e.clientY - cy, e.clientX - cx) * 180) / Math.PI + 90 })
    } else {
      const dist = Math.hypot(e.clientX - cx, e.clientY - cy)
      commit(String(d.i), { ...d.b0, scale: Math.max(0.5, Math.min(2.6, (d.b0.scale * dist) / d.startDist)) })
    }
  }

  const onUp = () => {
    const d = dragRef.current
    if (d && d.mode === "move" && !d.moved) {
      const rect = areaRef.current?.getBoundingClientRect()
      const dx = d.downX - (rect ? rect.left + d.b0.x * rect.width : 0)
      const dy = d.downY - (rect ? rect.top + d.b0.y * rect.height : 0)
      const b = { ...d.b0 }
      if (Math.abs(dx) > Math.abs(dy)) b.ry = d.b0.ry + (dx > 0 ? 1 : -1)
      else b.rx = d.b0.rx + (dy > 0 ? -1 : 1)
      clack.play()
      commit(String(d.i), b)
    }
    dragRef.current = null
  }

  return (
    <div ref={areaRef} onPointerMove={onMove} onPointerUp={onUp}
      onPointerLeave={() => { awareness?.setLocalStateField("cursor", null); dragRef.current = null }}
      style={{ position: "relative", height: "100%", width: "100%", overflow: "hidden", touchAction: "none" }}>
      {letters.map((ch, i) => {
        if (ch === " ") return null
        const b = at(i)
        return (
          <div key={i}
            onPointerDown={(e) => begin("move", i, e)}
            onMouseEnter={() => setHover(i)}
            onMouseLeave={() => setHover((h) => (h === i ? null : h))}
            style={{ position: "absolute", left: b.x * 100 + "%", top: b.y * 100 + "%", width: SIZE, height: SIZE, transform: "translate(-50%,-50%) rotate(" + b.rot + "deg) scale(" + b.scale + ")", cursor: canEdit ? "grab" : "default", touchAction: "none", zIndex: hover === i ? 3 : 1 }}>
            <div style={{ position: "absolute", inset: 0, perspective: 460 }}>
              <div style={{ position: "relative", width: "100%", height: "100%", transformStyle: "preserve-3d", transform: "rotateX(" + b.rx * 90 + "deg) rotateY(" + b.ry * 90 + "deg)", transition: "transform 0.6s cubic-bezier(0.34,1.4,0.5,1)" }}>
                {FACES.map((face, f) => (
                  <div key={f} style={{ position: "absolute", inset: 0, transform: face.t, backfaceVisibility: "hidden", display: "grid", placeItems: "center", borderRadius: 12, background: WOOD, boxShadow: "inset 0 0 0 2px rgba(70,45,20,0.28), inset 0 3px 5px rgba(255,240,210,0.4), inset 0 -7px 10px rgba(60,40,15,0.32), 0 6px 14px rgba(0,0,0,0.3)", overflow: "hidden", userSelect: "none" }}>
                    <span style={{ fontFamily: "Georgia, 'Times New Roman', serif", fontSize: 46, fontWeight: 800, color: face.c, textShadow: "0 1px 0 rgba(255,255,255,0.4), 0 -1px 1px rgba(0,0,0,0.4)" }}>{ch.toUpperCase()}</span>
                  </div>
                ))}
              </div>
            </div>
            {canEdit && hover === i && (
              <>
                <div onPointerDown={(e) => { e.stopPropagation(); begin("rotate", i, e) }} style={{ position: "absolute", left: "50%", top: -22, width: 13, height: 13, marginLeft: -6.5, borderRadius: "50%", background: "rgba(255,255,255,0.92)", border: "1px solid rgba(0,0,0,0.4)", boxShadow: "0 1px 3px rgba(0,0,0,0.3)", cursor: "grab", zIndex: 6 }} />
                <div onPointerDown={(e) => { e.stopPropagation(); begin("scale", i, e) }} style={{ position: "absolute", right: -9, bottom: -9, width: 13, height: 13, borderRadius: 3, background: "rgba(255,255,255,0.92)", border: "1px solid rgba(0,0,0,0.4)", boxShadow: "0 1px 3px rgba(0,0,0,0.3)", cursor: "nwse-resize", zIndex: 6 }} />
              </>
            )}
          </div>
        )
      })}

      {peers.map((p) => (
        <div key={p.id} style={{ position: "absolute", left: p.x * 100 + "%", top: p.y * 100 + "%", pointerEvents: "none", transform: "translate(-1px,-1px)", zIndex: 40 }}>
          <svg width="16" height="16" viewBox="0 0 20 20" fill={p.color}><path d="M3 2l13 7-5.5 1.6L8 17z" /></svg>
          {p.name ? <span style={{ marginLeft: 8, padding: "0 6px", borderRadius: 6, background: p.color, color: "#fff", fontSize: 10, fontWeight: 600, whiteSpace: "nowrap" }}>{p.name}</span> : null}
        </div>
      ))}

      <div style={{ position: "absolute", left: 0, right: 0, bottom: 22, textAlign: "center", pointerEvents: "none" }}>
        <p style={{ margin: "0 auto", maxWidth: 460, fontSize: 15, lineHeight: 1.55, opacity: 0.6 }}>
          Welcome to <span style={{ fontWeight: 600, opacity: 1 }}>{NAME}</span>, you can edit the code for this component in{" "}
          <code style={{ fontFamily: "ui-monospace, monospace", fontSize: 13.5 }}>{FILE}</code>.
        </p>
      </div>
    </div>
  )
}

const view = defineTool({
  id: "letter-blocks",
  name: "letter-blocks",
  documentTypes: ["letter-blocks"],
  needs: [],
  surface: "plain",
  render: function LetterBlocksView({ context }) {
    const { data, actions, handle } = useCollabState({
      blocks: field.map<Block>(),
    })
    const clack = useSound(CLACK ? { kind: "url", url: CLACK } : null, { volume: 0.5 })
    if (!data || !actions) return null
    return <BlockCanvas data={data} actions={actions} handle={handle} canEdit={context.canEdit} clack={clack} />
  },
})

export default defineApp({
  id: "letter-blocks",
  name: "letter-blocks",
  route: "letter-blocks",
  Host: function LetterBlocksHost({ children }) {
    return <>{children}</>
  },
  Surface: function LetterBlocksSurface({ children }) {
    return <div style={{ height: "100%", width: "100%" }}>{children}</div>
  },
  tools: new ToolRegistry().register(view),
})

One file, no codec module — the shape is declared inline in useCollabState, which is the short form an app can use when its state is small. The blocks themselves are CSS 3D transforms; there is no rendering library here at all.

How does a 60fps drag not flood the document?Deep dive

It doesn't send every frame. A drag is local state until it settles.

The gesture updates React state on every pointer move, so the block under your finger moves at frame rate. What goes into the shared map is the result — the position the block ends up in — plus enough intermediate updates that a watcher sees motion rather than teleporting. Your own smoothness never depends on the network, and the document never accumulates a history of pixels.

The general rule, and it applies far beyond blocks: shared state is for what a thing IS, not for every frame of it becoming that. Where the in-between genuinely matters — a live cursor, a selection — that goes over awareness, which is ephemeral and keeps no history. See Presence.

How it works

field.map keyed by block index. Two people dragging different blocks both land; the same block resolves last-write-wins. Same shape as every other kit here, for the same reason — see Storing data.

Sound goes through the host. useSound(...) from @vvd/sdk, never new Audio(). One audio engine per tab coordinates every creation's playback — which is what stops your clacking blocks from talking over the world's music.

Awareness carries the pointers. useDocumentAwareness hands the kit the document's raw awareness channel, and each peer's hand position is broadcast over it — so a drag reads as a person rather than a jump. See Presence.

Next steps

  • Wiki — the last kit, and the largest.
  • State and routes — how an app's state is scoped, and what a route buys you.
  • Presence — awareness, in detail.