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
onRemove
onSet
onGet
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 observingonAdd / onRemove
React when entities gain or lose components:
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`)
})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:
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 }))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:
// 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:
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
External Sync
Derived Data
Prefab Inheritance
// 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
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:
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 }