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.

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.

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. Views are React portals from one root, or GL compositor islands. Cull ≠ unmount; hidden trees freeze.

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 framePosition = 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 — components 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"] },

  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" }) },
});

What the compiler emits

  • One durable prefabPrefabId + Position/Size/StackZ + one generated component per group (todo-card:content, todo-card:style). The conflict unit is the component.
  • Capability tagsSelectable, 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.
  • A view registration — DOM widgets render as portals from one React root; GL widgets render as compositor islands with a pooled FBO.
  • A migration chain — validated for contiguity at definition time, run at document open.

Six planes, one camera

P0 ground · WebGL — grid + wires pass P1 content · DOM — widget hosts, world units P2 GL views · WebGL — composited island quads P3 lifted · DOM — promoted drags, portal swap P4 chrome · DOM pooled — selection, handles, guides P5 cursors · DOM — remote presence

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.

Runtime guarantees

  • Cull ≠ unmount — culled hosts hide but stay mounted within an LRU budget (default 256). React state survives scrolling away and back.
  • Hidden trees freeze — props subscriptions suspend while hidden: a doc edit re-renders zero off-screen widgets.
  • Auto-size without pollution — ResizeObserver → MeasuredSize session rider; the document stores authorial intent only.
  • GL islands — own Scene + ortho camera, size-keyed FBO pool, zoom-band resolution with hysteresis, DPR gated while gesturing.
  • Zero render→ECS writes — DEV-enforced with a write trap. The render loop is a consumer, never an author.
  • First tap works — the GL router point-picks synchronously at event time; widget opt-out is world state (surfaceHandled), not an event trick.

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

  • Lifecyclecreate() · 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 gateok / 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 undodocs.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, six entries, one direction.

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.

strata-ecs loro-crdt kernel pure math · 0 deps core the engine · headless dom planes · reflectors react InfiniteCanvas · hooks r3f GL islands · compositor devtools → core nobody imports devtools peers: react · three · @r3f
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, 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

Pan something real.

Each milestone exits through a runnable app — from a 10k-rectangle gray-box to a two-machine collab board.

graybox

10,000 rectangles: the frame-contract proof — O(1) pan, churn budget, telemetry.

pnpm --filter graybox dev
pointerlab

The interaction crucible: recognizers, arbitration, multi-tap, gesture graph.

pnpm --filter pointerlab dev
cardboard

DOM widget board — portals, measurement, breakpoints, keep-mounted LRU.

pnpm --filter cardboard dev
glboard

Mixed DOM + GL board — islands, FBO pool, zoom bands, GL pointer routing.

pnpm --filter glboard dev
nodeboard

The node editor — ports, wires, connect gesture, containers, nested canvas.

pnpm --filter nodeboard dev
moodboard

Facade-only collab proof — presence, undo, autosave, migrations. Built on the published surface alone.

pnpm --filter moodboard dev
widgetlab

Widget interaction lab — hold-to-lift drags, solid containers, fly-back.

pnpm --filter widgetlab dev
+ relay

Rooms over a dumb WebSocket relay: ?room=x&relay=ws://localhost:9301

pnpm relay
Open the API reference → $ pnpm install && pnpm run ci