ECS overview

How Meep's Entity Component System differs from Unity, Bevy, and the rest - and why.

Meep is a Pure ECS engine. The phrase carries weight, so this page unpacks what it means and how it shapes everything else in the engine.

The three pieces

Entities are identifiers. An entity is a 32-bit integer plus a generation counter - nothing more. No methods, no state of its own.

Components are plain data. A Transform64 is 23 doubles - a matrix, a translation, a rotation and a scale. A Velocity is { x, y, z }. Components don’t contain logic.

Systems are functions that iterate over entities matching a component shape. A MovementSystem runs over every entity with Transform64 + Velocity and advances position by velocity each frame.

”Pure” ECS

Many engines call themselves ECS but smuggle in OOP under the hood. Unity’s GameObject + MonoBehaviour is the canonical example - components are objects with methods, lifecycle hooks, and references to other components. The result is faster to prototype but slower to query and harder to optimize.

Meep is strict: one component instance per type per entity, no methods on components, no lifecycle hooks beyond init/destroy. Every system iterates over flat typed arrays whenever possible. Memory layout is predictable. Component queries are O(1) cached.

The trade is real - you write a little more code to wire systems together. In exchange, you get architectural clarity and performance that holds at scale.

The hierarchy problem

Pure ECS engines have historically struggled with scene graphs. Parent-child transforms imply a tree, and trees don’t fit naturally into flat component arrays.

Meep solves this with a dedicated hierarchy system that maintains parent-child links separately from the component arrays. Transforms still iterate as flat arrays for speed; hierarchy queries walk the tree only when needed. You get composable scene graphs without paying for them in the hot path. (Bevy uses a similar approach.) See Hierarchy & attachment for the two components that express it.

Tiered access

Meep gives you two levels of API for the same data:

  • High level. transform.setTranslation(x, y, z), entity.add(component). Comfortable, slightly slower.
  • Low level. Raw typed array views, manual index math. For tight inner loops where you measured a bottleneck.

You drop down only when you need to. The high-level API is usually fast enough. The transform below is that split made concrete: one type, reachable both ways.

Transform64, the pose component

Transform64 is the pose: the ECS component you add to an entity, and the same type Shade, meep’s renderer, reads an entity’s row from - there is no second transform anywhere. Every system that places something in the world - meshes, lights, decals, cameras, colliders, audio emitters - declares it in its dependency tuple.

It extends Float64Array: one 23-element buffer holding a matrix and the three components, no sub-objects, no signals, no flags. Double precision throughout, one allocation per pose, and plain array stores instead of setter dispatch.

import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";

const t = new Transform64();

t.setTranslation(10, 0, 20);
t.setScale(2, 2, 2);
t.updateMatrix();          // nothing touched the matrix before this line

The layout is part of the contract, so hot code can index the buffer directly instead of going through the accessors:

ElementsContents
0..15matrix, column-major
12..14translation x, y, z - aliased into the matrix
16..19rotation x, y, z, w
20..22scale x, y, z

The translation is not stored twice: it is the matrix’s translation column, which gives the staleness rule its exact shape. A translation-only change lands in the matrix as it is written; a rotation or a scale does not, and the matrix is whatever updateMatrix() last composed until you call it again. The flip side is a matrix that can describe a pose that never existed - write a translation and a rotation without an updateMatrix() between them and it carries the new position under the old orientation.

Because the matrix starts at element 0, a Transform64 can be handed straight to any mat4 helper, and translation_x … scale_z read a single component without allocating. The translation / rotation / scale getters return live views over the same buffer, but a freshly allocated view on every access - hold one rather than reading the property in a loop.

Those views are bare Float64Arrays, not a Vector3 or a Quaternion, so they carry no maths of their own: transform.rotation.fromAxisAngle(...) throws rather than turning anything. Compose the value first and hand it over, or use the helper that does both - t64_set_rotation_axis_angle(t, ax, ay, az, angle) (engine/ecs/transform/t64_set_rotation_axis_angle.js), the Transform64 counterpart of Quaternion.fromAxisAngle. Its axis must be unit length: a longer one does not fail, it quietly turns further.

Say when you have written one

A Transform64 has no signals, so a write is invisible until the writer announces it. Everything that reacts to motion - the mesh and light placement, the parent/child sync, the terrain cling, the grid mirror - wakes on one entity event:

import { t64_announce_change } from "@woosh/meep-engine/src/engine/ecs/transform/t64_announce_change.js";

transform.setRotation(x, y, z, w);
transform.updateMatrix();

t64_announce_change(ecd, entity);   // once, after the last write of the group

The announcement names the transform, not what changed inside it, so a consumer that only tracks translation still wakes on a scale write - redundant work, not wrong work. Engine systems that write a pose announce it themselves: PhysicsSystem does it once per awake body per step, TransformAttachmentSystem for every child it composes.

Transform is a second pose type in the package - a class holding a live Vector3 position, a Quaternion rotation and a Vector3 scale, each with its own change signal, over an f32 matrix it recomposes for you. The serialization registry reads it, so a save written against it still loads; no system depends on it. Write Transform64, and use t64_copy_from_transform / transform_copy_from_t64 where the two shapes meet.

Why this matters on the web

JavaScript’s garbage collector punishes per-frame allocation patterns. Every new Vector3() in your update loop is a potential GC pause; on low-spec devices, those pauses turn into frame drops you can’t paper over.

Meep’s zero-allocation discipline isn’t a marketing line - it’s a technical requirement. Components live in pre-allocated pools. Math operations write into out-parameters instead of returning new objects. Particle systems re-use buffers. The engine can run a million entities on a mid-range phone with stable frame timing because nothing is being allocated and garbage-collected each tick.

Where to go next

  • Physics overview - the rigid-body engine that ships in the box, built on the ECS.
  • AI & simulation tools - behavior trees, MCTS, and the decision-making toolkit.
  • FAQ - common questions.
  • The source itself: src/engine/ecs/ is well-organized. Start with EntityManager.js.