renew-ecs 0.1.1

Entities and component storage: a sparse set with a defined iteration order, so a query's result never depends on churn history
Documentation
# renew-ecs


Entity handles and component storage. A sparse set, chosen by measurement,
with an iteration order that is part of the contract rather than a
consequence of the layout.

## Contract


- **Every query iterates in ascending entity-slot order.** A system's
  result cannot depend on the order components happened to be inserted or
  removed in. That is the point of the whole design: it makes the
  determinism invariant structural, rather than a rule every future
  contributor has to remember and every reviewer has to catch.
- **A stale handle is dead, not dangerous — within two stated limits.** An
  entity is a slot plus a generation. Reusing a slot bumps its generation,
  so a handle to a despawned entity reports as not alive instead of
  quietly naming whoever took its place. Two cases it does not cover, both
  deliberate:
  - **A handle from a different `Entities` cannot be detected.** Same
    slot, same generation, different world: it reports alive. There is a
    test asserting exactly that, so the limit is pinned rather than
    latent.
  - **A generation wraps after 2^32 reuses of one slot**, at which point a
    handle from the first pass could alias. Wrapping is the honest choice
    — a saturating counter would silently stop detecting staleness at the
    same point while looking safe.
- **Ordered iteration costs the gaps.** It walks slots, so it is
  proportional to the highest occupied slot rather than to the number of
  components. The allocator reuses low slots first to keep that range
  tight, and `Store::iter_unordered` exists for systems that genuinely do
  not care about order — its name is the warning.
- **Nothing here reads a clock, opens a file, or spawns a thread**, and
  the crate's [clippy.toml]https://github.com/renew-engine/renew/blob/main/crates/ecs/clippy.toml rejects all three at lint time.
  `HashMap` is banned for the same reason: its hasher is seeded per
  process, so iterating one would make the order differ every run.

## Architecture


Three arrays per component type. `sparse` maps an entity slot to a dense
position, `dense` maps back, and `values` sits alongside `dense`. Insert
and remove are constant time; remove swaps the last element into the hole,
which keeps `values` contiguous.

That swap is why order is not free. After churn, `dense` is in no useful
order, so a query walking it would visit entities in an order decided by
their removal history. `Store::iter` walks `sparse` instead, which is
ascending by construction.

## Public API


`Entities` for spawn, despawn, `is_alive` and an ordered walk of the live
set. `Store<T>` for insert, remove, get, `get_mut`, `contains`, and the two
iterators. `join` for the ordered intersection of two stores — it walks one
side and probes the other, which is what a sparse set is good at.

A caller holds one store per component type explicitly. **There is no type
map**, and that absence is deliberate: asking a world for `Store<Position>`
by type needs a design for how systems declare what they touch, and there
is no system yet to design against.

## Thread safety and ownership


No shared state, no interior mutability, no globals. `Store<T>` and
`Entities` are `Send` and `Sync` exactly when their contents are; nothing
here synchronises, because nothing here is shared. Components are owned by
their store and borrowed out; despawning an entity does **not** remove its
components — nothing walks every store on despawn, and pretending
otherwise would be hidden behaviour. A caller filters against `Entities`.

## Testing


Unit tests for each operation, including the swap-remove back-pointer fix
that only misbehaves when the removed element is not the last one — the
classic bug in this structure.

Beyond that, model-based property tests: a `Vec<Option<T>>` is the
obvious, slow, obviously-correct version of the same thing, and both run
against the same random operation sequence with every slot compared after
**every step**. The failures in a sparse set are all about history, so a
divergence caught at step 3 names the operation; one caught at the end
names only the sequence.

The determinism property is tested directly: two stores reaching the same
contents by different histories iterate identically, and iteration is
sorted whatever the churn.

## Status


`bootstrap`. Storage and handles are settled; everything above them —
systems, scheduling, a type map — is not. The `[package.metadata.renew]`
table in [Cargo.toml](https://github.com/renew-engine/renew/blob/main/crates/ecs/Cargo.toml) is authoritative for maturity.

## Key decisions


- **Sparse set over archetype, decided by measurement** rather than
  preference, with an ordered-iteration cost of ~1.7% of a 16.7 ms frame
  at 100,000 entities against ~16% for the alternative. The archetype
  layout wins dense iteration by about 0.3% of a frame; that is the price
  of this choice and it is measured, not estimated.
- **A defined order, rather than forbidding order-dependence.** The
  cheaper option was to declare order-dependent queries a mistake and rely
  on review. Defining the order costs runtime and buys a property nobody
  has to remember.
- **`u32::MAX` as the absent sentinel**, not `Option<u32>`: four bytes per
  slot rather than eight at this alignment, on an array that is as long as
  the highest slot ever used.
- **Despawn does not cascade.** Removing an entity's components would mean
  walking every store, which the crate cannot do — it does not know what
  stores exist. A caller filters, and the alternative is a type map that
  has not been designed.