Killing the Build Pipeline: How ECS Let Us Remove Our Registry Entirely
By @arauser • April 17, 2026
When we rebuilt our procedural world system for our Roblox game, we were not trying to innovate.
We were just tired of our build pipeline breaking.
The Problem With "Normal" Build Pipelines
Like most systems, we started with a central pipeline:
- A list of build stages
- A shared state object
- Flags for ready, enabled, etc.
- A service to orchestrate everything
Something like:
local STAGES = {
"map",
"surfaceCells",
"roadEdges",
"roadSkeleton",
"racePath",
"props",
}
And a service to manage it:
BuildStateService = {
markReady(stage),
isReady(stage),
setEnabled(stage, enabled),
allEnabledReady(),
}
At first, this feels clean. Then reality hits.
What Actually Broke
1. You could forget to register a step
You would add a new system and nothing would happen because you forgot to add it to `STAGES`, wire it into the pipeline, or update dependencies.
No error. Just silent failure.
2. Ordering bugs everywhere
Execution order lived in the stage list, system logic, and implicit assumptions. Change one thing and something else breaks.
3. Hidden dependencies
A system might depend on `roadSkeleton`, but that dependency lived in pipeline config, not inside the system that needed it.
Disabling one system meant touching config, order, and collateral behavior.
4. Scaling made everything worse
Every new step required edits in registry, types, state service, and system logic. The pipeline itself became the bottleneck.
The Realization
We were not struggling with build logic. We were struggling with coordination.
So we asked: what if build steps were not configured, but existed in the world?
The ECS Shift
We moved everything into ECS, including the pipeline itself.
Core idea:
- Each build step is an entity
- Progress is components
- Execution is queries
No registry. No central pipeline.
The New Pattern: Build Step Singletons
Each build stage owns a single entity (a singleton).
Example:
local function initTerrainBuild()
w:add(c.TerrainBuild, c.TerrainBuild)
w:add(c.TerrainBuild, c.BuildStep)
local conn = w:added(c.BuildingWorld, function(entity)
if entity ~= c.GamePhase then
return
end
w:remove(c.TerrainBuild, c.Ready)
w:remove(c.TerrainBuild, c.Failed)
end)
return conn
end
Every step registers itself, marks itself as a `BuildStep`, and owns its lifecycle.
Execution Becomes a Query
for _ in w:query(c.BuildStep, c.TerrainBuild)
:without(c.Ready, c.Failed)
:iter() do
-- do work
w:add(c.TerrainBuild, c.Ready)
end
The rules are always:
- Must be in `BuildingWorld`
- Must have dependencies ready
- Must not already be `Ready` or `Failed`
Dependencies: Local, Not Global
Old way: the pipeline defines order.
New way: each system decides if it can run.
if not w:has(c.RouteBuild, c.Ready) then
return
end
That is the dependency. No graph, no config. Just: I run when my inputs are ready.
The Orchestrator (Now Tiny)
We still need to know when world build is done:
if query_count(w:query(c.BuildStep):with(c.Failed)) > 0 then
return
end
if query_count(w:query(c.BuildStep):without(c.Ready)) > 0 then
return
end
GamePhase.transitionPhase(c.BuildingWorld, c.StartingGrid)
That is the entire pipeline.
What "Auto-Register" Actually Means
To add a new step:
- Create a tag component (`LightsBuild`)
- Create a system that adds `LightsBuild` + `BuildStep`
- Mark `Ready` or `Failed`
Done. No registry, no central list, no wiring.
Real Example Dependency Chains
These emerge naturally from system checks:
- `WFCBuild` → `RouteBuild` → `CollapseBuild` → `TileBuild`
- `PathBuild` → `TerrainBuild` → `BuildingsBuild` → `PropsBuild`
Each step only checks what it needs, and nothing else.
Failure Handling (Finally Simple)
Failure is just:
w:add(c.SomeBuild, c.Failed)
The orchestrator sees it, stops progression, returns to lobby, and retries. No special-case logic.
The Biggest Shift
We stopped thinking about order. There is no pipeline to maintain, only systems, data, and readiness.
Why This Works
Build steps are now world state, so:
- 2 steps or 200 steps use the same logic
- No wiring between modules
- No central coordination
- Everything is discoverable via queries
Tradeoffs
- You must correctly mark `Ready` and `Failed`
- The ECS mental model is required
- Less explicit than a fixed pipeline definition
The payoff is huge: add or remove a system and everything still works.
Final Thought
Most pipelines ask: "What runs next?"
ECS flips it: "What is ready to run?"
That one shift let us delete our entire build registry, and we have not missed it once.
Original source: X article by @arauser