Files
godot-shader-experiments/addons/gecs/docs/BEST_PRACTICES.md
2026-01-15 15:27:48 +01:00

22 KiB

GECS Best Practices Guide

Write maintainable, performant ECS code

This guide covers proven patterns and practices for building robust games with GECS. Apply these patterns to keep your code clean, fast, and easy to debug.

📋 Prerequisites

🧱 Component Design Patterns

Keep Components Pure Data

Components should only hold data, never logic or behavior.

# ✅ Good - Pure data component
class_name C_Health
extends Component

@export var current: float = 100.0
@export var maximum: float = 100.0
@export var regeneration_rate: float = 1.0

func _init(max_health: float = 100.0):
    maximum = max_health
    current = max_health
# ❌ Avoid - Logic in components
class_name C_Health
extends Component

@export var current: float = 100.0
@export var maximum: float = 100.0

# This belongs in a system, not a component
func take_damage(amount: float):
    current -= amount
    if current <= 0:
        print("Entity died!")

Use Composition Over Inheritance

Build entities by combining simple components rather than complex inheritance hierarchies.

# ✅ Good - Composable components via define_components() or scene setup
class_name Player
extends Entity

func define_components() -> Array:
    return [
        C_Health.new(100),
        C_Transform.new(),
        C_Input.new()
    ]

class_name Enemy
extends Entity

func define_components() -> Array:
    return [
        C_Health.new(50),
        C_Transform.new(),
        C_AI.new()
    ]

Design for Configuration

Make components easily configurable through export properties.

# ✅ Good - Configurable component
class_name C_Movement
extends Component

@export var speed: float = 100.0
@export var acceleration: float = 500.0
@export var friction: float = 800.0
@export var max_speed: float = 300.0
@export var can_fly: bool = false

func _init(spd: float = 100.0, can_fly_: bool = false):
    speed = spd
    can_fly = can_fly_

⚙️ System Design Patterns

Single Responsibility Principle

Each system should handle one specific concern.

# ✅ Good - Focused systems
class_name MovementSystem extends System
func query(): return q.with_all([C_Position, C_Velocity])

class_name RenderSystem extends System
func query(): return q.with_all([C_Position, C_Sprite])

class_name HealthSystem extends System
func query(): return q.with_all([C_Health])

Use System Groups for Processing Order

Organize systems into logical groups using scene-based organization. Systems are grouped in scene nodes and processed in the correct order.

# main.gd - Process systems in correct order
func _process(delta):
    world.process(delta, "run-first")  # Initialization systems
    world.process(delta, "input")      # Input handling
    world.process(delta, "gameplay")   # Game logic
    world.process(delta, "ui")         # UI updates
    world.process(delta, "run-last")   # Cleanup systems

func _physics_process(delta):
    world.process(delta, "physics")    # Physics systems
    world.process(delta, "debug")      # Debug systems

Early Exit for Performance

Return early from system processing when no work is needed.

# ✅ Good - Early exit patterns
class_name HealthRegenerationSystem extends System

func query():
    return q.with_all([C_Health]).with_none([C_Dead])

func process(entities: Array[Entity], components: Array, delta: float):
    for entity in entities:
        var health = entity.get_component(C_Health)

        # Early exit if already at max health
        if health.current >= health.maximum:
            continue

        # Apply regeneration
        health.current = min(health.current + health.regeneration_rate * delta, health.maximum)

🏗️ Code Organization Patterns

GECS Naming Conventions

# ✅ GECS Standard naming patterns:

# Components: C_ComponentName class, c_component_name.gd file
class_name C_Health extends Component      # c_health.gd
class_name C_Position extends Component    # c_position.gd

# Systems: SystemNameSystem class, s_system_name.gd file
class_name MovementSystem extends System   # s_movement.gd
class_name RenderSystem extends System     # s_render.gd

# Entities: EntityName class, e_entity_name.gd file
class_name Player extends Entity           # e_player.gd
class_name Enemy extends Entity            # e_enemy.gd

# Observers: ObserverNameObserver class, o_observer_name.gd file
class_name HealthUIObserver extends Observer  # o_health_ui.gd

File Organization

Organize your ECS files by theme for better scalability:

project/
├── components/
│   ├── ai/              # AI-related components
│   ├── animation/       # Animation components
│   ├── gameplay/        # Core gameplay components
│   ├── gear/           # Equipment/gear components
│   ├── item/           # Item system components
│   ├── multiplayer/    # Multiplayer-specific
│   ├── relationships/  # Relationship components
│   ├── rendering/      # Visual/rendering
│   └── weapon/         # Weapon system
├── entities/
│   ├── enemies/        # Enemy entities
│   ├── gameplay/       # Core entities
│   ├── items/          # Item entities
│   └── ui/             # UI entities
├── systems/
│   ├── combat/         # Combat systems
│   ├── core/           # Core ECS systems
│   ├── gameplay/       # Gameplay systems
│   ├── input/          # Input systems
│   ├── interaction/    # Interaction systems
│   ├── physics/        # Physics systems
│   └── ui/             # UI systems
└── observers/
    └── o_transform.gd   # Reactive systems

🎮 Common Game Patterns

Player Character Pattern

# e_player.gd
class_name Player
extends Entity

func on_ready():
    # Common pattern: sync scene transform to component
    if has_component(C_Transform):
        var transform_comp = get_component(C_Transform)
        transform_comp.transform = global_transform
    add_to_group("player")

Enemy Pattern

# e_enemy.gd
class_name Enemy
extends Entity

func on_ready():
    # Sync transform and add to enemy group
    if has_component(C_Transform):
        var transform_comp = get_component(C_Transform)
        transform_comp.transform = global_transform
    add_to_group("enemies")

🚀 Performance Best Practices

Choose the Right Query Method NEW!

Query Performance Ranking (v5.0.0-rc4+):

# 🏆 FASTEST - Enabled/disabled queries (constant time)
class_name ActiveEntitiesOnly extends System
func query():
    return q.enabled(true)  # ~0.05ms for any number of entities

# 🥈 EXCELLENT - Component queries (heavily optimized)
class_name MovementSystem extends System
func query():
    return q.with_all([C_Position, C_Velocity])  # ~0.6ms for 10K entities

# 🥉 GOOD - Use with_any strategically
class_name DamageableSystem extends System
func query():
    return q.with_any([C_Player, C_Enemy]).with_all([C_Health])  # ~5.6ms for 10K

# 🐌 AVOID - Group queries are slowest
class_name PlayerSystem extends System
func query():
    return q.with_group("player")  # ~16ms for 10K entities
    # Better: q.with_all([C_Player])

Use iterate() for Batch Performance

# ✅ Good - Batch processing with iterate()
class_name TransformSystem
extends System

func query():
    # Use iterate() to get component arrays
    return q.with_all([C_Transform]).iterate([C_Transform])

func process(entities: Array[Entity], components: Array, delta: float):
    # Batch access to components for better performance
    var transforms = components[0]  # C_Transform array from iterate()
    for i in range(entities.size()):
        entities[i].global_transform = transforms[i].transform

Use Specific Queries

# ✅ BEST - Combine enabled filter with components
class_name ActivePlayerInputSystem extends System
func query():
    return q.with_all([C_Input, C_Movement]).enabled(true)
    # Super fast: enabled filtering + component matching

# ✅ GOOD - Specific component query
class_name ProjectileSystem extends System
func query():
    return q.with_all([C_Projectile, C_Velocity])  # Fast and specific

# ❌ AVOID - Group-based queries (slow)
class_name PlayerSystem extends System
func query():
    return q.with_group("player")  # Use q.with_all([C_Player]) instead

# ❌ AVOID - Overly broad queries
class_name UniversalMovementSystem extends System
func query():
    return q.with_all([C_Transform])  # Too broad - matches everything

🎭 Entity Prefabs (Scene Files)

Using Godot Scenes as Entity Prefabs

The most powerful pattern in GECS is using Godot's scene system (.tscn files) as entity prefabs. This combines ECS data with Godot's visual editor:

e_player.tscn Structure:
├── Player (Entity node - extends your e_player.gd class)
│   ├── MeshInstance3D (visual representation)
│   ├── CollisionShape3D (physics collision)
│   ├── AudioStreamPlayer3D (sound effects)
│   └── SkeletonAttachment3D (for equipment)

Benefits of Scene-based Prefabs:

  • Visual Editing: Design entities in Godot's 3D editor
  • Component Assignment: Set up ECS components in the Inspector
  • Godot Integration: Leverage existing Godot nodes and systems
  • Reusability: Instantiate the same prefab multiple times
  • Version Control: Scene files work well with git

Setting up Entity Prefabs:

  1. Create scene with Entity as root: e_player.tscn with Player entity node.
    • Another trick here is to add a CharacterBody3d and then extend that CharacterBody3D with the e_player.gd script this way you get Entity class and CharacterBody3D class data
  2. Add visual/physics children: Add MeshInstance3D, CollisionShape3D, etc. as children
  3. Configure components in Inspector: Add components to the component_resources array
  4. Save as reusable prefab: Save the .tscn file for instantiation
  5. Set up on_ready(): Handle any initialization logic

Component Assignment in Prefabs

Method 1: Inspector Assignment (Recommended)

Set up components directly in the Godot Inspector:

# In e_player.tscn entity root node Inspector:
# Component Resources array:
# - [0] C_Health.new() (max: 100, current: 100)
# - [1] C_Transform.new() (synced with scene transform)
# - [2] C_Input.new() (for player controls)
# - [3] C_LocalPlayer.new() (mark as local player)

Method 2: define_components() (Programmatic)

# e_player.gd attached to Player.tscn root
class_name Player
extends Entity

func define_components() -> Array:
    return [
        C_Health.new(100),
        C_Transform.new(),
        C_Input.new(),
        C_LocalPlayer.new()
    ]

func on_ready():
    # Initialize after components are ready
    if has_component(C_Transform):
        var transform_comp = get_component(C_Transform)
        transform_comp.transform = global_transform
    add_to_group("player")

Method 3: Hybrid Approach

# Core components via Inspector, dynamic components via script
func on_ready():
    # Sync scene transform to component
    if has_component(C_Transform):
        var transform_comp = get_component(C_Transform)
        transform_comp.transform = global_transform

    # Add conditional components based on game state
    if GameState.is_multiplayer:
        add_component(C_NetworkSync.new())

    if GameState.debug_mode:
        add_component(C_DebugInfo.new())

Instantiating Entity Prefabs

Basic Spawning Pattern:

# Spawn system or main scene
@export var player_prefab: PackedScene
@export var enemy_prefab: PackedScene

func spawn_player(position: Vector3) -> Entity:
    var player = player_prefab.instantiate() as Entity
    player.global_position = position
    get_tree().current_scene.add_child(player)  # Add to scene
    ECS.world.add_entity(player)  # Register with ECS
    return player

func spawn_enemy(position: Vector3) -> Entity:
    var enemy = enemy_prefab.instantiate() as Entity
    enemy.global_position = position
    get_tree().current_scene.add_child(enemy)
    ECS.world.add_entity(enemy)
    return enemy

Advanced Spawning with SpawnSystem:

# s_spawner.gd
class_name SpawnerSystem
extends System

func query():
    return q.with_all([C_SpawnPoint])

func process(entities: Array[Entity], components: Array, delta: float):
    for entity in entities:
        var spawn_point = entity.get_component(C_SpawnPoint)

        if spawn_point.should_spawn():
            var spawned = spawn_point.prefab.instantiate() as Entity
            spawned.global_position = entity.global_position
            get_tree().current_scene.add_child(spawned)
            ECS.world.add_entity(spawned)

            spawn_point.mark_spawned()

Prefab Management Best Practices:

# Organize prefabs in preload statements
const PLAYER_PREFAB = preload("res://entities/gameplay/e_player.tscn")
const ENEMY_PREFAB = preload("res://entities/enemies/e_enemy.tscn")
const WEAPON_PREFAB = preload("res://entities/items/e_weapon.tscn")

# Or use a prefab registry
class_name PrefabRegistry

static var prefabs = {
    "player": preload("res://entities/gameplay/e_player.tscn"),
    "enemy": preload("res://entities/enemies/e_enemy.tscn"),
    "weapon": preload("res://entities/items/e_weapon.tscn")
}

static func spawn(prefab_name: String, position: Vector3) -> Entity:
    var prefab = prefabs[prefab_name]
    var entity = prefab.instantiate() as Entity
    entity.global_position = position
    get_tree().current_scene.add_child(entity)
    ECS.world.add_entity(entity)
    return entity

🏗️ Main Scene Architecture

Scene Structure Pattern

Organize your main scene using the proven structure pattern:

Main.tscn
├── World (World node)
├── DefaultSystems (Node - instantiated from default_systems.tscn)
│   ├── run-first (Node - SystemGroup)
│   │   ├── VictimInitSystem
│   │   └── EcsStorageLoad
│   ├── input (Node - SystemGroup)
│   │   ├── ItemSystem
│   │   ├── WeaponsSystem
│   │   └── PlayerControlsSystem
│   ├── gameplay (Node - SystemGroup)
│   │   ├── GearSystem
│   │   ├── DeathSystem
│   │   └── EventSystem
│   ├── physics (Node - SystemGroup)
│   │   ├── FrictionSystem
│   │   ├── CharacterBody3DSystem
│   │   └── TransformSystem
│   ├── ui (Node - SystemGroup)
│   │   └── UiVisibilitySystem
│   ├── debug (Node - SystemGroup)
│   │   └── DebugLabel3DSystem
│   └── run-last (Node - SystemGroup)
│       ├── ActionsSystem
│       └── PendingDeleteSystem
├── Level (Node3D - for level geometry)
└── Entities (Node3D - spawned entities go here)

Systems Setup in Main Scene

Scene-based Systems Setup (Recommended)

Use scene composition to organize systems. The default_systems.tscn contains all systems organized by execution groups:

# main.gd - Simple main scene setup
extends Node

@onready var world: World = $World

func _ready():
    Bootstrap.bootstrap()  # Initialize any game-specific setup
    ECS.world = world
    # Systems are automatically registered via scene composition

Creating a Default Systems Scene:

  1. Create default_systems.tscn with system groups as Node children
  2. Add individual system scripts as children of each group
  3. Instantiate this scene in your main scene
  4. Systems are automatically discovered and registered by the World

Processing Systems by Group

# main.gd - Process systems in correct order
extends Node3D

func _process(delta):
    if ECS.world:
        ECS.process(delta, "input")     # Handle input first
        ECS.process(delta, "core")      # Core logic
        ECS.process(delta, "gameplay")  # Game mechanics
        ECS.process(delta, "render")    # UI/visual updates last

func _physics_process(delta):
    if ECS.world:
        ECS.process(delta, "physics")   # Physics systems

🛠️ Common Utility Patterns

Transform Synchronization

Common transform synchronization patterns:

# Sync entity transform TO component (scene → component)
static func sync_transform_to_component(entity: Entity):
    if entity.has_component(C_Transform):
        var transform_comp = entity.get_component(C_Transform)
        transform_comp.transform = entity.global_transform

# Sync component transform TO entity (component → scene)
static func sync_component_to_transform(entity: Entity):
    if entity.has_component(C_Transform):
        var transform_comp = entity.get_component(C_Transform)
        entity.global_transform = transform_comp.transform

# Common usage in entity on_ready()
func on_ready():
    sync_transform_to_component(self)  # Sync scene position to C_Transform

Component Helpers

Build helpers for common component operations:

# Helper functions you can add to your project
static func add_health_to_entity(entity: Entity, max_health: float):
    var health = C_Health.new(max_health)
    entity.add_component(health)
    return health

static func damage_entity(entity: Entity, amount: float):
    if entity.has_component(C_Health):
        var health = entity.get_component(C_Health)
        health.current = max(0, health.current - amount)
        return health.current <= 0  # Return true if entity died
    return false

🎛️ Relationship Management Best Practices

Limited Removal Patterns

Use Descriptive Constants:

# ✅ Good - Clear intent with constants
const WEAK_CLEANSE = 1
const MEDIUM_CLEANSE = 3
const STRONG_CLEANSE = -1  # All

# ✅ Good - Stack-based constants
const SINGLE_STACK = 1
const PARTIAL_STACKS = 3
const ALL_STACKS = -1

func cleanse_debuffs(entity: Entity, power: int):
    match power:
        1: entity.remove_relationship(Relations.any_debuff(), WEAK_CLEANSE)
        2: entity.remove_relationship(Relations.any_debuff(), MEDIUM_CLEANSE)
        3: entity.remove_relationship(Relations.any_debuff(), STRONG_CLEANSE)

Validate Before Removal:

# ✅ Excellent - Safe removal with validation
func safe_partial_heal(entity: Entity, heal_amount: int):
    var damage_rels = entity.get_relationships(Relations.any_damage())
    if damage_rels.is_empty():
        print("Entity has no damage to heal")
        return

    var to_heal = min(heal_amount, damage_rels.size())
    entity.remove_relationship(Relations.any_damage(), to_heal)
    print("Healed ", to_heal, " damage effects")

# ✅ Good - Helper function with built-in safety
func remove_poison_stacks(entity: Entity, stacks_to_remove: int):
    if stacks_to_remove <= 0:
        return
    entity.remove_relationship(Relations.poison_effect(), stacks_to_remove)

System Integration Patterns:

# ✅ Excellent - Integration with game systems
class_name StatusEffectSystem extends System

func process(entities: Array[Entity], components: Array, delta: float):
    # Example: process spell casting entities
    for entity in entities:
        var spell = entity.get_component(C_SpellCaster)
        if spell.is_casting_cleanse():
            process_cleanse_spell(entity, spell.target, spell.power)

func process_cleanse_spell(caster: Entity, target: Entity, spell_power: int):
    # Calculate cleanse strength based on spell power and caster stats
    var cleanse_strength = calculate_cleanse_strength(caster, spell_power)

    # Apply graduated cleansing based on strength
    match cleanse_strength:
        1..3:   target.remove_relationship(Relations.any_debuff(), 1)
        4..6:   target.remove_relationship(Relations.any_debuff(), 2)
        7..9:   target.remove_relationship(Relations.any_debuff(), 3)
        _:      target.remove_relationship(Relations.any_debuff())  # Remove all

func process_antidote_item(user: Entity, antidote_strength: int):
    # Remove poison based on antidote quality
    user.remove_relationship(Relations.poison_effect(), antidote_strength)

    # Remove poison resistance temporarily to prevent immediate repoison
    user.add_relationship(Relations.poison_immunity(), 5.0)  # 5 second immunity

class_name InventorySystem extends System

func consume_item_stack(entity: Entity, item_type: Script, count: int):
    # Consume specific number of items from inventory
    entity.remove_relationship(
        Relationship.new(C_HasItem.new(), item_type),
        count
    )

func use_consumable(entity: Entity, item: Component, quantity: int = 1):
    # Use consumable items with quantity
    entity.remove_relationship(
        Relationship.new(C_HasItem.new(), item),
        quantity
    )

Performance Optimization:

# ✅ Good - Cache relationships for multiple operations
func optimize_bulk_removal(entity: Entity):
    # Cache the relationship for reuse
    var poison_rel = Relations.poison_effect()
    var damage_rel = Relations.any_damage()

    # Multiple targeted removals
    entity.remove_relationship(poison_rel, 2)      # Remove 2 poison
    entity.remove_relationship(damage_rel, 1)      # Remove 1 damage
    entity.remove_relationship(poison_rel, 1)      # Remove 1 more poison

# ✅ Excellent - Batch removal patterns
func batch_cleanup(entities: Array[Entity]):
    var cleanup_rel = Relations.temporary_effect()

    for entity in entities:
        # Remove up to 3 temporary effects from each entity
        entity.remove_relationship(cleanup_rel, 3)

🎯 Next Steps

Now that you understand best practices:

  1. Apply these patterns in your projects
  2. Learn advanced topics in Core Concepts
  3. Optimize performance with Performance Guide

Need help? Join our Discord for community discussions and support.


"Good ECS code is like a well-organized toolbox - every component has its place, every system has its purpose, and everything works together smoothly."