Step 5: Two people at once
The same board, mounted twice — pins dropped on either side both land, undamaged.
No new code this step. Below is the exact tool from Step 4 (minus the "pretend view" picker — that was a teaching device for one page, not something you'd ship), mounted in two independent panes, as if two people had it open on two different computers.
import type { MouseEvent as ReactMouseEvent } from "react"
import { DocumentGate, type ToolRenderProps, defineStateCodec, defineTool, field, useDocument } from "@vvd/sdk"
/** One pin. x/y are pixels in the BOARD's own space — never a scaled ancestor's. */
export type Pin = { x: number; y: number; label: string }
export const codec = defineStateCodec({
// A per-KEY map: two people dropping different pins at once BOTH land.
pins: field.map<Pin>(),
})
function Board({ data, actions }: {
data: { pins: Readonly<Record<string, Pin>> }
actions: { map(name: "pins"): { set(key: string, value: Pin): void } }
}) {
const pins = Object.entries(data.pins)
const onBoardClick = (e: ReactMouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
actions.map("pins").set(crypto.randomUUID(), {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
label: "Pin",
})
}
return (
<div onClick={onBoardClick} style={{ position: "relative", width: "100%", height: "100%" }}>
{pins.map(([id, pin]) => (
<div key={id} style={{ position: "absolute", left: pin.x, top: pin.y }}>
📍
</div>
))}
</div>
)
}
export function BoardView({ document }: ToolRenderProps) {
const { data, status, actions, retry } = useDocument(
{ worldId: document.worldId, documentId: document.id },
codec,
)
return (
<DocumentGate status={status} onRetry={retry}>
{data && actions && <Board data={data} actions={actions} />}
</DocumentGate>
)
}
export default defineTool({
id: "pin-board",
name: "pin board",
documentTypes: ["pin-board"],
needs: [],
render: BoardView,
})Drop a pin on the left. It appears on the right — not copied, arrived. Now drag a pin in
both panes at once. Each side keeps moving the one it's holding; neither one stomps the
other's position, because field.map merges by key, the same guarantee Getting Started's
notes relied on.
You didn't write any sync code, and you didn't write any conflict resolution for two people
dragging different pins at once. field.map already handles it — Step 2's one-line field
choice is the entire reason.