
ECS Components Should Contain Data, Not Logic — Here’s Why
One of the most important ECS design rules is also one of the easiest to break:
Components should contain data, not gameplay logic.
At first, that can sound unnecessarily strict.
If a Health component stores health, why not give it a takeDamage() method?
If an Inventory component stores items, why not give it an addItem() or usePotion() method?
If an Equipment component knows what is equipped, why not let it calculate stat bonuses too?
Those choices often feel convenient in the beginning.
The problem is that as the game grows, those components slowly become miniature gameplay objects.
They stop being simple pieces of state and start accumulating rules, dependencies, side effects, and assumptions about the rest of the game.
In this guide, we'll look at why data-focused components are so useful in ECS, what goes wrong when components begin owning behaviour, and how systems give us a cleaner place for gameplay logic.
The Core Rule
A useful ECS mental model is:
1Entity2Identity3
4Component5State6
7System8BehaviourOr even more simply:
1Components describe what exists.2
3Systems decide what happens.For example, a Health component might contain:
1export class Health {2 constructor(max = 100) {3 this.current = max4 this.max = max5 }6}It tells us:
This entity has health, and these are its current values.
It does not decide:
- how damage is calculated
- whether armor reduces damage
- whether the hit is critical
- whether the entity dies
- whether a damage number appears
- whether the target flashes
- whether a sound plays
Those are gameplay behaviours.
The Tempting Version
The obvious object-oriented version might look like this:
1export class Health {2 constructor(max = 100) {3 this.current = max4 this.max = max5 }6
7 takeDamage(amount) {8 this.current -= amount9
10 if (this.current < 0) {11 this.current = 012 }13 }14
15 heal(amount) {16 this.current += amount17
18 if (this.current > this.max) {19 this.current = this.max20 }21 }22}This is not inherently bad JavaScript.
In many architectures, it may be perfectly reasonable.
But in a data-oriented ECS, we usually want to separate the state from the rules acting on that state.
Why?
Because damage almost never stays this simple.
Damage Grows Quickly
Imagine we start with:
1health.takeDamage(10)Later, damage needs to consider:
- attack power
- defense
- armor
- critical hits
- resistances
- shields
- buffs
- debuffs
- invulnerability
- damage types
- status effects
- difficulty modifiers
- multiplayer authority
Now Health.takeDamage() needs more context.
Perhaps we change it to:
1health.takeDamage(2 amount,3 attacker,4 stats,5 equipment,6 statusEffects,7)The component is no longer just storing health.
It now needs to understand a large portion of the combat system.
That is the kind of coupling ECS is usually trying to avoid.
Put the Rule in a System
Instead, Health can stay simple:
1export class Health {2 constructor(max = 100) {3 this.current = max4 this.max = max5 }6}Then a DamageSystem owns the gameplay rule.
Conceptually:
1const health =2 world.getComponent(3 target,4 Health,5 )6
7const stats =8 world.getComponent(9 target,10 FinalStats,11 )12
13const finalDamage =14 Math.max(15 1,16 incomingDamage - stats.defense,17 )18
19health.current -= finalDamageNow the component stores health.
The system decides what damage means.
That separation becomes more valuable as combat evolves.
Components Become Easier to Understand
A good component should usually be easy to inspect.
For example:
1class Velocity {2 constructor(3 x = 0,4 y = 0,5 z = 0,6 ) {7 this.x = x8 this.y = y9 this.z = z10 }11}You immediately understand the state.
Likewise:
1class Equipment {2 constructor() {3 this.weapon = null4 this.head = null5 this.body = null6 this.accessory = null7 }8}The component tells us what is equipped.
It does not contain hidden rules about:
- item compatibility
- stat modifiers
- inventory removal
- UI refresh
- combat changes
That makes the data model easier to reason about.
Data-Only Components Are Easier to Reuse
Suppose Health contains only:
1current2maxNow many systems can use it.
For example:
1DamageSystem2HealSystem3DeathSystem4HealthBarSystem5RegenerationSystem6StatusEffectSystemEach system can interpret the same data for a different responsibility.
If Health itself owns all health-related behaviour, those responsibilities become harder to separate.
The component starts becoming the center of the feature.
That moves us back toward object-oriented ownership rather than ECS composition.
Behaviour Belongs to the Context
Another reason to avoid component logic is that behaviour often depends on context outside the component.
Consider healing.
At first:
1health.heal(25)seems straightforward.
But later healing might depend on:
- healing bonuses
- anti-heal debuffs
- status effects
- maximum heal limits
- combat state
- equipment
- abilities
- difficulty
- source entity
The Health component does not naturally own all of that information.
A HealSystem can query or receive the context it needs.
Inventory Is Another Common Example
An inventory component might look like:
1export class Inventory {2 constructor() {3 this.items = new Map()4 }5}It stores inventory state.
A tempting version might add:
1addItem()2removeItem()3useItem()4equipItem()5dropItem()6sortItems()Soon the component needs to understand:
- item definitions
- equipment rules
- consumables
- player stats
- quickbar state
- UI selection
- loot
- crafting
- vendors
Now Inventory is no longer just inventory data.
It is becoming an inventory subsystem.
Separate Inventory Responsibilities
A cleaner ECS structure might be:
1Inventory2Stores item quantities.3
4Equipment5Stores equipped slots.6
7Quickbar8Stores quickbar assignments.Then systems handle behaviour:
1InventorySystem2Handles item pickups and inventory changes.3
4ItemUseSystem5Validates and resolves item use.6
7EquipmentSystem8Handles equipping and unequipping.9
10QuickbarSystem11Handles quickbar actions.The components remain focused on state.
The systems remain focused on behaviour.
Equipment Should Not Calculate Stats
Suppose we have:
1class Equipment {2 constructor() {3 this.weapon = null4 this.head = null5 this.body = null6 this.accessory = null7 }8}It may be tempting to add:
1calculateAttack()2calculateDefense()But equipment itself is only one source of stat modifiers.
Final stats may eventually depend on:
- base stats
- equipment
- buffs
- debuffs
- status effects
- passive abilities
- temporary bonuses
A StatsSystem is a better place to combine all of those sources.
For example:
1BaseStats2+3Equipment modifiers4+5Status effects6=7FinalStatsThe Equipment component does not need to know how final stats are calculated.
Static Definitions Are Different From Runtime Components
Not all data needs to live in components.
Static configuration can remain in ordinary definition objects.
For example:
1const ItemDatabase = {2 bronzeSword: {3 name: 'Bronze Sword',4
5 modifiers: {6 attack: 5,7 },8 },9
10 healthPotion: {11 name: 'Health Potion',12 healAmount: 25,13 },14}This is not mutable runtime state for one entity.
It describes reusable item definitions.
An Equipment component might store:
1equipment.weapon =2 'bronzeSword'Then StatsSystem looks up the item definition and applies its modifiers.
This gives us a useful separation:
1Component2Runtime state3
4Definition5Static configuration6
7System8Gameplay ruleStatus Effects Show Why This Matters
Status effects are another place where logic can quickly spread.
Imagine adding poison.
A logic-heavy health component might become:
1health.applyPoison()2health.updatePoison()3health.removePoison()Now Health knows about poison.
Then we add:
- burning
- regeneration
- stun
- slow
- bleed
- shields
Soon the health component is responsible for an entire status-effect system.
A better structure is to store status-effect state separately.
For example:
1class StatusEffects {2 constructor() {3 this.effects = []4 }5}An effect might store:
1type2durationRemaining3tickInterval4damageThen a StatusEffectSystem processes those effects.
If poison deals damage, it can feed into the existing damage pipeline rather than implementing its own special health logic.
Reuse Existing Pipelines
This is one of the biggest advantages of separating behaviour.
Suppose poison ticks for five damage.
Instead of:
1PoisonSystem2directly reduces Healthwe can use:
1StatusEffectSystem2 ↓3Damage Request4 ↓5DamageSystem6 ↓7Health updated8 ↓9Damage feedbackNow poison automatically benefits from the same:
- defense rules
- damage events
- damage numbers
- death detection
- combat logging
The status system only needs to decide when poison should deal damage.
The damage system handles what damage means.
Systems Can Coordinate Multiple Components
A component usually represents one piece of state.
A system can work across several components.
For example, StatsSystem might need:
1BaseStats2FinalStats3Equipment4StatusEffectsA PlayerControllerSystem might need:
1PlayerControlled2Input3Velocity4ControllerSettings5GroundedA DamageSystem might use:
1Health2FinalStats3StatusEffects4DeadThis is one reason systems are a natural place for gameplay logic.
They can coordinate the data required for a rule without forcing one component to own everything.
Logic-Heavy Components Create Hidden Dependencies
Imagine a component method like:
1equipment.equip(item)What does it actually do?
Maybe it:
- removes the old item
- adds the new item
- modifies stats
- updates quickbar slots
- emits an event
- updates inventory
- triggers UI refresh
That behaviour may not be obvious from the method call.
The component now contains hidden dependencies.
A system makes those responsibilities easier to see because its entire purpose is to coordinate that gameplay operation.
Data Components Are Easier to Serialize
Data-focused components also work well with save/load systems.
For example:
1{2 weapon: 'bronzeSword',3 head: null,4 body: 'leatherArmor',5 accessory: null,6}is straightforward to serialize.
Likewise:
1{2 current: 75,3 max: 100,4}is simple state.
When components contain methods, callbacks, references to systems, or other runtime behaviour, serialization becomes more complicated.
This matters for:
- save games
- level loading
- networking
- snapshots
- replays
- debugging tools
Data Components Are Easier to Replicate
The same principle helps with multiplayer.
Network replication usually cares about state.
For example:
1Health.current2Transform.position3Equipment.weaponThose are values that can be sent across a network.
A component method such as:
1health.takeDamage()is not state.
The server should usually resolve the gameplay action and then replicate the resulting state or event.
Keeping components data-focused makes the boundary between simulation and replication clearer.
Data Components Are Easier to Inspect
Imagine building an ECS debugger or editor.
If components contain simple data, an inspector can display:
1Transform2 x: 123 y: 04 z: -55
6Health7 current: 758 max: 1009
10Equipment11 weapon: bronzeSwordThat is very easy to visualize.
This is one reason data-oriented architecture fits naturally with editor tooling.
The state is explicit.
Data Components Are Easier to Test
Suppose we want to test damage.
We can create:
1Health2FinalStatswith known values.
Then run DamageSystem.
Afterward, we inspect the component data.
For example:
1expect(2 health.current,3).toBe(90)The component itself does not need to be mocked.
The test focuses on the system that owns the rule.
This can make behaviour easier to test in isolation.
Data-Only Does Not Mean Zero Methods Forever
This rule should not become dogma.
Sometimes a tiny helper method on a component can be harmless.
For example, depending on your codebase, you might choose to add a utility such as:
1reset()to simplify initialization.
The important question is not:
Does this component contain any function at all?
The better question is:
Is this component beginning to own gameplay behaviour?
A convenience method that only manages its own simple data is very different from a method coordinating combat, inventory, UI, and events.
Constructors Are Fine
A constructor is not gameplay logic.
For example:
1class Health {2 constructor(max = 100) {3 this.current = max4 this.max = max5 }6}The constructor initializes data.
Likewise:
1class Transform {2 constructor(3 x = 0,4 y = 0,5 z = 0,6 ) {7 this.position = {8 x,9 y,10 z,11 }12 }13}This is still a data-focused component.
The goal is not to eliminate normal JavaScript structure.
The goal is to keep gameplay rules out of the component.
Marker Components Are the Purest Example
A marker component may contain no data at all.
For example:
1export class PlayerControlled {}or:
1export class Dead {}Its presence itself carries meaning.
A system can query for:
1PlayerControlled + Input + Velocityor exclude:
1DeadMarker components demonstrate that a component does not need behaviour to be useful.
Sometimes merely adding the component changes which systems process the entity.
Components Describe Capabilities
When components stay focused on state, they become building blocks for capabilities.
For example:
1Transform + Velocityallows participation in movement.
1Healthallows participation in damage and healing.
1Inventory + Equipmentallows participation in equipment logic.
1PlayerControlled + Inputallows participation in player control.
The capability emerges because systems query those components.
The components themselves do not implement the capability.
Avoid Getter Methods That Hide Real Logic
Even logic that looks like a simple getter can grow unexpectedly.
For example:
1equipment.getAttackBonus()may start simple.
Later it needs:
- item definitions
- rarity
- durability
- enchantments
- set bonuses
Now a method that looked like a harmless getter is performing gameplay calculation.
It may be cleaner for a stat system to calculate the final value explicitly.
Derived Data Can Be Its Own Component
Sometimes a system repeatedly calculates an important result.
Instead of placing that calculation inside another component, we can store the result separately.
For example:
1BaseStatsstores:
1attack: 52defense: 2while:
1FinalStatsstores the calculated values after modifiers.
Then StatsSystem owns the calculation.
This gives other systems a clean, ready-to-use component.
For example:
1DamageSystem2reads FinalStats.attackIt does not need to recalculate equipment bonuses itself.
Systems Should Have Clear Responsibilities Too
Moving logic out of components does not mean putting everything into one enormous system.
For example, avoid replacing:
1Health.takeDamage()2Health.heal()3Health.die()with:
1EverythingGameplaySystemA cleaner separation might be:
1DamageSystem2Resolves damage.3
4HealSystem5Resolves healing.6
7DeathSystem8Handles death state.9
10StatsSystem11Calculates stats.The goal is clear responsibility at both levels.
Don't Split Systems Too Far Either
There is a balance.
You probably do not need:
1SubtractHealthSystem2ClampHealthSystem3CheckZeroHealthSystem4EmitDeathEventSystemfor every tiny step.
The objective is not maximum separation.
It is understandable ownership.
A good system should usually be easy to describe in one sentence.
Example: Health Done the ECS Way
A clean health pipeline might look like this:
1Health Component2
3current4maxThen:
1DamageSystem2reads/writes Health1HealSystem2reads/writes Health1DeathSystem2reads Health3adds Dead when appropriate1HealthBarSystem2reads Health3displays itThe component is shared state.
Each system owns a different responsibility.
Example: Equipment Done the ECS Way
The component:
1class Equipment {2 constructor() {3 this.weapon = null4 this.head = null5 this.body = null6 this.accessory = null7 }8}The item database:
1const ItemDatabase = {2 bronzeSword: {3 modifiers: {4 attack: 5,5 },6 },7}Then:
1EquipmentSystem2changes equipped itemsand:
1StatsSystem2reads Equipment3reads ItemDatabase4writes FinalStatsNo single component needs to understand the entire feature.
Example: Item Use Done the ECS Way
Inventory state:
1Inventory2itemsRequest:
1EVT_USE_ITEM_REQUESTGameplay system:
1ItemUseSystemPossible result:
1Heal eventThen:
1HealSystemupdates:
1HealthThe flow might look like:
1Input2↓3Use Item Request4↓5ItemUseSystem6↓7Inventory changes8↓9Heal event10↓11HealSystem12↓13Health changesEach stage owns one responsibility.
Why This Helps With Future Features
A data-focused design makes it easier to add new behaviour around existing state.
Suppose we add a shield system.
We do not need to modify Health.
We can add:
1Shieldand teach DamageSystem how shields affect incoming damage.
Later, we add resistance.
Again, Health can remain unchanged.
The gameplay rule evolves in the system that owns damage resolution.
That is a strong sign of good separation.
Why This Helps With Multiplayer
In multiplayer, gameplay authority becomes important.
A client should usually not be allowed to directly decide:
1target health -= 50The server resolves the rule.
A system-oriented design naturally supports that thinking.
For example:
1Attack Request2↓3Server validation4↓5DamageSystem6↓7Health updated8↓9Result replicatedThe Health component remains simple state.
The authority lives in the simulation logic.
Why This Helps With Replays and Determinism
Replays and deterministic simulations work best when state and behaviour are clearly separated.
If components contain hidden logic and side effects, reproducing the same simulation becomes harder.
With data-focused components, the simulation can operate on explicit state through ordered systems.
That makes it easier to understand:
1Input2↓3Systems4↓5State changesinstead of state mutating unpredictably from methods scattered throughout components.
Why This Helps With Editor Tools
Imagine building a Unity-style inspector for your ECS.
A data-focused component can be shown as editable fields.
For example:
1ControllerSettings2
3walkSpeed: 54runSpeed: 85jumpForce: 66acceleration: 12An editor can expose those values directly.
The less behaviour hidden inside components, the easier it is to build tools around them.
When You Notice a Component Growing
A useful warning sign is when a component begins importing many unrelated systems or game modules.
For example:
1import ItemDatabase from ...2import EventBus from ...3import DamageSystem from ...4import AudioManager from ...5import UIManager from ...inside a component should make you stop and ask:
Is this still just component data?
Usually, the answer is no.
Another warning sign is a component with dozens of gameplay methods.
That often means behaviour is drifting into the wrong layer.
A Useful Decision Test
When deciding whether something belongs in a component, ask:
Does this describe current state?
It probably belongs in a component.
Examples:
1current health2equipped weapon3position4velocity5inventory contents6cooldown remainingDoes this describe a gameplay rule?
It probably belongs in a system.
Examples:
1calculate damage2use an item3apply poison4equip a weapon5calculate final stats6respawn an entityIs this reusable static configuration?
Use a definition or database object.
Examples:
1item definitions2ability definitions3enemy templates4loot tablesDid something happen?
Consider an event.
Examples:
1damage resolved2item picked up3stats changed4item use requestedA Practical Mental Model
A useful way to think about the layers is:
1ENTITY2Who is this?3
4COMPONENT5What state does it have?6
7SYSTEM8What rules operate on that state?9
10EVENT11What happened?12
13DEFINITION14What static configuration describes it?For an equipped sword:
1Entity2Player3
4Component5Equipment.weapon = bronzeSword6
7Definition8bronzeSword9attack +510
11System12StatsSystem13
14Result15FinalStats.attack = 10Each part has a clear responsibility.
Components Are Data, But ECS Is Still Flexible
There is no universal law saying every ECS component in every engine must contain absolutely zero behaviour.
Different ECS libraries make different trade-offs.
The important architectural goal is to avoid turning components into large objects that own gameplay.
If your component remains:
- easy to serialize
- easy to inspect
- easy to copy
- easy to query
- free of unrelated dependencies
then you are probably still keeping the right separation.
The Bigger Picture
Keeping components data-focused supports the rest of the ECS architecture.
1Entity2Identity3
4↓5
6Components7State8
9↓10
11Queries12Find matching entities13
14↓15
16Systems17Apply rules18
19↓20
21Events22Communicate outcomesEach layer has a clear purpose.
That clarity becomes increasingly valuable as the game grows.
Conclusion
The rule:
Components should contain data, not logic.
is not about following ECS dogma.
It is about keeping responsibilities clear.
A Health component should tell us how much health an entity has.
A DamageSystem should decide how damage works.
An Inventory component should tell us what items an entity owns.
An ItemUseSystem should decide what happens when an item is used.
An Equipment component should tell us what is equipped.
A StatsSystem should decide how those items affect final stats.
That separation gives us components that are:
- easier to understand
- easier to serialize
- easier to inspect
- easier to replicate
- easier to test
- easier to reuse
and systems that can evolve independently as gameplay becomes more complex.
The useful mental model is simple:
1Components describe state.2
3Systems define behaviour.Once that boundary becomes natural, ECS architecture becomes much easier to extend without turning every component into another oversized gameplay object.

Learn how to decide whether game logic belongs in an Entity, Component, or System when building an Entity Component System in JavaScript.

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

Learn how ECS queries find entities by component combinations, why queries are central to system design, and how cached queries can improve performance.