Skip to content

Change Detection

v2.4.0+

The framework provides epoch-based frame-level change detection, allowing systems to process only entities that have changed, significantly improving performance.

  • Epoch: Global frame counter, incremented each frame
  • lastWriteEpoch: The epoch when a component was last modified
  • Change Detection: Determine if component changed after a specific point by comparing epochs

After modifying component data, you need to mark the component as changed. There are two approaches:

Section titled “Approach 1: Via Entity Helper Method (Recommended)”
// Mark component dirty via entity.markDirty() after modification
const pos = entity.getComponent(Position)!;
pos.x = 100;
pos.y = 200;
entity.markDirty(pos);
// Can mark multiple components at once
const vel = entity.getComponent(Velocity)!;
vel.vx = 10;
entity.markDirty(pos, vel);
class VelocityComponent extends Component {
private _vx: number = 0;
private _vy: number = 0;
// Provide modification method that accepts epoch parameter
public setVelocity(vx: number, vy: number, epoch: number): void {
this._vx = vx;
this._vy = vy;
this.markDirty(epoch);
}
public get vx(): number { return this._vx; }
public get vy(): number { return this._vy; }
}
// Usage in system
const vel = entity.getComponent(VelocityComponent)!;
vel.setVelocity(10, 20, this.currentEpoch);

EntitySystem provides several change detection helper methods:

@ECSSystem('Physics')
class PhysicsSystem extends EntitySystem {
constructor() {
super(Matcher.all(Position, Velocity));
}
protected process(entities: readonly Entity[]): void {
// Use forEachChanged to process only changed entities
// Automatically saves epoch checkpoint
this.forEachChanged(entities, [Velocity], (entity) => {
const pos = this.requireComponent(entity, Position);
const vel = this.requireComponent(entity, Velocity);
// Only update position when Velocity changes
pos.x += vel.vx * Time.deltaTime;
pos.y += vel.vy * Time.deltaTime;
});
}
}

filterChanged - Get List of Changed Entities

Section titled “filterChanged - Get List of Changed Entities”
@ECSSystem('Transform')
class TransformSystem extends EntitySystem {
constructor() {
super(Matcher.all(Transform, RigidBody));
}
protected process(entities: readonly Entity[]): void {
// Use filterChanged to get list of changed entities
const changedEntities = this.filterChanged(entities, [RigidBody]);
for (const entity of changedEntities) {
// Process entities with changed physics state
this.updatePhysics(entity);
}
// Manually save epoch checkpoint
this.saveEpoch();
}
protected updatePhysics(entity: Entity): void {
// Physics update logic
}
}
protected process(entities: readonly Entity[]): void {
for (const entity of entities) {
// Check if single entity's specified components have changed
if (this.hasChanged(entity, [Transform])) {
this.updateRenderData(entity);
}
}
}
MethodDescription
forEachChanged(entities, [Types], callback)Iterate entities with changed specified components, auto-saves checkpoint
filterChanged(entities, [Types])Return array of entities with changed specified components
hasChanged(entity, [Types])Check if single entity’s specified components have changed
saveEpoch()Manually save current epoch as checkpoint
lastProcessEpochGet last saved epoch checkpoint
currentEpochGet current scene epoch

Change detection is particularly suitable for:

Only update rendering when data changes:

@ECSSystem('RenderUpdate')
class RenderUpdateSystem extends EntitySystem {
constructor() {
super(Matcher.all(Transform, Sprite));
}
protected process(entities: readonly Entity[]): void {
// Only update changed sprites
this.forEachChanged(entities, [Transform, Sprite], (entity) => {
const transform = this.requireComponent(entity, Transform);
const sprite = this.requireComponent(entity, Sprite);
this.updateSpriteMatrix(sprite, transform);
});
}
}

Only send changed component data:

@ECSSystem('NetworkSync')
class NetworkSyncSystem extends EntitySystem {
constructor() {
super(Matcher.all(NetworkComponent, Transform));
}
protected process(entities: readonly Entity[]): void {
// Only sync changed entities, greatly reducing network traffic
this.forEachChanged(entities, [Transform], (entity) => {
const transform = this.requireComponent(entity, Transform);
const network = this.requireComponent(entity, NetworkComponent);
this.sendTransformUpdate(network.id, transform);
});
}
private sendTransformUpdate(id: string, transform: Transform): void {
// Send network update
}
}

Only sync entities with changed position/velocity:

@ECSSystem('PhysicsSync')
class PhysicsSyncSystem extends EntitySystem {
constructor() {
super(Matcher.all(Transform, RigidBody));
}
protected process(entities: readonly Entity[]): void {
// Sync changed entities from physics engine
this.forEachChanged(entities, [RigidBody], (entity) => {
const transform = entity.getComponent(Transform)!;
const rigidBody = entity.getComponent(RigidBody)!;
// Update Transform
transform.position = rigidBody.getPosition();
transform.rotation = rigidBody.getRotation();
// Mark Transform as changed
entity.markDirty(transform);
});
}
}

Only recalculate when dependent data changes:

@ECSSystem('PathCache')
class PathCacheSystem extends EntitySystem {
constructor() {
super(Matcher.all(PathFinder, Transform));
}
protected process(entities: readonly Entity[]): void {
// Only recalculate path when position changes
this.forEachChanged(entities, [Transform], (entity) => {
const pathFinder = entity.getComponent(PathFinder)!;
pathFinder.invalidateCache();
pathFinder.recalculatePath();
});
}
}
ScenarioWithout Change DetectionWith Change DetectionImprovement
1000 entities, 10% changed1000 processes100 processes10x
1000 entities, 1% changed1000 processes10 processes100x
Network syncFull sendIncremental send90%+ bandwidth saved