Expand description
English | 简体中文
§unlit_ecs
A compact archetype ECS. Components live in archetypes, one column per type, and
every component value lives in its own UnsafeCell — which is what lets a
shared &World read and write components. The aliasing those cells would
otherwise allow is caught by a borrow ledger: an access path claims the
components it will touch before it hands out any reference, and the panic on a
conflict names both holders.
The design is deliberately small: no change detection, no events or observers,
and no scheduler. Everything that would need
one of those is done by the caller on the entities themselves. The crate has no
dependencies beyond a hasher and depends on nothing else in the workspace, so it
can be used on its own; it knows nothing about rendering. The one exception is
the optional reflect feature, which derives facet for
the world information structures so a bridge can serialise
them as they are, and exports the EntityProxy an entity field reflects
through. A reflected field whose type is an entity names that proxy, so the ECS’s
own structures and any caller’s component spell an entity the same way — as its
Entity::to_bits integer. Nothing in the ECS reads the reflection.
It is at an early stage of development and its API changes freely.
§Model
- An entity’s components are fixed when it is spawned. Components are only read and written, never added or removed; to change the set, despawn the entity and spawn a new one. Immutable archetypes are what make the storage predictable and remove the leak-prone “component removed” bookkeeping other ECS designs need.
- Reading and writing need only a scope.
&Worldcan open the root scope, which sees everything; aScopeof a scene sees that scene’s subtree and nothing beside it. Structural changes — spawning and despawning — need a&mutScopeMut, or aCommandsqueue applied by the driver. - The world is
!Send. Components live inUnsafeCells so that a shared&Worldcan reach them, so the world stays on the thread that created it — this is where a renderer and other thread-bound state belong. Parallelism is the caller’s to arrange, by splitting work over plain data rather than by sharing the world. - There are no resources. Nothing in the crate marks or tracks a “resource”; an entity a caller wants to reach from anywhere is an ordinary entity whose handle the caller keeps, optionally marked with a marker component of the caller’s own.
- Every
'statictype is a component. No derive, no registration: a tuple is a component too. A bundle is therefore not a tuple of components but the listbundle!builds or aDynamicBundlegathers, and a plain(a, b)handed to a spawn is one component, the pair. The macro walks the syntax while expanding, sobundle![a, b]is two components and a parenthesised item is one component, tuples included. - There are no systems. Driving behaviour means the caller reading the world and calling the method or closure it wants — or running a behaviour component, a component that holds a closure and is invoked by whatever driver the caller writes. A behaviour that needs to wait returns a future and the caller decides when to poll it; no executor is built in.
- The one relation is the scope. Every entity lives in a scope, which is
another entity, and that relation is what makes a scene a subtree: the scene’s
scope sees it and its descendants, and nobody else does — a sibling’s subtree
is out of reach in both directions, so two scenes side by side share nothing.
Any scope also reaches the
World::sharedsubtree, which is how a resource every scene draws through stays put while the scenes are replaced. It is the only relation the world maintains, because visibility has to be decided on every access and a caller-maintained relation could not be. Any other reference between entities is still anEntityin a component, kept up to date by the caller. - A borrow conflict is a panic, not a compile error. A query claims the
component types it will reach before it hands out any reference, and the claim
lives as long as the holder does. Two holders clash when they name the same
component with at least one of them writing and their rows can overlap; the
panic names the component, both holders and both source locations, so it says
who is holding rather than only who was refused. That is the trade for not
doing access analysis. An optional component is optional only about the
component being absent: a row already claimed exclusively panics the same way
instead of reading as
None. - A failed access is an error, not a
None. Every function that reaches an entity answersResult, and theEntityAccessErrorit fails with names the handle that was asked about and the reason: the entity has gone, it is outside the scope, it does not match the query, it was not reserved, or it is not the caller’s to move. An archetype named by id answersArchetypeAccessErrorinstead.Optionstays where absence is not a failed access — a query itemOption<&C>is absent for an entity that merely lacksC. - Two entities are scaffolding.
World::newcreatesWorld::root— its own scope, and the one the root scope opens — andWorld::shared, whose subtree every scope sees. Both can carry components and be opened as scopes; neither is counted, queried, despawned or relinked. They keep the first two rows of their archetype, so a query steps over them once per archetype instead of testing the identity of every row.
§Scopes
A scope is an entity. Every entity lives in one, and that one relation is the
whole tree: a scene is a subtree, and what a caller holds is Scope, an
accessor narrowed to one entity’s place in it.
use unlit_ecs::{Entity, World, bundle};
let mut world = World::new();
let scene = world.root_scope_mut().spawn(bundle![]);
let cube = world.scope_mut(scene).unwrap().spawn(bundle![0u32]);
let camera = world.scope_mut(scene).unwrap().spawn(bundle![0f32]);
// The scene's scope sees the scene and everything below it, and nothing beside.
let scope = world.scope(scene).unwrap();
assert_eq!(scope.entity(), scene);
assert_eq!(scope.len(), 3);
assert!(scope.contains(cube) && scope.contains(camera));
assert_eq!(scope.children(scene).unwrap().len(), 2);
let mut seen = scope.query::<(&u32, Entity)>();
assert!(seen.iter().any(|(_, entity)| entity == cube));
// Root is a scope too, and it sees every scope.
assert_eq!(world.root_scope().len(), 3);Two entities are scaffolding rather than content. World::root is the scope
the root scope opens, and lives in itself; World::shared is its child, and
every scope reaches that subtree. Both can carry components and be
opened as scopes, so a resource belongs on either one — but neither is counted,
queried, despawned or relinked, which is why an empty world counts zero rather
than two.
What a scope sees. A scope of caller sees candidate when candidate
is alive and one of these holds:
| The candidate is | Seen by |
|---|---|
| the shared subtree | every scope |
| anything | the root scope |
| the scope’s own entity | that scope |
| below the scope’s entity | that scope |
Everything else is out of reach: a scope does not see its ancestors, and it does not see its siblings or their subtrees. The relation is deliberately asymmetric — a scene’s scope sees the shared resources, and a shared resource’s scope does not reach back into the scene — so “A sees B” never implies “B sees A”. A handle that is not alive is visible from nowhere, the root included: seeing a place is not the same as there being something in it.
What a scope may change. Ownership is stricter than visibility, and it covers only structural changes: a scope may despawn or relink an entity when the entity is its own or below it, or when the scope is the root. The shared subtree is readable from everywhere, but only the root may move or remove what is in it; the shared entity itself is the root’s child and belongs to no scope, so no scope despawns or relinks it, the root included. Opening a nested scope is a visibility test again, so a scene scope can open the shared one and spawn a resource there; despawning that resource afterwards is the root’s call, not the scene’s.
There is no way up. World::scope fails for an entity that is not a
scope, and Scope::scope only descends — into a visible child or into
the shared subtree. A scope cannot name the world it came from, so holding one
is not a way to reach the rest of it.
Changing the tree. ScopeMut::spawn puts the new entity in the scope
itself, and spawn_at puts it on a handle reserved by
World::reserve_entity — a handle that was released, already spawned on,
or never reserved is answered with EntityAccessError::NotReserved.
ScopeMut::despawn removes the entity and its
whole subtree, children first, so a child’s OnDespawn still sees its living
ancestors, and answers how many entities it took with it. ScopeMut::link
and unlink re-parent an entity inside the
same scope, refusing to move a scope into its own descendant or to touch the
scaffolding; they are pure structure changes and run no lifecycle callback.
Each of the three answers Result, and the error it fails with says which
of the reasons it was: the entity has gone, it is outside the scope, it is
shared so only the root may move it, it is scaffolding, or the move would cut
the tree in two. A caller that holds a handle from somewhere else can ask the
scope that owns it instead of guessing which of those it was. A
read-only scope queues the same changes through Scope::queue, and the
driver applies them with World::apply, which reports the first command that
failed and still runs the ones behind it.
World::children and Scope::children answer with an error for a handle
that is not live, rather than an empty list that would read the same as a
childless entity.
Where callbacks land. A lifecycle callback receives the scope of the
entity’s parent, so an OnSpawn on a scene’s child sees the scene. It cannot
change the tree either — it queues that, the same way a read-only scope does.
The callback is moved out of the row before it runs and put back after, so a
callback that reaches for its own lifecycle component reads the row instead of
tripping the borrow check; a callback that panics stays on the row it was
spawned with.
Cost. Whether a query row is visible costs one array read: an archetype
records the scope of every row beside its entities, so filtering no longer walks
the tree. Asking whether two entities are related still walks the parent chain,
bounded by the number of nodes, so a malformed tree cannot hang it. The relation
is kept in one place — the world’s tree — rather than in a component: visibility
is answered on every access, and a component would have to be borrowed to answer
it, so holding one entity’s component exclusively would make the visibility
check of another entity panic. One copy also means nothing to keep in agreement:
only ScopeMut::link and ScopeMut::unlink write the tree, and they need
&mut World,
so a borrow taken through a shared world can never race them. A read from a Scope pays one lookup in the tree per entity it asks
about, which is what Scope::entity_info reports as scope. Queries and counts skip the rows a scope
cannot see; Scope::archetype fails for an empty archetype and for one the
scope only partly sees, Scope::archetypes yields only the non-empty
archetypes the scope sees in full, and Scope::archetype_infos reports the
visible length of each. Scope::archetype_of is the per-row counterpart of
Scope::archetype: it asks about the one row an entity occupies and skips
the whole-archetype test, so it answers where Scope::archetype cannot —
two scopes sharing an archetype are the case that needs it.
Naming a component without the world. An Archetype carries the names of
its own components, so a reader holding one can describe it without going back
to World::type_name. A world keeps one copy of each name, not one per
entity: World::archetypes hands out the pool itself for a caller that
wants to walk it.
§Names
An entity carries a name, and {:?} prints it beside the bits that identify
the handle — Entity { name: "cube", bits: 3 } — so a panic or a log line
names the object it is about instead of a bare number. The name comes from the
bundle that spawned it, and a bundle names itself: a type of the caller’s own
answers with its own type name, while a bundle! answers with the source
position of the macro. Spawning an object therefore names the entity after the
object:
use unlit_ecs::{World, bundle};
let mut world = World::new();
let cube = world.root_scope_mut().spawn(bundle![0u32]);
assert!(!cube.name().is_empty(), "a bundle names the entity it builds");A dynamic bundle assembled at runtime has no such position to speak of, so
DynamicBundle::named gives it a label of its own, and spawn_named gives
one straight to the spawn. A handle from World::reserve_entity is named where
it is reserved, because the reservation is where the caller says what the entity
is.
The name is only a label. It takes no part in comparison, ordering or hashing,
so two handles to one entity are equal however they were named, and a handle
that went through Entity::to_bits comes back with no name at all. Names are
also per handle rather than per entity: the same entity seen from two places can
print two different labels, and a handle carried to another world keeps the name
of the world it came from.
A handle rebuilt from bare bits has nothing to print, so the row that stores the
entity is the one lasting copy of the label: Scope::entity_name reads it back
by handle. {:?} prints a handle as its name and its bits, and not the index
and generation they are made of: those are the allocator’s business, while the
bits are the only part a caller holds when the handle came over a wire. The
errors and the engine’s own messages print it that way. EntityInfo::name
carries the same label in a listing, where the handle itself travels as a bare
integer.
| The entity was spawned by | Its name |
|---|---|
a type implementing Bundle | the type’s own name, unlit3d_examples::scenes::spin_cube::TexturedCube |
bundle! | the source position of the macro, crates/unlit3d/src/source.rs:514 |
DynamicBundle::named | the label given there |
spawn_named | the label given there |
World::reserve_entity | the label given at the reservation |
| none of the above, a dynamic bundle by hand | empty |
Bundle::name is the one place that answers this, so a bundle with a naming
rule of its own overrides it.
§What is in the box
World, Entity, Location, Bundle / DynamicBundle (and the
bundle! macro that writes one), and the
Scope / ScopeMut that narrow the world to one scope,
OnSpawn / OnDespawn for the callbacks a spawn and a despawn run,
Query (the type parameter naming what a row yields, with Entity and
Has<C> as items), the query! macro that declares one from the shape of a
row, QueryBorrow / QueryIter / QueryOne (the holder
a query hands out and the rows and single row it yields, QueryBorrow::by as
the archetype predicate),
Command / Commands for queued structural changes, Archetype /
Archetypes for direct storage inspection (an archetype names its own
components), World::type_name for naming a component type a type-erased
reader only knows by TypeId, WorldInfo /
ArchetypeInfo / EntityInfo
for reporting a world’s shape as plain data (with EntityProxy and
EntityVecProxy as the reflection an entity field names), and the specialized
hash containers
TypeIdHashMap, EntityHashMap and friends.
A query is a type parameter. Scope::query and Scope::query_one name what
to fetch, and Q is &C, &mut C, Option<&C>, Option<&mut C>, Entity,
Has<C>, or a tuple of those, in that order. Each archetype resolves its
columns once, so a yielded row costs no lookup.
Scope::query does not yield rows itself: it returns a QueryBorrow that
claims the components the query names for as long as it lives, and rows come out
of QueryBorrow::iter or from QueryBorrow::by narrowing the archetypes it
visits with a closure over Archetype. Items borrow from the holder rather
than from the world, so they are gone before the claim is released: reading two
rows at once is allowed, and asking a second holder for a component the first one
holds — or writing the same component twice — panics. Has<C> reads presence
without claiming the column. Scope::location and Scope::archetype expose
the same shape to a caller that holds many entities — for instance one that
culled a scene into a list — so it can group them by archetype and resolve each
archetype’s columns once for all of them instead of re-looking up every entity.
A Commands queue exists because structural changes need &mut World: a
callback that only holds a shared world queues a spawn or despawn instead, and
the driver applies it with World::apply. Commands::spawn reserves an
Entity immediately, so the handle is usable — storable in a component,
passable to another callback — before the queue is applied.
Iteration is deterministic: archetypes are visited in creation order, rows in storage order. Archetypes are created on demand and never removed.
§Usage
use unlit_ecs::{Entity, Has, World, bundle};
struct Spin {
radians_per_second: f32,
angle: f32,
}
let mut world = World::new();
let cube = world.root_scope_mut().spawn(bundle![Spin { radians_per_second: 1.0, angle: 0.0 }]);
let clock = world.root_scope_mut().spawn(bundle![0.016f32]);
// Drive every `Spin`. The caller picks which entities to touch and in what
// order; the library has no built-in notion of a scene graph.
let delta = *world.root_scope().query_one::<&f32>(clock).unwrap().get();
{
let mut spins = world.root_scope().query::<&mut Spin>();
for spin in spins.iter() {
spin.angle += spin.radians_per_second * delta;
}
}
assert_ne!(world.root_scope().query_one::<&Spin>(cube).unwrap().get().angle, 0.0);
// A predicate narrows the archetypes a query visits, before any column is
// claimed: here, everything that is not a clock.
let _ = world
.root_scope()
.query::<&Spin>()
.by(|archetype: &unlit_ecs::Archetype| !archetype.has::<f32>())
.iter()
.count();
// `Entity` and `Has<C>` are query items too: presence reads claim no column.
let mut presence = world.root_scope().query::<(Entity, Has<Spin>)>();
for (entity, has_spin) in presence.iter() {
let _ = (entity, has_spin);
}A query is a type, not a value: &C reads a component, &mut C writes one,
Option<&C> and Option<&mut C> make it optional, Entity yields the
entity, and Has<C> yields whether C is there. Has<C> narrows nothing, so
it reports a false for an entity that lacks C rather than skipping it; a
query that should visit only the carriers pairs it with QueryBorrow::by,
by(|archetype| archetype.has::<C>()). A tuple of items yields a tuple of
their items, and tuples nest: (&a, (&b, &c)) asks for the same three
components as (&a, &b, &c). An item written &(a, b) is one component whose
type is that tuple, not two. A query with more components than a tuple impl
covers is declared with the query! macro, or written by implementing
Query yourself. Neither needs unsafe: a query resolves its columns
through PlanBuilder, which hands out Col and ColMut and records
what it was asked for, so the components a query claims are exactly the ones it
declares. Scope::query_one runs the same query over one entity and answers a
QueryOne holder whose QueryOne::get hands out the single row, so a
lookup by handle and a pass over a scope share one vocabulary; a handle that
names no visible entity, or one the query does not match, is an
EntityAccessError.
Spawning and despawning need a &mut scope, or a queued command:
use unlit_ecs::{World, bundle};
let mut world = World::new();
let entity = {
let commands = world.root_scope().queue();
commands.spawn(bundle!["entity"])
};
// Nothing has happened yet.
assert!(!world.root_scope().contains(entity));
world.apply().unwrap();
assert!(world.root_scope().contains(entity));A bundle is what bundle! writes or what a DynamicBundle gathers; a
tuple is not one, because a tuple is itself a component. Every item of the list
is one component — a parenthesised tuple stays a tuple, so bundle![(a, b), c]
is a tuple component and c, and bundle![()] is an empty tuple component. The
macro is written with brackets throughout, bundle![a, b], which keeps its
items apart from the arguments of a call; a list of one needs no comma, so
bundle![a] is the single component a. Its width is limited by the compiler’s
recursion limit, about a hundred components at the default, and a wider one is
built by raising #![recursion_limit]:
use unlit_ecs::{Has, World, bundle};
let mut world = World::new();
let entity = world.root_scope_mut().spawn(bundle![(1u32, 2.0f32), (true, ())]);
// Two components: one tuple, and a nested one.
let scope = world.root_scope();
assert!(scope.query_one::<Has<(u32, f32)>>(entity).unwrap().get());
assert!(scope.query_one::<Has<(bool, ())>>(entity).unwrap().get());
assert!(!scope.query_one::<Has<u32>>(entity).unwrap().get());A bundle written as ..value is spread into its own components, so a bundle
built elsewhere joins the spawn as itself. The syntax is needed because the
macro walks the source and cannot tell a bundle from a component — written
plainly, a bundle is one component:
use unlit_ecs::{DynamicBundle, Has, World, bundle};
let mut gathered = DynamicBundle::new();
gathered.push_component(1u32);
let mut world = World::new();
let entity = world.root_scope_mut().spawn(bundle![..gathered, 2.0f32]);
let scope = world.root_scope();
assert!(scope.query_one::<Has<u32>>(entity).unwrap().get());
assert!(scope.query_one::<Has<f32>>(entity).unwrap().get());§The MCP server
The mcp feature (off by default, native only) turns the crate into a
Model Context Protocol server that a client
drives over stdio. It lives here because the protocol’s transport side has to
name a World and hand it work: McpCommand carries a job, and the thread
that owns the world runs it. Nothing in the bridge borrows across that
boundary, and no other crate owns a type the transport needs.
The crate still keeps its one-dependency shape. The module is gated off on
wasm32, where the protocol’s transport cannot build, and every dependency it
adds is optional and target-gated, so a browser build carries none of them.
Tools come from the crates that define what they act on, not from this one.
A crate submits a crate::mcp::ToolSource to a link-time table — the same
inventory mechanism the reflect registry uses — and McpServer::new walks
the table to register each one:
unlit_ecs::mcp::inventory::submit! {
ToolSource { extend: register }
}
fn register(router: &mut ToolRouter<McpServer>) {
router.add_route(tool("my_tool", "What it does.", my_tool));
}unlit_ecs itself registers the entity and component tools —
world_summary, list_components, list_archetypes, list_entities,
get_entity, get_component, set_component, spawn_entity,
despawn_entity — which need nothing but the ECS; unlit3d registers the
rendering ones and unlit3dx the audio ones. None of those crates is a
dependency of this one, and the server a host builds carries all of them.
Every entity tool takes an optional scope argument naming the scope to act in,
defaulting to the root. A client that names a scene sees only that scene’s subtree, so
one MCP session can inspect a world’s private parts without the host exposing them.
An operation the scope does not own — a spawn outside its subtree, a despawn of an
ancestor — is refused rather than silently redirected.
Components are addressed by name through the reflect registry: a component’s
crate submits a crate::reflect::ComponentEntry to the same kind of link-time
table, so get_component and set_component can encode and decode a value the
ECS itself has never heard of.
§The MCP server, and what it cannot reach
The transport thread is not the world’s thread. It reads the protocol, turns a
call into a crate::mcp::McpCommand, and posts it through a channel the host
drains; whoever owns the world runs the job and answers on the command’s own
channel. A world is !Send, and this is the shape that keeps it that way.
Which world a tool names is the client’s to say: crate::mcp::WorldId is
"Host" for the world the server was built around, or {"Handle": bits} for a
world a host entity carries. A host that wants its other worlds addressable
answers list_worlds itself, because only it knows which entities carry one.
§Why the ECS is this small
The omissions are deliberate, and each has a substitute
- No change detection. In OOP an object maintains its own state internally; tracking what changed is the caller’s business.
- One relation, the scope, and no other. A
ChildrenorChildOfcomponent would be a reference the caller keeps up to date; the scope is the one that cannot be, because visibility is decided on every access. Anything else stays a plain entity in a component. - Lifecycle callbacks run on the entity, not on the world.
OnSpawnandOnDespawnare ordinary components holding a callback, so a world with no lifecycle components pays nothing for the feature and a caller can write its own runner instead. - No events or observers. As a substitute, call a component’s method directly or use a behaviour component. A behaviour component holds a function pointer or closure that can run accesses to the world inside it, just like a system or an observer, and the function inside it can also be async. Callbacks like a Godot node’s per-frame update, fixed-interval update and keyboard/mouse/touch input are then behaviour components on entities, and an external caller visits the world and calls them — such features are implemented compositionally.
- No resources. An entity that wants to be accessed from anywhere is an
ordinary entity and the caller keeps its
Entityhandle; a resource reference is an entity reference, and the library provides no dedicated marker or mechanism. - An entity’s archetype is immutable. In OOP a class/object’s state and
behavior are immutable, and an immutable archetype also gives state retention
a mandatory character, avoiding the
RemovedComponentsand component-leak pitfalls of change-tracking designs. - No systems (borrowed from
hecs). A system is only an external caller’s access to the world, or a component holding behavior (a closure). So there is no need for complex analysis of system parallelism — dependencies, access conflicts — or a multi-threaded scheduler: the external caller decides which thread to run on. - The world is
!Send. GPU resources and the renderer are!Send, and onwasm32they are not evenSendbehind a feature flag, so a world that could cross threads would buy nothing there. ASendworld was tried and removed; the storage a world shares is not the thing that makes its work partitionable. - Nothing is added until something uses it. A feature that is not currently needed does not go in.
§Tests
cargo nextest run -p unlit_ecs # this crate's unit and integration tests
cargo xtask test # the whole workspaceThe tests cover world and query behaviour and deferred commands. The test layers as a whole, and what CI runs, are described in the root README.
This crate’s access paths are benchmarked by
unlit3d_benchmarks’s ecs target, which
is where the cost of a World::get against resolving a whole archetype’s
columns once is measured:
cargo bench -p unlit3d_benchmarks --bench ecs§License
Dual-licensed under MIT or Apache-2.0, at your option.
Re-exports§
pub use archetype::Archetype;pub use archetype::Archetypes;pub use bundle::Bundle;pub use bundle::DynamicBundle;pub use command::Command;pub use command::CommandErase;pub use command::Commands;pub use entity::Entity;pub use entity::Location;pub use entity::EntityProxy;reflectpub use entity::EntityVecProxy;reflectpub use error::ArchetypeAccessError;pub use error::EntityAccessError;pub use hash::EntityHashMap;pub use hash::EntityHashSet;pub use hash::TypeIdHashMap;pub use hash::TypeIdHashSet;pub use info::ArchetypeInfo;pub use info::EntityInfo;pub use info::WorldInfo;pub use lifecycle::Lifecycle;pub use lifecycle::LifecycleCallback;pub use lifecycle::OnDespawn;pub use lifecycle::OnSpawn;pub use query::ArchetypeFilter;pub use query::Col;pub use query::ColMut;pub use query::Has;pub use query::PlanBuilder;pub use query::Query;pub use query::QueryBorrow;pub use query::QueryIter;pub use query::QueryOne;pub use view::Scope;pub use view::ScopeMut;pub use world::World;
Modules§
- archetype 🔒
- Archetype storage.
- borrow 🔒
- The ledger of who holds a component.
- bundle 🔒
- Bundles: the set of components an entity is spawned with.
- column 🔒
- Component storage.
- command 🔒
- Deferred structural changes.
- component 🔒
- Components.
- entity 🔒
- Entity identifiers and their allocator.
- error 🔒
- The errors an access that named an entity or an archetype answers with.
- hash 🔒
- Specialized hash containers.
- info 🔒
- Plain-data summaries of a world’s shape.
- lifecycle 🔒
- Lifecycle behaviour components: callbacks that run when an entity is spawned or despawned.
- mcp
mcpand non-WebAssembly - The Model Context Protocol server and the tools a world answers through it.
- naming
- File-naming helpers that need neither a filesystem nor a random id. File-naming helpers every save path shares.
- prelude
- The types most callers need.
- query
- Queries: picking entities by the components they carry.
- reflect
reflect - The component registry: which components a bridge can address.
- scope 🔒
- Entity scopes: which entity an entity lives in, and the tree that makes.
- sync
- Lock helpers that ignore poisoning, so one panic cannot fail every later caller. Poison-proof lock helpers, and the rules the locks are taken by.
- view 🔒
- The scope accessors: the read and write entry points of a world.
- world 🔒
- The world: entities, their components, and the scopes they live in.