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.
// 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.
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 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).
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 many — ChildOf, 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
| Type | Value in TS | Backing column |
f32 f64 | number | Float32Array / Float64Array |
i8…i32, u8…u32 | number | the matching integer typed array |
bool | boolean | Uint8Array (0 / 1) |
string | string | null | per-cell (string | null)[] |
eid | Entity | Uint32Array (a packed handle) |
key | EntityKey | null | string column — a stable cross-session reference (see the document layer) |
enumOf([…]) | the label union | smallest 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.
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.
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.
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:
| Tier | Lives in | Lifetime | Who writes | Example |
| runtime-only | archetype columns | this session | anyone, immediately | Selected, camera, drag drafts |
| document | synced doc → projected | forever, converges | the committer, at boundaries | Position, Size, Fill |
| presence | presence store → projected | TTL, self-expiring | the owner, its partition only | CursorPos, 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.
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).
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 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.
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:
MutationValues flow immediately and are order-sensitive within a phase; structure lands at phase boundaries.
FrameOne settled reactive boundary per frame — notify() once, after all ticks, before render.
ReactivityobserveQuery may over-fire but never misses; observeValue is equality-suppressed. Zero cost until the first observer.
Document · visibilityDocument value writes to existing components are visible immediately; every structural effect is visible after the next sync().
Document · conflictCommitter-wins, per component — split independently-edited fields into separate components so they never contend.
PresenceOnly the owner writes its partition; read every other peer as Not(Local). peerId is session-unique.
TransportDeliver 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.