· 6 min read
The invisible army
Building a 251-line terminal RTS on aztecs, the Haskell ECS, uncovers a genuinely pleasant pure-world core underneath stale documentation, a scheduler that does not exist, and an optional query that is secretly mandatory.

Aztecs is an ECS for Haskell in the Bevy tradition: game state is a database, entities are row IDs, components are columns, and systems are queries. Bevy proved the shape in Rust. Aztecs asks what the same database looks like as a pure value — a world you can pass to a function and get a new world back.
I wanted to know how that feels to work with, so I built the smallest real-time strategy game I could get away with on version 0.17.1: two bases, three workers each, five crystal mines, soldiers at eight crystal a head, last base standing wins.
The game came out at 251 lines, and the first build ran — rare enough for me against an unfamiliar API that it says something about how the core is put together. The rest of the story happens outside the type checker's jurisdiction: seventeen major versions in thirteen months, a front page documenting an API removed several rewrites ago, and a battle that froze because an optional query turned out to be mandatory.
A war as a database#
A component is any type you are willing to write an instance for:
newtype Pos = Pos (Int, Int) deriving (Eq, Show)instance (Monad m) => Component m Posdata Unit = Unit Team Job deriving (Show)instance (Monad m) => Component m UnitThe m exists so components can declare lifecycle hooks that run in your monad, and you pay the (Monad m) => incantation on every declaration whether you use hooks or not. Spawning reads nicely, though. Bundles form a monoid, so a soldier is bundle (Unit t Fight) <> bundle (Pos p) <> bundle (Health 10), and the world sorts it into the right archetype.
Queries are applicative and see one entity at a time, which is exactly the wrong shape for an RTS. To march a soldier you need the position of every enemy on the map, and no per-entity query can tell you that. So each tick became three moves: snapshot the world with readQuery, decide everything in ordinary Haskell over lists, and write the decisions back.
workers <- system . readQuery $ (,,,) <$> entity <*> query <*> query <*> query :: Access IO (V.Vector (EntityID, Unit, Pos, Cargo))-- ...decide the whole tick in plain Haskell, ending in-- moves :: Map EntityID (Int, Int), then join on EntityID:void . system . runQuery $ queryMapWith (move moves) entity where move ms e (Pos p) = Pos (Map.findWithDefault p e ms)That last line is the trick none of the examples show. queryMapWith rewrites a component as a function of another query against the same entity, and entity is a query for the row's own ID, so closing over the plan gives you a bulk write joined on EntityID. GHC does demand the type annotation on every snapshot, because query alone pins down nothing.
The army that would not march#
The first full run produced a war of great dignity. Workers walked to the mines and dug them empty. Then both stockpiles parked at three, forever short of a soldier's price, while the one soldier each side had already trained stood at attention beside its barracks from tick 40 to tick 400, when I stopped waiting. Bases are # and =, workers are lowercase, soldiers are capitals, and a spent mine is a dot:
tick 280 stockpiles [(Red,3),(Blue,3)] . . #R r r. b bB= . .I blamed my own planner first — some ordering bug in nearest-enemy selection — and spent a while rereading thirty innocent lines. Printing the snapshot settled it: every worker was present, every mine and base accounted for, and not a single soldier among them.
My unit query had asked for queryMaybe on Cargo, since workers carry crystal and soldiers travel light. But when aztecs runs a query it collects the component IDs of everything the query touches, the optional ones included, and matches only archetypes that contain them all. Eight lines reproduce it:
spawn_ $ bundle (Name "worker") <> bundle (Cargo 3)spawn_ $ bundle (Name "soldier")found <- system . readQuery $ (,) <$> query <*> queryMaybe :: Access IO (V.Vector (Name, Maybe Cargo))liftIO $ print (V.toList found)-- [(Name "worker",Just (Cargo 3))]The soldier is not returned with Nothing, it is filtered out before the query runs, so through the public runners queryMaybe can never actually answer Nothing. Why ship an optional query that the archetype index makes mandatory? Once you know, the workaround is easy — snapshot soldiers separately through readQueryFiltered with a without @IO @Cargo filter, monad before component — and my army marched. Blue razed the Red base on tick 202.
The front door describes a different house#
By then I had stopped trusting prose and started reading source. Holding the Hackage landing page for Aztecs.ECS — the first thing a newcomer reads — against what 0.17.1 actually accepts produces a table like this:
| The front door | The source at 0.17.1 | |
|---|---|---|
| Query API | instance Component Position, systems in arrow notation with proc and runSystem_ | Predates the 0.11 interpreter rewrite, two redesigns ago, and no longer compiles |
| Rendering backend | Recommends aztecs-sdl | The ecosystem replaced it with GL and GLFW packages |
| Changelog | Ends at 0.15 | Two major versions of unrecorded history |
| Scheduling | Systems run “in sequence or in parallel automatically” | Everything runs sequentially; the parallel path has no callers |
Some of it bites harder than a stale example. Components can declare componentOnInsert, componentOnChange, and componentOnRemove hooks — "reactive" is a headline feature in the README. I gave a marker component all three, spawned two entities, called remove on one and despawn on the other, and only remove said goodbye. Death by despawn — the one lifecycle event an RTS produces in volume — runs no hooks at all.
The scheduling row deserves a closer look, because I went hunting for the scheduler itself. The one function that would make the parallel-or-not decision is exported, called by nothing, and wrong:
disjoint :: ReadsWrites -> ReadsWrites -> Booldisjoint a b = Set.disjoint (reads a) (writes b) || Set.disjoint (reads b) (writes a) || Set.disjoint (writes b) (writes a)Every definition of parallel safety demands all three of those conditions, and this one is satisfied by any. Two queries writing the same component sail through the first ||. If the automatic scheduler ever arrives, its gatekeeper will wave the collisions in.
I want to be fair about whose house this is. Aztecs is one person's library, and the churn is experimentation done in the open — the 0.15 changelog says "revert to v0.9.0-style archetypes" out loud, which is more honesty than most of us manage in a release note. The core survived those rewrites and I trust it. The prose around it still describes the versions that lost.
Blue wins, and I cannot fully tell you why#
Here is the loose end I keep turning over. Fearing a mirror-image stalemate, I had set the center mine one cell closer to Red, a small deliberate thumb on the scale. Blue won anyway.
The frames suggest Red banked its economic lead into an earlier first soldier and sent it marching alone across the map into two waiting Blue soldiers — the oldest RTS blunder there is, trickling units into a fight one at a time, emerging from a thirty-line planner that has never heard of army composition. That reading might be taste; I never proved it, because every attempt to trace the causality ended with me watching the whole war again instead. As reviews of a game engine core go, that is a strong one.
The final frame is still in my scrollback: two Blue soldiers standing where the Red base used to be, next to a mine showing ., on a map that went quiet at tick 202. Two hundred and fifty-one lines bought me one library bug and a lesson in military logistics I never asked for.