ICE — infinite canvas engine · built on strata-ecs
defineObject turns a GPU object kind into a canvas citizen — selectable, movable, resizable, nested in mini mats, synced over CRDT, undoable per gesture — drawn with every other object by one WebGPU renderer from the world. Underneath: an archetype ECS where every pointer, gesture, and claim is queryable world state.
First principles
Rebuilt three times until the architecture held. Every decision reviewed, with the losing alternative on record.
Pointers, gestures, recognizers, claims, selection, camera, ports — all entities and resources in one ECS. No closure FSMs, no singletons: devtools and collab see everything, mid-gesture included.
engine.step(now) = sync → tick (11 fixed phases) → publish → notify → reflect. Systems never touch output; reflectors never write ECS; observers fire exactly once per frame.
Durable entities live in the CRDT document; runtime entities die with the session; ephemeral ones ride presence. Components stay pure — the spawn path is the class.
A drag writes cells live under a claim; release commits one transaction — one undo step. A remote edit to the same cell mid-gesture simply wins or loses at commit. Divergence is the signal, not an error.
defineWidget compiles props into conflict-group components on a durable prefab; its face is an object kind the desk's one WebGPU renderer draws from the world. Idle is zero; the cull rides the spatial index.
A tool parameterizes recognizer spawn, drag routing, capability gates, and cursor — it adds no code paths. Custom behaviors register systems through the same slot the built-ins use.
Proof, not promises
A live board — the published @vibecook/ice, loaded from esm.sh, running in this page right now. Drag a note and snap guides appear; drag empty canvas for a marquee; grab a corner to resize; ⌘Z undoes exactly one gesture.
The loop
A strata phase flush is the only structural settle point — wherever a stage must see the previous stage's spawns within one frame, the stages are separate phases. Ordering is the contract, not a convention.
Interaction
Pointer events are facts. Gestures are interpretations. Behaviors self-select by capability — there is no dispatcher, and a claim is a relation on the pointer entity, not a router back-channel. Five layers, one job each, all of it plain queryable world state.
Recognition is target-agnostic; meaning is capability-driven. No layer skips.
{Tap, LongPress, Drag} recognizers watch the pointer.Active, claims the pointer; losers Failed. Priority: Pinch > Drag > LongPress > Tap.Grab records each widget's origin; Drags edges span the selection. Readable the same frame.Position = Grab + total / zoomAtClaim + snap. Absolute writes only; snap candidates from the spatial index.doc.transaction over live edges, liveness-guarded. One undo step. Cancel restores from riders instead.The flagship primitive
Props compile to conflict-group components on a durable prefab. Interaction flags become capability tags. Ports opt into the node editor; container opts into nesting. Nothing here is unavailable to a hand-rolled prefab — it is sugar, not a second object model.
import { defineWidget, p } from "@vibecook/ice";
const TodoCard = defineWidget({
type: "todo-card",
version: 1,
// props DSL → typed components, one per conflict group
props: {
title: p.string({ default: "Untitled" }),
color: p.enum(["gray", "blue", "amber"], { default: "gray" }),
items: p.json(p.array(p.object({
text: p.string(), done: p.boolean(),
}))), // the explicit conflict-coarse escape hatch
},
groups: { content: ["title", "items"], style: ["color"] },
object: todoCardKind, // the FACE: a GPU kind the desk draws (design-015 §5.2) — no component
stratum: "things", // pads · sheets · things: where on the desk it lies
defaultSize: { w: 280, h: 200 },
interaction: {
selectable: true, movable: true, resizable: true,
snap: "both", dragOn: "longPress", // iOS hold-to-lift
},
ports: [{ id: "out", side: "e", accepts: ["todo"] }],
// read-repair at open — one {undoable:false} tx, convergent
migrate: { 1: (prev) => ({ ...prev, color: "gray" }) },
});
PrefabId + Position/Size/StackZ + one generated component per group (todo-card:content, todo-card:style). The conflict unit is the component.Selectable, Movable, Resizable, snap roles: stamped as runtime tags at projection. Tags stay pure.object kind: the desk draws it from the world, and nothing mounts a component (the view fields are refused since design-015 D5b).Everything under the camera — the cutting mat, the mini mats and their insides, every object — is drawn by one WebGPU renderer from the ECS world, in strata (pads · sheets · things) and then the marks (the selection, the snap guides as lasers, the marquee's vellum). The DOM lives only in screen space: the one focused editor, the cursors, the selection menu. No DOM element carries a camera transform, so a pan re-renders one canvas, never a tree of elements.
onReflectorFault; a guest's throw trips its breaker (onGuestFault).Documents & presence
Every editor session starts with engine.docs.create() — a Loro-CRDT document. Join a room and nothing about the programming model changes: gestures still diverge and commit; remote edits to a cell you are dragging are held, then win or lose at your commit.
create() · open(bytes) · join(channel) · switch · close; open never throws — quarantine is a return value.ok / read-only / migrate / reject per app policy. Read-only viewers still project and see presence.{undoable:false} transaction. Idempotent, convergent under concurrent migrators; user history survives.docs.undo() restores selection with it; status survives doc swaps.Uint8Array works — adapters ship for BroadcastChannel and WebSocket relays.Not(Local).await engine.docs.join(webSocketByteChannel(ws), {
presence: { name: "James", color: "#4f8ef7" },
seed: (s) => s.spawnWidget("sticky", { at: origin }),
}); // ran only by the first peer
What's in the box
Selection, moving, resizing, snapping, marquee, camera, containers, wiring, undo, autosave, presence — all built in, all documented behavior by behavior with defaults you can tune and examples you can run.
Six gestures, per-device pick radii, clean cancellation, true multi-touch concurrency.
Built-in toolsselect · pan · connect, plus a one-line factory for draw tools. Tools are routing tables, not modes.
Select · move · resizeShift-toggle, marquee, hold-to-lift drags, eight handles with ⌥/⇧ modifiers, arrow nudges.
Snap & guidesEdge and center alignment plus equal-spacing detection, 5 px screen-constant, per-widget roles.
CameraAnchored zoom everywhere, wheel and touch pan, inertia, zoom-to-fit, tunable limits.
Containers & nestingDrop to consume, fly-back rejection, solid widgets, enter/exit navigation with camera memory.
Node editorDeclared ports, compatibility-checked wiring, light-up targets, cascade on delete.
Documents & undoOne undo step per gesture, selection restore, gesture-aware autosave, versioned open.
PresenceNamed live cursors and selection outlines that can never repaint your content.
Rendering & perfOne renderer, idle-zero, the cull on the spatial index, the raster budget, breakpoints.
The vocabularyEvery built-in component, tag, relation, and resource — queryable from your own code.
Getting startedA complete minimal app in one file, and seven runnable demo boards.
Receipts
Every subsystem shipped against a measured bar, and adversarial frame-trace tests assert tick-by-tick outcomes in CI. The claims on this page run as tests or on the recorded bench — the numbers below are the baseline on 10,000 entities.
Pan is O(planes), not O(n)
1×
One transform write per plane per moving frame, independent of node count.
| entities | 10,000 |
| scripted camera frames | 150 |
| plane transform writes | 150 / 150 |
| widget style writes | 0 |
| pan-frame tick | 0.25 µs |
Drag churn budget holds
1style write / frame
A 150-frame scripted drag of one entity among 10,000: the reflector's change-only cache suppresses the other 9,999.
| widget style writes | 150 / 150 |
| plane transform writes | 0 |
| DOM node-count delta | 0 |
| enter / exit migrations | 0 |
The reactivity tax, audited
+1.3%
Always-armed reactive stamping measured against an unarmed twin world — budgeted at +17–28% for write-heavy paths, measured far under it on the steady-state workload.
| unarmed world | 15.40 µs/frame |
| armed world | 15.59 µs/frame |
| run-to-run variance | +0.4 – 3.0% |
Baseline machine: Apple M1 Max · Node 24 · happy-dom counters (the write counts are the signal) · full method in docs/benchmarks.md — scripted harnesses, 5-run medians.
Structure
Dependencies flow one way and the walls are CI-fatal. core never imports React or the DOM, and three.js is imported nowhere (design-015 no-three) — the engine is headless by construction, and every surface is an adapter.
| Entry | Contents | May import |
|---|---|---|
@vibecook/ice/kernel |
Pure math: coordinates (the one Y-flip), snap guides, RBush spatial index, bezier/anchors, the flight maths, easings, layout. | rbush only |
@vibecook/ice |
The engine: ECS catalog, frame contract, interaction stack, widget runtime, node graph, nested canvas, doc kit, presence, migrations, createCanvasEngine. |
strata-ecs · kernel · loro-crdt |
@vibecook/ice/dom |
Screen space only: the canvas host, the pointer adapter, the rAF loop, input ownership + the editor's focus, OS and remote cursors, createDeskHost (the vanilla mount). |
core · kernel |
@vibecook/ice/react |
<Desk>, EngineProvider, hooks (useCommit, useWidgetProps, useSelected, …), keymap, the screen-space selection menu and held bar. |
dom · core · kernel + react peers |
@vibecook/ice/desk |
The desk: one WebGPU renderer drawing every object from the world — the cutting mat, deskLayer, the kind contract (defineObject, ObjectKind), the theme — it names no kind. |
core · kernel — nobody imports desk but apps and the objects |
@vibecook/ice/desk/engine · /desk/objects |
The raw-WebGPU engine (device, surface, passes) · the six reference object kinds (note, mini mat, notebook, whiteboard, calendar, photo) — their own package since design-016 K4b, built on the desk's public entries alone, as a plugin kind is. | core · kernel (the objects: the desk's entries too) |
@vibecook/ice/devtools |
attachDevtools(engine) — pointers/recognizers, planes, sovereignty, loop tabs. |
core only — nobody imports devtools |
One showcase ships in the repo (design-015 D5b)
The desk: a cutting mat with notes, mini mats, notebooks, whiteboards, calendars and photos — every object drawn by one WebGPU renderer from the world, collaborative in a room. Its twelve rigs are the landing gate.
The showcase — w sticks a note, m lays a mini mat, b a notebook, W a whiteboard, C a calendar; paste a picture for a print; double-tap to pick up; the backtick opens the dev panel.
pnpm --filter desk devTwo tabs share a desk over BroadcastChannel: ?room=x; two machines over the relay: &relay=ws://localhost:9301
pnpm relayparity · world · interact · idle · portal · nav · open · sticky · two-tab · collab · ruler · panel — headless Chrome against the Dawn oracle's bytes.
pnpm run gate:landing