tur-ecs's pitch is "take aztecs's structural clarity and apecs's
ergonomic queries, but lean on Turmeric's type system (HKTs, coherent
typeclasses, substructural types, sized types) to push more invariants
to compile time than Haskell's typeclass-soup approach can."
This guide tests the pitch against working code. It walks a small bouncing-balls simulation in apecs, aztecs, and tur-ecs side by side, then tabulates what each system rejects at compile time, what it catches at runtime, and what it can't catch at all. The goal is to be honest -- the rows where tur-ecs is unambiguously better are real, but so are the rows where Haskell's ad-hoc polymorphism still buys you something we don't have.
For the introductory tutorial see
ecs-guide.md. For the long-form plan and the
load-bearing prereqs that closed in 2026-06-11 see
../upcoming/ecs-spice-plan.md.
| Property | apecs | aztecs | tur-ecs |
|---|---|---|---|
Component membership ("does this world have Pos?") |
Runtime hashmap lookup keyed by TypeRep |
Runtime hashmap lookup keyed by TypeRep |
Struct-field access; unbound name at compile time if missing |
Write-set enforcement (system declares it only writes Pos, body actually writes Vel) |
Trust the programmer | Trust the programmer | Compile-time error (cap-gated set-<Comp>!) |
| Read-set enforcement | Trust the programmer | Trust the programmer | Same -- read caps in scope iff comp is in :reads |
| Storage choice (dense vs sparse vs tag) | Type-family Storage c -- resolved at compile time, but the user can lie via orphan instances |
Associated type, same caveat | Per-component; macro registers, accessor type bakes it in. No orphan-instance surface |
| Typeclass coherence | Open instances; orphans are a maintenance hazard | Open instances | Coherent -- one Component T instance per T, enforced by the elaborator |
Polymorphic-system bound ("any world with Pos and Vel") |
Has w Pos, Has w Vel => … constraint, solved by GHC |
Same | (HasPos W) (HasVel W) => … Turmeric class constraint -- shipped via defcomponent-class / definstance |
| Entity aliveness | Maybe-returning reads |
Maybe-returning reads |
option-returning reads ((none) on dead-handle) -- same model, runtime-checked |
| Query arity | Tuples up to 8-ish via type-class hackery; degrades past that | Query arrow combinators -- no cap, but composition cost is real |
Truly variadic via row-kinded for-each; row type is the kind-[*] of components |
| Dense-storage length matching | Runtime check on zip | Runtime check on zip | Runtime check (lifts when the spice wires SizedVec<n, T> -- SZ6+ shipped, spice wiring still TODO) |
| Cross-world systems | Out of scope | Out of scope | Planned (v1/ecs-cross-world-systems-plan.md); single-world is v1 |
The single largest delta is write-set enforcement. Both Haskell
libraries trust the programmer not to write to a component they didn't
declare; tur-ecs gates writes through per-component WriteCap<T>
linear capabilities that are only in scope when the comp is in
:writes. The plan calls this "the single biggest delta vs. Haskell
ECSes"; the rest of this guide shows it in real code.
A canvas of N balls with position, velocity, and color. Each frame:
integrate position, bounce off the walls, render. Spawn 1000 of them.
Everyone uses the same surface, so the only thing that varies is the
ECS framework.
{-# LANGUAGE TemplateHaskell, ScopedTypeVariables, TypeFamilies, FlexibleContexts #-}
module BouncingBalls where
import Apecs
import Apecs.Stores (Map)
import Linear
newtype Pos = Pos (V2 Float) deriving (Show)
newtype Vel = Vel (V2 Float) deriving (Show)
newtype Color = Color (V4 Float) deriving (Show)
instance Component Pos where type Storage Pos = Map Pos
instance Component Vel where type Storage Vel = Map Vel
instance Component Color where type Storage Color = Map Color
makeWorld "World" [''Pos, ''Vel, ''Color]
integrate :: Float -> System World ()
integrate dt = cmap $ \(Pos p, Vel v) -> Pos (p + dt *^ v)
bounce :: System World ()
bounce = cmap $ \(Pos (V2 x y), Vel (V2 vx vy)) ->
let vx' = if x < 0 || x > 800 then -vx else vx
vy' = if y < 0 || y > 600 then -vy else vy
in Vel (V2 vx' vy')
frame :: Float -> System World ()
frame dt = integrate dt >> bounce
The cmap is the workhorse: it picks up the row by matching
constructors in the lambda. integrate says "I touch Pos and Vel"
by writing the lambda's pattern. Nothing prevents the body from also
touching Color; the type system doesn't know.
The Has constraints (Has World IO Pos, Has World IO Vel) appear
in inferred types but rarely in user code; GHC threads them. The
Storage Pos = Map Pos line is the actual compile-time choice -- if
you write Map twice you have two stores for Pos, which is an error
GHC will catch via overlap rejection (or fail to catch if you let it
in via orphan instances and -fno-warn-orphans).
{-# LANGUAGE TypeApplications #-}
module BouncingBalls where
import Aztecs.ECS as ECS
import Linear
newtype Pos = Pos (V2 Float)
newtype Vel = Vel (V2 Float)
newtype Color = Color (V4 Float)
integrate :: Float -> System ()
integrate dt = ECS.map_ $ proc () -> do
Pos p <- ECS.read @Pos -< ()
Vel v <- ECS.read @Vel -< ()
ECS.write @Pos -< Pos (p + dt *^ v)
bounce :: System ()
bounce = ECS.map_ $ proc () -> do
Pos (V2 x y) <- ECS.read @Pos -< ()
Vel (V2 vx vy) <- ECS.read @Vel -< ()
let vx' = if x < 0 || x > 800 then -vx else vx
vy' = if y < 0 || y > 600 then -vy else vy
ECS.write @Vel -< Vel (V2 vx' vy')
aztecs makes the read/write explicit via arrow notation. That's an
improvement over cmap: you can read the system's declared effects
off the arrow body. But the enforcement is the same as apecs --
nothing makes integrate actually use only Pos and Vel. A line
that says ECS.write @Color -< … would type-check fine.
The query value here is proc … do, which composes via arrow
combinators. Past three or four reads/writes this gets hard to read,
which is the cost aztecs pays for its compile-time row shape.
(defmodule bouncing-balls (export)
(import ecs/system :refer [System make-system system-run! defsystem])
(import ecs/storage :refer [dense-new dense-set!])
(import ecs/world :refer [defworld defcomponent-accessors world-alloc-entity!])
(import ecs/cap :refer [WriteCap ReadCap make-write-cap make-read-cap use-cap!])
(defstruct Pos [x : float y : float])
(defstruct Vel [x : float y : float])
(defstruct Color [r : float g : float b : float a : float])
(defworld World [Pos Vel Color])
(defcomponent-accessors World Pos)
(defcomponent-accessors World Vel)
(defcomponent-accessors World Color)
(def Pos-cid 0)
(def Vel-cid 1)
(def Color-cid 2)
;; integrate declares :reads [Pos Vel], :writes [Pos]. The body has
;; Pos-write-cap in scope but no Color-write-cap; writing Color is a
;; compile-time error.
(defsystem integrate
[Pos Vel]
[Pos]
(let [w' : World (:: w World)]
;; ... read Pos, read Vel, set-Pos! Pos-write-cap w' e new-pos ...
nil))
(defsystem bounce
[Pos]
[Vel]
(let [w' : World (:: w World)]
;; ... read Pos, set-Vel! Vel-write-cap w' e new-vel ...
nil))
)
The line you cannot write here:
(defsystem integrate
[Pos Vel] ;; :reads
[Pos] ;; :writes -- only Pos!
(let [w' : World (:: w World)]
(set-Color! Color-write-cap w' 0 (make-struct Color 1.0 0.0 0.0 1.0))))
;; ^^^^^^^^^^^^^^^
;; error [TUR-E0003]: unbound symbol 'Color-write-cap'
Color is not in :writes, so the defsystem macro never binds
Color-write-cap in body scope, so the set-Color! call cannot
resolve its required cap argument. The body refuses to elaborate.
This is the compile-time enforcement neither apecs nor aztecs deliver
-- it's what the spec'd Phase I cap-gating shipped.
The corresponding positive case is just as direct:
(defsystem integrate
[Pos Vel]
[Pos]
(let [w' : World (:: w World)
rp (get-Pos Pos-read-cap w' 0)
rv (get-Vel Vel-read-cap w' 0)]
(set-Pos! Pos-write-cap w' 0 (make-struct Pos
(+ (.x rp) (.x rv))
(+ (.y rp) (.y rv))))))
Pos-read-cap, Vel-read-cap, and Pos-write-cap are bound by the
defsystem macro for each entry in the :reads / :writes vectors.
The cap names are predictable; the user does not mint them by hand.
In apecs and aztecs, World is a heterogeneous container keyed by
TypeRep. Asking "does this world have Pos?" is a runtime hash
lookup. If you query a component you didn't register, the lookup
returns Nothing and you find out at runtime.
In tur-ecs, World is a defstruct with one storage field per
declared component. Querying a component the world doesn't declare is
an unbound-name error from the elaborator:
(defworld Game [Pos Vel])
(defsystem render
[Sprite] ;; Sprite is not on Game
[]
...)
;; error: defworld Game has no field 'Sprite'
This is the structural-membership lever. We can claim it not because
of any clever type-system feature but because we made the world a
named, closed type instead of a TypeRep-keyed map.
The headline. defsystem's :writes [Pos] binds a linear
Pos-write-cap : (WriteCap Pos) in body scope. The accessors emitted
by defcomponent-accessors require the cap as their first argument:
(defn set-Pos! [^borrow cap : (WriteCap Pos)
^borrow w : World
e : int
v : Pos]
: nil
(dense-set! (.Pos w) e v))
A body that did not declare :writes [Vel] has no Vel-write-cap;
the set-Vel! call name-resolves, but its first argument is unbound.
The Phase I report
(docs/archive/history/ecs-defsystem-write-caps-not-enforced.md)
walks the implementation. The load-bearing prereq was the parametric
:linear propagation fix
(docs/archive/history/parametric-linear-opaque-not-enforced.md);
without it, WriteCap<T> would compile-check fine but its
single-use discipline would silently drop on every application.
apecs and aztecs cannot offer this. Haskell's substructural-types
story (linear-base, LinearTypes) exists but is not what either
library uses; both inherit Haskell's "the system says it writes Pos
but the body can do whatever" trust model.
apecs's type Storage Pos = Map Pos and aztecs's analog are
compile-time choices. If a downstream consumer wants Pos in a sparse
set, they cannot override the choice without an orphan instance --
which Haskell allows but discourages.
tur-ecs's defcomponent registers a storage choice per (world,
component) pair. There is no orphan-instance surface because there are
no orphan instances of Component T -- the elaborator rejects them.
A world that uses Pos in :dense and another that uses Pos in
:sparse are different worlds; both compile.
Component T has exactly one instance per T. The elaborator
enforces this. apecs and aztecs both ride on Haskell's open-world
typeclass model, which is fine in practice but leaves a known
maintenance hazard: an orphan instance imported from a library can
silently change behaviour, and -fno-warn-orphans is depressingly
common.
The cost: less ad-hoc polymorphism. apecs lets you swap storages by swapping instances at import time; tur-ecs makes that an explicit choice at the world's declaration site.
Being honest matters. The "compile-time everything" pitch is wrong; here's where tur-ecs still trusts runtime checks.
(defstruct Entity [index : int generation : int])
get-Pos returns option<Pos>. A dead-entity handle's generation
counter mismatches the slot's current generation, so the read returns
(none). This is a runtime u32 compare per access. apecs and aztecs
do the same thing the same way -- nobody ships compile-time alive-set
proofs.
A compile-time version would require refinement types
(docs/upcoming/v1/refinement-types-plan.md),
which are in plan but not shipping. When they do land, the
entity-alive! strict surface will lift the runtime check out of the
inner loop in the cases where the elaborator can prove it -- but the
default option-returning forgiving API will stay, because that's the
sound choice when the prover can't.
for-each [Pos Vel] zips two dense storages. The spice currently
checks at runtime that both storages have the same length and rejects
otherwise. The prereq for lifting this to compile time -- SizedVec<n,
T> with a load-bearing size index -- shipped in 2026-06-10 (SZ6-SZ8;
see
docs/archive/history/sized-types-phantom-index.md).
The spice has not yet wired its dense storages through SizedVec.
When it does, dense-vs-dense zip becomes statically rectangular; the
runtime check disappears for that case.
:int at the system body's signature(defsystem foo [Pos Vel] [Pos] body) lowers to (defn foo-impl [w :
int] : nil …). The body receives w as an int and recovers the
typed world with (:: w World). This was the cheapest way to ship a
working scheduler without rewriting the system trampoline contract.
The downstream effect is that the world type is recovered inside
the body, not enforced at the system's external signature. Nothing
unsound happens -- the cap surface still gates writes -- but the
narrowing happens one layer in.
A future revision will likely type the world parameter directly; it isn't load-bearing for the v1 wins above.
| Loss | What it costs in practice |
|---|---|
Ad-hoc polymorphism over component sets. cmap lets a system body decide at the lambda which tuple of components it touches. tur-ecs makes you declare :reads / :writes up front. |
Declaration ceremony per system. Pays off the moment your codebase has more than one system writing the same component. |
Orphan-instance flexibility. A downstream consumer cannot swap Storage Pos = Map for Storage Pos = Cache (Map Pos) from outside the component-declaration site. |
Largely a non-cost in practice; orphan instances are a maintenance hazard apecs codebases regret. |
Open-world typeclass extension. A new library can register itself as a Component for an existing type without source access to the original module. |
We force the registration into the world declaration. For a library author this means the world author has to opt in -- which is what you'd want anyway for cap-gated writes. |
GHC's constraint inference. (Has w Pos, Has w Vel) => … is solved silently; the user often doesn't see it. tur-ecs makes typeclass-bounded systems explicit via (HasPos W) (HasVel W). |
Slightly more verbose at the declaration site. Identical runtime cost (one dictionary lookup per polymorphic call). |
The recurring theme: tur-ecs trades flexibility-at-the-import-site for locality-of-reasoning-at-the-declaration-site. In a small project this feels like ceremony; in a large project this is the whole point.
aztecs's arrow-based queries are genuinely more compositional than
tur-ecs's for-each. A Query a b arrow can be passed around,
combined with >>> and ***, and run against multiple worlds. tur-ecs's
for-each is a macro that splices code inline. The defquery /
run-query! surface is the closest analog and gets you the
named-query reuse, but not the arrow-algebra composition.
This is a real loss for code that wants to express queries as data. For code that wants to express them as loops, tur-ecs is direct and aztecs is indirect -- the trade-off cuts both ways.
(defsystem physics [Pos Vel] [Pos] body)
(defsystem render [Pos Sprite] [] body)
These two systems' write sets are [Pos] and []. Their read sets
share Pos. By the v1 rule "no write/read overlap on any component
bit," render cannot run in parallel with physics. The scheduler
proves this statically from the declared sets.
(defsystem physics [Pos Vel] [Pos] body)
(defsystem ai [Brain] [Brain] body)
These don't share any component. Disjoint, parallelisable, scheduler
runs them concurrently. The proof is type-level set disjointness on
the declared :reads / :writes vectors, computed at macro-expansion
time.
apecs and aztecs both ship parallel schedulers; both rely on the programmer to declare disjoint sets accurately. tur-ecs rejects inaccurate declarations at the body's elaboration step, before the scheduler ever runs.
ecs-guide.md -- the introductory tutorial.../upcoming/ecs-spice-plan.md
-- the long-form plan, status, and what's still queued for v2.../archive/history/ecs-defsystem-write-caps-not-enforced.md
-- the Phase I implementation log for the cap-gating surface that
delivered the headline compile-time-write-set claim.../upcoming/v1/ecs-cross-world-systems-plan.md
-- post-v1 follow-up extending the cap surface to multi-world
render-extract / client-prediction patterns.substructural-types-guide.md --
the ^linear / ^borrow machinery the cap surface is built on.If you take only one thing from this guide, take this:
option-returning reads, and where the compile-time
upgrade is on the refinement-types roadmap for all three (Haskell's
liquid-haskell is the analog; nobody ships it as default).cmap is still the lowest-ceremony surface; tur-ecs's declaration
vector is a real cost for code that doesn't need the cap surface.The pitch holds. The honest scorecard says we won the rows we claimed to and didn't win the ones we didn't.