Building a Scalable Garage System: From Classic Roblox Code to Query-Driven ECS

By @arauser • April 8, 2026

We built a garage system in a multiplayer Roblox lobby where each player gets exactly one garage, garages are assigned into deterministic slots, and garage UI reactively updates from player state.

This article walks through the architecture shift from classic Roblox service-style code to query-driven ECS.

1. What We're Building

We are implementing a garage system where:

  • Each player gets exactly one garage
  • Garages are placed in a structured layout (16 slots across lanes)
  • Each garage displays player avatar, cash, and wins
  • UI updates reactively when stats change
  • Slots are reused deterministically

It looks simple but combines player lifecycle, spatial layout, UI synchronization, and persistent data.

2. How This Is Done in Classic Roblox Code

In a traditional setup:

  • Instances are the source of truth
  • ModuleScripts act as services
  • Events (`.Connect`) glue everything together

Typical flow:

  • `PlayerAdded` → `CreateGarage` → set UI
  • `StatChanged` → update UI
  • `PlayerRemoving` → cleanup

Under the hood this turns into manually coordinated state across service tables, cross-calls, and implicit dependencies.

3. How We Built It in ECS (Query-Driven)

ECS is often summarized as entities, components, and systems, but the real power is queries.

Behavior is not triggered by code paths. It emerges from data and queries.

Instead of writing:

if player has no garage then
	create garage
end

We define state and query for it:

query(Player, ProfileHydrated):without(Garage)
-- filter players WITHOUT garage
-- create garage

4. Breaking the System into Query-Driven Units

System 1: Ensure Every Player Has a Garage

for playerEntity in w:query(c.Player, c.ProfileHydrated):iter() do
    if query_count(
        w:query(c.Garage):with(Jecs.pair(Jecs.ChildOf, playerEntity))
    ) == 0 then
        local garageEntity = w:entity()
        w:add(garageEntity, c.Garage)
        w:add(garageEntity, Jecs.pair(Jecs.ChildOf, playerEntity))
        w:set(garageEntity, c.ModelSpawnRequest, { modelName = "garage" })
    end
end

Rule: for all hydrated players without a garage, create one. No event fanout, no ownership tables, no manual bookkeeping.

System 2: Assign Garage Slots

for garageEntity in w:query(c.Garage):without(c.Index):iter() do
    -- assign first free slot
end

We also validate and correct bad state:

for garageEntity, index in w:query(c.Index):with(c.Garage):iter() do
    if duplicate or invalid then
        remove Index
    end
end

The system does not trust state assumptions. It continuously enforces correctness.

System 3: Model Ready, Hydrate UI

w:added(c.ModelRef, function(entity)
    if not w:has(entity, c.Garage) then
        return
    end

    local playerEntity = w:target(entity, Jecs.ChildOf)
    GarageView.updateDisplay(w, c, playerEntity, entity)
end)

System 4: Stats Change, Update UI

w:changed(c.Cash, function(playerEntity)
    onStatsChanged(playerEntity)
end)

Helper query:

w:query(c.Garage)
    :with(Jecs.pair(Jecs.ChildOf, playerEntity))

Relationships are queried when needed instead of manually tracked in external lookup structures.

5. Why This Is Fundamentally Better

  1. Behavior is explicit: each system is a readable rule over concrete state.
  2. No hidden state: ownership is derived from queries, not side tables.
  3. Self-healing: invalid slot/index state is corrected automatically.
  4. Separation of concerns: each system does one job and stays decoupled.
  5. Extensibility: new features become new components + new queries, not edits across fragile code paths.

6. Compare That to Classic Roblox

Classic additions usually require touching multiple systems:

  • Conditionals in creation flow
  • Slot allocation adjustments
  • Manual UI update changes
  • Ordering/race checks

In ECS, the same extension is often one component and one query.

Final Insight

ECS is not just entities/components instead of objects. It is a way to describe game logic as queries over state.

The mental shift is to stop asking "When should this run?" and start asking "What state should trigger this behavior?"

Final Thoughts

This garage system moved from tightly coupled services and implicit dependencies to explicit state transitions and decoupled, query-driven systems.

That is the difference between code that works and systems that scale.

Original source: X article by @arauser