gecs — ECS Framework for Godot 4.x
$ARGUMENTS
gecs is the ECS backend for GodotMaker. It has zero LLM training data coverage —
all API knowledge must come from this skill.
Core Concept Mapping
| gecs Class |
Godot Base |
Key Insight |
Entity |
extends Node |
Entity IS a Node — lives in scene tree, can have child nodes |
Component |
extends Resource |
Pure data, @export properties with defaults, no logic |
System |
extends Node |
Contains game logic, queries entities, placed in scene tree |
World |
extends Node |
Manages all entities/systems, archetype storage, query engine |
QueryBuilder |
extends RefCounted |
Chain API: with_all/with_any/with_none, auto-cached |
Relationship |
extends Resource |
Pair (relation_component, target), archetype-level indexing |
Observer |
extends Node |
Reactive: fires on component add/remove/change events |
CommandBuffer |
extends RefCounted |
Safe structural changes during iteration via cmd |
ECS |
Autoload singleton |
Global access: ECS.world, ECS.process(delta, group) |
Quick Start
# --- Component (pure data, extends Resource) ---
class_name C_Health extends Component
@export var current: float = 100.0
@export var maximum: float = 100.0
class_name C_Velocity extends Component
@export var direction: Vector3 = Vector3.ZERO
@export var speed: float = 100.0
# --- Entity (extends Node, define default components) ---
class_name Player extends Entity
func define_components() -> Array:
return [C_Health.new(), C_Velocity.new()]
func on_ready():
add_to_group("player")
# --- System (game logic, extends Node) ---
class_name MovementSystem extends System
func query() -> QueryBuilder:
return q.with_all([C_Velocity])
func process(entities: Array[Entity], components: Array, delta: float) -> void:
for entity in entities:
var vel = entity.get_component(C_Velocity)
var pos = entity.get_component(C_Position)
pos.value += vel.direction * vel.speed * delta # Entity is Node, not Node2D!
# --- Main scene processing ---
# main.gd
func _process(delta):
ECS.process(delta, "input")
ECS.process(delta, "gameplay")
func _physics_process(delta):
ECS.process(delta, "physics")
ECS.process(delta, "run-last")
Naming Conventions
| Type |
Class Name |
File Name |
Example |
| Component |
C_Name |
c_name.gd |
C_Health / c_health.gd |
| System |
NameSystem |
s_name.gd |
MovementSystem / s_movement.gd |
| Entity |
Name |
e_name.gd |
Player / e_player.gd |
| Observer |
NameObserver |
o_name.gd |
HealthUIObserver / o_health_ui.gd |
| Relationship component |
R_Action |
r_action.gd |
R_ChildOf / r_child_of.gd |
Common Operations
Entity
# Create programmatically
var entity = Player.new()
ECS.world.add_entity(entity)
# Instantiate from scene prefab (.tscn with Entity root)
var entity = preload("res://entities/e_player.tscn").instantiate()
get_tree().current_scene.add_child(entity)
ECS.world.add_entity(entity)
# Component operations (pass CLASS to get/has, INSTANCE to add/remove)
entity.add_component(C_Health.new(100))
var health = entity.get_component(C_Health) # returns instance or null
var has = entity.has_component(C_Health) # bool check
entity.remove_component(health) # pass the instance
# Enable/disable
entity.enabled = false # excluded from queries
ECS.world.disable_entity(entity)
ECS.world.enable_entity(entity)
# Destroy (calls on_destroy, queue_free, cleans up relationships)
ECS.world.remove_entity(entity)
Query
# In a System — use q shorthand
func query() -> QueryBuilder:
return q.with_all([C_Health, C_Velocity]) # must have ALL
.with_any([C_Player, C_Enemy]) # must have at least ONE
.with_none([C_Dead]) # must NOT have
.enabled() # only enabled entities
# Batch component access (faster — avoids per-entity get_component):
func query() -> QueryBuilder:
return q.with_all([C_Velocity]).iterate([C_Velocity])
func process(entities: Array[Entity], components: Array, delta: float):
var velocities = components[0] # Array of C_Velocity, same order as entities
for i in entities.size():
var pos = entities[i].get_component(C_Position)
pos.value += velocities[i].direction * delta # Entity is Node, not Node2D!
# Standalone query (outside a System):
var enemies = ECS.world.query.with_all([C_Health, C_Enemy]).execute()
var player = ECS.world.query.with_all([C_Player]).execute_one()
CommandBuffer (safe structural changes during iteration)
class_name LifetimeSystem extends System
func query():
return q.with_all([C_Lifetime])
func process(entities: Array[Entity], components: Array, delta: float):
for entity in entities: # safe forward iteration
var lt = entity.get_component(C_Lifetime)
lt.time -= delta
if lt.time <= 0:
cmd.remove_entity(entity) # queued
if should_upgrade(entity):
cmd.remove_component(entity, C_OldState) # queued
cmd.add_component(entity, C_NewState.new()) # queued
# auto-executes after system completes (FlushMode.PER_SYSTEM default)
Relationships
# Add a relationship
entity.add_relationship(Relationship.new(R_ChildOf.new(), parent_entity))
# Query entities with a relationship
var children = ECS.world.query.with_relationship([
Relationship.new(R_ChildOf.new(), parent_entity)
]).execute()
# Wildcard query (any target)
var has_allies = entity.has_relationship(Relationship.new(R_AllyTo.new(), null))
# Remove with limit
entity.remove_relationship(Relationship.new(R_Buff.new(), null), 1) # remove 1
entity.remove_relationship(Relationship.new(R_Effect.new(), null)) # remove all
System Groups & Scene Architecture
Main.tscn
+-- World (World node)
+-- Systems (Node)
| +-- input (SystemGroup)
| | +-- PlayerControlsSystem
| +-- gameplay (SystemGroup)
| | +-- HealthSystem
| | +-- DeathSystem
| +-- physics (SystemGroup)
| | +-- MovementSystem
| | +-- CollisionSystem
| +-- run-last (SystemGroup)
| +-- PendingDeleteSystem
+-- Entities (Node — spawned entities go here)
+-- Level (Node3D — level geometry)
SystemGroup nodes auto-assign their name as the group property of child Systems.
Reference Files
Read the relevant file when you need detailed API beyond this quick reference:
| Need |
File |
When to read |
| Entity lifecycle, prefabs, spawning |
references/entity.md |
Creating entities, scene prefab setup, on_ready/on_destroy |
| Component design, @export patterns |
references/component.md |
Defining new components, constructor patterns |
| System impl, CommandBuffer, timers |
references/system.md |
Writing systems, sub_systems, tick rates, deps, parallel |
| World setup, entity management |
references/world.md |
World init, add/remove entities/systems, process groups |
| Queries, Relationships, Observers |
references/query.md |
Complex queries, entity linking, reactive systems |
| Debug tools, profiling |
references/debug.md |
Runtime inspection, editor debugger, performance |
| Naming, file org, scene architecture |
references/patterns.md |
Project structure, cross-cutting patterns, ECS_DESIGN adaptation |
MANDATORY: Read gotchas.md Before Writing ECS Code
Before writing ANY gecs code, read gotchas.md. It contains 19 hard-won pitfalls with wrong→correct code examples.
If you hit a compile or runtime error, check gotchas.md first — most ECS errors are covered there.
Critical Gotchas (summary)
- All
@export properties MUST have default values — Godot errors on Resource export without defaults
get_component() takes the CLASS, not an instance — entity.get_component(C_Health) not entity.get_component(health_instance)
- Never put logic in Components — all behavior belongs in Systems
- Use
cmd for structural changes during iteration — direct add/remove during process() causes entity skipping
- Avoid
with_group() in queries — ~50x slower than with_all([C_Tag]) due to SceneTree traversal. Use tag components instead
define_components() must return fresh .new() instances — returning cached/shared instances causes state leakage between entities
- Entity extends Node, NOT Node2D —
entity.position does NOT work. Store position in a C_Position component. See gotchas.md G1
- Component
_init() must have default params — func _init(v: float = 0.0) so Component.new() works for duplication
- Components are inaccessible before
add_entity() — get_component() returns null until entity enters scene tree. See gotchas.md G2
- Never call
system.process() directly in tests — causes Array[Entity] type error + CommandBuffer not flushed. Never write test_system_has_query — q is null outside World. See G10, G14
add_system() second param is bool, not group name — set system.group before add_system(). See G15
world.process(delta) without group skips grouped systems — must pass group name. See G16
- NodePath
@export in .tscn unreliable for World — set entity_nodes_root/system_nodes_root in _init(). See G17
- Area2D overlap data stale in
_process — overlap systems must run in physics group. See G18
:= type inference fails with ternary + null — use explicit type annotation. See G19
1---2name: gecs3description: gecs ECS framework API reference — the Entity-Component-System addon for Godot 4.x used by this project. Covers Entity, Component, System, World, QueryBuilder, Relationship, Observer, CommandBuffer, SystemTimer. Use when the task involves ECS architecture: creating entities with components, defining component data classes (C_ prefix), writing game logic systems, querying entities by component composition, entity relationships or links, reactive observers for component changes, safe structural changes during iteration, system tick rates, ECS world setup, debugging ECS queries or cache, or deferred entity destruction. Also trigger on gecs API calls: q.with_all(), ECS.world, ECS.process(), define_components(), cmd.add_component(). gecs has zero LLM training data — without this skill all ECS API calls will be fabricated. NOT relevant for standard Godot subsystems (physics, animation, UI, tilemap, shader, particles, navigation, signals, audio) unless explicitly connected to ECS.4---56# gecs — ECS Framework for Godot 4.x78$ARGUMENTS910gecs is the ECS backend for GodotMaker. It has **zero LLM training data coverage** —11all API knowledge must come from this skill.1213## Core Concept Mapping1415| gecs Class | Godot Base | Key Insight |16|------------|-----------|-------------|17| `Entity` | `extends Node` | Entity IS a Node — lives in scene tree, can have child nodes |18| `Component` | `extends Resource` | Pure data, `@export` properties with defaults, no logic |19| `System` | `extends Node` | Contains game logic, queries entities, placed in scene tree |20| `World` | `extends Node` | Manages all entities/systems, archetype storage, query engine |21| `QueryBuilder` | `extends RefCounted` | Chain API: `with_all`/`with_any`/`with_none`, auto-cached |22| `Relationship` | `extends Resource` | Pair (relation_component, target), archetype-level indexing |23| `Observer` | `extends Node` | Reactive: fires on component add/remove/change events |24| `CommandBuffer` | `extends RefCounted` | Safe structural changes during iteration via `cmd` |25| `ECS` | Autoload singleton | Global access: `ECS.world`, `ECS.process(delta, group)` |2627## Quick Start2829```gdscript30# --- Component (pure data, extends Resource) ---31class_name C_Health extends Component32@export var current: float = 100.033@export var maximum: float = 100.03435class_name C_Velocity extends Component36@export var direction: Vector3 = Vector3.ZERO37@export var speed: float = 100.03839# --- Entity (extends Node, define default components) ---40class_name Player extends Entity41func define_components() -> Array:42 return [C_Health.new(), C_Velocity.new()]4344func on_ready():45 add_to_group("player")4647# --- System (game logic, extends Node) ---48class_name MovementSystem extends System49func query() -> QueryBuilder:50 return q.with_all([C_Velocity])5152func process(entities: Array[Entity], components: Array, delta: float) -> void:53 for entity in entities:54 var vel = entity.get_component(C_Velocity)55 var pos = entity.get_component(C_Position)56 pos.value += vel.direction * vel.speed * delta # Entity is Node, not Node2D!5758# --- Main scene processing ---59# main.gd60func _process(delta):61 ECS.process(delta, "input")62 ECS.process(delta, "gameplay")6364func _physics_process(delta):65 ECS.process(delta, "physics")66 ECS.process(delta, "run-last")67```6869## Naming Conventions7071| Type | Class Name | File Name | Example |72|------|-----------|-----------|---------|73| Component | `C_Name` | `c_name.gd` | `C_Health` / `c_health.gd` |74| System | `NameSystem` | `s_name.gd` | `MovementSystem` / `s_movement.gd` |75| Entity | `Name` | `e_name.gd` | `Player` / `e_player.gd` |76| Observer | `NameObserver` | `o_name.gd` | `HealthUIObserver` / `o_health_ui.gd` |77| Relationship component | `R_Action` | `r_action.gd` | `R_ChildOf` / `r_child_of.gd` |7879## Common Operations8081### Entity8283```gdscript84# Create programmatically85var entity = Player.new()86ECS.world.add_entity(entity)8788# Instantiate from scene prefab (.tscn with Entity root)89var entity = preload("res://entities/e_player.tscn").instantiate()90get_tree().current_scene.add_child(entity)91ECS.world.add_entity(entity)9293# Component operations (pass CLASS to get/has, INSTANCE to add/remove)94entity.add_component(C_Health.new(100))95var health = entity.get_component(C_Health) # returns instance or null96var has = entity.has_component(C_Health) # bool check97entity.remove_component(health) # pass the instance9899# Enable/disable100entity.enabled = false # excluded from queries101ECS.world.disable_entity(entity)102ECS.world.enable_entity(entity)103104# Destroy (calls on_destroy, queue_free, cleans up relationships)105ECS.world.remove_entity(entity)106```107108### Query109110```gdscript111# In a System — use q shorthand112func query() -> QueryBuilder:113 return q.with_all([C_Health, C_Velocity]) # must have ALL114 .with_any([C_Player, C_Enemy]) # must have at least ONE115 .with_none([C_Dead]) # must NOT have116 .enabled() # only enabled entities117118# Batch component access (faster — avoids per-entity get_component):119func query() -> QueryBuilder:120 return q.with_all([C_Velocity]).iterate([C_Velocity])121122func process(entities: Array[Entity], components: Array, delta: float):123 var velocities = components[0] # Array of C_Velocity, same order as entities124 for i in entities.size():125 var pos = entities[i].get_component(C_Position)126 pos.value += velocities[i].direction * delta # Entity is Node, not Node2D!127128# Standalone query (outside a System):129var enemies = ECS.world.query.with_all([C_Health, C_Enemy]).execute()130var player = ECS.world.query.with_all([C_Player]).execute_one()131```132133### CommandBuffer (safe structural changes during iteration)134135```gdscript136class_name LifetimeSystem extends System137138func query():139 return q.with_all([C_Lifetime])140141func process(entities: Array[Entity], components: Array, delta: float):142 for entity in entities: # safe forward iteration143 var lt = entity.get_component(C_Lifetime)144 lt.time -= delta145 if lt.time <= 0:146 cmd.remove_entity(entity) # queued147 if should_upgrade(entity):148 cmd.remove_component(entity, C_OldState) # queued149 cmd.add_component(entity, C_NewState.new()) # queued150 # auto-executes after system completes (FlushMode.PER_SYSTEM default)151```152153### Relationships154155```gdscript156# Add a relationship157entity.add_relationship(Relationship.new(R_ChildOf.new(), parent_entity))158159# Query entities with a relationship160var children = ECS.world.query.with_relationship([161 Relationship.new(R_ChildOf.new(), parent_entity)162]).execute()163164# Wildcard query (any target)165var has_allies = entity.has_relationship(Relationship.new(R_AllyTo.new(), null))166167# Remove with limit168entity.remove_relationship(Relationship.new(R_Buff.new(), null), 1) # remove 1169entity.remove_relationship(Relationship.new(R_Effect.new(), null)) # remove all170```171172### System Groups & Scene Architecture173174```175Main.tscn176+-- World (World node)177+-- Systems (Node)178| +-- input (SystemGroup)179| | +-- PlayerControlsSystem180| +-- gameplay (SystemGroup)181| | +-- HealthSystem182| | +-- DeathSystem183| +-- physics (SystemGroup)184| | +-- MovementSystem185| | +-- CollisionSystem186| +-- run-last (SystemGroup)187| +-- PendingDeleteSystem188+-- Entities (Node — spawned entities go here)189+-- Level (Node3D — level geometry)190```191192SystemGroup nodes auto-assign their name as the `group` property of child Systems.193194## Reference Files195196Read the relevant file when you need detailed API beyond this quick reference:197198| Need | File | When to read |199|------|------|-------------|200| Entity lifecycle, prefabs, spawning | [`references/entity.md`](references/entity.md) | Creating entities, scene prefab setup, on_ready/on_destroy |201| Component design, @export patterns | [`references/component.md`](references/component.md) | Defining new components, constructor patterns |202| System impl, CommandBuffer, timers | [`references/system.md`](references/system.md) | Writing systems, sub_systems, tick rates, deps, parallel |203| World setup, entity management | [`references/world.md`](references/world.md) | World init, add/remove entities/systems, process groups |204| Queries, Relationships, Observers | [`references/query.md`](references/query.md) | Complex queries, entity linking, reactive systems |205| Debug tools, profiling | [`references/debug.md`](references/debug.md) | Runtime inspection, editor debugger, performance |206| Naming, file org, scene architecture | [`references/patterns.md`](references/patterns.md) | Project structure, cross-cutting patterns, ECS_DESIGN adaptation |207208## MANDATORY: Read gotchas.md Before Writing ECS Code209210**Before writing ANY gecs code**, read [`gotchas.md`](gotchas.md). It contains 19 hard-won pitfalls with wrong→correct code examples.211212**If you hit a compile or runtime error**, check `gotchas.md` first — most ECS errors are covered there.213214## Critical Gotchas (summary)2152161. **All `@export` properties MUST have default values** — Godot errors on Resource export without defaults2172. **`get_component()` takes the CLASS, not an instance** — `entity.get_component(C_Health)` not `entity.get_component(health_instance)`2183. **Never put logic in Components** — all behavior belongs in Systems2194. **Use `cmd` for structural changes during iteration** — direct add/remove during `process()` causes entity skipping2205. **Avoid `with_group()` in queries** — ~50x slower than `with_all([C_Tag])` due to SceneTree traversal. Use tag components instead2216. **`define_components()` must return fresh `.new()` instances** — returning cached/shared instances causes state leakage between entities2227. **Entity extends Node, NOT Node2D** — `entity.position` does NOT work. Store position in a `C_Position` component. See gotchas.md G12238. **Component `_init()` must have default params** — `func _init(v: float = 0.0)` so `Component.new()` works for duplication2249. **Components are inaccessible before `add_entity()`** — `get_component()` returns null until entity enters scene tree. See gotchas.md G222510. **Never call `system.process()` directly in tests** — causes Array[Entity] type error + CommandBuffer not flushed. **Never write `test_system_has_query`** — `q` is null outside World. See G10, G1422611. **`add_system()` second param is `bool`, not group name** — set `system.group` before `add_system()`. See G1522712. **`world.process(delta)` without group skips grouped systems** — must pass group name. See G1622813. **NodePath `@export` in `.tscn` unreliable for World** — set `entity_nodes_root`/`system_nodes_root` in `_init()`. See G1722914. **Area2D overlap data stale in `_process`** — overlap systems must run in `physics` group. See G1823015. **`:=` type inference fails with ternary + null** — use explicit type annotation. See G19