Chris Sprance

Associate Technical Art Director

GECS: Godot Entity Component System - Systems and Queries

https://github.com/csprance/gecs

Systems

Systems in GECS are nodes extending System.gd, containing game logic to process specific entities based on component queries.

Systems are the core units of work that make an ECS world come alive. They should be small and atomic (Only do one thing).

If you keep them small they're easier to reason about and easier to test.

Systems are made of two parts:

  • A Query - Defines what entities we're interested in running code on based on the components and relationships they have
  • A Process - The function that runs on either all entities at once or a single entity. There are two different process functions you can override
    • process(entity: Entity, delta: float) -> void:
    • process_all(entity: Array[Entity], delta: float) -> void:

A good way to see what I mean by this is to take a look at some examples.

Writing a System

Systems use a query() function to collect entities and either process(entity, delta) or process_all(entities, delta) to update them each frame. Keep logic small and focused in each system, ensuring easier reuse and maintenance.

Transform System

We often want to process a bunch of entities all in one go because the code should apply to all of them and running a function for each one has some overhead if all we need to do is some simple data transformation and we have a lot of entities.

For that you can use the process_all(entities: Array, delta: float) to run a single process on all the entities at once.

This is usually a good thing to do for physics and simple transformation. You can also get all the components from a list of entities using the ECS.get_components(entities: Array[Entities], ComponentType) to make it easier to grab all the components of the same type.

## TransformSystem.
##
## Synchronizes the `Transform` component with the entity's actual transform in the scene.
## Updates the entity's position, rotation, and scale based on the `Transform` component.
## Processes entities with the `Transform` component.
class_name TransformSystem
extends System

func query() -> QueryBuilder:
    return q.with_all([C_Transform])

func process_all(entities: Array, _delta):
    var transforms = ECS.get_components(entities, C_Transform) as Array[C_Transform]
    for i in range(entities.size()):
        entities[i].global_transform = transforms[i].transform

Lifetime System

Sometimes you just want to grab components off a single entity and process it and we don't need to worry about it being a lot of entities we can do a more simple version using the process(entity: Entity, delta: float).

This is great when we just need to do something that will run on a few entities and it's easier to have the iterator code handled for us.

## Removes any entity after its lifetime has expired
class_name LifetimeSystem
extends System

func query() -> QueryBuilder:
    return q.with_all([C_Lifetime])

func process(entity: Entity, delta: float):
    var c_lifetime = entity.get_component(C_Lifetime) as C_Lifetime
    c_lifetime.lifetime -= delta

    if c_lifetime.lifetime <= 0:
        ECS.world.remove_entity(entity)

process_all vs process

Use process_all for batch operations, or process if you need per-entity granularity. Striking a balance between the two makes your system code more efficient.

Sub-Systems

Sometimes you want to create a system that runs multiple systems together and groups them into one file. For that we can override the sub_systems() function.

This function returns an array of ECS.world.query and the Callable in the form of my_process_function(entity: Entity, delta: float) that will run on each one of the entities that matches the query.

Damage System

## DamageSystem.
##
## Processes entities that have taken damage.
## Reduces the entity's health based on the `Damage` component.
## Plays a sound effect when damage is taken.
## Removes the `Damage` component after processing.
class_name DamageSystem
extends System

func sub_systems():
    return [
        # Handles damage on entities with health
        [ECS.world.query.with_all([C_Health]).with_any([C_Damage, C_HeavyDamage]).with_none([C_Death, C_Invunerable, C_Breakable]), health_damage_subsys], 
        # Handles damage on breakable entities and heavy damage done to them
        [ECS.world.query.with_all([C_Breakable, C_Health,C_HeavyDamage]).with_none([C_Death, C_Invunerable]), breakable_damage_subsys], 
    ]

func breakable_damage_subsys(entity, delta):
    var c_heavy_damage = entity.get_component(C_HeavyDamage) as C_HeavyDamage
    var c_health = entity.get_component(C_Health) as C_Health

    c_health.current -= c_heavy_damage.amount

    if c_health.current <= 0:
        entity.add_component(C_Death.new())

func health_damage_subsys(entity: Entity, _delta: float):
    var c_damage = entity.get_component(C_Damage) as C_Damage
    var c_heavy_damage = entity.get_component(C_Damage) as C_Damage
    var c_health = entity.get_component(C_Health) as C_Health

    var damages = [c_damage, c_heavy_damage].filter( func(damage): return damage != null )

    for damage in damages:
        # Damage the Health Component by the damage amount
        c_health.current -= damage.amount
        entity.remove_component(damage.get_script())

    if c_health.current > 0:
        Loggie.debug('Damaged', c_damage, c_health)
        #SoundManager.play('fx', 'c_damage')

    if c_health.current <= 0:
        entity.add_component(C_Death.new())
    
    if entity is Player:
        GameState.health_changed.emit(c_health.current)

Naming Systems

Systems are named in ClassCase and should end with System and the files should be snake case and be prefixed with s_ for example:

  • TransformSystem - s_transform.gd
  • PhysicsSystem - s_physics.gd
  • CharacterBody3DSystem - s_character_body_3d.gd

System Lifecycle and Setup

Systems extend System.gd and follow a typical node lifecycle in Godot:

  • on_ready(): Perform initial setup after all components are loaded.
  • process(delta): Called each frame for matching entities if overridden.
  • process_all(entities, delta): Batch processing approach for more complex logic.

In your game loop, call ECS.process(delta) or ECS.process(delta, "group") to update all systems or a specific group respectively.

Adding Systems to the World

You can attach your systems to the World node via the editor or dynamically in code: ECS.world.add_system(MovementSystem.new()) This ensures they stay synchronized when ECS.process(delta)or ECS.process('group', delta) runs, leveraging queries (via with_all, with_none, etc.) to handle entities matching component filters.

Queries

Queries are at the core of how GECS finds the entities it operates on. A query uses a builder style function to specify the kind of query you want to create for example:

ECS.world.query
  .with_all([]) # Find entities that have all these components
  .with_any([]) # Find entities that have any of these components
  .with_none([]) # Exclude entities that have these components
  .with_relationship([]) # Must have these relationships
  .without_relationship([]) # must not  these relationships
  .with_reverse_relationship([]) # must have these reverse relationships

Each of the functions is described like:

  • with_all: Entities must have all of these components.
  • with_any: Entities must have at least one of these components.
  • with_none: Entities must not have any of these components.
  • with_relationship: Entities must have these relationships
  • without_relationship: Entities must not have these relationships
  • with_reverse_relationship: This finds the entities of reverse relationships (aka the target of the relationship, not the source)

Once you create a query you can do a few things with it.

  • query.execute() - Execute the query and get the results of the query back which will be a list of entities in the world that match that query.
  • query.matches(entities: Array[Entity]) - Sometimes you already have a list of entities and you want to further filter down this list of entities based on another query. You can use matches for this and it will return you back a list of entities that match the query.
  • combine(q: QueryBuilder) - Combine together a query with an existing one.

Putting It All Together

Add each system to the World, then call ECS.process(delta, group) to execute only that group’s systems or ECS.process(delta) to run them all. Stay consistent in your naming and grouping conventions so you can quickly find or disable relevant systems when debugging.

GECS on Github

https://github.com/csprance/gecs