Change Detection

Flax tracks when a component is added, mutably accessed, or removed.

A query allows filtering the entities based on a change event since it last ran.

  • modified filter mutated or new components
  • added only new components
  • removed filter recently removed components.

The modified filter is best used for queries which calculate or update a value based on one or more components, or in other ways react to a changed value.

A change filter can be added to a single component, or to a tuple of components. Applying a .modified() transform on a tuple will create a query which yields if any of the constituents were modified.

The following example creates a system which prints the updated health values for each entity.

#![allow(unused)]

fn main() {
    let query = Query::new((name(), health().modified()));

    let health_changes = System::builder()
        .with_query(query)
        .build(|mut query: QueryBorrow<_>| {
            info_span!("health_changes");
            for (name, health) in &mut query {
                tracing::info!("{name:?}: is now at {health} health");
            }
        });

}

Combining filters

Change filters can be combined with other filters, which leads to queries needing to perform even even less work.

The following example creates a query which removes despawns entities when their health becomes 0. Noteworthy in particular, is that this system can run in parallel with the previously discussed system, since they do not overlap in mutable access.

#![allow(unused)]

fn main() {
    let query = Query::new((name().opt(), entity_ids(), player().satisfied()))
        .with_filter(health().le(0.0).modified());

    let cleanup = System::builder()
        .with_name("cleanup")
        .with_query(query)
        .with_cmd_mut()
        .build(|mut q: QueryBorrow<_, _>, cmd: &mut CommandBuffer| {
            for (name, id, is_player) in &mut q {
                if is_player {
                    tracing::info!("Player died");
                }
                tracing::info!(name, is_player, "Despawning {id}");
                cmd.despawn(id);
            }
        });

}

Bringing it all together

In order for the health monitoring and cleanup systems to be effective, there needs to be something to modify the health of entities.

Such as a random damage system, and a poison status effect.

#![allow(unused)]

fn main() {
    let player_id = Entity::builder()
        .set(name(), "player".into())
        .set(health(), 100.0)
        .set_default(player())
        .spawn(&mut world);

    let enemies = (0..10)
        .map(|i| {
            Entity::builder()
                .set(name(), format!("enemy.{i}"))
                .set(health(), 50.0)
                .spawn(&mut world)
        })
        .collect_vec();

    let all = enemies.iter().copied().chain([player_id]).collect_vec();

    let mut rng = StdRng::from_entropy();

    all.choose_multiple(&mut rng, all.len() / 5)
        .for_each(|&id| {
            world.set(id, poison(), 10.0).unwrap();
        });

    let damage_random = System::builder()
        .with_world_mut()
        .build(move |world: &mut World| {
            let count = rng.gen_range(0..enemies.len());
            let targets = all.choose_multiple(&mut rng, count);
            for &enemy in targets {
                if let Ok(mut health) = world.get_mut(enemy, health()) {
                    *health -= 1.0;
                }
            }
        });

    let update_poison = System::builder()
        .with_query(Query::new((name().opt(), health().as_mut(), poison())))
        .for_each(|(name, health, poison)| {
            *health -= poison;
            tracing::info!("{name:?} suffered {poison} in poison damage");
        });

}

Using a schedule allows for easy parallelization and execution of the systems, but is not a requirement for change detection.

#![allow(unused)]

fn main() {
    let mut schedule = Schedule::new()
        .with_system(damage_random)
        .with_system(update_poison)
        .with_system(health_changes)
        .flush()
        .with_system(cleanup)
        .flush();

    while world.is_alive(player_id) {
        schedule
            .execute_par(&mut world)
            .expect("Failed to run schedule");

        sleep(Duration::from_millis(1000));
    }

}

See the full example here

Implementation details

Each ChangeEvent consists of a subslice of adjacent entities in the same archetype, the change type, and when the change occurred.

Two change events where the entities are adjacent will be joined into a single one will be joined. This means the change list is always rather small compared to the number of changing entities (especially compared to using a HashSet).

The following example combines optional queries with change detection to create a small physic calculation.