Skip to content

Change Detection

Change detection lets systems react only when components are added, modified, or removed — instead of processing every entity every frame. This is essential for performance when you have many entities but only a few change per frame.

Added

Added() filters a query to only yield entities where a component was added since the system last ran:

import { defineSystem, addSystem, Query, Added, Transform, Sprite } from 'esengine';
addSystem(defineSystem(
[Query(Added(Sprite), Transform)],
(query) => {
for (const [entity, sprite, transform] of query) {
// Only runs for entities that just received a Sprite
console.log('New sprite at', transform.position.x, transform.position.y);
}
}
));

An entity appears in an Added query in the frame it was spawned or the frame a component was inserted. It won’t appear again unless the component is removed and re-added.

Changed

Changed() filters a query to only yield entities where a component value was modified:

import { defineSystem, defineComponent, addSystem, Query, Changed, Mut, Sprite } from 'esengine';
const Health = defineComponent('Health', { current: 100, max: 100 });
const healthBarSystem = defineSystem(
[Query(Changed(Health), Mut(Sprite))],
(query) => {
for (const [entity, health, sprite] of query) {
// Only runs when Health was actually modified
sprite.size.x = (health.current / health.max) * 100;
}
}
);

A component is marked as changed whenever world.insert(), world.set(), or Commands.insert() is called on it — even if the new value is identical to the old one.

Removed

Removed() is a standalone query type (not a filter on Query). It yields the entity IDs of entities that had a specific component removed:

import { defineSystem, defineComponent, addSystem, Removed, Commands } from 'esengine';
const Health = defineComponent('Health', { current: 100, max: 100 });
addSystem(defineSystem(
[Removed(Health), Commands()],
(removed, cmds) => {
for (const entity of removed) {
// Health was removed — despawn the entity
cmds.despawn(entity);
}
}
));

Removed entries are kept in a buffer for 2 ticks, then automatically cleaned up. This means a system running every frame will always see removals in time.

Combining Filters

You can mix Added, Changed, and Removed in the same system:

import { defineSystem, defineComponent, Query, Added, Changed, Removed, Mut, Transform, Velocity, Commands, Sprite } from 'esengine';
const Health = defineComponent('Health', { current: 100, max: 100 });
defineSystem(
[
Query(Added(Velocity), Transform),
Query(Changed(Health), Mut(Sprite)),
Removed(Health),
Commands(),
],
(newVelocities, damagedEntities, removedHealth, cmds) => {
for (const [entity, velocity, transform] of newVelocities) {
// Initialize movement
}
for (const [entity, health, sprite] of damagedEntities) {
// Update health bar
}
for (const entity of removedHealth) {
cmds.despawn(entity);
}
}
);

Change filters also work with .with() and .without():

Query(Changed(Health)).with(Player) // Changed health on players only
Query(Added(Sprite)).without(UIRect) // New sprites that aren't UI elements

How It Works

Estella uses tick-based tracking:

  1. The world maintains a global tick counter that increments once per frame
  2. Every component insertion or modification records the current tick
  3. Every system records the tick of its last run
  4. When a system runs, change filters compare: component tick > system’s last-run tick

This means:

  • A system that runs every frame sees changes from the previous frame
  • A system that skips frames (e.g., via wrapSceneSystem) will see all accumulated changes since it last ran
  • No manual “dirty flag” management is needed — it’s fully automatic

Common Patterns

Sync Transform to Render Position

const syncRenderSystem = defineSystem(
[Query(Changed(Transform), Sprite)],
(query) => {
for (const [entity, transform, sprite] of query) {
updateRenderPosition(entity, transform.position);
}
}
);

React to Spawns

const onSpawnSystem = defineSystem(
[Query(Added(Transform), Sprite)],
(query) => {
for (const [entity, transform, sprite] of query) {
playSpawnAnimation(entity);
}
}
);

Cleanup on Component Removal

const cleanupSystem = defineSystem(
[Removed(RigidBody)],
(removed) => {
for (const entity of removed) {
destroyPhysicsBody(entity);
}
}
);

Next Steps

  • Events — send and receive messages between systems
  • Queries — basic query filters and iteration
  • Systems — system definition and scheduling