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
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
→ 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.
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.