Observers

Subscribe to component changes and react to entity lifecycle events.

Overview

Observers let you react to component additions, removals, and data changes. They're useful for side effects like logging, syncing with external systems (renderers, physics engines), and implementing prefab inheritance.

onAdd

Entity gained component(s)

onRemove

Entity lost component(s)

onSet

Data was set via set() helper

onGet

Data retrieved via getComponent()
import { observe, onAdd, onRemove, onSet, onGet } from 'bitecs'

// Subscribe to component additions
observe(world, onAdd(Position), (eid) => {
  console.log(`Entity ${eid} gained Position`)
})

// The callback fires when the component is added
const eid = addEntity(world)
addComponent(world, eid, Position)  // logs: "Entity 1 gained Position"

// observe() returns an unsubscribe function
const unsubscribe = observe(world, onRemove(Health), (eid) => {
  console.log(`Entity ${eid} lost Health`)
})
unsubscribe()  // Stop observing

onAdd / onRemove

React when entities gain or lose components:

add-remove.ts
import { observe, onAdd, onRemove } from 'bitecs'

// Called when entity gains Position component
observe(world, onAdd(Position), (eid) => {
  console.log(`Entity ${eid} now has Position`)
  // Initialize position
  Position.x[eid] = 0
  Position.y[eid] = 0
})

// Called when entity loses Health component
observe(world, onRemove(Health), (eid) => {
  console.log(`Entity ${eid} died!`)
  spawnDeathEffect(eid)
})

// Multiple components - triggers when entity has ALL
observe(world, onAdd(Position, Velocity), (eid) => {
  console.log(`Entity ${eid} is now movable`)
})
Initialization Pattern

Use onAdd to set default values when a component is attached. This ensures every entity starts with consistent state.

onSet / onGet

Intercept data being set or retrieved. These enable custom storage and computed values:

set-get.ts
import { observe, onSet, onGet, set, addComponent } from 'bitecs'

// Custom setter - receives entity and params
observe(world, onSet(Position), (eid, params) => {
  Position.x[eid] = params.x
  Position.y[eid] = params.y
})

// Custom getter - returns data for entity
observe(world, onGet(Position), (eid) => ({
  x: Position.x[eid],
  y: Position.y[eid]
}))

// Use set() helper to trigger onSet
addComponent(world, entity, set(Position, { x: 10, y: 20 }))
i
onSet and onGet are essential for prefab inheritance. They define how data flows through the IsA hierarchy.

Validation and Transformation

Use onSet to validate or transform data:

validation.ts
// Clamp health between 0 and max
observe(world, onSet(Health), (eid, params) => {
  const max = MaxHealth.value[eid] || 100
  Health.value[eid] = Math.max(0, Math.min(max, params.value))
})

// Normalize velocity
observe(world, onSet(Velocity), (eid, params) => {
  const mag = Math.sqrt(params.x ** 2 + params.y ** 2)
  if (mag > 0) {
    Velocity.x[eid] = params.x / mag
    Velocity.y[eid] = params.y / mag
  }
})

Query Operators

Use query operators in observer hooks for complex matching:

operators.ts
import { observe, onAdd, onRemove, Or, Not } from 'bitecs'

// Trigger when entity gains Position but doesn't have Velocity
observe(world, onAdd(Position, Not(Velocity)), (eid) => {
  console.log(`Static entity ${eid} created`)
})

// Trigger when entity loses either Health OR Shield
observe(world, onRemove(Or(Health, Shield)), (eid) => {
  console.log(`Entity ${eid} lost protection`)
})

Appropriate Use Cases

Observers excel at bridging the ECS world with external systems. Use them for side effects that don't feed back into your game logic:

Logging & Debug

Track entity lifecycle for debugging, analytics, or profiling.

External Sync

Update renderers, physics engines, audio systems, or network layers.

Derived Data

Compute bounding boxes, spatial hashes, or other derived values.

Prefab Inheritance

Power the IsA system for data lookup through prototype chains.
appropriate-use-cases.ts
// Logging - pure side effect, no ECS mutation
observe(world, onAdd(Position), (eid) => {
  console.log(`[DEBUG] Entity ${eid} added Position`)
})

// Renderer sync - update external system
observe(world, onAdd(Sprite), (eid) => {
  renderSystem.createSprite(eid, Sprite.texture[eid])
})
observe(world, onRemove(Sprite), (eid) => {
  renderSystem.destroySprite(eid)
})

// Derived data - only modifies component data on the same entity
observe(world, onSet(Position), (eid, params) => {
  Position.x[eid] = params.x
  Position.y[eid] = params.y
  // Update bounding box (component data, not ECS structure)
  Bounds.minX[eid] = params.x - Size.width[eid] / 2
  Bounds.maxX[eid] = params.x + Size.width[eid] / 2
})

// Network sync - send to external system
observe(world, onSet(Inventory), (eid, params) => {
  Inventory.items[eid] = params.items
  socket.emit('inventory-changed', { eid, items: params.items })
})

Best Practices

!
Avoid Game Logic in Observers
Observers execute during ECS operations, not at a predictable point in your game loop. Using them for game logic leads to unpredictable execution order, re-entrancy hazards, and hidden control flow.

Use extreme caution when doing these inside an observer callback:

  • addEntity() / removeEntity()
  • addComponent() / removeComponent()
  • Any operation that triggers other observers

The Queue Pattern

If observers need to trigger ECS changes, use a queue. The observer pushes to the queue, and you drain it at a controlled point in your game loop:

queue-pattern.ts
const removalQueue: number[] = []

// Observer only pushes to queue — no direct ECS mutation
observe(world, onRemove(Health), (eid) => {
  if (Health.value[eid] <= 0) {
    removalQueue.push(eid)
  }
})

// Drain the queue at a predictable point in your game loop
const drainRemovalQueue = (world) => {
  while (removalQueue.length > 0) {
    const eid = removalQueue.pop()!
    spawnDeathEffect(eid)
    removeEntity(world, eid)
  }
}

Common Patterns

Unsubscribing

Observers return an unsubscribe function:

const unsubscribe = observe(world, onAdd(Enemy), (eid) => {
  console.log('New enemy spawned!')
})

// Later, stop observing
unsubscribe()

Cascade Updates

Update related components when one changes:

// When position changes, update bounding box
observe(world, onSet(Position), (eid, params) => {
  Position.x[eid] = params.x
  Position.y[eid] = params.y

  if (hasComponent(world, eid, Size)) {
    Bounds.minX[eid] = params.x - Size.width[eid] / 2
    Bounds.maxX[eid] = params.x + Size.width[eid] / 2
    Bounds.minY[eid] = params.y - Size.height[eid] / 2
    Bounds.maxY[eid] = params.y + Size.height[eid] / 2
  }
})

State Machine Transitions

React to state component changes:

const Walking = {}
const Running = {}
const Jumping = {}

observe(world, onAdd(Jumping), (eid) => {
  // Start jump animation
  Animation.state[eid] = 'jump_start'
  Velocity.y[eid] = -10
})

observe(world, onRemove(Jumping), (eid) => {
  // Land animation
  Animation.state[eid] = 'land'
})

Network Sync

Automatically sync changes to server:

const NetworkSync = {}  // Tag for synced entities

observe(world, onSet(Position), (eid, params) => {
  Position.x[eid] = params.x
  Position.y[eid] = params.y

  if (hasComponent(world, eid, NetworkSync)) {
    socket.emit('position', { eid, x: params.x, y: params.y })
  }
})

observe(world, onSet(Health), (eid, params) => {
  Health.value[eid] = params.value

  if (hasComponent(world, eid, NetworkSync)) {
    socket.emit('health', { eid, value: params.value })
  }
})

AoS Storage with onSet/onGet

Use observers to implement Array-of-Structures storage:

// AoS component - the component itself is the store
const Transform = [] as { x: number; y: number; rotation: number; scale: number }[]

observe(world, onSet(Transform), (eid, params) => {
  Transform[eid] = {
    x: params.x ?? 0,
    y: params.y ?? 0,
    rotation: params.rotation ?? 0,
    scale: params.scale ?? 1
  }
})

observe(world, onGet(Transform), (eid) => Transform[eid])

// Usage
addComponent(world, entity, set(Transform, { x: 10, y: 20, rotation: 45 }))
const data = getComponent(world, entity, Transform)  // { x: 10, y: 20, rotation: 45, scale: 1 }

Next Steps

Continue Learning