@vibecook/strata-ecs
v0.7.0
Published
An entity-component-system for TypeScript apps that edit documents — typed-array fast, reactive, with CRDT-backed multiplayer (Loro) as opt-in layers.
Maintainers
Readme
strata-ecs
A collaborative entity-component-system 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.
It is built for the editor workload, not games: tens of thousands of live objects, undo, autosave, live cursors, and a render loop that must never pay for features you haven't turned on.
- Typed-array fast — components live in struct-of-arrays columns grouped by
archetype; a system is a contiguous loop over a
Float32Array. Ties or beats bitecs on the canonical iteration benchmarks, leads on entity-lifecycle churn (BENCHMARKS.md). - Schema-first types — field types flow from one schema literal into both the
storage and the TypeScript types.
batch.col(Position).xis aFloat32Arrayat compile time; a typo in a field name is a type error. - Reactivity built in, free until used — subscribe to queries, values, or resources instead of scattering dirty flags. Dormant until the first observer; an idle check over 10,000 watches costs ~39 ns.
- CRDT-backed multiplayer as opt-in layers — a durable Document layer (permanently stored, merges without conflicts) and an ephemeral Presence layer (cursors and selections that reset on disconnect), both projecting into the same runtime your systems already read. Convergence comes from Loro, not from code you write — and the hot path never imports it. Undo and redo ship built in and multiplayer-correct: each peer undoes only its own changes.
- Transport-agnostic — the framework converges documents; your app moves bytes.
Any channel that carries a
Uint8Arrayworks: WebSocket,BroadcastChannel, WebRTC. - First-party devtools and React binding — a drop-in inspector panel and a frame-profiler
overlay (
@vibecook/strata-ecs/tools), plus twouseSyncExternalStorehooks (@vibecook/strata-ecs/react).
Documentation: the guide · API reference · live demo · benchmarks
Contents
- Install
- Quick start
- The five words
- When not to use it
- Architecture
- Reactivity
- Save & load
- Going multiplayer
- Devtools
- The example app
- Performance
- Package exports
- Development
- Status
- License
Install
npm install @vibecook/strata-ecsPre-1.0: the API may still move between minor versions — pin your version and read the changelog when bumping.
loro-crdt and react are optional peer dependencies — you install them only if
you use the collaboration layers or the React binding. The core has zero dependencies.
Quick start
Components hold typed data. Entities carry components. Systems run over queries. The world ticks:
import {
createWorld,
defineComponent,
defineQuery,
defineSystem,
phase,
} from "@vibecook/strata-ecs";
const Position = defineComponent("Position", { x: "f32", y: "f32" });
const Velocity = defineComponent("Velocity", { x: "f32", y: "f32" });
const Movement = defineSystem(defineQuery([Position, Velocity]), (batch) => {
const px = batch.col(Position).x; // Float32Array — typed from the schema, no cast
const py = batch.col(Position).y;
const vx = batch.col(Velocity).x;
const vy = batch.col(Velocity).y;
for (const r of batch) {
// matched rows; filters fused
px[r] += vx[r];
py[r] += vy[r];
}
});
const world = createWorld();
const e = world.spawn({
components: [
[Position, { x: 0, y: 0 }],
[Velocity, { x: 1, y: 2 }],
],
});
world.tick([phase("sim", [Movement])]);
world.read(e, Position); // { x: 1, y: 2 }Two system forms, named for their cardinality: a chunk system (above) pairs a query with a body that runs once per matching chunk — the fast SoA path for per-row transforms; every batch it sees has at least one matched row. A tick system runs exactly once per frame dispatch — the home for whole-frame effects like camera math or input drains, iterating real data inside when it needs to:
import { defineTickSystem } from "@vibecook/strata-ecs";
const CameraControl = defineTickSystem((ctx) => {
// runs once per tick — never multiplied by how many archetypes exist
ctx.query(wheelEvents).each((b) => {
/* integrate pan/zoom exactly once per frame */
});
});A real app runs one frame loop — and writes it the same way from day one, so the collaboration layers attach later with zero rewrite:
function frame() {
requestAnimationFrame(frame);
world.sync(); // drain inbound layers (no-op until a collab layer attaches)
world.tick(pipeline()); // run systems; shape changes flush at phase boundaries
world.reactive.notify(); // THE settled point — fire dirty observers, once per frame
paint(); // render reads the finished state
}
requestAnimationFrame(frame);The five words
| Term | One line | Guide |
| ------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| Entity | a stable handle naming one thing in the world — a shape, a node, an edge | Entities |
| Component | typed fields attached to an entity; entities with the same set share storage | Components |
| System | a function that runs over every entity matching a query, once per tick | Systems |
| Query | a compiled description of "entities with these components" — define once, reuse | Queries |
| World | the container that holds it all; world.tick() runs your systems over it | The frame loop |
Tags (zero-data markers), relations (indexed edges with cascade-on-despawn), and resources (world singletons) round out the data model.
When not to use it
strata-ecs is built for one workload. If yours is a different one, a different tool will serve you better:
- A traditional game. strata's documented weak spots are per-frame component add/remove churn and random access by handle (the archetype-migration and row-indirection trade-offs — see the losses in BENCHMARKS.md). A flat-bitmask ECS like bitecs fits a game loop's shape better, and game engines ship their own.
- A few dozen stateful objects. Below a few hundred entities the schema and system ceremony buys nothing — React state, Zustand, or plain objects are simpler and fast enough.
- Server-side business models. This is a client-side document runtime organized around a frame loop; CRUD models belong in a database, not a tick.
- "Everything persists automatically." Durability here is an explicit layer you attach and route state into — the runtime/document split is the point. If every last piece of state should persist with no distinction, a document store alone (Loro, Yjs, Automerge) is the simpler system.
Architecture
The name is the map: a stack of independent layers — strata — over one queryable runtime. The core ECS is a complete product on its own; everything above it is opt-in.
┌────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ REACTIVITY │ │ DOCUMENT │ │ PRESENCE │
│ built in │ │ durable │ │ ephemeral │ ← optional
│ watch queries │ │ stored · synced │ │ cursors · TTL │ layers
│ & values │ │ conflict-free │ │ self-expiring │
└───────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ observes │ projects ↕ │ projects ↕
│ ┌────────┴─────────────────────┴────────┐
│ │ RECONCILE SUBSTRATE (internal) │
│ │ converges concurrent edits — │
│ │ you never import this │
│ └────────┬──────────────────────────────┘
┌───────▼─────────────────────▼──────────────────────────────┐
│ CORE ECS (strata-ecs) │
│ entities · components · tags · relations · queries · │
│ systems · resources — typed-array archetype columns │
│ the hot path: a tick touches only this layer │
└────────────────────────────────────────────────────────────┘Layers you haven't attached cost nothing; layers you have do their work at the frame's
sync()/notify() boundaries, never inside your systems' loops. The CRDT is confined
to two adapter classes; the core never imports it.
Two rules carry most of the mental model:
- Mutation timing — values flow immediately; structure (spawn, destroy, add/remove component) lands at phase boundaries.
- One settled boundary per frame — call
world.reactive.notify()exactly once, after all ticks, before you render.
Reactivity
The world is the change detector. Three tiers, from coarse-and-cheap to precise:
// did anything matching this query move? (may over-fire, never misses)
// the watched columns default to the query's own components; pass { cols } to narrow or widen
world.reactive.observeQuery(renderable, () => {
repaint = true;
});
// this exact value changed (equal-value writes are suppressed)
world.reactive.observeValue(e, Position, (pos) => {
/* { x, y } | undefined */
});
// a world singleton changed
world.reactive.observeResource(Camera, () => {
repaint = true;
});React components subscribe with two hooks — re-rendering exactly once per real change:
import { useComponent, useResource } from "@vibecook/strata-ecs/react";
function ShapeInspector({ world, entity }: { world: World; entity: Entity }) {
const pos = useComponent(world, entity, Position);
const cam = useResource(world, Camera);
return <span>{pos ? `${pos.x}, ${pos.y}` : "—"}</span>;
}Save & load
The runtime serializes itself — no collaboration required:
const bytes = world.export(); // readable UTF-8 JSON
world.import(bytes, { replace: true }); // validates, then resets the SAME world in
// place — observers and subscriptions surviveGoing multiplayer
Everything above runs entirely local. When you want other people in the same document, two layers attach:
| | Document (@vibecook/strata-ecs/durable) | Presence (@vibecook/strata-ecs/ephemeral) |
| --------- | --------------------------------------------- | ----------------------------------------------- |
| Holds | the board itself — shapes, edges, styles | the people on it — cursors, selections |
| Lifetime | permanently stored, merges without conflicts | self-expiring; resets when a peer disconnects |
| Backed by | Loro's CRDT document | Loro's ephemeral store |
import { LoroDoc } from "loro-crdt";
import { createDurableStore, attachDurable } from "@vibecook/strata-ecs/durable";
const doc = createDurableStore(new LoroDoc()); // the ONE place the CRDT enters
attachDurable(world, doc); // project it into the runtime
doc.subscribeOutbound((bytes) => transport.send(bytes)); // outbound wire
transport.onMessage((bytes) => doc.applyRemote(bytes)); // inbound wire
// Change the document only inside a transaction — one commit, one undo unit.
doc.transaction((tx) => {
const shape = tx.spawn({
components: [
[Position, { x: 0, y: 0 }],
[Size, { w: 100, h: 60 }],
],
});
});Convergence is not code you write: concurrent edits merge automatically on every peer, offline peers catch up from increments, and no server has to arbitrate — a relay that moves bytes is enough. Conflict resolution is committer-wins, per component: a drag writes the runtime every frame and commits once at gesture end; the framework holds conflicting remote edits off while your gesture is in flight, then everyone converges.
Presence is the same idea for people: each peer owns a partition, writes it immediately,
and every other peer projects in as a live Not(Local) entity that self-expires on TTL.
Undo and redo are built in and local-only — doc.undo() / doc.redo(), one transaction
per step, doc.undoGroup(fn) to collapse a gesture, and a DurableUndoStatus resource to
drive toolbar enablement. See Undo & history.
See Going multiplayer in the guide for the full story — the visibility rules, entity keys vs. handles, and the transport bootstrap protocol.
Devtools
import { attachObserver } from "@vibecook/strata-ecs/tools";
const obs = attachObserver(world, { describe });A zero-dependency inspector panel: live entity list, per-system timings, a birth-to-death timeline — and, when collaboration is attached, a durable tab (baseline vs. converged document side by side; highlighted rows are the un-reconciled sync delta) and an ephemeral tab (every peer's live presence, grouped by writer).
import { attachProfiler } from "@vibecook/strata-ecs/tools";
const prof = attachProfiler(world); // fps + sparkline, one line, no loop rewiring
prof.lane("paint", paintMs); // optional: stack host costs next to the ECS shareIts sibling is a frame-profiler overlay — a meter you leave on rather than a panel you
open. Collapsed: fps, tick cost, and a frame-time sparkline against the budget line.
Expanded: frame/tick percentiles (p50/p95/p99/max — averages hide spikes), per-lane
costs, the hottest systems, and a worst-frame capture — the full per-system breakdown
of the worst frame since reset, so "what made that frame slow" stays answerable after
the fact. stats() returns the same snapshot programmatically for tests or telemetry.
The example app
examples/canvas-editor — an infinite-canvas whiteboard in
vanilla TypeScript + Canvas2D whose only runtime dependency is strata-ecs. Try it live:
the hosted demo — open two tabs at
?collab=demo for multiplayer. 10k+ shapes
with brute-force culling, reactive repaint and autosave from one observeQuery, and the
full collaboration stack:
pnpm install
pnpm example:canvas # → http://localhost:5173
# multiplayer between two tabs (no server):
# http://localhost:5173/?collab=demo
# across machines over a WebSocket relay:
pnpm collab:server # dumb byte relay — ws://localhost:8787
# http://localhost:5173/?collab=demo&wsPerformance
On the canonical ECS micro-benchmarks (Node 24), strata-ecs ties or beats bitecs — the flat-array specialist — on dense iteration, and leads on entity-lifecycle churn. A movement tick over 10,000 entities runs in ~0.2 ms with zero per-row allocation. The archetype trade-off is real and documented: adding or removing a component migrates the row between tables.
Full cross-library tables, methodology, and the losses included: BENCHMARKS.md.
Package exports
| Import | What | Requires |
| -------------------------------- | ------------------------------------ | ------------- |
| @vibecook/strata-ecs | the core ECS + reactivity | nothing |
| @vibecook/strata-ecs/durable | the Document (durable) layer | loro-crdt |
| @vibecook/strata-ecs/ephemeral | the Presence (ephemeral) layer | loro-crdt |
| @vibecook/strata-ecs/react | useComponent / useResource hooks | react >= 18 |
| @vibecook/strata-ecs/tools | the inspector panel + frame profiler | nothing |
ESM-only. loro-crdt and react are optional peer dependencies — never bundled, only
needed for the entry points that use them.
Every entry ships two builds behind export conditions: a development build with
dev-mode diagnostics (misuse warnings on the console) and a production build with those
branches — message strings included — compiled out entirely. App bundlers like Vite and
webpack pick the right one automatically (they apply the development condition in dev
mode and the default in production builds); no process.env shim or define is needed.
Tools that don't apply the condition (esbuild, plain Rollup, Bun) and plain Node resolve
the production build — opt into diagnostics there with --conditions=development.
Development
Requires Node 24+ (see .nvmrc) and pnpm.
pnpm install
pnpm typecheck # tsc, all sub-projects
pnpm test # vitest unit suites
pnpm test:stress # property/fuzz suites (longer)
pnpm bench # microbenchmarks
pnpm build # bundle to dist/ (tsup → ESM + .d.ts)
pnpm run ci # typecheck + lint + tests + stress smoke — the merge gate
pnpm example:canvas # the flagship example, live-reloading against src/Repository layout: src/core (the runtime), src/substrate
(the internal reconcile layer), src/durable + src/ephemeral
(the collaboration layers), src/tools + src/react (devtools,
React binding), examples/canvas-editor (the flagship demo),
docs/ (the docs site: the guide + the API reference).
Status
Pre-1.0. The core, the reactivity tier, both collaboration layers, the devtools,
and the example app are complete, benchmarked, and green under the full test suite —
this README describes what is built, not what is planned. Published to npm as
@vibecook/strata-ecs; minor
versions may still break APIs before 1.0.
