0.4

Patterns

Common game patterns implemented with ECS. Each shows a typical game mechanic using entities, components, and systems.

Particles

Spawn short-lived particles with position, velocity, and lifetime. The lifetime system removes expired entities.

// Components
const Position = { x: [], y: [] }
const Velocity = { vx: [], vy: [] }
const Lifetime = { remaining: [] }
const Particle = {}  // Tag

// Spawn system
const spawnParticle = (x, y) => {
  const eid = addEntity(world)
  addComponent(world, eid, Particle)
  addComponent(world, eid, Position)
  addComponent(world, eid, Velocity)
  addComponent(world, eid, Lifetime)

  Position.x[eid] = x
  Position.y[eid] = y
  Velocity.vx[eid] = (Math.random() - 0.5) * 4
  Velocity.vy[eid] = (Math.random() - 0.5) * 4
  Lifetime.remaining[eid] = 60  // frames
}

// Lifetime system - remove expired
const lifetimeSystem = (world) => {
  for (const eid of query(world, [Lifetime])) {
    Lifetime.remaining[eid]--
    if (Lifetime.remaining[eid] <= 0) {
      removeEntity(world, eid)
    }
  }
}

Gravity

Apply gravity to entities with mass. Entities fall and bounce off the ground. Shows force accumulation pattern.

// Components
const Position = { x: [], y: [] }
const Velocity = { vx: [], vy: [] }
const Mass = { value: [] }
const Grounded = {}  // Tag

const GRAVITY = 0.5
const GROUND_Y = 280
const BOUNCE = 0.7

// Gravity system
const gravitySystem = (world) => {
  for (const eid of query(world, [Velocity, Mass])) {
    Velocity.vy[eid] += GRAVITY * Mass.value[eid]
  }
}

// Ground collision system
const groundSystem = (world) => {
  for (const eid of query(world, [Position, Velocity])) {
    if (Position.y[eid] >= GROUND_Y) {
      Position.y[eid] = GROUND_Y
      Velocity.vy[eid] *= -BOUNCE

      if (Math.abs(Velocity.vy[eid]) < 0.5) {
        Velocity.vy[eid] = 0
        addComponent(world, eid, Grounded)
      }
    }
  }
}

Top-Down Movement

WASD controls with 8-directional movement. Input is stored in a component, then processed by the movement system.

// Components
const Position = { x: [], y: [] }
const Input = { x: [], y: [] }  // -1 to 1
const Speed = { value: [] }
const Player = {}  // Tag

// Input system - read keyboard state
const inputSystem = (world) => {
  for (const eid of query(world, [Input, Player])) {
    Input.x[eid] = 0
    Input.y[eid] = 0

    if (keys['KeyW']) Input.y[eid] -= 1
    if (keys['KeyS']) Input.y[eid] += 1
    if (keys['KeyA']) Input.x[eid] -= 1
    if (keys['KeyD']) Input.x[eid] += 1

    // Normalize diagonal
    const len = Math.hypot(Input.x[eid], Input.y[eid])
    if (len > 1) {
      Input.x[eid] /= len
      Input.y[eid] /= len
    }
  }
}

// Movement system
const movementSystem = (world) => {
  for (const eid of query(world, [Position, Input, Speed])) {
    Position.x[eid] += Input.x[eid] * Speed.value[eid]
    Position.y[eid] += Input.y[eid] * Speed.value[eid]
  }
}

Steering Behaviors

Entities seek toward targets. Classic AI pattern: calculate desired velocity, steer toward it with limited force.

// Components
const Position = { x: [], y: [] }
const Velocity = { vx: [], vy: [] }
const Seek = { targetX: [], targetY: [] }
const MaxSpeed = { value: [] }
const MaxForce = { value: [] }

// Seek system - steer toward target
const seekSystem = (world) => {
  for (const eid of query(world, [Position, Velocity, Seek])) {
    // Desired velocity toward target
    let dx = Seek.targetX[eid] - Position.x[eid]
    let dy = Seek.targetY[eid] - Position.y[eid]

    // Normalize and scale to max speed
    const dist = Math.hypot(dx, dy)
    if (dist > 0) {
      dx = (dx / dist) * MaxSpeed.value[eid]
      dy = (dy / dist) * MaxSpeed.value[eid]
    }

    // Steering = desired - current
    let sx = dx - Velocity.vx[eid]
    let sy = dy - Velocity.vy[eid]

    // Limit steering force
    const force = Math.hypot(sx, sy)
    if (force > MaxForce.value[eid]) {
      sx = (sx / force) * MaxForce.value[eid]
      sy = (sy / force) * MaxForce.value[eid]
    }

    Velocity.vx[eid] += sx
    Velocity.vy[eid] += sy
  }
}

Collision Detection

Simple circle-circle collision. When entities overlap, they bounce apart. Shows N×N query pattern.

// Components
const Position = { x: [], y: [] }
const Velocity = { vx: [], vy: [] }
const Radius = { value: [] }

// Collision system - O(n²) broad phase
const collisionSystem = (world) => {
  const entities = query(world, [Position, Radius])

  for (let i = 0; i < entities.length; i++) {
    const a = entities[i]

    for (let j = i + 1; j < entities.length; j++) {
      const b = entities[j]

      const dx = Position.x[b] - Position.x[a]
      const dy = Position.y[b] - Position.y[a]
      const dist = Math.hypot(dx, dy)
      const minDist = Radius.value[a] + Radius.value[b]

      if (dist < minDist && dist > 0) {
        // Separate entities
        const overlap = (minDist - dist) / 2
        const nx = dx / dist
        const ny = dy / dist

        Position.x[a] -= nx * overlap
        Position.y[a] -= ny * overlap
        Position.x[b] += nx * overlap
        Position.y[b] += ny * overlap

        // Bounce velocities (simple)
        if (hasComponent(world, a, Velocity)) {
          Velocity.vx[a] = -nx * 2
          Velocity.vy[a] = -ny * 2
        }
      }
    }
  }
}

Spawner Pattern

Entities that spawn other entities on a timer. Common for enemy waves, particle emitters, projectile launchers.

// Components
const Position = { x: [], y: [] }
const Spawner = {
  interval: [],   // frames between spawns
  timer: [],      // current countdown
  prefab: []      // what to spawn (entity ID)
}

// Spawner system
const spawnerSystem = (world) => {
  for (const eid of query(world, [Spawner, Position])) {
    Spawner.timer[eid]--

    if (Spawner.timer[eid] <= 0) {
      // Reset timer
      Spawner.timer[eid] = Spawner.interval[eid]

      // Spawn entity at spawner position
      const spawned = addEntity(world)
      addComponent(world, spawned, Position)
      addComponent(world, spawned, Velocity)

      Position.x[spawned] = Position.x[eid]
      Position.y[spawned] = Position.y[eid]

      // Random direction
      const angle = Math.random() * Math.PI * 2
      Velocity.vx[spawned] = Math.cos(angle) * 2
      Velocity.vy[spawned] = Math.sin(angle) * 2
    }
  }
}

// Setup: create a spawner
const spawner = addEntity(world)
addComponent(world, spawner, Position)
addComponent(world, spawner, Spawner)
Position.x[spawner] = 200
Position.y[spawner] = 150
Spawner.interval[spawner] = 30
Spawner.timer[spawner] = 30

Health & Damage

Combat basics: health pools, taking damage, death handling. Uses a queue pattern to safely defer entity removal.

// Components
const Health = { current: [], max: [] }
const Damage = { amount: [] }  // Incoming damage
const Dead = {}  // Tag

// Queue for deferred removals (never mutate ECS in observers!)
const removalQueue: number[] = []

// Apply damage system
const damageSystem = (world) => {
  for (const eid of query(world, [Health, Damage])) {
    Health.current[eid] -= Damage.amount[eid]

    // Clear damage after applying
    removeComponent(world, eid, Damage)

    // Check for death
    if (Health.current[eid] <= 0) {
      Health.current[eid] = 0
      addComponent(world, eid, Dead)
    }
  }
}

// Observer only queues removal and spawns effects (no ECS mutation!)
observe(world, onAdd(Dead), (eid) => {
  spawnParticles(Position.x[eid], Position.y[eid], 10)
  removalQueue.push(eid)
})

// Drain removal queue at a controlled point in game loop
const cleanupSystem = (world) => {
  while (removalQueue.length > 0) {
    const eid = removalQueue.pop()!
    removeEntity(world, eid)
  }
}

// Deal damage helper
const dealDamage = (target, amount) => {
  if (hasComponent(world, target, Health)) {
    addComponent(world, target, Damage)
    Damage.amount[target] = amount
  }
}

State Machine

Tag components as states. Only one state active at a time. State systems only process entities in their state.

// State components (tags)
const Idle = {}
const Walking = {}
const Jumping = {}
const Falling = {}

// State data
const StateTimer = { value: [] }

// Idle system - check for transitions
const idleSystem = (world) => {
  for (const eid of query(world, [Idle, Input])) {
    if (Input.x[eid] !== 0 || Input.y[eid] !== 0) {
      removeComponent(world, eid, Idle)
      addComponent(world, eid, Walking)
    }
    if (Input.jump[eid] && hasComponent(world, eid, Grounded)) {
      removeComponent(world, eid, Idle)
      addComponent(world, eid, Jumping)
      StateTimer.value[eid] = 15  // Jump frames
    }
  }
}

// Jump system
const jumpSystem = (world) => {
  for (const eid of query(world, [Jumping])) {
    Velocity.vy[eid] = -8  // Jump force

    StateTimer.value[eid]--
    if (StateTimer.value[eid] <= 0) {
      removeComponent(world, eid, Jumping)
      addComponent(world, eid, Falling)
    }
  }
}

// Falling system
const fallingSystem = (world) => {
  for (const eid of query(world, [Falling])) {
    if (hasComponent(world, eid, Grounded)) {
      removeComponent(world, eid, Falling)
      addComponent(world, eid, Idle)
    }
  }
}

Flocking

Boids algorithm: separation, alignment, cohesion. Each entity steers based on neighbors within perception radius.

// Components
const Position = { x: [], y: [] }
const Velocity = { vx: [], vy: [] }
const Boid = { perception: [] }

// Flocking system
const flockingSystem = (world) => {
  const boids = query(world, [Boid, Position, Velocity])

  for (const eid of boids) {
    let sepX = 0, sepY = 0, sepCount = 0
    let aliX = 0, aliY = 0
    let cohX = 0, cohY = 0
    let neighborCount = 0

    const perception = Boid.perception[eid]

    for (const other of boids) {
      if (other === eid) continue

      const dx = Position.x[other] - Position.x[eid]
      const dy = Position.y[other] - Position.y[eid]
      const dist = Math.hypot(dx, dy)

      if (dist < perception) {
        neighborCount++

        // Separation - steer away from close neighbors
        if (dist < perception * 0.5) {
          sepX -= dx / dist
          sepY -= dy / dist
          sepCount++
        }

        // Alignment - match neighbor velocities
        aliX += Velocity.vx[other]
        aliY += Velocity.vy[other]

        // Cohesion - steer toward center
        cohX += Position.x[other]
        cohY += Position.y[other]
      }
    }

    if (neighborCount > 0) {
      // Apply forces (weighted)
      Velocity.vx[eid] += sepX * 0.05
      Velocity.vy[eid] += sepY * 0.05
      Velocity.vx[eid] += (aliX / neighborCount - Velocity.vx[eid]) * 0.02
      Velocity.vy[eid] += (aliY / neighborCount - Velocity.vy[eid]) * 0.02
      Velocity.vx[eid] += (cohX / neighborCount - Position.x[eid]) * 0.01
      Velocity.vy[eid] += (cohY / neighborCount - Position.y[eid]) * 0.01
    }
  }
}

Next Steps