one gesture,
one undo step.
sticky · dom · auto-height
mika
gl · island band ×1 · fbo 512² Hot
oscillator
emits a value stream
freq440 Hz
outnumber
filter
low-pass, resonant
cutoff1.2 kHz
in / outnumber
idle · runIf → skip · 0 writes
drag to pan · pinch / ⌘-scroll to zoom

ICE — infinite canvas engine · built on strata-ecs

An infinite canvas that shows its work.

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.

Read the API → The architecture $ pnpm add @vibecook/ice

First principles

The architecture in six sentences.

Rebuilt three times until the architecture held. Every decision reviewed, with the losing alternative on record.

world-state

Everything is world state

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.

frame-contract

One frame contract

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.

sovereignty

Sovereignty is decided at spawn

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.

gestures

Gestures are divergence

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.

widgets

Widgets are prefabs

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.

tools

Tools are configuration

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

This is not a video. It's the engine.

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.

more live boards in the docs: containers · node editor · presence, two peers · open this one standalone ↗

The loop

One frame contract, eleven settle points.

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.

sync CRDT drains TICK — 11 PHASES, STRUCTURE FLUSHES AT EVERY BOUNDARY input react spawn recognize arbitrate claim behave ctl:* — the control sub-phases simulate derive present cleanup ← one-tick marks cleared, recognizers reaped publish presence i/o notify once / frame reflect DOM · GL · R3F never writes ECS pointer down recognizers spawn claim arbitrated riders attached first visual move — all within frame N
The sweep the sub-phases exist for: down → picking → spawn → recognize → arbitrate → claim → first mutation, zero added frames. Verified by frame-trace tests for move, resize, and camera.
systems never touch output reflectors never write ECS adapters only enqueue notify() fires observers exactly once per frame runIf-skipped systems stamp nothing

Interaction

Facts, interpretations, meaning.

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.

The stack input → cursor

input pointer entities · one-frame marks · wheel accumulation — facts targeting dual pick: hover is forgiving (Targets) · grab is exact (TouchesExact) gestures recognizers · arbitration · ClaimedBy relation · drag routing behaviors gesture × capability → live absolute writes + ONE commit cursor memoryless projection of the layers above — resolved, never remembered

Recognition is target-agnostic; meaning is capability-driven. No layer skips.

Anatomy of a drag

  • down — tool spawn profile: {Tap, LongPress, Drag} recognizers watch the pointer.
  • slop > 10 px — Drag enters Active, claims the pointer; losers Failed. Priority: Pinch > Drag > LongPress > Tap.
  • claim frame — runtime riders attach: Grab records each widget's origin; Drags edges span the selection. Readable the same frame.
  • every frame — Position = Grab + total / zoomAtClaim + snap. Absolute writes only; snap candidates from the spatial index.
  • release — ONE doc.transaction over live edges, liveness-guarded. One undo step. Cancel restores from riders instead.
  • concurrency — two touches on two widgets = two recognizers, two claims, two commits, two undo steps. No shared state to fight over.
7 mid-gesture failure paths, one cancellation matrix remote despawn mid-drag → survivors commit wheel-zoom during drag → Simultaneous, never claims escape → one-tick CancelRequest, riders restored

The flagship primitive

defineWidget — object kinds become canvas citizens.

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.

 todo-card.tsx
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" }) },
});

What the compiler emits

  • One durable prefab — PrefabId + Position/Size/StackZ + one generated component per group (todo-card:content, todo-card:style). The conflict unit is the component.
  • Capability tags — Selectable, Movable, Resizable, snap roles: stamped as runtime tags at projection. Tags stay pure.
  • Port declarations — kernel anchor input; port entities are runtime and materialize on demand. Panning with the select tool spawns zero.
  • The face — the widget's object kind: the desk draws it from the world, and nothing mounts a component (the view fields are refused since design-015 D5b).
  • A migration chain — validated for contiguity at definition time, run at document open.

One renderer, one camera

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.

Runtime guarantees

  • Idle is zero — a quiet desk submits nothing to the GPU: the reflector paints only on a change it can name (a write, the camera, a theme, a spring still moving, the wind when the ambient is live).
  • The cull rides the one spatial index — with a hysteresis margin, so a pan is O(1) in the objects off screen.
  • Zero render→ECS writes — reflectors run after notify and never write the world. The render loop is a consumer, never an author.
  • What you hit is what was drawn — each kind's CPU mirror answers the pick on the same geometry the renderer drew; picking is the interaction stack's, at the tick, never a DOM hit test.
  • Contained faults are counted — a reflector that throws skips its frame and is routed to onReflectorFault; a guest's throw trips its breaker (onGuestFault).

Documents & presence

Local-first documents. Collaboration is a posture, not a mode.

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.

runtime this session document loro CRDT · replicated GESTURE CLAIM — CELL DIVERGES claim · Grab rider live absolute writes, every frame release remote edit arrives → held by the divergence ledger ONE doc.transaction = one undo step converged — remote edits win or lose here, never mid-drag cancel → restore from Grab, held edits release — nothing enters the document
The gesture write protocol: live-write under a claim, commit once at release. Never lose remote data, never yank a live drag.

The doc kit engine-owned

  • Lifecycle — create() · open(bytes) · join(channel) · switch · close; open never throws — quarantine is a return value.
  • ICE1 envelope — versioned at-rest format; the header is read before any import. Corrupt autosave quarantines instead of bricking boot.
  • Version gate — ok / read-only / migrate / reject per app policy. Read-only viewers still project and see presence.
  • Migrations — read-repair at open: per-type chains in one {undoable:false} transaction. Idempotent, convergent under concurrent migrators; user history survives.
  • Autosave — debounced 800 ms, 10 s max-wait, deferred while any gesture is live. App owns storage.
  • Per-gesture undo — docs.undo() restores selection with it; status survives doc swaps.

Presence ephemeral store

  • The app moves bytes; the engine owns the protocol. Any channel carrying Uint8Array works — adapters ship for BroadcastChannel and WebSocket relays.
  • Bootstrap kit — hello → buffer → snapshot-as-causal-base → drain; 800 ms of silence elects the seeder. Reconnect is a clean re-join.
  • Peer facets — cursors and selection summaries ride the ephemeral store with a TTL; remote peers project read-only as Not(Local).
  • Presence can never repaint content — cursors and outlines live on their own planes with their own reflectors.
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

Batteries included.

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.

Receipts

Measured, not asserted.

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.

entities10,000
scripted camera frames150
plane transform writes150 / 150
widget style writes0
pan-frame tick0.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 writes150 / 150
plane transform writes0
DOM node-count delta0
enter / exit migrations0

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 world15.40 µs/frame
armed world15.59 µs/frame
run-to-run variance+0.4 – 3.0%
~440 tests — the merge gate frame-trace tests assert tick-by-tick outcomes import walls: dependency-cruiser, CI-fatal access.write declared on every writing system — undeclared writes DEV-throw

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

One package, eight entries, one direction.

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.

strata-ecs loro-crdt kernel pure math · 0 deps core the engine · headless dom screen space · createDeskHost react <Desk> · hooks r3f deleted at design-015 D5b devtools → core nobody imports devtools peers: react · retired at D5b
The Y-flip and every screen↔world conversion live in exactly one kernel module. The spatial index is the only hit path. Walls enforced by dependency-cruiser — a violating import fails CI.
EntryContentsMay 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)

Pan something real.

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.

desk

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 dev
+ room

Two tabs share a desk over BroadcastChannel: ?room=x; two machines over the relay: &relay=ws://localhost:9301

pnpm relay
the rigs

parity · world · interact · idle · portal · nav · open · sticky · two-tab · collab · ruler · panel — headless Chrome against the Dawn oracle's bytes.

pnpm run gate:landing
Open the API reference → $ pnpm install && pnpm run ci