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
  • 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.

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.

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.

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.

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.

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.