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:

  1. Create a tag component (`LightsBuild`)
  2. Create a system that adds `LightsBuild` + `BuildStep`
  3. 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

Video