npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

Readme

strata-ecs

A collaborative entity-component-system for editors and infinite-canvas apps.

TypeScript license status collab ci npm

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).x is a Float32Array at 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 Uint8Array works: WebSocket, BroadcastChannel, WebRTC.
  • First-party devtools and React binding — a drop-in inspector panel and a frame-profiler overlay (@vibecook/strata-ecs/tools), plus two useSyncExternalStore hooks (@vibecook/strata-ecs/react).

Documentation: the guide · API reference · live demo · benchmarks


Contents

Install

npm install @vibecook/strata-ecs

Pre-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 survive

Going 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 share

Its 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&ws

Performance

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.

License

MIT