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
- Behavior is explicit: each system is a readable rule over concrete state.
- No hidden state: ownership is derived from queries, not side tables.
- Self-healing: invalid slot/index state is corrected automatically.
- Separation of concerns: each system does one job and stays decoupled.
- 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