Guide
How mErCS works and how to use it. Every function is described in the API reference, which
is generated from the doc comments of src/: mErCS
(the module, the builtin ids and types), World,
Query and the
jabby adapter. The names follow
jecs; the comparison lists the
differences.
local ecs = require(ReplicatedStorage.mErCS)
local world = ecs.world()
Contents
- Worlds, entities and ids
- Components and tags
- Queries
- Batch operations
- Relationships
- Hooks and signals
- Change tracking
- Disabled entities
- Introspection and jabby
- Types
- Coming from jecs
Worlds, entities and ids
A world holds entities and the components, tags and pairs on them. ecs.world(true) makes a
debug world: changes of entities or ids that are not alive raise errors instead of being
ignored, which helps while code is written or migrated.
Entity ids are numbers: slot + generation × 2^24. A deleted slot is reused with the next
generation, so an old handle of it is not alive anymore — world:contains(e) tells.
world:delete(e) removes every id of the entity, applies the cleanup policies of e used as
an id or a target (see Relationships) and frees the slot; world:clear(e)
removes the ids and keeps the entity.
world:component() makes an id that can hold data (an entity with the Component trait);
the first 256 are the ids 1..256, then any entity id: there is no limit. Networked worlds can
split the id space with world:range(first, last).
Components and tags
local Position = world:component() :: ecs.Component<Vector3>
local Frozen = world:entity() -- any entity can be used as a tag
local e = world:entity()
world:set(e, Position, Vector3.zero) -- adds the component, or replaces its value
world:add(e, Frozen) -- adds an id without a value
print(world:get(e, Position), world:has(e, Position, Frozen))
world:remove(e, Frozen)
- Adding or removing an id sets a bit and a value: the entity never moves, and nothing is created per combination of components.
- Only ids with the
Componenttrait hold data; setting a value on a tag raises an error. getandhastake up to 8 ids;get_listandhas_alltake any number.world:each(id)iterates the entities that have an id, andworld:remove_all(id)removes it from all of them at once (about 20 ns per entity when the id has noOnRemovehook).
Queries
local q = world:query(Position, Velocity) -- returned values: Position, Velocity
:with(Alive) -- must also have
:without(Frozen) -- must not have
:any(Burning, Poisoned) -- must have at least one of these
for entity, position, velocity in q do end -- for-in
q:each(function(entity, position, velocity) end) -- faster: a plain callback
local ok = q:has(entity) -- the entity matches the query
- A query never goes stale: the ids in it may be created and deleted at any time.
world:query(...)inside a system is cheap. Queries of the same shape (returned ids,withandwithoutids) share one internal state, so a query written inline allocates only a small handle. While the result does not change and its matches are scattered, the shared state keeps a dense list of them (up to 65 536 entities) and iterates that.- Queries with change filters or on pairs with a concrete target get their own state: call
q:cached()on such a query when it is stored and iterated many times. An iteration of a stored query allocates nothing. - Changing the current entity inside the loop is allowed, deleting it too. Changes of other
entities may or may not be seen by the running loop; an entity deleted ahead of the loop
(for example by a
ChildOfcascade) may still be returned once, as a dead id with nil values: loops that delete other entities should checkworld:contains(e). - Loops over the same query may be nested, also when the inner loop changes the world. The iteration order is ascending by entity slot.
The fast path: spans and column
q:spans() yields runs of matching slots: when mask == 0xFFFFFFFF every slot of
first..last matches, otherwise first..last is one 32-slot word and the set bits of mask
are the matching slots (first + bit index). world:column(id) returns the value pages of a
component: page slot // ecs.PAGE_SIZE + 1, index slot % ecs.PAGE_SIZE + 1;
world:alive_table()[slot] is the entity of a slot.
local positions = world:column(Position)
for first, last, mask in q:spans() do
local page = positions[first // ecs.PAGE_SIZE + 1]
local offset = (first // ecs.PAGE_SIZE) * ecs.PAGE_SIZE - 1
if mask == 0xFFFFFFFF then
for slot = first, last do
local position = page[slot - offset]
end
else
repeat
local slot = first + bit32.countrz(mask)
mask = bit32.band(mask, mask - 1)
local position = page[slot - offset]
until mask == 0
end
end
A complete example with four columns is the fast4 helper in bench/impl_defs.luau.
Batch operations
One change to every entity that matches a query at the time of the call:
local n = world:query(Enemy):without(Dead):count()
world:query(Enemy):with(InExplosion):add_all(Stunned)
world:query(Enemy):with(Stunned):set_all(Velocity, ZERO) -- the same value for every match
world:query(Projectile):with(Expired):remove_all(Velocity)
world:query(Projectile):with(Expired):delete_all()
A component or tag without hooks is added, set or removed 32 entities at a time. Pairs and
ids with hooks (tracked ids included) go entity by entity, and every hook runs. Like an
iteration, a batch operation on a query with change filters consumes the changes it saw;
count does not.
Relationships
local Likes = world:entity()
world:add(alice, ecs.pair(Likes, bob))
world:add(child, ecs.pair(ecs.ChildOf, parent))
print(world:target(alice, Likes), world:parent(child))
for liked in world:targets(alice, Likes) do end
for entity in world:query(ecs.pair(Likes, ecs.Wildcard)) do end -- any target of Likes
- A pair holds data when the relation is a component, or, for a tag relation, when the
target is one.
world:target(e, R, index)counts targets from 0, ordered by entity slot. world:add(R, ecs.Exclusive): an entity has at most one target ofR; adding another replaces it.ChildOfis exclusive.- Cleanup policies decide what happens to the users of a deleted entity:
world:add(R, ecs.pair(ecs.OnDelete, ecs.Delete))deletes the entities that haveRwhenRis deleted, andworld:add(R, ecs.pair(ecs.OnDeleteTarget, ecs.Delete))deletes the sources of a deleted target (ChildOfdoes this). The default isRemove: they lose the id or the pair. - The record of a pair is freed when its last entity loses it or when its relation or target is deleted: nothing accumulates. Cascading deletes of any depth work (past 100 nested levels the rest is queued).
Hooks and signals
world:set(Health, ecs.OnAdd, function(entity, id, value) end)
world:set(Health, ecs.OnChange, function(entity, id, value) end)
world:set(Health, ecs.OnRemove, function(entity, id, deleting) end)
local disconnect = world:added(Health, function(entity, id, value) end) -- also changed / removed
disconnect()
- Hooks may be set and removed at any time; hooks set on a relation apply to all its pairs.
- A hook and the signal listeners of an id (change tracking included) all run, the hook first, whatever the order they were set in.
- A listener may connect or disconnect listeners, itself included, while it runs: the change applies from the next event.
OnRemoveruns before anything is removed, so the other values of the entity can still be read;deletingis true when the entity itself is being deleted. When a hook removes another id of the entity, theOnRemoveof that id runs once, from the removal.
Change tracking
Records additions, value changes and removals of an id per tick:
world:track(Health)
-- a system: sees what changed since its previous run (a new query sees the previous tick)
local damaged = world:query(Health):changed(Health):cached()
for entity, health in damaged do end
world:tick() -- once per frame: this frame's changes become visible
:added(id),:changed(id)and:removed(id)filter a query by what happened to a tracked id: added, its value set again (world:setof an existing value), or removed from an entity that stays alive.- Each query keeps the first tick it has not seen yet: a system that runs less often sees the changes of every tick since its previous run, up to the last 8 ticks.
- A deleted entity is gone from every filter, and a new entity that reuses its slot starts clean.
- Untracked ids cost nothing. A tracked id costs a call per add, change or remove; deleting an entity costs extra only when it changed during the last 8 ticks.
Disabled entities
world:add(e, ecs.Disabled) hides e from every query that does not name Disabled itself
(:with(ecs.Disabled) lists the disabled ones). The components stay in place;
world:remove(e, ecs.Disabled) shows the entity again.
Introspection and jabby
world:ids(e) lists every id of an entity (components, tags and pairs), sorted. ecs.Name
names entities; the builtins are named ("mercs.ChildOf").
jabby, a debugger written against jecs internals,
connects through the adapter mErCS.jabby (src/jabby.luau, a child module that the library
itself never requires):
-
Replace the source of the
jecsmodule of the jabby package (with Wally:Packages/_Index/alicesaidhi_jabby@<version>/jecs) withlocal mErCS = game:GetService("ReplicatedStorage").mErCS -- the ModuleScriptreturn require(mErCS.jabby)(require(mErCS)) -
Attach every world before registering it:
require(mErCS.jabby)(ecs).attach(world)jabby.register({ applet = jabby.applets.world, name = "World", configuration = { world = world } })
With Wally, Packages.mercs is only a link module without children: take the ModuleScript
of the library (the one with the jabby child) from the package folder in Packages._Index.
Types
world:component() returns Component<unknown>; a cast gives it the data type, and then
get, set and queries are typed:
local Position = world:component() :: ecs.Component<Vector3>
local Health = world:component() :: ecs.Component<number>
local position: Vector3? = world:get(e, Position)
for entity, position, health in world:query(Position, Health) do end -- Vector3, number
Exported types: ecs.Id<T>, ecs.Entity<T>, ecs.Component<T>, ecs.Pair<R, T> (the
data of the relation, or of the target for a tag relation), ecs.Query<T...>, ecs.World.
test/types.luau shows the typed API as a whole.
Coming from jecs
The library has no functions that exist only for jecs code: the jecs helpers and the calls that would do nothing here are not provided, and Migrating from jecs lists what to write instead. What jabby reads from jecs internals lives in the jabby adapter.
query:cached() does nothing on a shared query (without a concrete pair and without change
filters): world:query(...) returns a state that is cached already. Hooks do not receive the
oldarchetype argument of jecs (there are no archetypes).