Core Concepts

Systems

Systems process entities each frame (or on demand), updating trait data and implementing game logic.

Introduction

In ECS, Systems are the logic executors. They run over queries of entities that have the required traits, performing operations such as movement, AI, physics, or effects.

import { World } from 'koota'
import { Transform, Movement } from '../traits'

function movementSystem(world: World) {
  // Example: let's assume we have a Time trait
  const time = world.get(Time)
  if (!time) return

  const { delta } = time
  world.query(Transform, Movement).updateEach(([transform, movement]) => {
    transform.position.add(movement.velocity.clone().multiplyScalar(delta))
  })
}

Core Patterns

Update Systems

Most systems run every frame (or every tick). They typically:

  1. Retrieve relevant data from the world (e.g., Time, Input).
  2. Query entities matching a specific trait combination.
  3. Update trait data as needed.
function healthRegenSystem(world: World) {
  const time = world.get(Time)!
  world.query(Health, Regeneration).updateEach(([health, regen]) => {
    health.amount = Math.min(health.max, health.amount + regen.rate * time.delta)
  })
}

Event Systems

Sometimes, you only want to process logic when an event happens. Koota has an event system:

world.on('entityDamaged', ({ entity, amount }) => {
  // maybe spawn a blood effect or play a sound
})

You can emit events in your code:

entity.set(Health, (h) => {
  const newAmount = Math.max(0, h.amount - damage)
  world.emit('entityDamaged', { entity, amount: damage })
  return { ...h, amount: newAmount }
})

Cleanup Systems

Systems that remove entities or reset them:

function cleanupSystem(world: World) {
  world.query(Health).removeIf(([health]) => health.amount <= 0)
}

System Organization

Generally, you have a systems/ directory with one file per system or small system groups. In your main loop (or React’s useFrame), you call them in order:

export function GameLoop(world: World) {
  // Possibly in a requestAnimationFrame or React useFrame
  timeSystem(world)
  inputSystem(world)
  movementSystem(world)
  collisionSystem(world)
  cleanupSystem(world)
}

Performance Tips

  1. Cache queries: Repeatedly calling the same query can be more expensive. If you must query the same traits many times, store the result.
  2. Use SoA: For large numeric datasets, prefer schema-based traits for better performance.
  3. Selective Scheduling: Not every system needs to run every frame (e.g., AI pathfinding might run less often).

Change Detection Options

When using .updateEach(), you can specify how Koota handles change events:

  • changeDetection: 'never' — no change events triggered.
  • changeDetection: 'always' — changes are always emitted for mutated traits.
  • (default) — triggers changes only for traits or queries specifically tracking them.
world.query(Position, Velocity).updateEach(
  ([pos, vel]) => {
    // ...
  },
  { changeDetection: 'never' }
)

Testing Systems

Because systems are just functions, you can test them easily:

describe('movementSystem', () => {
  it('updates position based on velocity', () => {
    const world = createWorld()
    const ent = world.spawn(Transform(), Movement({ velocity: new Vector3(1, 0, 0) }))
    world.add(Time({ delta: 1 })) // simulate 1 second

    movementSystem(world)
    expect(ent.get(Transform)!.position.x).toBe(1)
  })
})

Next Steps

  • Check out Entities for more on entity life cycles.
  • Learn about Actions to see how systems integrate with centralized function calls.
  • Components covers how to render ECS data in React + Three.js.