Build simulations out of plain data, and let ecsia run them across threads for you.
πΉ Play ECHO SURVIVORS β the live demo Β· Getting Started Β· Core Concepts Β· Multithreading Β· Performance
ecsia is an entity component system (ECS) for TypeScript. If you haven't met the pattern before, it's a way of organizing a simulation β a game, a physics sandbox, an agent model β around three simple ideas:
- An entity is just an id. A thing in your world, with no data of its own.
- A component is a typed piece of data you attach to an entity β a position, a velocity, a health value.
- A system is a function that runs every frame over all entities that have a particular set of components.
A "bird" isn't a class β it's whatever entity happens to have a position and a velocity, and movement is a system that adds one to the other sixty times a second. Want a bird with health? Attach a health component. Composition replaces class hierarchies.
The payoff is speed and safety at the same time. Under the hood, each component field
lives in its own contiguous typed array, so looping over 50,000 entities walks
straight through memory the way CPUs like. On top of that sits a fully typed API β
e.position.x is a number, not a cast.
import {
createWorld, defineComponent, defineSystem, createScheduler, read, write,
} from '@ecsia/kit'
const Position = defineComponent({ x: 'f32', y: 'f32' }, { name: 'position' })
const Velocity = defineComponent({ dx: 'f32', dy: 'f32' }, { name: 'velocity' })
const world = createWorld({ components: [Position, Velocity], maxEntities: 1 << 16 })
const e = world.spawnWith(Position, Velocity)
world.entity(e).write(Velocity).dx = 5
const dt = 1 / 60
const Movement = defineSystem({
name: 'Movement',
read: [Velocity], // this system only reads velocitiesβ¦
write: [Position], // β¦and only writes positions
run({ query }) {
for (const e of query(read(Velocity), write(Position))) {
e.position.x += e.velocity.dx * dt
e.position.y += e.velocity.dy * dt
}
},
})
const scheduler = createScheduler(world, [Movement])
scheduler.update(dt) // run one frameThose read and write declarations aren't just documentation. From them, the
scheduler works out which systems can never interfere with each other β and runs
those at the same time on worker threads. Going parallel is one flag, with no changes
to any system, query, or accessor code:
import { createWorld } from '@ecsia/kit'
const world = createWorld({ components: [/* ... */], threaded: true })The pool runs on Node worker_threads and β behind cross-origin isolation β on browser Web
Workers, where the scheduler waits on the wave fence with Atomics.waitAsync so the page's
main thread never blocks. Both paths are CI-tested byte-identical to the single-threaded run
(the browser path in a real Chromium tab).
- Threads never change your results. A threaded run produces exactly the same entities, the same component values, and the same change events as running everything on one thread β byte for byte. This isn't a promise, it's a tested property: the test suite runs the same simulations both ways, against a real worker pool, and checks the outputs are identical.
- Stale references throw.
world.entity()hands out a reusable reference object rather than allocating a new one each call. If you hold onto one after it has been re-pointed at another entity, you get an error β never another entity's data. - No silent slowdowns. Threading needs shared memory (
SharedArrayBuffer; in browsers that requires cross-origin isolation, a server-side opt-in). Where it's unavailable, ecsia logs a warning and runs on one thread. Work is never silently dropped.
A retro-CRT, time-loop survivors game built on ecsia β and a live proof of the guarantees above. Survive a 90-second loop against a horde of tens of thousands of entities; when you die, time rewinds and your past self fights beside you, replaying your exact inputs. Eight lives, one timeline.
Everything in it is the real engine, not a staged capture:
- The horde's steering runs on ecsia's browser Web-Worker pool over
SharedArrayBuffer(a service worker supplies the cross-origin-isolation headers GitHub Pages can't). Toggle threading live β the state hash in the corner doesn't change, because parallel equals serial. - The echo mechanic is deterministic replay: a ghost is nothing but a recorded input stream re-fed into the same simulation.
- A finished run compresses into its URL (seed + inputs). Opening the link re-simulates the whole thing and checks the recorded state hash β byte-identical, on any machine.
The source is in demo/ β game logic as ordinary ecsia components and systems.
Rendering, physics, input, audio, networking β ecsia is the data and scheduling layer
underneath a simulation, not an engine. @ecsia/three keeps
three.js objects in sync with your components, and
@ecsia/serialization turns world state into payloads you
could save or send over a network β but drawing frames and shipping packets is your
code.
Two constraints worth knowing up front: every component's fields are declared ahead of time (both the memory layout and the type inference depend on it), and the whole thing is ESM-only.
0.x and API-frozen β feature-complete but pre-1.0; young, expect rough edges. Runs on Node 22+, Bun, Deno, and modern browsers.
pnpm add @ecsia/kitThe umbrella package is the intended entry point. The layers underneath
(@ecsia/core, @ecsia/scheduler, β¦) publish separately for anyone who wants to
compose them by hand.
- Getting Started β install, first world, first system
- Core Concepts β entities, components, queries, and how ecsia stores them
- Multithreading β how
threaded: trueworks, what runs where, and why results stay identical - Linking entities β parent/child hierarchies and other entity-to-entity links
- Reacting to changes β run code when components are added, removed, or modified
- Saving and syncing β snapshots, change payloads, worker handoff
- three.js bridge β keeping three.js objects in sync with your data
- Devtools β inspect a world and see why the scheduler made its choices
- Performance β measured benchmarks, methodology, reproduce instructions
The full site β these guides plus a generated API reference β lives at
andymai.github.io/ecsia and redeploys on every
push to main. The sources are in website/ (pnpm docs:dev for a local
preview).
| Package | Role |
|---|---|
@ecsia/kit |
the batteries-included umbrella β start here |
@ecsia/core |
component storage, typed accessors, queries, change tracking |
@ecsia/schema |
component field types and query type inference |
@ecsia/scheduler |
works out which systems can run together, and runs them across threads |
@ecsia/relations |
entity-to-entity links with fast queries and automatic cleanup |
@ecsia/serialization |
snapshots, change payloads, worker bootstrap |
@ecsia/three |
three.js bindings (opt-in, not in the umbrella) |
@ecsia/devtools |
world inspector and schedule explainer (opt-in) |
@ecsia/core is a complete single-threaded ECS on its own. Scheduling, relations,
and serialization plug into it without core knowing about them; nothing imports
upward. All packages are sideEffects: false, so bundlers drop whatever you don't
use.
Real measured numbers, regenerated by pnpm bench:report (one machine, one moment β
treat the shapes as durable, the milliseconds as a snapshot; AMD Ryzen 9 7950X3D,
Node v24.11.0).
Single-thread iteration β the classic ECS workload of adding each entity's velocity to its position, over 50,000 entities, against bitECS 0.4.0 and miniplex 2.0.0. Lower is faster (nanoseconds per entity):
| loop | ns per entity |
|---|---|
ecsia bindColumns |
0.97 |
| bitECS | 1.35 |
ecsia eachChunk |
1.47 |
ecsia .each |
10.14 |
| miniplex | 12.15 |
.each is the ergonomic accessor path from the example above; eachChunk loops over
the raw storage arrays directly; bindColumns goes one step further and compiles a
specialized loop per archetype β which is what lets it beat bitECS, and it holds that
edge as the world grows (no pre-sizing required; it falls back to a plain loop where a
strict CSP or sandbox forbids dynamic compilation).
You don't have to choose between the readable .each body and that speed:
query.compile
takes the same e.position.x += β¦ callback, rewrites it into the bindColumns-shape loop, and lands
near eachChunk β roughly 6Γ faster than the plain .each it's written like β while still feeding
.changed()/observers. It's a pure speedup that falls back to the normal loop for anything it can't
compile.
Worker-thread speedup on a compute-heavy simulation (8,192 entities, 512 physics steps per frame, 60 frames), with every threaded run byte-identical to the single-threaded result:
| workers | speedup |
|---|---|
| 1 | 0.99x |
| 2 | 1.90x |
| 4 | 3.62x |
| 8 | 6.38x |
Methodology and full tables on the performance page.
pnpm install
pnpm build # tsc -b across all packages
pnpm test # vitest: unit + property + worker + type-level
pnpm typecheck:extras # type-check examples/ and bench/
pnpm bench:macro # cross-library macro-benchmarksRunnable examples in examples/: a flock of birds, a parent/child
scene hierarchy, a worker-parallel simulation, and a damage-over-time effect with
automatic cleanup.