entity-component-system · typescript · editors & canvases

A collaborative ECS for editors and infinite-canvas apps.

strata-ecs is an entity-component-system for TypeScript apps that edit documents — whiteboards, node graphs, design tools. Model the document as entities and components, query it at typed-array speed, and add CRDT-backed multiplayer later as a layer — not a rewrite.

npm i @vibecook/strata-ecs zero dependencies CRDT-backed · Loro MIT try the live demo ↗

// pre-1.0 — minor versions may break APIs; pin your version.

The whole model in 20 lines

Components hold typed data. Entities carry components. Systems run over queries. The world ticks. That's the vocabulary — here it is running:

Entitya stable handle naming one thing in the world — a shape, a node, an edge.→ Entities
Componenttyped fields attached to an entity; entities with the same set share storage.→ Components
Systema function that runs over every entity matching a query, once per tick.→ Systems
Querya compiled description of “entities with these components” — define once, reuse.→ Queries
Worldthe container that holds it all; world.tick() runs your systems over it.→ The frame loop

Positioning

Is it for you?#

You don't need an ECS to build an editor. You need one when the object count outgrows ad-hoc state: when dragging a selection re-renders the world, when every feature adds another dirty flag, when “add multiplayer” quietly means “rewrite the data layer.” strata-ecs is built for exactly that shape of app — and honestly not for others:

Built for

  • Infinite canvases, whiteboards, node & graph editors, design and diagram tools
  • Documents with thousands to ~100k live objects that must hold 60 fps
  • Apps that will eventually want undo, autosave, or multiplayer without re-architecting — multiplayer carries its own document-size ceiling
  • React apps whose document state needs to live outside the render cycle

Reach for something else

  • Games — it works, but game-oriented ECSes (bitecs, koota) and engines bring schedulers, physics, and asset pipelines we don't
  • Rich text — use a text CRDT (Loro, Yjs) directly; our unit of collaboration is the object, not the character
  • Small UI state — a form or a settings page wants a plain store, not an ECS

Iterate 100k objects like it's an array — because it is

Canvas apps usually survive scale with spatial indexes and dirty-flag bookkeeping. strata-ecs's answer is storage: components live as struct-of-arrays typed-array columns grouped by archetype, so a system is a contiguous loop over a Float32Array. On the canonical iteration micro-benchmarks strata ties or beats bitecs, the flat-array specialist, and leads on entity-lifecycle churn. The trade is the archetype model's usual one: adding or removing a component migrates the row between tables. See the benchmarks page for the full cross-library table, losses included.

Change detection you don't have to hand-roll

Instead of dirty flags scattered through the app, the world is the change detector: subscribe to a query or a value and it tells you when the data behind it moved. The machinery is dormant until the first observer registers, and an idle check is about 39 ns over 10,000 watches — cheap enough to leave on every frame.

CRDT-backed multiplayer that never touches the hot path

Convergence is not code you write. The collaborative layers are backed by a CRDT — currently Loro — so concurrent edits merge automatically on every peer, offline peers catch up from increments, and no server has to arbitrate conflicts: any channel that moves bytes is a valid transport. Persistence and live presence are separate imports — @vibecook/strata-ecs/durable and @vibecook/strata-ecs/ephemeral — that project into the same runtime your systems already read. Your render loop and your systems don't change when a second person opens the document: Loro is an optional peer dependency, quarantined behind two adapter classes, and the core never imports it.

The layer map

Architecture#

The name is the map. A strata-ecs app is a stack of independent layers — strata — over one queryable runtime. The core ECS at the bottom is a complete product on its own; everything above it is opt-in, added when (and only when) your app needs it.

strata-ecs architecture — a core, a substrate, three opt-in layers REACTIVITY · built in watch queries & values zero cost until the first observer strata-ecs · strata-ecs/react DOCUMENT · durable stored · synced · conflict-free persistence + multiplayer strata-ecs/durable PRESENCE · ephemeral cursors · selections · previews self-expiring, resets on disconnect strata-ecs/ephemeral project ↕ project ↕ observes the core directly RECONCILE SUBSTRATE · internal projects document & presence into the runtime · converges concurrent edits you never import this CORE ECS · strata-ecs entities · components · tags · relations · queries · systems · resources archetype typed-array columns — the hot path; a tick touches only this layer complete on its own: build the whole editor here first your systems read & write here every layer above the core is optional · adopt in order of need · the CRDT (Loro) is confined to two adapter classes
Bottom-up: the core ECS is the hot path and a complete product alone. The reconcile substrate (internal — never in your imports) projects the optional document and presence layers into that same runtime and converges concurrent edits. Reactivity observes the core's change stamps directly. Layers you haven't attached cost nothing; layers you have do their work at frame boundaries, never inside your systems' loops.

The practical consequence: you build the whole editor against the core first — local, single-user, no server. Save/load, live queries, multiplayer document sync, and cursors then attach in any order, each as one import and a few lines at the frame boundary. Nothing you wrote against the core changes.

Concepts

Entities#

An entity is a stable handle naming one thing in your document — a shape, a node, an edge. It carries no data itself; data lives in the components attached to it.

Concretely, an Entity is an opaque u32 — a 20-bit slot index packed with a 12-bit generation. The slot is a direct row into the entity table (O(1) resolve); the generation is bumped every time a slot is recycled, so a handle you kept past its entity's death reads dead forever rather than silently aliasing a new entity. A handle is a name, not a pointer: it stays constant as the entity migrates between archetypes.

The packed handle and the slot table Entity — one u32 generation 12 bits slot 20 bits indexes slot table slot generation → archetype, row 2603A2, row 14 stale handle gen 2, slot 260 → table says gen 3 mismatch ⇒ reads dead, never aliases world.isAlive(stale) === false — permanently.
The slot indexes the table; the generation is the guard. A recycled slot bumps its generation, so any handle minted against the old occupant fails the check.

Concepts

Components#

A component is a named set of typed fields — Position { x, y }, Fill { r, g, b, a }. Entities with the exact same set of components share an archetype — a table whose rows are entities and whose columns are the fields, each a real typed array. Adding or removing a component moves the entity's row to the archetype with the new shape; that migration is the archetype model's cost, and the reason to split fields that are edited independently into separate components (which also pays off under collaboration).

Archetype tables — rows are entities, columns are fields archetype [Position, Velocity] entityPosition.xPosition.yVelocity… e310.04.01,0 e722.59.00,2 e93.07.01,1 columns are Float32Arrays — a system loops them contiguously addComponent migrate row archetype [Position, Velocity, Frozen…] entityPosition.x e722.5 e7's row now lives here; its handle did not change tags bitsets — outside archetype identity; presence is the information relations directed edges, inverse- indexed, arity one/many, cascade on despawn resources world singletons — camera, tool mode, config; not attached to any entity
Rows are entities, columns are fields. A shape change migrates one row between tables; tags, relations, and resources live outside the archetype and carry their own indices.

Three siblings round out the data model:

  • Tags are zero-data markers backed by bitsets — Selected, Frozen. Presence is the whole signal; they sit outside archetype identity, so toggling one never migrates a row.
  • Relations are directed, inverse-indexed edges with arity one or manyChildOf, ConnectedTo. Deleting either endpoint cascades the edge away with zero cleanup code.
  • Resources are world singletons — the camera, the active tool, a config object — read and written by name, not attached to entities.

Concepts

Schema#

You declare each primitive once with a define* call and hold the returned handle as a module constant. The schema literal is the source of truth: field types flow from it into both the runtime storage and the TypeScript value type, so there is no hand-written <S> that can drift from the columns.

Field types

TypeValue in TSBacking column
f32 f64numberFloat32Array / Float64Array
i8i32, u8u32numberthe matching integer typed array
boolbooleanUint8Array (0 / 1)
stringstring | nullper-cell (string | null)[]
eidEntityUint32Array (a packed handle)
keyEntityKey | nullstring column — a stable cross-session reference (see the document layer)
enumOf([…])the label unionsmallest unsigned int that fits

Wrap a type in field(type, { default }) to make it optional at spawn; a bare type is required. enumOf takes an array for positional discriminants (local-only, reorder-hostile) or an object for explicit stable ones (safe to persist and sync).

Type inference — the columns are real typed arrays

Because the handle remembers its schema literal, batch.col(Position).x is a Float32Array at compile time — no cast, and a typo in the field name is a type error.

Note — the schema registry is process-global

define* populate module-level registries, so a name is defined exactly once per process. Under Vite HMR, escalate the schema module to a full reload (never a hot re-run) and cache the definitions on globalThis — this is the reference pattern the example app uses, in ecs/schema.ts.

Concepts

Queries#

A query is an array of terms AND-ed together, compiled once and held as a constant. Component terms fold into archetype-level checks (evaluated once per archetype and cached); tag and relation terms become per-row filters; a relation with a concrete target seeds iteration from the reverse index. defineQuery returns an opaque handle; the store caches its matching archetypes.

Iterating a batch

A chunk-system body runs once per matching archetype chunk, and every batch it receives has at least one matched row — zero-match chunks are never delivered, so a body needs no count guard. Read columns through col() for typed access, then loop the matched rows. for (const r of batch) fuses the row filters; the explicit rows/count loop is the fastest idiom and valid for every chunk.

Note — cost honesty

Component membership is the cheapest term (archetype-level, checked once per table). Tags and relations cost a per-row probe. A relation is a single hop — Related(rel) for any edge, Related(rel, target) for a specific one — there is no multi-hop traversal in a query; walk further edges yourself. batch.rows and the columns are chunk-scoped: read what you need inside the loop, never retain them past it.

Ordered relations

By default a relation is an unordered set: getReverse hands you a target's inbound edges in no guaranteed order. Declare one ordered and that reverse set becomes a sequence — the children of a parent, in an order you control that survives edits, reparents, and multiplayer merges. Ordered relations are arity one only: each child has a single parent, which is what makes “where does it sit among its siblings?” a well-defined question.

There is no second order API to keep in sync. For an ordered relation, getReverse(parent, ChildOf) is the sibling order — read it and render. You place a child as you link it: setRelation takes a fourth argument — "first", "last" (the default), or { before: sibling } / { after: sibling } — and moveRelation(child, ChildOf, place) reorders it in place afterward. An anchor that is no longer a sibling (a teammate reparented it mid-gesture) never throws: the child lands "last" with a dev-mode warning, because placement races reparents by design.

A reorder changes no component value, so a plain query watch on the parent may not fire for it alone. orderStamp(parent, ChildOf) is the cheap poll for exactly that case: a monotonic version number that bumps whenever the parent's sibling order moves, so a render cache can compare last-seen against current and rebuild only the lists that actually reordered. It arms on first read and is pull-only — pair it with a query watch on the relation for the wake, then read the stamp for the did-it-change filter.

Concepts

Systems#

There are two system forms, named for how often their body runs. A chunk system (defineSystem) pairs a query with a body that runs once per matching chunk — the fast SoA path for per-row transforms. A tick system (defineTickSystem) has no query: its body runs exactly once per tick in its phase slot — the home for whole-frame effects and coordination. Both take the same options: a name for the tools, a runIf gate on non-query state, and an access declaration of the columns the body reads and writes. Systems are grouped into named phases; a pipeline is just an array of phases, run in order. Because it is a plain array, you rebuild it as a value to reconfigure the frame.

Tick systems: effects run once per frame

The scheduler owns system cardinality; queries own data cardinality. A chunk body that performs a global effect — camera math, draining an input queue — silently repeats that effect once per matching chunk, and how many chunks exist depends on the world's shape history, not your schedule. When the work is "once per frame", say so: define a tick system and iterate whatever data it needs inside with ctx.query(q).each(batch => …). The inner walk behaves exactly like the chunk form's dispatch (same batches, never empty), it sees all matching rows across every archetype in one pass, and its reads and writes are attributed to the running system's access declaration. With no query there is no default read set — declare access explicitly.

Access declarations

The access.write envelope names every column a system may mutate across all of its conditions. It is optional in the base runtime, but once reactivity is active it does real work: it is how the framework knows which columns a system stamped, and — in dev builds, on the typed write path — it enforces that a system only writes what it declared. A gated-off system never runs, so it never stamps; that keeps idle frames stamp-free and is why the runIf gate is load-bearing under reactivity.

When two systems in one phase write the same column, a dev advisory flags the possible order-dependence. If you know those writes are order-tolerant — row-disjoint, commutative, or last-write-wins-safe — list the column in access.orderIndependent on every writer to opt out; the warning falls silent only once all of them attest.

What access metadata is not: a capability or security boundary. It is diagnostic and scheduling metadata — it drives dev-mode misuse warnings and reactive wake-ups, and it depends on declarations being honest. The dev-mode subset check covers the typed write path (edit().set(...) and friends); writing through a raw col() array bypasses it, which is legal and why the framework attributes such writes conservatively (whole-query stamping at walk end) rather than pretending to have seen them. Production builds run no enforcement at all. Trust it for correctness of wake-ups, never for containing untrusted code.

The core mental model

Mutations & timing#

There are two mutation surfaces, and the difference between them is the single most important thing to hold in your head. Outside a system, world.* mutations apply immediately — nothing is iterating, so a shape change is safe at once. Inside a system, you get a ctx whose value writes are immediate but whose shape changes (spawn, destroy, add/remove component, tags, relations) are deferred: they enqueue into the phase's command buffer and land at the phase boundary.

Identity is the exception that makes deferral usable: ctx.spawn() mints a real handle now — you can tag it, reference it, store it immediately — even though its components are placed at the flush. It is queryable from the next phase on.

Rule

Values flow immediately and are order-sensitive within a phase; structure lands at phase boundaries.

The tick timeline — phases, systems, flush points world.tick(pipeline) — one call, per phase in array order phase "sim" SystemA · SystemB — run in order value writes visible at once flush ① shape changes land phase "post" SystemC — sees ① results flush ② ctx.spawn() in "sim": handle usable now … … queryable from "post"
Systems run in order within a phase; the phase boundary flushes queued shape changes. A spawn issued in one phase returns a live handle immediately but becomes visible to queries only after the next flush.

Composition

The frame loop#

A frame is four stations, in order: drain any inbound layers, run your systems, settle the reactive boundary, then render. With no layers attached, sync() is a no-op — but you write it from day one, so the document and presence layers attach later with zero rewrite.

The frame ring — four stations sync() drain inbound layers tick(pipeline) run systems, any cadence reactive.notify() the one settled boundary render read after tick returns every rAF
sync() drains attached layers into the runtime; tick() runs systems and flushes their shape changes; notify() settles the reactive boundary once; render reads the finished state. One trip per frame.
Rule

One settled reactive boundary per frame: call world.reactive.notify() exactly once, after all ticks, before you render.

Layer · the change detector

Reactivity#

The first opt-in layer replaces hand-rolled dirty flags: subscribe to a query, a value, or a resource, and the world tells you when the data behind it moved. Observers are compared against the store's change stamps at one settled point per frame — your notify() — so reactivity never runs inside your systems' loops, and it costs nothing at all until the first observer registers. There are three tiers, a visible cost-and-precision ladder.

The reactivity ladder — cost vs precision coarser · cheaper finer · more precise precision observeQuery did anything matching move? observeValue this value actually differs observeResource a singleton changed observeQuery may over-fire, never misses · observeValue is equality-suppressed
Pick the coarsest tier that still tells you what you need: a query watch to know a collection may have moved, a value watch for an exact per-entity value, a resource watch for a singleton.
  • Registration is a frame boundary. Subscribing never back-fires; a change made in the same frame as registration surfaces at the very next notify(), and nothing stamped before you subscribed ever fires.
  • peek reads the current value without subscribing; invalidate(C) is the manual stamp for a write no chokepoint saw.
  • Watches die with their world. A world.import(bytes, { replace: true }) settles them at the next notify() (entity watches fire undefined once and self-remove).
Rule

observeQuery may over-fire but never misses; observeValue fires only on a real change. Both cost nothing until the first observer registers.

React binding

@vibecook/strata-ecs/react is two hooks over useSyncExternalStore: they subscribe to the value channel and re-render exactly once per real change at your next notify(). An equal-value write does not re-render.

Local save & load

Save & load#

Before any collaboration, the runtime serializes itself. world.export() returns bytes (readable UTF-8 JSON); world.import() loads them. A plain import needs an empty world; import(bytes, { replace: true }) resets the world in place first — the document-open path that keeps the same World object, and every observer and reactive registration on it, instead of forcing a swap. It validates before it resets, so an incompatible snapshot throws with the live board intact.

Rule

Import in place with { replace: true }: every pre-reset handle reads dead afterward (generations bump), never aliased to a later entity, and your subscriptions survive.

The example app's autosave is the pattern: a single observeQuery over the drawable columns raises a debounced export on idle, deferring while a gesture is in flight so a multi-ms world walk never hitches a drag.

That autosave serialises the runtime; the document layer (below) saves itself separately. doc.exportSnapshot() writes the whole converged document, and a long-lived one accumulates a lot of edit history — so doc.exportSnapshot({ mode: "shallow" }) writes a history-compacted at-rest save instead: the live state complete, the oplog truncated at the current point (roughly 1.5–2.6x smaller). Its one law: a document reloaded from a shallow save can't exchange edits with a peer that still holds the full history — everyone reloading from the same shallow save syncs normally, but a mixed base needs a fresh re-bootstrap.

Multiplayer, in layers

Going multiplayer#

Everything so far runs entirely local — no server, no setup. When you want other people in the same document, two layers attach, and they split the problem the way you'd explain it to a teammate:

Documentdurable

The board itself — shapes, edges, styles. Permanently stored, edited concurrently, merges without conflicts. No changes are lost.

@vibecook/strata-ecs/durable

Presenceephemeral

The people on the board — cursors, selections, live previews. Broadcast at cursor rate, self-expiring: a peer's presence resets when they disconnect.

@vibecook/strata-ecs/ephemeral

Both project into the same runtime your systems already read — document content as ordinary entities, other peers as Not(Local) entities. Under the hood, both ride on Loro, a production CRDT engine — the document on its CRDT document, presence on its lightweight ephemeral store — so convergence, causal ordering, and undo come from the engine, not from code you write. Loro is an optional peer dependency (it enters your bundle only when you attach a layer), and the adapter seam it sits behind is deliberately small. Where a piece of state lives is a per-component decision, made by how you create it:

TierLives inLifetimeWho writesExample
runtime-onlyarchetype columnsthis sessionanyone, immediatelySelected, camera, drag drafts
documentsynced doc → projectedforever, convergesthe committer, at boundariesPosition, Size, Fill
presencepresence store → projectedTTL, self-expiringthe owner, its partition onlyCursorPos, selection refs

The example app draws this line on day one — document content vs. interaction state — so the collaborative layers attach without a rewrite. Below: how each layer works, and where your app's job stops and the framework's begins.

Note — how big a shared document can get

Applying one incoming edit currently costs time proportional to the whole document, not to the size of the edit. Measured on an M1 Max with pnpm bench:sync, a peer receiving a single ~120-byte change pays roughly:

The receiving peerCost per incoming editStays inside a 60 fps frame up to
is also editing~6 ms per 1,000 objects~3,000 objects
is only receiving~0.4 ms per 1,000 objects~40,000 objects

So a 10,000-object board costs an actively-editing peer roughly 60 ms per change arriving from someone else. Edits arrive at gesture boundaries rather than per frame — a drag commits once, on release — so this is felt as an occasional hitch rather than a dropped frame rate, but it does put a practical ceiling on shared documents well below the ~100k a local document handles comfortably. Editing your own copy is unaffected: local commits are flat at ~0.35 ms whatever the document size, as is the initial join.

The cost is Loro's, not this library's — it is import reconciling an incoming change against concurrent local history, reproducible in a handful of lines with no strata‑ecs in the picture, and we are tracking it upstream. If your documents run larger than a few thousand shared objects, splitting them into several smaller ones keeps every edit cheap, because the cost is per document.

The document layer

createDurableStore(loroDoc) is the one place the Loro document enters — you own the document and its transport. attachDurable(world, doc) projects it into the runtime and registers the drain that sync() pumps. You change the document only inside doc.transaction, which records a batch of mutations and seals them as one commit (one commit is one undo unit).

Inside a transaction, the tx vocabulary mirrors world/ctx — but the timing rule is sharp and worth memorizing.

Rule — visibility

A document value write to an existing component is visible immediately; every structural effect (spawn, add/remove component, tags, relations, despawn) is visible after the next sync().

That split is what makes a drag cheap and correct. A drag writes runtime Position every frame and commits once at gesture end; the framework holds remote edits to those cells off while your drag is in flight, and on release everyone converges on the last committer's value. You write no conflict code.

Synced doc, baseline, runtime — the lagging baseline as drag detector synced document — the converged truth baseline — last agreed value (lags) runtime — what the user sees now the drag detector, in 4 steps 1 · idle: runtime == baseline == doc 2 · drag: runtime moves ahead each frame; baseline lags behind 3 · remote edit to same cell arrives: runtime ≠ baseline ⇒ held off 4 · commit: all three snap to the committed value — converged "runtime ahead of baseline" IS "a drag is in flight".
The baseline is the last value all peers agreed on. When the runtime runs ahead of it, a local gesture is in flight — so the framework knows to hold conflicting remote edits until you commit, then converge.
  • Committer-wins, per component. The last commit to a component wins; because the component is the conflict unit, splitting independently-edited fields into separate components (Position vs. Fill) means they never contend.
  • Keys, not handles, cross the boundary. A runtime Entity handle is session-local; a document EntityKey is the stable cross-session identity. Use doc.keyOf(e) to get one and doc.resolve(key) to turn it back into a handle.
  • Undo is local-ops-only by construction — a transaction is the unit. See Undo & history.
  • DurableSyncStatus is a runtime-local resource (pendingInbound, heldCells, lastAppliedFrame) you read with useResource; it only moves on real activity, so an idle network re-renders nothing.
Rule — identity

Persist keys, never handles: doc.keyOf(e) on the way out, doc.resolve(key) on the way in. A stored handle from a past session is meaningless.

Ordered relations sync through this layer like any other structure: sibling order converges across peers, and two people reordering the same list at once resolve cleanly — the Figma-style sibling-list merge you'd expect, with no ordering code of your own. Because a reorder is recorded inside a doc.transaction, a drag that moves an item is one gesture and one undo step; moveRelation rides the same commit as the rest of the gesture.

Rule — everyone on 0.8.0+

Documents that use ordered relations need every collaborator on 0.8.0 or newer. Older builds delete order state when an entity despawns and drop order changes outright, so a single out-of-date peer is enough to lose sibling order for the whole document.

Document metadata

Sometimes you need to stamp something on the document itself — a schema version, a format marker, a feature flag — that should travel and persist with it but isn't an entity or component. doc.metaTransaction(fn) is the sanctioned door: it writes primitive string / number / boolean values into the document's reserved metadata map, under a dotted namespace of your own. A meta write is invisible to the runtime — no observer, no useResource, no sync() wake — and is excluded from undo, so a user's undo() never rolls back your version stamp. It converges per key by last-writer-wins and needs no attachment, so you can stamp a document before it is attached.

Undo & history

Undo and redo come with the document layer — no separate store, no plumbing. They follow the rule every mature multiplayer editor follows (Figma, Liveblocks, Google Docs): undo undoes only your changes. A collaborator's edits never land on your undo stack, and yours never land on theirs. doc.undo() and doc.redo() return false on an empty stack, so they're safe to call straight from a keyboard handler.

  • A transaction is one step. Each doc.transaction is one undoable unit; wrap a multi-part gesture in doc.undoGroup(fn) to collapse its commits into a single step.
  • Engine work can opt out. doc.transaction(fn, { undoable: false }) commits without adding an undo step — for document migrations, format upgrades, and read-repair at open. The user's existing history (and any pending redo) stays intact, and peers just see a normal commit.
  • Redo survives a teammate's edit. A new local commit clears the redo stack — you've branched — but a remote edit arriving over the wire does not, so you can still redo what you just undid.
  • Drive the toolbar from DurableUndoStatus. The runtime-local { canUndo, canRedo } resource updates exactly when a button's enabled state flips, so an idle stack re-renders nothing. Detached, the store's canUndo() / canRedo() methods are the pull-based equivalent.
  • Selection rides the stack. setHistoryHooks stores a JSON-safe snapshot (typically the selected keys) with each step and restores it on undo — so undo puts the selection back where it was, the way users expect.
Rule — undo is yours, and it wins

Undo touches only your own changes, but it re-asserts the pre-edit state as the newest value — so undoing an edit can overwrite a teammate's later change to the same component, and undoing a spawn removes the entity even if a teammate added to it since. Everyone still converges on the result: undo is a real forward edit, not a rewind of history. Attached, the reversal shows up at the next sync().

The presence layer

Presence — cursors, selection highlights, live previews — is modelled as entities too, but on a writer-partitioned store. Each peer owns its own partition, writes to it immediately, and the binding projects every other peer's entries in as live entities that self-expire on a TTL. Your own presence entity carries the framework's Local tag, so the overlay query is simply Not(Local).

Writer-partitioned presence — Local applied locally only peer A (me) A's partition · writes only here my entity — tagged Local Local is applied on MY runtime only; never sent blob (throttled) transport — TTL keepalive refreshes peer B projects A in as an entity A here is Not(Local) no Local tag on B's copy of A → the overlay draws it
You write only your own partition; every other peer arrives as a Not(Local) entity. The Local tag is applied on your runtime only and never crosses the wire, so each peer sees itself as owner and everyone else as remote.
  • The facet pattern. Presence-of-a-component is a signal: the example adds a SelectionRef (a document key) to its presence entity while it has a selection and removes it otherwise — membership carries the meaning.
  • Self-healing lifetime. A ttlMs sets the expiry; a keepalive re-stamps live keys so idle-but-present peers don't vanish; eph.leave() on pagehide is a best-effort early departure. The TTL is always the guarantee.
  • EphemeralSyncStatus (peerCount, lastInboundFrame) is the presence sibling of DurableSyncStatus.
Rule — partitioning & identity

Only the owner writes its partition; everyone else reads it as Not(Local). The peerId is session-unique (crypto.randomUUID()) — a reused id would keep refreshing a departed peer's ghost forever, so display identity goes in a component, never in the key prefix.

Transport — the app's job

The framework converges a document; it never moves bytes. Moving bytes is your transport — any channel that can carry a Uint8Array: a WebSocket, a BroadcastChannel between tabs, WebRTC. The document layer's outbound wire is doc.subscribeOutbound(bytes => send(bytes)); inbound is transport.onMessage(bytes => doc.applyRemote(bytes)). There is no conflict code anywhere in it.

A joiner cannot apply a bare increment before it has the document it references, so the app owns a small bootstrap: broadcast hello, buffer inbound increments, and wait for a snapshot (a full doc.exportSnapshot()) addressed to you; import it as the causal base, then drain the buffer in arrival order. The handshake is bidirectional — the joiner also broadcasts its own base so peers who predate it learn the joiner's construction commit; otherwise the joiner's later increments would quarantine as missing dependencies.

The bootstrap handshake over a relay joiner relay (dumb) existing peer hello (buffer inbound meanwhile) snapshot → import as causal base my base (bidirectional) — so peers learn me drain buffer then: sealed-commit increments both ways
The relay never decodes a frame — it routes bytes by room. Peers bootstrap each other over the app's own hello→snapshot protocol, exchanging bases bidirectionally, then trade sealed-commit increments. Convergence is the framework's, behind applyRemote.
Rule — delivery

Deliver each peer's messages causally in order, or exchange snapshots to re-sync. The quarantine backstop is PendingImportError — if an out-of-order import ever slips through, catch it and re-bootstrap. A reconnect is a full re-bootstrap, never a resumed half-synced stream.

Server-side, no browser

Headless in Node#

strata runs headless in Node as a first-class mode, not a workaround. The core reaches for no DOM and no requestAnimationFrame anywhere, and the document layer's wire is plain bytes — subscribeOutbound hands you a room's outgoing commits, applyRemote takes an incoming one, exportSnapshot serialises the whole converged document — all of it transport-agnostic. The same document that syncs between two browser tabs syncs, unchanged, between a Node server and its clients.

The natural server shape is a document host: one process holding many rooms, each an authoritative World plus DurableStore that applies every client's commit into its own converged document, persists it, and relays it to the room's other clients. Many rooms means many Worlds in one process — the one thing they share is the schema. defineComponent and its siblings register names in a process-global table, so you define the schema once at module level and every World speaks that one vocabulary; a duplicate name throws. Bytes cross the wire matched by component name, so the host and every client must import the identical schema module.

Nothing drives the frame for you server-side — there is no rAF — so you drive it with a plain timer: world.sync() to absorb inbound commits, then world.tick(pipeline) to run systems, on whatever cadence the room needs.

There is deliberately no framework "tick driver" to call. The driver is those three lines, and wrapping them would only hide the one decision an embedder actually makes — the cadence, and what runs in the pipeline.

Rule — operating headless

Bootstrap is bidirectional. A joiner imports the host's snapshot as its causal base, then sends its own base back; the host absorbs it so the joiner's later increments don't quarantine, and relays it to the room's other clients so a peer that predates the joiner absorbs it too.

A quarantine is permanent for that document instance. Once an import throws PendingImportError, that store is poisoned for good — there is no in-place repair. Recovery is to reconnect and re-bootstrap from a fresh document; never retry the import.

Restore by importing the bytes before you construct the store. Import a saved snapshot into the bare LoroDoc first, then wrap it with createDurableStore — the reload recipe, and the fastest restore path. If that import quarantines, quarantine the file (rename it aside) and start the room on a fresh document — never reuse the poisoned instance, and never let a bad file brick boot.

A complete, runnable version — rooms by URL path, disk persistence, the bidirectional relay, and a setInterval tick driver — is examples/headless-host.

Introspection

Devtools#

The runtime exposes a telemetry contract, WorldObserver: attach one with world.observe(obs) and it receives spawn/destroy and per-tick/per-system/per-flush events. It is zero-cost when nothing is attached — the hot paths pay one branch-on-null and take no timings. Callbacks must not mutate the world and must not throw.

Built on that contract is @vibecook/strata-ecs/tools — a zero-dependency vanilla-DOM inspector panel you mount with one call. Three tabs are always there: entities (a live, virtualized list with a component/tag/relation detail pane), systems (per-system microseconds, run/skip idle percentage, per-phase flush timings), and timeline (a canvas waterfall of every entity's birth-to-death). Attach the collab layers and two more light up: durable (the document's baseline and converged state side by side — the highlighted rows are the un-reconciled sync delta) and ephemeral (every peer's live presence, grouped by writer). You pass a describe callback to label entities in your own domain terms.

Its sibling is attachProfiler — a frame-profiler overlay: a meter you leave on rather than a panel you open. Collapsed it is fps, tick cost and a frame-time sparkline against the budget line; expanded it adds frame/tick percentiles (p50/p95/p99/max — averages hide the spikes profilers exist to find), host-reported per-lane costs, the hottest systems, and a worst-frame capture: the full per-system breakdown of the worst frame since reset, frozen so "what made that frame slow" stays answerable after it scrolled by. A "frame" is one tick-to-tick interval — in the canonical one-tick-per-rAF loop that is the render frame, so attaching is one line with no loop rewiring.

Asserting a read-only window

Code that should only read the world — a render pass, a selector, a derivation — can be held to that with a matched pair. ReadonlyWorld is the compile-time half: a World with every mutating method removed, so a stray world.spawn(…) handed one fails to typecheck. world.devOnWrite(cb) is the runtime half: a dev-only hook that fires synchronously before every mutation — by any route, not just world.* — and whose throw propagates (unlike an observer callback, which is swallowed). Register it once and gate the throw behind your own armed flag, so it vetoes a write only during the window you mean to protect.

Note — dev-only

Both compile out where DEV is false — and since 0.5.1 the production artifact (the default export condition) ships with DEV constant-folded to false, so this holds in browser bundles too. Under the development condition registration stays live and each mutation pays one tiny roster null-check.

The flagship demo

The example app#

examples/canvas-editor is an infinite-canvas whiteboard in vanilla TypeScript and Canvas2D whose only runtime dependency is @vibecook/strata-ecs — and it runs live, right here (open two tabs of ?collab=demo for multiplayer between them). The ECS is the state manager; the frame is the subscription. It demonstrates, end to end:

  • 10k+ shapes with brute-force culling — no spatial index; the typed-array column sweep does what an R-tree usually would, sub-millisecond.
  • A HUD and the inspector panel — the framework's own @vibecook/strata-ecs/tools, mounted with one call, splitting ECS time from paint time honestly.
  • Reactive repaint + autosave from a single observeQuery — no hand-set dirty flags; watch the console stay silent at idle, then drag.
  • ?collab mode — the same editor, multiplayer, over a same-origin BroadcastChannel between tabs, or a real WebSocket relay across machines with ?ws.
  • Headless smoke suites?script=collab-smoke runs bootstrap, create/duplicate/delete, edit-vs-drag convergence, late-join, and presence as an acceptance suite in one page.

The rules, collected

The rules#

The normative one-liners from across this guide, in one place:

Mutation

Values flow immediately and are order-sensitive within a phase; structure lands at phase boundaries.

Frame

One settled reactive boundary per frame — notify() once, after all ticks, before render.

Reactivity

observeQuery may over-fire but never misses; observeValue is equality-suppressed. Zero cost until the first observer.

Document · visibility

Document value writes to existing components are visible immediately; every structural effect is visible after the next sync().

Document · conflict

Committer-wins, per component — split independently-edited fields into separate components so they never contend.

Presence

Only the owner writes its partition; read every other peer as Not(Local). peerId is session-unique.

Transport

Deliver per-peer messages causally in order, or exchange snapshots; the quarantine backstop is PendingImportError.

Further reading

Status

strata-ecs is pre-1.0: minor versions may still break APIs. The core, the layers, the devtools, and the example app are complete and green under the full test suite; the docs on this page describe what is built, not what is planned.