ICE — infinite canvas engine · documentation

The guide, and the published surface.

Start with getting started and the built-in behavior guide — a complete tour of what the engine does out of the box. The per-package reference follows. Everything listed is importable from the package root — deep imports are unsupported and wall-checked; source of truth is each package's barrel.

durable — lives in the CRDT doc · tx.spawn · undoable runtime — dies with the session · world.spawn ephemeral — rides presence · eph.spawn · TTL
no entries match — try a shorter query

Getting started

One widget definition, one engine, one component tree. Everything on this page — selection, moving, resizing, snapping, marquee, camera, undo — works on this app with no further code.

A complete minimal app

pnpm add @vibecook/ice react react-dom   # react/react-dom are peers of the react entry
import { createCanvasEngine, defineWidget, p } from "@vibecook/ice";
import { EngineProvider, InfiniteCanvas, attachKeymap, useCommit, useWidgetProps } from "@vibecook/ice/react";
import { createRoot } from "react-dom/client";

// 1 · Define a widget — any React component becomes a canvas citizen.
const Sticky = defineWidget({
  type: "sticky",
  surface: "dom",
  props: { text: p.string({ default: "Write something…" }) },
  component: StickyView,
  defaultSize: { w: 220, h: 160 },
  interaction: { selectable: true, movable: true, resizable: true, snap: "both" },
});

// 2 · The view reads props with a hook and writes through commit —
//     one commit call = one undo step, synced to every peer.
function StickyView({ entity, world }) {
  const props = useWidgetProps(world, entity, "sticky");
  const commit = useCommit();
  return (
    <textarea
      value={props.text}
      onChange={(e) =>
        commit((tx) => tx.edit(entity).set(Sticky.groups[0].component, { text: e.target.value }))
      }
    />
  );
}

// 3 · Boot: engine → document → content → mount.
const engine = createCanvasEngine({ widgets: [Sticky] });
engine.docs.create();                                  // a local-first document
engine.ops.spawnWidget("sticky", { x: 120, y: 120 });
attachKeymap(engine);                                   // ⌫ · ⌘Z · ⌘D · ⌘A · Esc · arrows · v/h/c

createRoot(document.getElementById("root")).render(
  <EngineProvider engine={engine}>
    <InfiniteCanvas engine={engine} />
  </EngineProvider>,
);

What you now have, with zero extra code: click to select (shift-click toggles), drag to move with snap guides, eight resize handles, marquee select on empty canvas, wheel to pan, pinch or -scroll to zoom, per-gesture undo, and full keyboard support. Every behavior is documented in the sections below.

Try it in the repo

pnpm --filter cardboard dev     # DOM widget board
pnpm --filter nodeboard dev     # node editor (add ?room=x&relay=ws://localhost:9301 after `pnpm relay`)
pnpm --filter glboard dev       # mixed DOM + GL board

live — this exact app, served from the published package via esm.sh. open standalone ↗

Built-in behavior

Everything the engine does out of the box, and the knobs on each behavior. Numbers quoted here are the shipped defaults — all of them are live-tunable through createCanvasEngine({ settings }) or the corresponding resource (see defaults & budgets).

Pointers & gestures

How picking works

Hovering is forgiving, grabbing is precise. Hover targeting uses a pick radius around the pointer — 0 px for mouse, 4 px for pen, 12 px for touch — so small targets are easy to hit on the right device. The press that starts a gesture uses an exact point pick, so you never grab something you weren't on.

  • Pick priority, top to bottom: resize handles → ports → widgets (front-most first) → wires → the canvas itself. Render order and pick order always agree.
  • Widget content wins. Native interactive elements (input, textarea, button, links…) automatically keep their events. Custom interactive content calls stopPropagation(); sustained interactions (sliders, drawing surfaces) also call setPointerCapture so they survive a simultaneous canvas drag.
  • Everything is inspectable. Each pointer is an entity; what it hovers and touches are relations on it (Targets, TouchesExact) you can query or watch in devtools.

The six gestures

Every pointer-down mints a set of gesture candidates (the active tool decides which — see built-in tools). The first one to commit to an interpretation claims the pointer; the rest stand down. You never wire these up — they are the engine's native vocabulary.

gesturefires whendrives
Tapreleased within 250 ms and 8 px of the pressselect / toggle-select / clear
Long-pressheld 500 ms within 10 px — keeps tracking after it firestouch marquee · hold-to-lift dragging
Dragmoved past 10 px of slop (origin measured from where slop was exited — no jump)move · resize · marquee · pan · connect · draw
Pinchtwo free fingers change spread by 6 pxanchored zoom (a finger mid-drag is never stolen)
Wheel-pan / wheel-zoomwheel activity; the gesture ends after 150 ms of silencecamera — runs alongside other gestures, never claims
  • Ties resolve by priority: Pinch > Drag > Long-press > Tap.
  • True concurrency. Two fingers on two widgets are two independent gestures — two commits, two undo steps. Wheel-zoom during a drag is legal, and the in-flight drag stays glued to the cursor.
  • Every failure path cancels cleanly. Esc, switching tools, window blur, a lost touch, or a collaborator deleting the widget mid-drag: the gesture cancels and everything it touched is restored. If one of several dragged widgets is deleted remotely, the survivors keep dragging and commit normally.
  • Double-tap is opt-in per target — attach MultiTap to a widget and Tap reports a count (280 ms window, 20 px slop). Single taps stay zero-latency on widgets that don't opt in.

Try it

// double-tap support on one widget (runtime component, from an app handler):
world.addComponent(card, MultiTap, { max: 2, windowMs: 280, slopPx: 20 });

Built-in tools

A tool is a routing table, not a mode with its own code: it decides which gestures spawn and where a drag goes based on what it started on. Three ship enabled; a fourth is a one-line factory. Switching tools always cancels in-flight gestures first.

toolkeydrag on canvasdrag on widgetdrag on port
selectVmarqueemoveconnect
panHpanpan (grab cursor)
connectCpanconnect (crosshair)connect
createDrawTool(type)yoursdraw the rect, spawn type

Device overrides come first, whatever the tool: one-finger drag on empty canvas pans on touch; holding Space or dragging with the middle button pans on desktop.

Try it

const StampNote = createDrawTool("sticky", { shortcut: "n" });  // drag a rect → sticky appears
const engine = createCanvasEngine({ widgets: [Sticky], tools: [StampNote] });
engine.ops.setTool("draw:sticky");   // or press N

Select, move, resize

Selection

  • Click / tap a widget replaces the selection; -click toggles membership; clicking empty canvas clears; clicking a wire selects the wire.
  • Marquee: drag on empty canvas with the select tool. Hit previews update live; the selection itself commits once, on release ( extends the existing selection). On touch: long-press empty canvas, then drag.
  • Chrome: the selection draws one combined box with eight screen-constant handles, always above content — as do snap guides and remote collaborators' selection outlines.
  • Keyboard: deletes (children and attached wires cascade), ⌘D duplicates at a +16/+16 offset, ⌘A selects all, arrows nudge ±1 px ( ±10 px) — each press is one undo step. mod is on macOS, Ctrl elsewhere. Keystrokes in editable content are always ignored.

Try it

engine.ops.setSelection([a, b]);      // programmatic selection — same path the gestures use
engine.ops.selectAll();
engine.ops.clearSelection();

Moving

Grabbing an unselected widget selects it first, so the drag always moves the whole selection as one rigid group. While dragging, the group elevates to the top of the stacking order; a cancelled drag restores both positions and stacking. Releasing commits one transaction — one undo step, no matter how many widgets moved.

  • Hold-to-lift: declare dragOn: "longPress" and the widget uses the iOS home-screen model — press and hold ~500 ms, the card lifts under your finger, then it drags. A plain drag on it pans/marquees instead, so scrollable content stays reachable on touch.
  • Reordering without moving: ops.reorder(ids, "top" | "bottom").

Try it

interaction: { movable: true, dragOn: "longPress" }   // hold-to-lift, like iOS
pnpm --filter widgetlab dev                           // feel both drag models side by side

Resizing

Drag any of the eight handles: the opposite edge or corner stays anchored. Hold to resize about the center, to keep the aspect ratio. A multi-selection resizes together about the shared anchor. minSize from the widget definition is respected; release commits one undo step.

Try it

interaction: { resizable: true }, minSize: { w: 160, h: 80 }

Snap & guides

Snapping

While you move widgets, the engine tests the intended position against nearby widgets for edge and center alignment (nearest match wins per axis) and for equal-spacing runs — “these three are 24 px apart, snap the fourth to continue the rhythm.” Guides and spacing bars draw above content and never oscillate. The threshold is 5 px on screen at any zoom.

  • Per-widget roles: snap: "source" (snaps to others), "target" (others snap to it), "both", or "none". Cards that snap to frames but not vice-versa are one line each.
  • Candidates come from the spatial index — snapping stays flat-cost on boards with thousands of widgets.

Try it

createCanvasEngine({ settings: { snap: { enabled: true, thresholdPx: 8 } } })
// or flip it live: world.setResource(SnapConfig, { enabled: false, thresholdPx: 5 })

Camera

Pan & zoom

  • Wheel scrolls the canvas — scroll down, travel down (the Figma / Freeform convention for mice and natural-scroll trackpads alike).
  • Zoom is always anchored: trackpad pinch and /Ctrl-scroll zoom at the cursor; touch pinch zooms at the finger centroid. Default range 0.1× – 5×.
  • Drag-pan via the pan tool, Space-drag, middle-button drag, or one-finger drag on touch. Touch pans release with inertia (past 120 px/s, ~325 ms glide, touch again to stop dead).
  • Zooming mid-gesture is safe — a widget you're dragging stays under the cursor while the camera zooms underneath it.
  • Panning costs the same at 10 widgets or 10,000: the camera lands as one transform per output plane, never per widget.

Try it

engine.ops.zoomToFit();            // frame everything (or zoomToFit(ids) for a subset)
engine.ops.zoomTo(1, { x, y });    // 100% at an anchor
engine.ops.panTo(0, 0);
createCanvasEngine({ settings: { zoom: { min: 0.25, max: 3 } } })

Containers & nesting

Drop, consume, fly back

Declare container: { accepts: [...] } on a widget and it becomes a frame. Drag something whose provides keys match over it and it highlights as a drop candidate; release inside and the drop consumes — the child reparents, its position converts into the frame's space, and the whole outcome is one transaction. Release over a frame that doesn't accept it and the widget flies back to where it started — an animated rejection that commits nothing.

  • Solid widgets (interaction: { solid: true }) reject all drops — things dropped on them fly back rather than stacking on top.
  • Deleting a frame cascades: children and any wires attached to them go with it, in the same transaction — and one undo brings the whole subtree back.
  • Nesting is navigation. ops.enterContainer(frame) steps inside — the canvas re-roots to the frame's content, and each container remembers its own camera. ops.exitContainer() steps back out. "Pages" are exactly this: root-level frames plus a toolbar that calls these two ops.

Try it

const Frame = defineWidget({
  type: "frame", surface: "dom", component: FrameView,
  defaultSize: { w: 640, h: 420 },
  interaction: { selectable: true, movable: true, resizable: true },
  container: { accepts: ["card"] },
});
const Card = defineWidget({ /* … */ provides: ["card"] });
// drag a card onto the frame → consumed; onto anything solid → flies back
engine.ops.enterContainer(frame);   // step in (camera remembered per container)
engine.ops.exitContainer();         // step out

live — drop a card in the frame (consume), onto solid (fly-back); the amber card drags on hold. open standalone ↗

Node editor

Ports & wires

Declare ports on a widget and it becomes a node. Drag from a port (any tool) or from the node body with the connect tool: a preview wire follows the pointer, compatible ports nearby light up (staged by distance, within ~600 px), and the wire snaps to a port when you hover it. Release on a compatible port and the connection commits as a durable wire — release anywhere else and the preview simply fades.

  • Compatibility is declared, not coded: a connection requires the ports' accepts keys to match. Incompatible ports never light up.
  • Ports cost nothing until needed. Port entities materialize on demand — near the viewport with the connect tool, on hover, or during a connect drag — and reap afterward. Panning a 10,000-node board with the select tool creates zero port entities.
  • Wires render under content and pick above the canvas, so they're clickable but never in the way. Deleting either endpoint deletes the wire in the same transaction.
  • No flicker on commit: the preview persists exactly until the real wire appears.

Try it

const Osc = defineWidget({ /* … */ ports: [{ id: "out", side: "e", accepts: ["signal"] }] });
const Filter = defineWidget({ /* … */ ports: [
  { id: "in", side: "w", accepts: ["signal"] },
  { id: "out", side: "e", accepts: ["signal"] },
]});
// run it:  pnpm --filter nodeboard dev

live — the connect tool is active: drag from a port to a compatible port. open standalone ↗

Documents & undo

Undo that matches what you did

History is recorded per gesture, not per frame. Moving five widgets is one step. Dropping into a container — move plus reparent — is one step. A resize, a nudge, a duplicate: one step each. Undo also restores the selection you had, so redo-after-undo lands you exactly where you were. Camera moves, hovers, and marquees never enter history.

Try it

engine.docs.undo();     engine.docs.redo();
const { canUndo, canRedo } = useUndoStatus();   // for toolbar buttons

Saving, opening, evolving

  • Documents are local-first. docs.create() starts one; exportEnvelope() yields versioned bytes you can store anywhere.
  • Opening never crashes the app. Corrupt or incompatible bytes quarantine as a result value. On version skew your policy decides: reject, open read-only (viewers still see live presence), or migrate.
  • Migrations are the migrate chains from your widget definitions, run once at open, invisibly to user history — a stale redo even survives.
  • Autosave is gesture-aware: debounced 800 ms with a 10 s ceiling, scheduled on idle, and never while a drag is live — you'll never persist a half-finished move.

Try it

engine.docs.autosave({ get: () => load(), put: (bytes) => store(bytes) });
const gate = engine.docs.open(bytes);   // "ok" | "read-only" | "migrate" | "reject" — never a throw

Presence

Live collaborators

Join a document over any byte channel and presence comes with it: every peer's named cursor moves live on its own layer, and their selection outlines draw around what they have selected (item-accurate up to 32 entities, a bounding box beyond). Presence data expires on disconnect and can never trigger a content re-render — a busy room costs nothing on your widgets.

Try it

const res = await engine.docs.join(webSocketByteChannel(ws), {
  presence: { name: "James", color: "#4f8ef7" },
});
if (res.role === "seeder")                                 // first peer seeds the board
  engine.ops.spawnWidget("sticky", { x: 0, y: 0, undoable: false });
const peers = usePresencePeers();                          // avatar stack
// two-tab demo, no server: broadcastChannelByteChannel("room-1")

live — two peers sharing one document over BroadcastChannel, no server: drag in one frame, watch the other. cursors, selection outlines, and edits sync; each peer's undo is their own.

Rendering & performance

What the renderer does for you

  • Off-screen widgets hide, they don't unmount. The most recent 256 stay mounted (tunable), so scrolling away and back preserves React state — scroll positions, focus, in-progress input.
  • Hidden widgets are frozen. Their prop subscriptions suspend while hidden: a collaborator editing 500 off-screen cards re-renders zero of your trees.
  • Auto-sizing without polluting the document: sizeMode: "auto-height" or "auto" measures real content (ResizeObserver) into a session-only value; the document keeps only authored sizes.
  • Responsive widgets: useBreakpoint(entity) returns one of five tiers computed from on-screen size × zoom — swap dense/full layouts without measuring anything yourself.
  • GL widgets are islands: each renders into a pooled texture at zoom-banded resolution (256 MB budget), composites as a quad, and repaints only when it declares animated, subscribes via useIslandFrame, or its props change. While you pan or zoom, resolution gates down and repaints defer — gestures stay at full frame rate.
  • Rendering never writes back: reflectors and the GL loop are consumers of world state, enforced in dev builds. If a frame is wrong, the truth is in the world, not in a render callback.

Try it

const bp = useBreakpoint(entity);          // "xs" … tier 5 — render dense vs full
createCanvasEngine({ budgets: { keepMounted: 512, fboBytes: 128e6 } })
pnpm --filter glboard dev                  // islands, bands, and GL input live

The vocabulary

The engine's state is ordinary ECS data, and the whole vocabulary is exported from @vibecook/ice — your systems, tools, and debug panels query the very same components the built-ins write. The tables below are the complete shipped catalog.

Scene & identitydocument data

exportkindmeaning
Position · Size · Rotation · StackZcomponentsWhere a widget is, how big, and its stacking order (fractional, so reorders never renumber neighbors).
PrefabIdcomponentThe widget's type id — document identity for the version gate and migrations.
ChildOfrelationContainment edge (child → frame). Cascade delete walks it.
Container · Accepts · Providestag + componentsThe containment contract compiled from defineWidget.

Capabilities & selectionruntime

exportkindmeaning
Selectable · Movable · ResizabletagsWhat gestures may do to a widget — stamped from interaction at load, so behaviors self-select by capability.
SnapSource · SnapTargettagsThe snap roles.
SolidtagDrop-rejecting: things dropped here fly back.
LongPressDragtagHold-to-lift drag model (dragOn: "longPress").
SelectedtagSelection membership. Flip it only through ops.setSelection — never per-frame.
MultiTapcomponentPer-widget double/triple-tap opt-in: {max, windowMs, slopPx}.

Pointers (input facts)runtime

exportkindmeaning
Pointer · PointerScreen · PointerWorld · PointerButtons · PointerMods · PointerWheel · PointerRadiuscomponentsOne entity per active pointer: raw screen position, derived world position, buttons, modifiers, wheel accumulation, device pick radius.
WentDown · WentUp · WentCancelledtagsOne-frame edge marks, cleared automatically at end of frame.
Targets · TouchesExactrelationsWhat the pointer is over: forgiving (radiused) and exact. The dual pick.
HandledByWidget · LocalPointertags"Widget content took this event" (the canvas stands down) · marks your own pointers vs. remote ones.
Keyboard · CancelRequestresourcesLive modifier state · the one-frame "cancel everything" signal behind Esc and tool switches.

Gestures in flightruntime

exportkindmeaning
Tap · LongPress · Drag · Pinch · WheelPan · WheelZoomcomponentsOne entity per gesture candidate, carrying its live math (totals, velocity, zoom-at-claim…).
Watches · Captures · ClaimedByrelationsWhich pointers a gesture reads · what it latched at press · which gesture owns a pointer (the arbitration result, queryable).
RoutedMove · RoutedResize · RoutedMarquee · RoutedPan · RoutedConnect · RoutedDrawtagsWhere a drag was routed — latched once at activation, never changed mid-gesture.
Drags · Grab · SnapStaterelation + componentsThe live edge set a move drives · each widget's grabbed origin (position, size, stacking) · the current snap correction.
DropTarget · OverlapCandidate · OverlapRejectedrelation + tagsContainer-drop candidacy while dragging.
TransformTweencomponentThe fly-back animation rider; it owns the cell until the widget lands.
Simultaneous · GestureSuspended · Sequence · RequiresFail · Down · MultiTapmiscCamera gestures that never claim · pinch suspension · gesture-graph edges for advanced combos · press records.

Node graph

exportkindmeaning
Wire · WireFrom · WireTo · WirePortstag + relations + componentA durable wire entity and its endpoints (stored by widget + port id, so wires survive reload and replication).
Port · PortAnchor · PortOfcomponents + relationOn-demand port entities: declaration, computed anchor point, owning widget.
CanvasSurfacetagThe canvas-background entity — the guaranteed pick fallback.

Derived UI & navigationruntime

exportkindmeaning
SelectionBox · HandleSpec · GuideLine · MarqueeBox · CursorVisual · VisualOf · FollowschromeThe selection box, the eight handles, snap guides, the marquee rect, cursor visuals — all plain entities you can query or restyle.
Visible · Culled · ActivetagsViewport partition (visible vs culled) · membership in the current navigation frame.
MeasuredSize · WidgetBreakpointcomponentsMeasured content size (session-only) · the 5-tier responsive breakpoint.
NavDepth · NavCamera · NavFrame · ContainerCameranavThe container navigation stack and per-container camera memory.

Resources & presence

exportkindmeaning
Camera · Viewport · ActiveToolresourcesThe one camera (x, y, zoom, gesturing flag) · stage size · current tool id.
GestureSettings · PointerSettings · CameraLimits · SnapConfigresources live-tunableEvery constant from the appendix, adjustable at runtime.
FrameInfo · PointerVersion · SelectionVersion · SpatialVersionresourcesTick/dt/now · cheap change stamps systems use to skip work.
PresencePeer · PresenceInfo · PresenceCursor · SelectionSummaryephemeral presenceRemote collaborators: identity, live cursor, selection summary. Your partition is writable; peers project read-only.
DurableUndoStatusresource{canUndo, canRedo}, kept fresh across document swaps.

Try it

import { Position, Camera, SnapConfig } from "@vibecook/ice";
const pos = useWorldComponent(world, entity, Position);        // subscribe in React
world.getResource(Camera);                                     // read in a handler — { x, y, zoom, gesturing }
world.setResource(SnapConfig, { enabled: false, thresholdPx: 5 });

@vibecook/ice

imports: strata-ecs · @vibecook/ice/kernel · loro-crdt — never react, dom, or three

The engine: ECS catalog, frame contract, interaction stack, widget runtime, node graph, nested canvas, doc kit, presence, migrations, and the createCanvasEngine facade. Headless by construction.

Definition primitives

defineWidgetfunctiondurable prefab

defineWidget(def: WidgetDef): WidgetType

The flagship compiler. Props compile to one component per conflict group on a durable prefab; interaction flags become runtime capability tags stamped at projection; ports and containers opt the widget into the node editor and nesting. Sugar over definePrefab + registries — nothing here is unavailable to a hand-rolled prefab.

fieldtypenotes
typereqstringPrefab id and document identity (PrefabId). Duplicate types are rejected.
versionnumber = 1Per-type document version — the open gate and migrations key off it.
propsRecord<string, p.*>The props DSL (below). Every top-level field must carry a default.
groupsRecord<group, prop[]>Conflict groups → one generated component each (<type>:<group>). Membership must be total and disjoint; ungrouped props form the "props" group. The component is the CRDT conflict unit.
surfacereq"dom" | "gl"DOM portal widget, or R3F compositor island.
componentreqFC<{entity, world}>Opaque to core — the react package narrows it.
sizeMode"fixed" | "auto-height" | "auto"Auto modes measure via ResizeObserver → MeasuredSize rider; auto widgets never commit Size — the doc stores authorial intent only.
defaultSize · minSize{w, h}World units.
animatedbooleanGL only: repaint every visible frame. Plain useFrame inside a portal cannot be attributed to an island — content-driven animation goes through useIslandFrame.
interactionWidgetInteractionselectable · movable · resizable · snap: "source"|"target"|"both"|"none" · solid (drop-rejecting target — dropped widgets fly back) · dragOn: "press" | "longPress" (hold-to-lift, the iOS model).
ports{id, side, index?, accepts?}[]Wires bind to widget + port id; port entities are runtime and materialize on demand. accepts is the compatibility key list the connect gesture checks.
container{accepts, provides?}Containment contract: makes this widget a drop target and an enterable frame.
providesstring[]Provides-keys without container semantics — a leaf that offers itself to containers without becoming one. Ignored when container is present.
migrate{[from]: (prev) ⇒ next}Idempotent absolute transforms over flat prop records; chain contiguity validated at definition time; run at document open as read-repair.
Authoring contract. Native interactives auto-opt-out of canvas gestures; custom interactive content calls stopPropagation() (DOM) or stops the synthetic event (GL); sustained widget-owned interactions must setPointerCapture — capture survives the inert-during-drag window. Mark non-native interactive roots with data-canvas-interactive. Anything worth persisting lives in props; React state is best-effort under the keep-mounted budget.

pnamespace

p.string · p.number · p.boolean · p.enum · p.json  (+ p.array / p.object shapes)

The engine props DSL: introspectable (so it compiles to typed strata components), and every spec implements Standard Schema v1 so it plugs into ecosystem tooling.

specsignaturenotes
p.stringp.string({default?})
p.numberp.number({default?, min?, max?})Bounds validated at write.
p.booleanp.boolean({default?})
p.enump.enum(options, {default?})Persists as labels; options are additive-only across versions.
p.jsonp.json(shape, {default?})The only nesting escape hatch — an explicit string cell, visibly conflict-coarse (last write wins for the whole value).
p.array / p.objectp.array(item) · p.object(fields)JSON shape builders, used inside p.json.

defineTool / createDrawToolfunction

defineTool(def: ToolDef): Tool
createDrawTool(widgetType: string): Tool

A tool is pure configuration — it parameterizes recognizer spawn, drag routing, capability gates, and the cursor. It adds no code paths; genuinely new behaviors register systems through the same extension slot the built-ins use.

const ConnectTool = defineTool({
  id: "connect",
  cursor: "crosshair",                                 // cursor while this tool is active
  spawnProfile: { onDown: ["drag"], wheel: true },     // recognizer kinds per pointer-down
  route: { canvasDrag: "pan", widgetDrag: "connect", portDrag: "connect" },
  gates: { movable: false, resizable: false },        // capability gating while active
  shortcut: "c",
});
  • Built-ins: select (V), pan (H, grab cursor), connect (C, crosshair); createDrawTool(type, {id?, shortcut?}) registers a draw:<type> tool whose canvas drag draws the spawn rect. Full routing tables in built-in tools.
  • Switching tools runs cancelActiveGestures() first — no gesture survives a context it can't commit into.

definePrefabfunction

definePrefab(id: string, def: PrefabDef): Prefab   // store: "durable" | "runtime" | "ephemeral"

The base primitive defineWidget sugars over: an entity kind's essential component set, its durable-eligible extras, and its store. The spawn path is the sovereignty class — components and tags stay pure.

storespawn pathlives inreplicatedundoable
durabletx.spawnLoro doc, projected into runtimeyes (CRDT)yes
runtimeworld.spawnruntime world onlynono
ephemeraleph.spawnpresence store, own peer partitionyes (TTL)no

Definition-time validation (all DEV-throw): eligible components are eid-free; every field defaulted or supplied; ephemeral prefabs carry components + tags only; prefab ids unique. Anything world.*-added to a durable entity is a runtime rider — never committed, never replicated.

defineComponent / defineTag / defineRelation / defineResourcefunction

strata-ecs wrappers that record engine metadata (sovereignty, devtools badges)

Thin wrappers over strata's definitions that register metadata for the sovereignty panel and DEV guards. The catalog in @vibecook/ice ships the full engine vocabulary — Position, Size, StackZ, Selected, Camera, ChildOf, ClaimedBy, Targets, and the rest — so apps and custom systems query the same components the engine writes.

The facade

createCanvasEnginefunction

createCanvasEngine({ widgets?, tools?, budgets?, settings?, policy?, measureQueue? })
  ⇒ { world, engine, stack, runtime, nav, ops, docs, budgets, step(now), dispose() }

Construction wires the world, schema/prefab registration (HMR-safe boot kit), the pipeline, built-in systems, the reflector registry, and settings resources. Doc-less by construction — ops that need a document throw with guidance; engine.docs.create() is the first call of every editor app.

optionnotes
widgets · toolsWidgetType[] / Tool[] to register. Built-in tools ship regardless.
budgets{keepMounted: 256, fboBytes: 256e6, portSpawnPerFrame: 64, portRadiusPx: 600}
settingsSeeds live-tunable resources: GestureSettings, PointerSettings, CameraLimits (zoom clamp 0.1–5), SnapConfig (threshold 5 px). Resource first, reviewed constants as fallback.
policy{versionGate: "reject" | "read-only" | "migrate"} — what docs.open does on version skew.

engine.opsnamespacewrite paths

every op is ONE engine-owned write path — one transaction or one resource write

App handlers (toolbar buttons, shortcuts) call ops between frames. Ops are the only public write paths; each is sovereignty-correct by construction.

oppath
setTool(id)Cancels active gestures first, then flips the ActiveTool resource.
spawnWidget(type, {x, y, w?, h?, props?, undoable?})One tx spawning the durable prefab. undoable: false keeps batch seeds out of the user's undo stack.
deleteSelection()One tx + engine cascade: containment recursion + wire cleanup.
duplicateSelection()Twin spawns offset +16/+16 — one tx, one undo step.
setSelection(ids, mode?) · clearSelection() · selectAll()Tag flips + SelectionVersion bump. Selection is never per-frame.
reorder(ids, "top" | "bottom")Fractional StackZ writes, one tx.
zoomToFit(ids?) · zoomTo(z, anchor?) · panTo(x, y)Camera resource writes.
enterContainer(frame) · exitContainer()Nav stack push/pop, camera memory, explicit spatial-index rebuild, nav integrity.
cancelActiveGestures()Sets the one-tick CancelRequest; all non-terminal recognizers cancel next tick with riders restored.

engine.docsnamespacedocuments

create() · open(bytes) · join(channel, {presence?, seed?}) · current() · close()
undo() · redo() · autosave(storage)
membernotes
create()New LoroDoc + version stamps + attach. Local-first — collab is a posture, not a mode.
open(bytes)Parses the ICE1 envelope header before any import; never throws — corrupt or incompatible input quarantines as a return value. Version gate: ok / read-only / migrate / reject per facade policy.
join(channel, opts)Bootstrap protocol over any Uint8Array channel, then attach. presence publishes cursor + selection facets; seed runs only on the first peer.
undo() / redo()Per-gesture steps; selection restored via history hooks; status survives doc swaps (DurableUndoStatus).
autosave(storage)Debounce 800 ms, 10 s max-wait, idle-scheduled, deferred while any gesture is live, quarantine-on-incompatible-restore. App provides get/put.

Documents & collab (under the facade)

createDocSession / openDocSessionfunction

createDocSession(world) · openDocSession(world, bytes)

The primitives under engine.docs, for custom shells. Open never throws — quarantine is a return value. Detach kills undo history, baselines, and held cells with the attachment.

runMigrationsfunction

runMigrations(ctx): MigrationOutcome

Read-repair at open (the facade runs it on migrate): per-type chains execute as one {undoable: false} transaction — the commit never enters the local undo stack, and concurrent migrators converge (idempotent absolute writes + first-writer-wins marker keys).

joinDocfunction

joinDoc(world, channel, opts)

The bootstrap state machine: broadcast hello → buffer inbound → import addressed snapshot as causal base → drain. 800 ms of silence elects the seeder. Reconnect is a full re-bootstrap — never a resumed stream.

broadcastChannelByteChannel / webSocketByteChannelfunction

broadcastChannelByteChannel(name) · webSocketByteChannel(ws) ⇒ ByteChannel

ByteChannel adapters. The app moves bytes and owns the transport; the engine owns the protocol — anything carrying Uint8Array works.

startAutosave / restoreAutosavefunction

startAutosave(session, storage, opts?) · restoreAutosave(world, storage)

The kit under docs.autosave: envelope writes on a debounced, gesture-deferred idle schedule; restore quarantines incompatible bytes instead of bricking boot.

attachPresence / installPresencefunctionephemeral

attachPresence(world, room, info) · installPresence(engine)

Ephemeral peer facets: PresenceInfo, PresenceCursor, SelectionSummary. Your own partition is writable; remote peers project read-only as Not(Local). Publishing happens in the engine's post-tick publish step — presence can never trigger content repaints.

guardedTransactionfunctionwrite path

guardedTransaction(store, world, fn, {undoable?})

The eligibility-guarded transaction primitive under useCommit and every op. One call = one undo step; DEV-validates tx eligibility (durable entity × eligible component).

cascadeDestroyfunction

cascadeDestroy(tx, world, root)

Containment recursion + wire cascade in one transaction: children despawn with their frame; wires despawn with either endpoint.

Extension seams

engine.addSystemsmethod

engine.addSystems(phase, ...systems)   // 11 fixed phases — see the phase reference

Custom behaviors join the same pipeline the built-ins use. defineTickSystem wraps once-per-frame work. Two laws bind: structure via ctx.* only, and access.write declared on every value-writing system — reflectors arm strata enforcement permanently, so undeclared writes DEV-throw.

engine.registerReflectormethodoutput

engine.registerReflector({ name, observe, always?, flush })
engine.registerReflector({
  name: "minimap",
  observe: { queries: [{ q: boxes, cols: [Position, Size] }], resources: [Camera] },
  always: false,               // true = flush every frame (cheap overlays)
  flush(view) { /* batched DOM/GL writes; no layout reads; never writes ECS */ },
});

Reflectors are the only writers of DOM/GL/R3F. Observer-armed with per-reflector dirty sets, they run after notify(), in registration order, fault-isolated: a throwing reflector is skipped for the frame, never crashes the loop.

engine.onPublish / enableTelemetry / reflectorNamesmethod

engine.onPublish(hook)      // the post-tick presence I/O slot
engine.enableTelemetry()    // per-system run/skip + µs → devtools loop tab
engine.reflectorNames()

publish is the designated home for ephemeral structural writes — they are illegal for the whole tick. It runs before notify(), so observers see presence the same frame.

@vibecook/ice/react

imports: @vibecook/ice/dom · @vibecook/ice · @vibecook/ice/kernel — peers: react, react-dom

The app surface: mount the canvas, write through useCommit, subscribe with equality-suppressed hooks.

<EngineProvider>component

<EngineProvider engine={ce}>…</EngineProvider>

Context root — all hooks require it. Threads the engine (and its current doc session) to widgets so userland never hand-rolls store plumbing.

<InfiniteCanvas>component

<InfiniteCanvas engine={ce} measureQueue={q?} onReady={(host) ⇒ …}? />

Mounts the six output planes, pointer/measure adapters, built-in reflectors, the keymap, the rAF loop, and the widget portal root. Unmount detaches cleanly — the engine outlives it. GL islands and devtools attach app-side via onReady (the import walls keep core react-free and react three-free).

useCommithookwrite path

const commit = useCommit()
commit((tx: GuardedTx) ⇒ void, {undoable?})   // one call = one undo step

The widget write path. Resolves engine.docs.current() at call time and runs a guardedTransaction; doc-less calls throw with guidance.

const props = useWidgetProps(world, entity, "sticky");
const commit = useCommit();
commit((tx) =>
  tx.edit(entity).set(Sticky.groups[0].component, { ...props, text }));

useWidgetPropshook

useWidgetProps(world, entity, type, group?) ⇒ props

Row-granular, equality-suppressed subscription, json-parsed, typed from the DSL — and frozen while the widget is hidden: a doc edit re-renders zero off-screen trees; on show, resubscribe + one refresh render.

useSelected / useBreakpoint / useWorldComponenthook

useSelected(entity) · useBreakpoint(entity) · useWorldComponent(world, entity, C)

Equality-suppressed snapshots (strata's get() returns fresh objects — these hooks cache by shallow-eq). useBreakpoint reads the 5-tier responsive component derived from effective size × zoom.

useTool / useToolStatehook

const [toolId, setTool] = useTool()   ·   useToolState(id) ⇒ boolean

Over the ActiveTool resource; setTool routes through ops.setTool (which cancels active gestures first).

useUndoStatushook

useUndoStatus() ⇒ {canUndo, canRedo}

Via the DurableUndoStatus resource — survives document swaps.

usePresencePeershookephemeral

usePresencePeers() ⇒ PresencePeerView[]

Remote peers (PresencePeer × Not(Local)), membership-keyed stable snapshots — for avatar stacks and peer lists.

attachKeymapfunction

attachKeymap(ce, target?, overrides?) ⇒ detach

Ships the defaults below; app entries override or extend. Every entry resolves to an op — there is no system-side keyboard logic — and editable targets are skipped automatically.

useWorldhook

useWorld() ⇒ World   // read + observe only
Escape hatch. Reading and observing are fine; mutation from components is DEV-warned — every v1 entanglement started as a convenient direct write. Write through useCommit or ops.

WidgetRootcomponentadvanced

<WidgetRoot engine hosts />   // portal-list owner; InfiniteCanvas mounts it for you

Owns portal-list membership via useSyncExternalStore over the engine's enter/exit/evict feed. Exported for custom shells that assemble planes by hand.

@vibecook/ice/dom

imports: @vibecook/ice · @vibecook/ice/kernel

<InfiniteCanvas> wires all of this — direct use is for custom shells (vanilla JS, other frameworks).

createCanvasHost / createPlanesfunction

createCanvasHost(container) ⇒ CanvasHost   ·   createPlanes(host) ⇒ Planes

Builds the stage and the six output planes (P0 ground GL · P1 content DOM · P2 GL views · P3 lifted · P4 chrome · P5 cursors).

attachPointerAdapter / attachMeasureAdapter / wireMeasurementfunction

attachPointerAdapter(host, queue, {glRoute?})   ·   attachMeasureAdapter(…) · wireMeasurement(…)

Adapters only enqueue — normalized events into the input queue drained once per tick by pointerIngest. glRoute is the seam the r3f router plugs into. The measure adapter feeds ResizeObserver measurements the same way (facts in, via the queue).

Built-in reflectorsoutput

createPlaneTransformReflector · createGridReflector · createWiresReflector
createDomWidgetsReflector · createChromeReflector · createCursorReflector
createRemoteCursorsReflector
  • planeTransform — observes Camera only; ONE transform per camera-applied plane. The O(1)-pan reflector.
  • domWidgets — host enter/exit + world-unit position/size on change; content stays React's.
  • chrome — pooled nodes: selection box + 8 handles, guides, marquee (+ preview outlines from the marquee render buffer).
  • cursor — one style.cursor write on the stage (the OS cursor is the local cursor); remoteCursors — presence peers on P5.
  • grid / wires — kernel-driven GL passes on P0.

startRafLoopfunction

startRafLoop(engine) ⇒ stop

Drives engine.step(now) — the whole frame contract, one entry point.

@vibecook/ice/r3f

imports: @vibecook/ice/react — peers: three · @react-three/fiber

GL widget islands and the virtual-texture compositor. Zero render→ECS writes, DEV-enforced via a write trap.

createGLBridgefunction

createGLBridge(engine, {devAssertRenderWrites?}) ⇒ GLBridge

The engine↔R3F seam: island lifecycle, FBO pool (size-keyed, MSAA, LRU under budgets.fboBytes), zoom bands with hysteresis, Hot/Warm/Waking/Cold/Dormant phases, two-level invalidation (cheap composite vs expensive island repaint).

<GLViews>component

<Canvas frameloop="demand"><GLViews engine={ce} bridge={bridge} store={store} /></Canvas>

Mounts inside an R3F <Canvas> on the P2 plane. Each GL widget renders to its own island (own Scene + ortho camera via createPortal) and composites as a textured quad — the per-widget composite fact is exactly {opacity}.

useIslandFrame / useIslandInvalidatehook

useIslandFrame(cb) · useIslandInvalidate() ⇒ invalidate
The only sanctioned island animation paths. R3F portals share one commingled subscriber array, so a plain useFrame cannot be attributed to an island and will not repaint reliably. Declare animated: true or subscribe here.

createGLPointerRouterfunction

createGLPointerRouter({world, bridge, index}) ⇒ GLRoute   // plug into attachPointerAdapter

At event time: synchronous read-only point-pick against the spatial index → island-local conversion (kernel) → raycast → synthetic R3F events with per-island capture. A handler that stops propagation marks the pointer surfaceHandled — a world-state fact recognizers respect. First tap on a GL widget works; no stale-frame routing.

Compositor internalsadvanced

Island · RenderTargetPool · ResourceRegistry · CompositeMaterial
createIslandStateStore · createRenderWriteTrap

Exported for custom compositor shells and tests; <GLViews> assembles them for you.

@vibecook/ice/devtools

imports: @vibecook/ice only — nobody imports devtools

Because all interaction state is world state, the panel sees everything — live recognizers, claims, and phases included.

attachDevtoolsfunction

attachDevtools(engine, {container?, intervalMs?, keyOf?, cellInDoc?, telemetry?}) ⇒ {detach}
  • pointers / recognizers — live pointers and gestures: phases, claims, routes, watches.
  • planes — per-plane node counts and reflector timings.
  • sovereignty — per-entity doc/runtime cell badges from the prefab registry.
  • loop — per-system run/skip + µs (honest telemetry: a skipped system stamps nothing).
Dev builds only. Arming telemetry permanently arms reactive stamping (+17–28% on write-heavy paths — the budgeted always-on tax).

@vibecook/ice/kernel

imports: rbush only — no ECS, no DOM, no React. Plain structs in, plain structs out.

Pure math. Every screen↔world conversion — and the one Y-flip — lives here and nowhere else.

Coordinatesfunctions

screenToWorld · worldToScreen · zoomAtPoint · planeCssTransform
worldToIsland · islandToWorld · compositeCameraFrustum

zoomAtPoint is the anchored-zoom math under wheel/pinch; planeCssTransform feeds the O(1)-pan reflector; the island pair converts through widget-local space for GL routing and composite placement.

SpatialIndexclass

new SpatialIndex()   // rbush-backed; O(log n) removal

The only hit path. Point picks, rect queries, snap-candidate gathering, marquee hits, and the GL router all query this index — one truth for "what is here".

computeSnapGuidesfunction

computeSnapGuides(dragged, candidates, threshold)

Edge/center alignment (7X + 7Y pairs, nearest-wins per axis) + equal-spacing detection with gap-pattern extension, then merged. Candidates come from the spatial index — no full-scene scans at any board size.

Ports & wiresfunctions

portAnchor · wireCubic · distanceToCubic

Anchor placement along widget sides, the wire's cubic geometry, and segment-distance hit math (wires pick below widgets, above canvas).

Zoom bands & evictionfunctions

selectBand · isOutOfBand · fboPixelSize · selectEvictions · computeIslandPhase

Power-of-two FBO resolution bands with [×0.5, ×2] hysteresis; LRU eviction policy and the Hot/Warm/Waking/Cold/Dormant phase function for the compositor.

Appendix

The ordering contract, the reviewed constants, and the shipped keymap.

Phase reference — engine.step(now)

sync → tick (11 phases) → publish → notify → reflect

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.

phasewhat runs
inputpointerIngest (queue → pointer values, one-tick WentDown/WentUp, wheel accumulation) · lifecycle · pointerWorldSync.
reactpicking — dual pick via the spatial index: Targets (radiused, forgiving) + TouchesExact (precise). runIf: pointer/camera/spatial versions.
ctl:spawnrecognizerSpawn (tool spawn profiles; pinch on 2nd free touch) · recognizerIntegrity (required-watch counts; capture death cancels).
ctl:recognizePer-kind updates: tap · longPress · drag · pinch · wheel — dead-zones, promotion, phase transitions with one-frame Just* markers (the edge triggers systems gate on).
ctl:arbitrateFirst claiming recognizer wins its pointers (ClaimedBy relation; priority Pinch > Drag > LongPress > Tap) · dragRoute latches pan/marquee/move/resize/connect.
ctl:claimClaim systems attach riders (Grab with origin + z) and Drags edges; StackZ elevates; select-on-grab. Flush ⇒ behaviors read them this frame.
ctl:behaveBehaviors: select · snap · move · drop/consume · resize · marquee · connect · cameraControl. Live absolute writes; commit on release = one doc.transaction.
simulatecameraInertia · tweenSystem (fly-back TransformTween — the tween holds the claim until reconvergence).
derivepromoteSweep · activeMembership · cull · breakpoint · port materialize/reap · selection chrome · guides · marquee chrome · cursor resolve · wire geometry.
presentframeStats and render-buffer fills. Presence I/O is not here — it lives in the post-tick publish step.
cleanupOne-tick marks cleared · terminal recognizers reaped (+1 frame) · CancelRequest consumed.

Defaults & budgets

Reviewed constantslive-tunable resources

Everything below seeds a resource (GestureSettings · PointerSettings · CameraLimits · SnapConfig · budgets) — tune at construction or live.

constantdefaultwhere
tap≤ 250 ms · ≤ 8 px slopGestureSettings
long-press500 ms hold · ≤ 10 px slopGestureSettings
drag activation> 10 px slop (origin measured from dead-zone exit)GestureSettings
pinch activationspread change > 6 px (re-baselined)GestureSettings
wheel gesture end150 ms silenceGestureSettings
multi-tap (opt-in)280 ms window · 20 px slopMultiTap component
camera inertiaseed > 120 px/s · 325 ms decay · touch-to-stopGestureSettings
pick radiusmouse 0 · pen 4 px · touch 12 px (grab stays exact)PointerSettings
zoom clamp0.1 – 5CameraLimits
snap threshold5 px screen-constant (threshold/zoom in world)SnapConfig
autosave800 ms debounce · 10 s max-wait · gesture-deferredautosave kit
keep-mounted LRU256 widgetsbudgets
FBO pool256 MBbudgets
port materialization≤ 64 spawns/frame · 600 px radiusbudgets

Keymap defaults

attachKeymap defaults

All entries resolve to ops; editable targets (inputs, contenteditable) are skipped automatically.

keysop
/ Deleteops.deleteSelection() — one transaction, cascade included
mod Z · ⇧ mod Zdocs.undo() / docs.redo() — per-gesture steps, selection restored
mod Dops.duplicateSelection() — twins at +16/+16
mod Aops.selectAll()
Escops.cancelActiveGestures() — cancels and restores every in-flight gesture
arrows / arrowsnudge selection ±1 px / ±10 px — one transaction per press (each press is an undo step)
V · H · Cops.setTool — select · pan · connect, plus any registered tool's shortcut
Space (hold) · middle buttonpan routing — a device override in the drag router, not a keymap entry