tur-ecs

Entity-Component-System for Turmeric. Pairs with tur-raylib for real-time games. Long-form plan and rationale: docs/archive/ecs-spice-plan.md.

Status

E1 -- variadic-looking queries + row-typed Query value. Shipped 2026-06-11.

Since then (unreleased): E2 systems + parallel scheduler with substructural write-cap enforcement; E2d associated-type storage projection (the world's field types project through (Storage Comp)) plus the StorageOps backend class; and E2c bounded-capacity ("sized") worlds -- see the dedicated section below. See CHANGELOG.md for the per-slice log.

The D1 prerequisite (variadic HKT rows) and all four L6 follow-ups (strict row elements, ^& on defgadt/ADT/deftype, row-polymorphic defn/fn, permutation-aware row equality) landed in the main repo this session, which unblocked the E1 surface. See ../../../turmeric/docs/archive/history/variadic-hkt-rows-missing.md.

What's in

Bounded-capacity (sized) worlds -- E2c

A parallel surface where the world commits to a capacity n at construction and threads it through every storage as a type-level index, so iteration is statically rectangular -- the sized-for-each loop bound comes from the world's type, not a runtime min-capacity probe. Design plan: docs/archive/ecs-sized-world-plan.md.

What's not in (yet)

Smoke / regression tests

tur run tests/spawn1k.tur                 # E0 int-carrier dense (sum = 499500)
tur run tests/spawn1k-pos.tur             # E1' multi-field Pos via dense-get (sum = 499500)
tur run tests/sparse-rt.tur               # E1' sparse insert / get / has / del
tur run tests/sparse-rt-large.tur         # E1' patch-1 RH regression (500/250/0)
tur run tests/sparse-stress.tur           # E1' patch-1 10k mixed ops vs bitset (-> ok)
tur run tests/tag-rt.tur                  # E1' tag set / has / clear / popcount
tur run tests/integrate2.tur              # E1' for-each2 Pos+Vel integrate (sum = 125750)
tur run tests/filter-with-without.tur     # E1' tag filters inside body (sum = 1368)
tur run tests/for-each-variadic.tur       # E1  variadic (for-each w [Pos Vel] ...) (125750)
tur run tests/for-each-arity-4.tur        # E1  arity-4 dispatch (49500)
tur run tests/for-each-arity-5.tur        # E1  arity-5 dispatch (18375)
tur run tests/for-each-arity-8.tur        # E1  arity-8 (15660)
tur run tests/for-each-arity-12.tur       # E1  arity-12 no-cap demo (3510)
tur run tests/defquery-integrate.tur      # E1  defquery + run-query! (125750)
tur run -Xdata-literals tests/query-typed.tur   # E1  row-typed Query value (prints 42)
tur run tests/sized-world-spawn.tur       # E2c sized-spawn! / despawn / live / cap
tur run tests/sized-world-spawn-result.tur # E2c fallible sized-spawn -> (Result int WorldFull)
tur run tests/sized-for-each.tur          # E2c statically-rectangular sized-for-each
tur run tests/sized-world-resize.tur      # E2c world-resize -> (exists [n'] (World n')) grow

Each exits 0 on success.

Known limitations

Each of the original prerequisite gaps (A-I, archived at docs/archive/history/ecs-prereq-plan.md) has shipped; the residual list below is empirical limits of the v1 surface, not language gaps.

  1. for-eachN iterates dense storages only. Listing a sparse or tag component on the primary loop would either need a different primary (smallest-set iteration) or a dense-then-test fallback. Today, use sparse/tag components only via filters inside the body.

  2. defsystem bodies cannot reach a typed world -- use defsystem-world. defsystem is never told which world it runs against, so it binds w : int and the body must reinterpret that carrier itself. Since worlds became by-value aggregates, the old (:: w GameWorld) bridge is TUR-E0295, so the typed get-<Comp> / set-<Comp>! accessors are unreachable from a plain defsystem. Use defsystem-world (which takes the world type and lowers to a typed impl plus an int-carrier wrapper, exactly as sized-defsystem-scheduled does) when the body needs its world; keep defsystem when the int carrier is all you want.

  3. ecs/xworld shadows ecs/cap. ecs/xcap exports use-cap!, make-write-cap and make-read-cap under the same names as ecs/cap, and ecs/xworld pulls them in. A file importing both must import ecs/xworld before ecs/cap, or the two-parameter XWriteCap versions win and every component-only cap call fails with TUR-E0001. This bites defsystem-world users in particular, since the load-<World> its expansion names comes from ecs/xworld.

Performance

bench/ measures the surface against hand-rolled C: 100k entities, dense float Pos/Vel, 100 frames. for-each2 runs 8.42x hand-rolled and sized-for-each 3.50x -- the archived plan's within-2x target is not met. The gap is not Turmeric's codegen (a raw Turmeric loop over flat buffers runs 1.04x C) and not the query macro; it is the unsized dense-set! write path, which carries an auto-grow capacity branch, a present[] byte write and a len update per store. Prefer sized worlds where the entity budget is known. See bench/README.md.

Breaking change in the I3-I4 ship (2026-06-11)

defsystem's :reads/:writes are component-name vectors, not bitmask ints. Per-component CIDs use the <Comp>-cid titlecase convention (Pos-cid, not pos-cid). Required imports at the call site:

(import ecs/system :refer [defsystem])
(import ecs/cap    :refer [WriteCap ReadCap make-write-cap make-read-cap use-cap!])

Then:

;; Before (E2 surface):
(defsystem physics
  (cid-bit pos-cid)
  (cid-bit pos-cid)
  body)

;; After (I3 surface):
(defsystem physics
  [Pos]
  [Pos]
  body)

defcomponent-accessors now emits set-<Comp>! / get-<Comp> that require a WriteCap<Comp> / ReadCap<Comp> first arg; pass either the <Comp>-write-cap / <Comp>-read-cap bound by defsystem in body scope, or a freshly minted cap from make-write-cap / make-read-cap (gated by the call site's declared :writes). A body that writes a component it did not declare now fails to elaborate. See the original report at docs/archive/history/ecs-defsystem-write-caps-not-enforced.md.

See also

License

MIT.