AR
AgentRuss
ECS architecture diagram comparing data-only components with logic-heavy components and showing systems as the place for gameplay behaviour.
AgentRuss Guide

ECS Components Should Contain Data, Not Logic — Here’s Why

Written by
AgentRuss
Published

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:

JavaScript
1Entity
2Identity
3
4Component
5State
6
7System
8Behaviour

Or even more simply:

JavaScript
1Components describe what exists.
2
3Systems decide what happens.

For example, a Health component might contain:

JavaScript
1export class Health {
2 constructor(max = 100) {
3 this.current = max
4 this.max = max
5 }
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:

JavaScript
1export class Health {
2 constructor(max = 100) {
3 this.current = max
4 this.max = max
5 }
6
7 takeDamage(amount) {
8 this.current -= amount
9
10 if (this.current < 0) {
11 this.current = 0
12 }
13 }
14
15 heal(amount) {
16 this.current += amount
17
18 if (this.current > this.max) {
19 this.current = this.max
20 }
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:

JavaScript
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:

JavaScript
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:

JavaScript
1export class Health {
2 constructor(max = 100) {
3 this.current = max
4 this.max = max
5 }
6}

Then a DamageSystem owns the gameplay rule.

Conceptually:

JavaScript
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 -= finalDamage

Now 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:

JavaScript
1class Velocity {
2 constructor(
3 x = 0,
4 y = 0,
5 z = 0,
6 ) {
7 this.x = x
8 this.y = y
9 this.z = z
10 }
11}

You immediately understand the state.

Likewise:

JavaScript
1class Equipment {
2 constructor() {
3 this.weapon = null
4 this.head = null
5 this.body = null
6 this.accessory = null
7 }
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:

JavaScript
1current
2max

Now many systems can use it.

For example:

JavaScript
1DamageSystem
2HealSystem
3DeathSystem
4HealthBarSystem
5RegenerationSystem
6StatusEffectSystem

Each 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:

JavaScript
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:

JavaScript
1export class Inventory {
2 constructor() {
3 this.items = new Map()
4 }
5}

It stores inventory state.

A tempting version might add:

JavaScript
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:

JavaScript
1Inventory
2Stores item quantities.
3
4Equipment
5Stores equipped slots.
6
7Quickbar
8Stores quickbar assignments.

Then systems handle behaviour:

JavaScript
1InventorySystem
2Handles item pickups and inventory changes.
3
4ItemUseSystem
5Validates and resolves item use.
6
7EquipmentSystem
8Handles equipping and unequipping.
9
10QuickbarSystem
11Handles quickbar actions.

The components remain focused on state.

The systems remain focused on behaviour.


Equipment Should Not Calculate Stats

Suppose we have:

JavaScript
1class Equipment {
2 constructor() {
3 this.weapon = null
4 this.head = null
5 this.body = null
6 this.accessory = null
7 }
8}

It may be tempting to add:

JavaScript
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:

JavaScript
1BaseStats
2+
3Equipment modifiers
4+
5Status effects
6=
7FinalStats

The 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:

JavaScript
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:

JavaScript
1equipment.weapon =
2 'bronzeSword'

Then StatsSystem looks up the item definition and applies its modifiers.

This gives us a useful separation:

JavaScript
1Component
2Runtime state
3
4Definition
5Static configuration
6
7System
8Gameplay rule

Status 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:

JavaScript
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:

JavaScript
1class StatusEffects {
2 constructor() {
3 this.effects = []
4 }
5}

An effect might store:

JavaScript
1type
2durationRemaining
3tickInterval
4damage

Then 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:

JavaScript
1PoisonSystem
2directly reduces Health

we can use:

JavaScript
1StatusEffectSystem
2
3Damage Request
4
5DamageSystem
6
7Health updated
8
9Damage feedback

Now 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:

JavaScript
1BaseStats
2FinalStats
3Equipment
4StatusEffects

A PlayerControllerSystem might need:

JavaScript
1PlayerControlled
2Input
3Velocity
4ControllerSettings
5Grounded

A DamageSystem might use:

JavaScript
1Health
2FinalStats
3StatusEffects
4Dead

This 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:

JavaScript
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:

JavaScript
1{
2 weapon: 'bronzeSword',
3 head: null,
4 body: 'leatherArmor',
5 accessory: null,
6}

is straightforward to serialize.

Likewise:

JavaScript
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:

JavaScript
1Health.current
2Transform.position
3Equipment.weapon

Those are values that can be sent across a network.

A component method such as:

JavaScript
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:

JavaScript
1Transform
2 x: 12
3 y: 0
4 z: -5
5
6Health
7 current: 75
8 max: 100
9
10Equipment
11 weapon: bronzeSword

That 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:

JavaScript
1Health
2FinalStats

with known values.

Then run DamageSystem.

Afterward, we inspect the component data.

For example:

JavaScript
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:

JavaScript
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:

JavaScript
1class Health {
2 constructor(max = 100) {
3 this.current = max
4 this.max = max
5 }
6}

The constructor initializes data.

Likewise:

JavaScript
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:

JavaScript
1export class PlayerControlled {}

or:

JavaScript
1export class Dead {}

Its presence itself carries meaning.

A system can query for:

JavaScript
1PlayerControlled + Input + Velocity

or exclude:

JavaScript
1Dead

Marker 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:

JavaScript
1Transform + Velocity

allows participation in movement.

JavaScript
1Health

allows participation in damage and healing.

JavaScript
1Inventory + Equipment

allows participation in equipment logic.

JavaScript
1PlayerControlled + Input

allows 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:

JavaScript
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:

JavaScript
1BaseStats

stores:

JavaScript
1attack: 5
2defense: 2

while:

JavaScript
1FinalStats

stores the calculated values after modifiers.

Then StatsSystem owns the calculation.

This gives other systems a clean, ready-to-use component.

For example:

JavaScript
1DamageSystem
2reads FinalStats.attack

It 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:

JavaScript
1Health.takeDamage()
2Health.heal()
3Health.die()

with:

JavaScript
1EverythingGameplaySystem

A cleaner separation might be:

JavaScript
1DamageSystem
2Resolves damage.
3
4HealSystem
5Resolves healing.
6
7DeathSystem
8Handles death state.
9
10StatsSystem
11Calculates 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:

JavaScript
1SubtractHealthSystem
2ClampHealthSystem
3CheckZeroHealthSystem
4EmitDeathEventSystem

for 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:

JavaScript
1Health Component
2
3current
4max

Then:

JavaScript
1DamageSystem
2reads/writes Health
JavaScript
1HealSystem
2reads/writes Health
JavaScript
1DeathSystem
2reads Health
3adds Dead when appropriate
JavaScript
1HealthBarSystem
2reads Health
3displays it

The component is shared state.

Each system owns a different responsibility.


Example: Equipment Done the ECS Way

The component:

JavaScript
1class Equipment {
2 constructor() {
3 this.weapon = null
4 this.head = null
5 this.body = null
6 this.accessory = null
7 }
8}

The item database:

JavaScript
1const ItemDatabase = {
2 bronzeSword: {
3 modifiers: {
4 attack: 5,
5 },
6 },
7}

Then:

JavaScript
1EquipmentSystem
2changes equipped items

and:

JavaScript
1StatsSystem
2reads Equipment
3reads ItemDatabase
4writes FinalStats

No single component needs to understand the entire feature.


Example: Item Use Done the ECS Way

Inventory state:

JavaScript
1Inventory
2items

Request:

JavaScript
1EVT_USE_ITEM_REQUEST

Gameplay system:

JavaScript
1ItemUseSystem

Possible result:

JavaScript
1Heal event

Then:

JavaScript
1HealSystem

updates:

JavaScript
1Health

The flow might look like:

JavaScript
1Input
2
3Use Item Request
4
5ItemUseSystem
6
7Inventory changes
8
9Heal event
10
11HealSystem
12
13Health changes

Each 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:

JavaScript
1Shield

and 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:

JavaScript
1target health -= 50

The server resolves the rule.

A system-oriented design naturally supports that thinking.

For example:

JavaScript
1Attack Request
2
3Server validation
4
5DamageSystem
6
7Health updated
8
9Result replicated

The 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:

JavaScript
1Input
2
3Systems
4
5State changes

instead 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:

JavaScript
1ControllerSettings
2
3walkSpeed: 5
4runSpeed: 8
5jumpForce: 6
6acceleration: 12

An 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:

JavaScript
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:

JavaScript
1current health
2equipped weapon
3position
4velocity
5inventory contents
6cooldown remaining

Does this describe a gameplay rule?

It probably belongs in a system.

Examples:

JavaScript
1calculate damage
2use an item
3apply poison
4equip a weapon
5calculate final stats
6respawn an entity

Is this reusable static configuration?

Use a definition or database object.

Examples:

JavaScript
1item definitions
2ability definitions
3enemy templates
4loot tables

Did something happen?

Consider an event.

Examples:

JavaScript
1damage resolved
2item picked up
3stats changed
4item use requested

A Practical Mental Model

A useful way to think about the layers is:

JavaScript
1ENTITY
2Who is this?
3
4COMPONENT
5What state does it have?
6
7SYSTEM
8What rules operate on that state?
9
10EVENT
11What happened?
12
13DEFINITION
14What static configuration describes it?

For an equipped sword:

JavaScript
1Entity
2Player
3
4Component
5Equipment.weapon = bronzeSword
6
7Definition
8bronzeSword
9attack +5
10
11System
12StatsSystem
13
14Result
15FinalStats.attack = 10

Each 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.

JavaScript
1Entity
2Identity
3
4
5
6Components
7State
8
9
10
11Queries
12Find matching entities
13
14
15
16Systems
17Apply rules
18
19
20
21Events
22Communicate outcomes

Each 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:

JavaScript
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.