
How to Structure an ECS Game Engine Project
An Entity Component System can start with only a handful of files.
You might begin with:
World.jsComponent.jsSystem.js- one or two components
- one movement system
main.js
That is enough to understand the architecture.
But a real game does not stay that small.
Soon you add input, gravity, collision, cameras, interaction, inventory, equipment, stats, combat, targeting, health bars, damage numbers, status effects, abilities, death, respawning, UI, and eventually networking.
At that point, the problem is no longer:
How do I build an ECS?
The problem becomes:
How do I organize all of this without turning the project into a giant folder full of unrelated files?
A good project structure does not make the game better by itself, but it makes the architecture easier to understand, maintain, debug, and extend.
In this guide, we'll look at a practical way to structure an ECS game engine as it grows from a small experiment into a larger game project.
Start by Separating the ECS Core From the Game
One of the most useful boundaries is between:
- reusable ECS infrastructure
- game-specific code
The ECS core should not know what a sword, player, potion, enemy, health bar, or checkpoint is.
It should only understand concepts such as:
- entities
- components
- systems
- queries
- events
- world updates
That means a folder such as src/ecs can remain small and generic.
For example:
src/ecs/World.jssrc/ecs/Component.jssrc/ecs/System.jssrc/ecs/Query.jssrc/ecs/EventBus.js
These files form the foundation of the architecture.
Everything else builds on top of them.
What Belongs in the ECS Core?
The ECS core should contain infrastructure that could theoretically be reused in another game.
World
World.js might be responsible for:
- creating entities
- destroying entities
- adding components
- removing components
- retrieving components
- registering systems
- managing queries
- updating systems
- owning the Event Bus
It should not contain code such as:
1if (itemId === 'healthPotion') {2 // heal player3}That is gameplay logic.
The world should not care what a health potion is.
Component Base Class
If your architecture uses class-based components, a base component can live in the ECS core:
1export class Component {}Game components can then extend it:
1import { Component } from '../ecs/Component.js'2
3export class Health extends Component {4 constructor(max = 100) {5 super()6
7 this.current = max8 this.max = max9 }10}The ECS core knows what a component is.
It does not need to know what Health means.
System Base Class
The same applies to systems.
A reusable base class might look like:
1export class System {2 constructor(world) {3 this.world = world4 }5
6 update(deltaTime) {}7}The ECS core understands how a system participates in the update loop.
Specific behaviour belongs elsewhere.
Queries Belong in the Core
Queries are part of the ECS infrastructure because every gameplay domain may use them.
For example:
1world.createQuery([2 Transform,3 Velocity,4])Movement may use queries.
Combat may use queries.
UI may use queries.
Interaction may use queries.
The query implementation itself does not need to know anything about those features.
That makes Query.js a natural part of src/ecs.
EventBus Belongs in the Core Too
An Event Bus is another reusable infrastructure piece.
It might provide operations such as:
1events.emit(2 EVT_DAMAGE_RESOLVED,3 payload,4)or:
1events.emitNext(2 EVT_RESPAWN,3 payload,4)The Event Bus manages queues and timing.
It should not define what damage, healing, inventory, or combat actually mean.
That distinction keeps the core reusable.
Put Runtime State in Components
The next major folder is usually src/components.
This contains the data attached to entities.
A growing game might contain components such as:
TransformVelocityMeshHealthInputPlayerControlledControllerSettingsGroundedCapsuleColliderColliderAABBStaticBodyDynamicBodyControlModeCameraRigCameraFollowCameraCollisionInventoryEquipmentBaseStatsFinalStatsQuickbarInteractableCheckpointDeadPickupHealthBarVisibility
These files describe the current state of entities.
They should remain focused on data rather than accumulating gameplay rules.
Keep Component Files Small
A component file should usually be one of the simplest files in the project.
For example:
1import { Component } from '../ecs/Component.js'2
3export class Equipment extends Component {4 constructor() {5 super()6
7 this.weapon = null8 this.head = null9 this.body = null10 this.accessory = null11 }12}If Equipment.js begins importing:
- inventory systems
- item databases
- UI managers
- damage systems
- event buses
that is a warning sign.
The component is probably becoming responsible for behaviour that belongs somewhere else.
Systems Contain the Game Behaviour
The src/systems folder is where most runtime behaviour lives.
A larger game may eventually contain systems such as:
InputSystemMouseLookSystemPlayerControllerSystemGravitySystemMovementSystemCapsuleVsAABBCollisionSystemAABBCollisionSystemCameraSystemCameraCollisionSystemInteractionSystemInteractionResolveSystemInventorySystemItemUseRequestSystemItemUseSystemHealSystemStatsSystemDamageSystemDeathSystemRespawnSystemTargetingSystemHitFlashSystemRenderSystem
At first, keeping all systems in one folder is perfectly reasonable.
It makes them easy to find and keeps the architecture simple.
Don't Organize Too Early
When a project only has ten systems, this structure is fine:
src/systems/InputSystem.jssrc/systems/MovementSystem.jssrc/systems/DamageSystem.jssrc/systems/RenderSystem.js
You do not need to immediately create twenty subfolders.
Over-organizing a tiny project can make navigation harder rather than easier.
A useful rule is:
Add structure when the current structure becomes uncomfortable.
Not before.
Split Systems Into Domains When the Folder Becomes Crowded
Once the system count becomes large, domain folders can help.
For example, src/systems could eventually contain groups such as:
inputmovementphysicscamerainteractioninventorycombatstatstargetinguirendering
Then the movement domain might contain:
PlayerControllerSystem.jsGravitySystem.jsMovementSystem.js
Combat might contain:
DamageSystem.jsDeathSystem.jsHealSystem.js
UI might contain:
StatsUISystem.jsQuickbarUISystem.jsDamageNumberUISystem.jsTargetHealthBarUISystem.js
The important thing is that the folders reflect real responsibilities rather than arbitrary file grouping.
Keep UI Systems Separate by Responsibility
UI can become one of the largest parts of a game.
It is tempting to create one giant:
UISystem.js
that handles everything.
That usually becomes difficult to maintain.
A better approach is to keep UI responsibilities separate.
For example:
InventoryToggleSystemInventoryUISystemQuickbarUISystemStatsUISystemTargetHealthBarUISystemFloatingHealthBarSystemDamageNumberUISystem
These systems may all affect the UI, but they do different jobs.
One may control visibility.
Another renders inventory data.
Another displays combat feedback.
Another updates stats.
This makes each system easier to reason about.
UI Does Not Need to Become a Separate ECS
Keeping UI systems separate does not mean building another ECS specifically for the UI.
A DOM-based game UI can still react to ECS state.
For example, StatsUISystem might query:
PlayerControlledFinalStats
Then render:
- Attack
- Defense
The ECS remains the gameplay source of truth.
The UI observes that state.
Events Deserve Their Own Folder
As the game grows, event names should not be scattered as strings throughout the codebase.
A folder such as src/events gives them a clear home.
For example:
src/events/EventTypes.js
might contain:
1export const EVT_DAMAGE_RESOLVED =2 'damage-resolved'3
4export const EVT_STATS_CHANGED =5 'stats-changed'6
7export const EVT_PICKUP =8 'pickup'9
10export const EVT_USE_ITEM_REQUEST =11 'use-item-request'Now systems import shared event identifiers instead of recreating strings.
This gives the project one source of truth for event names.
Events and the Event Bus Are Different Things
It is useful to distinguish between:
src/ecs/EventBus.js
and:
src/events/EventTypes.js
The Event Bus is infrastructure.
It knows how to:
- queue events
- consume events
- move next-frame events
- manage event lifetime
EventTypes.js is game-specific.
It knows that the game has events such as:
- damage resolved
- pickup
- use-item request
- stats changed
That separation keeps the reusable engine layer independent from the game.
Use Factories to Compose Entities
Entity creation can become complicated very quickly.
Creating a player might require:
- Transform
- Velocity
- Mesh
- Health
- Input
- PlayerControlled
- ControllerSettings
- Grounded
- CapsuleCollider
- Inventory
- Equipment
- BaseStats
- FinalStats
- Quickbar
Putting all of that directly into main.js makes the bootstrap file enormous.
Factories solve that problem.
For example:
src/game/PlayerFactory.js
could be responsible for creating and composing the player.
A simplified version might look like:
1export function createPlayer(2 world,3 options = {},4) {5 const entity =6 world.createEntity()7
8 world.addComponent(9 entity,10 new Transform(0, 1, 0),11 )12
13 world.addComponent(14 entity,15 new Velocity(),16 )17
18 world.addComponent(19 entity,20 new Health(100),21 )22
23 world.addComponent(24 entity,25 new PlayerControlled(),26 )27
28 return entity29}The factory knows how a player is assembled.
The player entity itself remains only an ID.
Factories Are Composition Recipes
A useful way to think about factories is:
A factory describes how to assemble a particular game entity.
For example:
PlayerFactory
might compose the player.
DummyFactory
might compose a test enemy.
GroundFactory
might create ground geometry and collision.
WallFactory
might create static walls.
CameraFactory
might create the camera entity.
TestLevelFactory
might assemble a test scene.
Factories let the rest of the project say:
1const player =2 createPlayer(world)instead of repeating a large component setup everywhere.
Factories Should Not Become Gameplay Systems
A factory should usually assemble initial state.
It should not remain responsible for the entity after creation.
For example, PlayerFactory can create:
- health
- inventory
- controller settings
But it should not later:
- move the player
- apply damage
- process input
- update inventory
- respawn the player
Those behaviours belong in systems.
The factory builds the entity.
Systems operate on it afterward.
Keep Static Definitions Separate From Runtime State
Some data describes reusable game content rather than entity state.
Examples include:
- item definitions
- ability definitions
- loot tables
- enemy templates
- weapon modifiers
- status-effect definitions
Those do not necessarily belong in components.
For example:
1export const ItemDatabase = {2 bronzeSword: {3 name: 'Bronze Sword',4
5 modifiers: {6 attack: 5,7 },8 },9
10 healthPotion: {11 name: 'Health Potion',12
13 type: 'heal',14 amount: 25,15 },16}An Inventory component may store the item ID.
A system can look up the reusable definition.
This keeps static configuration separate from mutable runtime state.
Give Definitions a Clear Home
As the project grows, static definitions can live somewhere such as:
src/datasrc/definitionssrc/game/data
The exact folder name is less important than the boundary.
The important distinction is:
- components describe current entity state
- definitions describe reusable game configuration
- systems interpret those values
Keep the Bootstrap File Focused
Eventually main.js becomes the place where the engine is assembled.
That file should not contain the implementation of every system.
Instead, it should mainly:
- create the world
- create the scene
- create initial entities
- register systems
- establish system order
- start the game loop
A simplified setup could look like:
1const world = new World()2
3createTestLevel(world)4createPlayer(world)5createCamera(world)6
7world.addSystem(8 new InputSystem(world),9)10
11world.addSystem(12 new PlayerControllerSystem(world),13)14
15world.addSystem(16 new GravitySystem(world),17)18
19world.addSystem(20 new MovementSystem(world),21)22
23world.addSystem(24 new CollisionSystem(world),25)26
27world.addSystem(28 new CameraSystem(world),29)30
31world.addSystem(32 new RenderSystem(world),33)The bootstrap shows how the engine is wired together without containing the implementation details.
System Registration Documents the Pipeline
The order in which systems are registered is part of the architecture.
That means main.js or another bootstrap file becomes useful documentation.
Someone reading it can see:
- input happens first
- control logic follows
- movement and physics run
- camera updates
- UI reacts
- rendering happens last
Grouping the registration by phase can make this even clearer.
Consider a Dedicated System Registration Function
As the system list grows, even main.js can become crowded.
At that point, you might extract system setup:
1export function registerSystems(2 world,3 dependencies,4) {5 world.addSystem(6 new InputSystem(world),7 )8
9 world.addSystem(10 new PlayerControllerSystem(world),11 )12
13 world.addSystem(14 new GravitySystem(world),15 )16
17 world.addSystem(18 new MovementSystem(world),19 )20}Then main.js remains focused on application startup.
This is optional.
Do it when the registration list becomes difficult to manage.
Keep Engine Dependencies Flowing in One Direction
A useful project rule is to make dependencies flow from generic code toward specific code.
For example:
- ECS core knows nothing about gameplay
- components may depend on ECS base classes
- systems depend on ECS infrastructure and components
- factories depend on the world and components
- gameplay code may depend on static definitions
- bootstrap code assembles everything
The dangerous direction is when the ECS core starts importing game-specific systems or components.
For example, World.js should not import PlayerControlled or DamageSystem.
If it does, the supposedly reusable core now depends on one particular game.
Think in Layers
A useful high-level structure is:
Core ECS
- World
- Component
- System
- Query
- EventBus
Game State
- components
Game Rules
- systems
Communication
- event types
Entity Composition
- factories
Static Content
- item, ability, enemy, and loot definitions
Application Bootstrap
- world creation
- system registration
- game loop
Each layer has a different responsibility.
Avoid Circular Dependencies
Poor project boundaries often produce circular imports.
For example:
DamageSystem imports Health.
Then Health imports DamageSystem.
Then another component imports both.
That usually means behaviour and data have become too tightly coupled.
Data-focused components help avoid this.
Components generally do not need to import systems.
Systems can import components.
That creates a much cleaner dependency direction.
Components Should Rarely Know About Systems
A simple rule that catches many architectural problems is:
Components should almost never import systems.
For example, this should make you suspicious:
1import { DamageSystem }2 from '../systems/DamageSystem.js'inside Health.js.
The component should not need to know which system operates on it.
The relationship should go the other way.
DamageSystem knows it needs Health.
Health does not know DamageSystem exists.
Systems Should Not Directly Own Other Systems Either
Another common coupling problem is:
1class DamageSystem {2 constructor(3 world,4 damageNumberSystem,5 hitFlashSystem,6 ) {7 // ...8 }9}Sometimes direct dependencies are justified.
But when many systems begin owning references to other systems, the architecture becomes difficult to change.
Events can often provide a cleaner boundary.
For example, DamageSystem can emit:
EVT_DAMAGE_RESOLVED
Then:
DamageNumberUISystemHitFlashSystem- health-bar systems
can react independently.
Put Test Systems Somewhere Obvious
During development, temporary systems are extremely useful.
Examples might include:
DamageTestEmitterSystemMeleeAttackTestSystem- debug spawning systems
- collision visualizers
- stat-debug systems
There is nothing wrong with having development helpers.
The important thing is making them obvious.
A test system should not look like permanent production gameplay by accident.
You could:
- keep
Testin the filename - place them under a
debugortestfolder later - clearly group them during system registration
That makes cleanup easier when the project matures.
Debug UI Can Be Separate From Player UI
Debug information may use the same DOM technology as gameplay UI, but it serves a different purpose.
For example:
StatsUISystem
might initially be useful as a development HUD.
Later, the real player interface may show stats somewhere else.
Keeping debugging responsibilities easy to identify prevents temporary tools from becoming permanent architecture accidentally.
Don't Put Everything in utils
A common project smell is a growing folder called:
utils
containing hundreds of unrelated files.
Utility folders are fine for genuinely generic helpers.
But gameplay concepts deserve meaningful homes.
For example:
ItemDatabase.js
is not just a utility.
DamageSystem.js
is not a utility.
PlayerFactory.js
is not a utility.
Good names and folders make architecture visible.
Prefer Domain Names Over Vague Names
Files such as:
Manager.jsHelper.jsUtils.jsHandler.js
often become dumping grounds.
Prefer names that explain the responsibility.
For example:
InventorySystemStatsSystemTargetingSystemPlayerFactoryEventTypesItemDatabase
The filename itself should help someone understand the architecture.
When Should You Split a Large System?
Folder structure cannot fix a system that owns too many responsibilities.
Suppose InventorySystem eventually handles:
- pickups
- item consumption
- equipment
- quickbar
- UI
- crafting
- vendors
Moving it into a folder called inventory does not solve the problem.
The responsibilities may need to become separate systems.
For example:
InventorySystemItemUseSystemEquipmentSystemQuickbarSystemInventoryUISystemVendorSystem
Good project structure reflects good responsibility boundaries.
It does not replace them.
When Should You Combine Systems?
The opposite problem is also possible.
You do not need a new system for every tiny operation.
If two pieces of logic:
- always run together
- operate on the same data
- share the same responsibility
- are unlikely to evolve independently
keeping them together may be simpler.
Project structure should make the code easier to understand, not maximize the number of files.
One Folder Per Feature Is Another Valid Approach
So far, we've mostly discussed organizing by technical type:
- components
- systems
- events
- game
Another approach is organizing by feature.
For example:
combat/inventory/movement/camera/
A combat folder might contain:
Health.jsDamageSystem.jsDeathSystem.js- combat events
This can work very well in large projects.
Neither approach is automatically correct.
Type-Based vs Feature-Based Structure
A type-based structure groups similar architectural objects:
- all components together
- all systems together
- all events together
Advantages:
- easy to learn
- easy to browse when the project is small
- architecture is obvious
A feature-based structure groups everything related to one gameplay domain.
Advantages:
- easier to work on one feature in a large project
- related files stay close together
- fewer giant global folders
A practical project can even use a hybrid approach.
Start Type-Based, Move Toward Domains When Needed
For a growing ECS engine, a sensible progression is:
- start with
ecs,components,systems,events, andgame - keep the structure simple while the project is small
- introduce subfolders when a category becomes crowded
- group by gameplay domain when that improves navigation
You do not need to predict the final folder structure on day one.
Let the project tell you when organization needs to evolve.
Keep Factories Near Game-Specific Composition
Factories usually belong closer to game code than ECS infrastructure.
For example:
PlayerFactoryDummyFactoryGroundFactoryWallFactoryCameraFactoryTestLevelFactory
These are specific to the game being built.
A generic ECS library should not know what any of those things mean.
That is why something like src/game is a useful boundary.
Separate Engine Architecture From Level Content
A level factory can create:
- ground
- walls
- enemies
- checkpoints
- pickups
- lights
But that content should not leak into the ECS infrastructure.
World.js should not know that the test level contains three hostile dummies.
The level creates entities using the world.
The world simply manages those entities afterward.
Static Data Can Eventually Become Authoring Data
At first, game definitions may be JavaScript objects.
Later, they may come from:
- JSON
- an editor
- a database
- external content files
- server data
If the ECS core does not depend on where those definitions came from, changing the authoring pipeline becomes much easier.
That is another benefit of keeping data definitions separate from systems.
Structure Helps Future Editor Development
A clear architecture also helps if you eventually build game-engine editor tools.
An editor may need to discover:
- components
- entities
- component fields
- levels
- static definitions
If runtime state and behaviour are cleanly separated, it becomes easier to build:
- scene trees
- inspectors
- component editors
- level creation tools
- property panels
- debug visualizers
Good folder structure is not enough by itself, but clean architectural boundaries make tooling much easier.
Structure Helps Multiplayer Too
Multiplayer adds another major domain:
- network input
- snapshots
- replication
- prediction
- reconciliation
- authority
If core gameplay already has clear boundaries, networking can be introduced without rewriting every feature.
For example:
- components remain state
- systems remain simulation rules
- request events represent intent
- networking transports selected information
- server-side systems remain authoritative
A project with everything mixed into main.js is much harder to evolve in this direction.
A Practical Project Layout
A growing ECS game might eventually have a structure like:
src/ecs- core ECS infrastructure
src/components- runtime entity state
src/systems- gameplay and presentation behaviour
src/events- game-specific event definitions
src/game- entity factories and level creation
src/data- static item, ability, loot, and enemy definitions
src/components/ui- optional UI-specific helpers if needed
src/debug- optional debug and test tools
main.js- application startup and system registration
This is only one possible structure.
The important part is the responsibility of each area.
A Practical Dependency Direction
A healthy dependency flow might look like this:
ECS core
does not depend on game code.
Components
may depend on basic ECS types.
Systems
depend on ECS infrastructure, components, events, and definitions.
Factories
depend on the world and components to compose entities.
Bootstrap
depends on everything necessary to assemble the application.
Dependencies flow toward the application layer.
The generic layers do not need to know about the specific game built on top of them.
Signs Your Project Structure Needs Work
A few warning signs are worth watching for.
main.js contains thousands of lines
Entity construction or gameplay logic probably needs to move elsewhere.
Components import systems
State and behaviour are becoming coupled.
One system imports ten other systems
Direct system dependencies may be getting out of control.
Every file lives in systems
It may be time for domain subfolders.
Everything lives in utils
Responsibilities are not being named clearly.
Factories contain update logic
Entity creation and runtime behaviour are becoming mixed.
Item definitions are duplicated everywhere
Static configuration needs a shared home.
You cannot find where an event is defined
Event names probably need central organization.
Don't Refactor Structure Constantly
There is also a danger in spending too much time reorganizing folders.
A project can become stuck in endless architecture refactors.
If the current structure is easy to understand and easy to work with, it may already be good enough.
Refactor when you have a real problem:
- navigation is becoming difficult
- responsibilities are unclear
- circular dependencies are appearing
- files are becoming enormous
- multiple features are interfering with each other
Structure should support development rather than replace it.
A Useful Rule for New Files
Whenever you add a new piece of functionality, ask what kind of thing it is.
ECS infrastructure?
Put it in the ECS core.
Examples:
- query implementation
- Event Bus
- world management
Runtime state?
Create a component.
Examples:
- health
- inventory
- velocity
- status effects
Behaviour?
Create or extend a system.
Examples:
- damage resolution
- movement
- targeting
Something that happened?
Define an event.
Examples:
- damage resolved
- item picked up
- stats changed
A recipe for creating an entity?
Use a factory.
Examples:
- player
- enemy
- camera
- checkpoint
Reusable static configuration?
Use a definition or data file.
Examples:
- item database
- abilities
- loot tables
That simple classification keeps many files from ending up in the wrong layer.
The Bigger Architectural Picture
A well-structured ECS project has a clear flow.
The ECS core provides the machinery.
Components provide runtime state.
Factories compose entities from components.
Systems process those entities through queries.
Events communicate meaningful requests and outcomes.
Static definitions provide reusable configuration.
The bootstrap establishes system order and starts the simulation.
Each part has a clear responsibility.
Conclusion
A good ECS project structure does not need to be complicated.
A strong starting point is simply:
ecsfor reusable infrastructurecomponentsfor statesystemsfor behavioureventsfor communication contractsgamefor factories and level composition- a dedicated location for static definitions
- a small bootstrap responsible for assembling everything
The most important rule is not the exact folder names.
It is the direction of responsibility.
The ECS core should remain generic.
Components should remain data-focused.
Systems should own gameplay behaviour.
Factories should compose entities.
Events should describe meaningful requests and outcomes.
Definitions should describe reusable static content.
And the bootstrap should make the update pipeline easy to understand.
As the project grows, folders can evolve into domains such as combat, movement, inventory, UI, and networking.
But those changes should happen because the project needs them, not because a perfect folder structure was designed before the game existed.
Good organization should make the architecture easier to see.
When you can open a project and quickly understand where state lives, where behaviour lives, how entities are created, how systems communicate, and where the update pipeline is assembled, the project becomes much easier to keep growing.

Build a simple Entity Component System in JavaScript from scratch with entities, data-only components, queries, systems, and a working update loop.

Learn why ECS system order matters and how to structure an update pipeline for input, movement, physics, combat, UI, cameras, and rendering.

Learn why ECS components should stay data-focused, how logic-heavy components create coupling, and why systems are a better place for gameplay behaviour.