ICE — infinite canvas engine · built on strata-ecs
defineWidget turns React and R3F components into canvas citizens — selectable, movable, resizable, wired into node graphs, synced over CRDT, undoable per gesture. 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. Views are React portals from one root, or GL compositor islands. Cull ≠ unmount; hidden trees freeze.
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"] },
surface: "dom", // | "gl" — R3F island
component: TodoCardView, // a plain React component
sizeMode: "auto-height",
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.P0–P3 apply the camera as one transform each — pan cost is O(planes), never O(widgets). P4–P5 are deliberately screen-space. Chrome always renders above content.
MeasuredSize session rider; the document stores authorial intent only.surfaceHandled), not an event trick.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 & perfCull ≠ unmount, frozen hidden trees, auto-sizing, breakpoints, GL islands.
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, DOM, or three.js — 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, zoom bands, eviction policy. | 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 |
DOM planes + reflectors (grid, widgets, wires, chrome, cursors), pointer/measure adapters, rAF loop. | core · kernel |
@vibecook/ice/react |
<InfiniteCanvas>, EngineProvider, hooks (useCommit, useWidgetProps, useSelected, …), keymap. |
dom · core · kernel + react peers |
@vibecook/ice/r3f |
GL widget islands + virtual-texture compositor, GL pointer router. | react-pkg + three/@react-three/fiber peers |
@vibecook/ice/devtools |
attachDevtools(engine) — pointers/recognizers, planes, sovereignty, loop tabs. |
core only — nobody imports devtools |
Seven demo apps ship in the repo
Each milestone exits through a runnable app — from a 10k-rectangle gray-box to a two-machine collab board.
10,000 rectangles: the frame-contract proof — O(1) pan, churn budget, telemetry.
pnpm --filter graybox devThe interaction crucible: recognizers, arbitration, multi-tap, gesture graph.
pnpm --filter pointerlab devDOM widget board — portals, measurement, breakpoints, keep-mounted LRU.
pnpm --filter cardboard devMixed DOM + GL board — islands, FBO pool, zoom bands, GL pointer routing.
pnpm --filter glboard devThe node editor — ports, wires, connect gesture, containers, nested canvas.
pnpm --filter nodeboard devFacade-only collab proof — presence, undo, autosave, migrations. Built on the published surface alone.
pnpm --filter moodboard devWidget interaction lab — hold-to-lift drags, solid containers, fly-back.
pnpm --filter widgetlab devRooms over a dumb WebSocket relay: ?room=x&relay=ws://localhost:9301
pnpm relay