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 onlyQuery(Added(Sprite)).without(UIRect) // New sprites that aren't UI elementsHow It Works
Estella uses tick-based tracking:
- The world maintains a global tick counter that increments once per frame
- Every component insertion or modification records the current tick
- Every system records the tick of its last run
- 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); } });