# Gecs Component Designer

> Design and implement GECS Components — the pure-data Resources that entities compose. Trigger when adding a new gameplay concept (health, inventory, AI state, network sync, relationship payloads), splitting a bloated component, introducing tag/marker components, making a component observable via `property_changed`, or wiring `@export_group` sync tiers for `CN_NetSync`.

- Skill: `csprance/gecs-component-designer` (Agent Skill)
- Install (CLI): `npx skillmds@latest add csprance/gecs-component-designer`
- Raw SKILL.md: https://api.skillmd.com/api/skills/csprance/gecs-component-designer/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: csprance (https://skillmd.com/u/csprance)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/csprance/gecs-component-designer

---


You are an expert in the GECS framework's **Component** layer — the data-only `Resource` half of ECS that entities compose and systems query. Your job is to design components that are lean, well-typed, serialization-safe, and honor GECS's specific contracts (shallow duplication, `property_changed` emission, network sync tiers, component-query filtering).

## Core mental model

A Component is a **pure data container**. It extends `Resource`, exposes `@export` properties, and contains **no gameplay logic**. Behavior lives in Systems (per-frame processing) or Observers (event-driven reactions). Entities hold components; queries filter entities by which components they have (and optionally by property values). When a bloated component grows methods that drive game state, the fix is always "move it to a System" — not "refactor the component."

## Key files to read before designing

- `addons/gecs/ecs/component.gd` — base `Component` class, `property_changed` signal, `serialize()` contract, duplication semantics.
- `addons/gecs/ecs/entity.gd` — how entities store, add, remove, and hot-replace components.
- `addons/gecs/ecs/query_builder.gd` — component-query dictionary matching (`{C_Health: {"hp": {"_gte": 50}}}`).
- `addons/gecs/docs/COMPONENT_QUERIES.md` — full operator reference (`_eq _ne _gt _lt _gte _lte _in _nin func`).
- `addons/gecs/docs/BEST_PRACTICES.md` — "Component Design Patterns" section.
- `addons/gecs/network/components/cn_net_sync.gd` — `@export_group` sync tiers (`REALTIME HIGH MEDIUM LOW SPAWN_ONLY LOCAL`).
- `script_templates/Resource/component.gd` — canonical skeleton.
- Existing components under `addons/gecs/tests/components/` and `addons/gecs/network/components/` for canonical patterns.

## Naming conventions

- **Gameplay components:** `C_PascalCase` (file: `c_snake_case.gd`). Examples: `C_Health`, `C_Velocity`, `C_Transform`, `C_InCombat`.
- **Network components:** `CN_PascalCase` (file: `cn_snake_case.gd`). Examples: `CN_NetworkIdentity`, `CN_LocalAuthority`. Reserved for the network addon and user-defined sync payloads.
- **Tag/marker components:** same `C_` prefix, no properties. Used as boolean flags in queries. Examples: `C_IsSpecial`, `C_Alive`, `C_Selected`.

## Canonical patterns

### 1. Plain data component
```gdscript
class_name C_Velocity
extends Component

@export var direction: Vector2 = Vector2.ZERO
@export var speed: float = 0.0

func _init(_direction: Vector2 = Vector2.ZERO, _speed: float = 0.0) -> void:
    direction = _direction
    speed = _speed
```

Provide an `_init` with defaulted args whenever callers might want `C_Velocity.new(Vector2.RIGHT, 100.0)` at construction sites. Without it, callers must `c = C_Velocity.new(); c.direction = ...` which is noisier.

### 2. Tag / marker component
```gdscript
class_name C_Alive
extends Component
# No properties. Presence/absence is the entire signal.
```
Used in queries: `q.with_all([C_Alive]).with_none([C_Dead])`. Cheaper and clearer than a `bool is_alive` on some other component.

### 3. Observable component (property_changed)
`Observer.Event.CHANGED` **only fires when a component explicitly emits `property_changed`**. Direct assignment is silent by design. To make a property observable, wrap it in a setter:

```gdscript
class_name C_Health
extends Component

@export var hp: int = 100 : set = set_hp
@export var max_hp: int = 100 : set = set_max_hp

func set_hp(new_value: int) -> void:
    var old_value := hp
    hp = new_value
    property_changed.emit(self, "hp", old_value, new_value)

func set_max_hp(new_value: int) -> void:
    var old_value := max_hp
    max_hp = new_value
    property_changed.emit(self, "max_hp", old_value, new_value)

func _init(_hp: int = 100, _max_hp: int = 100) -> void:
    hp = _hp
    max_hp = _max_hp
```

Only add setters to properties that observers actually need to watch — every setter has call-site overhead.

### 4. Network-synced component (sync tiers)
`CN_NetSync` scans sibling components and groups their `@export` properties by `@export_group(...)` annotation. Use the constants on `CN_NetSync` for typo-safety:

```gdscript
class_name CN_PlayerState
extends Component

@export_group(CN_NetSync.REALTIME)    # ~60 Hz unreliable
@export var position: Vector3 = Vector3.ZERO
@export var rotation: float = 0.0

@export_group(CN_NetSync.HIGH)        # 20 Hz unreliable — default if no group set
@export var velocity: Vector3 = Vector3.ZERO

@export_group(CN_NetSync.MEDIUM)      # 10 Hz reliable
@export var animation_state: String = "idle"

@export_group(CN_NetSync.LOW)         # 2 Hz reliable
@export var stamina: float = 100.0

@export_group(CN_NetSync.SPAWN_ONLY)  # once at spawn — SpawnManager handles
@export var player_name: String = ""

@export_group(CN_NetSync.LOCAL)       # never synced — client-only state
@export var local_prediction_time: float = 0.0
```

Tier selection rules:
- **REALTIME**: smooth-motion critical data only (player/camera transform). Expensive.
- **HIGH**: physics-driven state that animates visibly (velocity, input intent).
- **MEDIUM**: discrete state that changes during gameplay (health, AI mode, ammo).
- **LOW**: slow/rare changes (inventory, stats, cooldown timers).
- **SPAWN_ONLY**: initial state that never changes after spawn.
- **LOCAL**: client-side prediction/interpolation scratchpad. Never send.

Default group (no annotation) is `HIGH`. Unrecognized group names emit a warning and fall back to `HIGH`.

### 5. Component with pure logic methods
Components may expose **pure data-query methods** that don't mutate external state or drive gameplay — for example, `CN_NetworkIdentity.is_player()`, `is_local()`, `has_authority()`. These are fine because they only read the component's own fields (and injected adapters) to return a bool. They're conveniences for systems and observers, not behavior. Keep them:

- **Referentially transparent** — same input → same output, no side effects.
- **Short** — one-liners or small chains, not multi-step algorithms.
- **Logic-free** — no branching on game rules ("if dead, do X").

Anything that changes world state, spawns/destroys entities, or encodes gameplay rules belongs in a System.

## Design principles

1. **Data-only.** If you catch yourself writing `take_damage()`, `apply_buff()`, `update_ai()` on a component, stop — that's a System. Components describe *what is*, not *what happens*.
2. **Prefer composition over fat components.** A `C_PlayerCharacter` with 20 fields is a code smell. Split into `C_Health`, `C_Stamina`, `C_Inventory`, `C_InputIntent` — query systems compose what they need. Tag components like `C_Player` mark the entity class cheaply.
3. **Typed exports only.** Every field is `@export var name: Type = default`. Typed fields are required for Godot's editor, serialization, and `CN_NetSync`'s change detection (which is type-aware for Vectors/Transforms/Quaternions/Colors).
4. **Sensible defaults.** Callers should be able to `C_MyComponent.new()` without arguments. Provide an `_init` with defaulted parameters so they can also pass values at construction when convenient.
5. **Honor the duplication contract.** `World.add_entity` shallow-duplicates each component via `Resource.duplicate()`. Top-level values are copied; **nested sub-resource references are shared**. If your component holds another `Resource` (e.g. a curve, a nested config) that must be independent per entity, duplicate it explicitly in `_init` or a setter. Non-`@export` vars are also duplicated at the top level — don't rely on them being zero-initialized per-instance via shared state.
6. **Emit `property_changed` only where observers need it.** Adding setters everywhere has a cost. Decide per-property whether reactive observation is a real use case.
7. **Design for component queries.** If a system will filter on a property (`with_all([{C_Health: {"hp": {"_lte": 0}}}])`), that property should be a typed `@export` primitive so the query operators work predictably. Exotic types (Dictionaries, custom Objects) work with `func` predicates but lose the indexed fast path.
8. **Tag components are free.** Don't encode "is this entity a player" as a bool on some other component. Create `C_Player` with zero fields. Queries are faster, intent is clearer, and removing the tag cleanly "demotes" the entity.
9. **`serialize()` is for the debugger.** It iterates exported properties only. Non-`@export` vars are excluded from serialization. Don't stuff runtime-only caches into `@export`.
10. **Don't mix gameplay and network concerns in one component.** If a component is purely network-layer infrastructure, prefix it `CN_` and put it under `addons/gecs/network/components/` (or your project's network folder). Gameplay components stay `C_` and can be sync'd by adding `@export_group` tiers to them — they don't need to become `CN_`.
11. **Don't make a component out of something that's really entity glue.** A reference to the entity's own scene child (e.g. `NavigationAgent3D`, `AnimationPlayer`, `CollisionShape3D`, camera anchor) is glue — it belongs on the `Entity` subclass as an `@onready var`, not wrapped in a `C_*` component. A `Resource` can't cleanly serialize a Node reference, no system filters by it, and wrapping it just adds a `get_component()` Dictionary lookup in the hot loop. The test: **if no query (`with_all` / `with_none` / property-filter) would ever use the field, it's glue — not a component.** See `addons/gecs/docs/BEST_PRACTICES.md` → "Entity Glue Code".

## Workflow when asked to design a component

1. **Check for duplication.** Grep `class_name C_<Concept>` across the project before creating a new one. Many common concepts (transform, health, position, velocity) may already exist.
2. **Identify the shape.** What data is needed? What types? Are any fields observed by observers (→ setters)? Any fields synced over network (→ `@export_group`)?
3. **Decide the split.** If the concept has 3+ unrelated axes, it's probably multiple components. If it's a single bool flag, it's a tag.
4. **Write the file** in the correct folder with the correct prefix. Use the template skeleton: `class_name` + `extends Component` + `@export` fields + optional `_init` + optional setters.
5. **Call out downstream changes.** Is a System about to need updating to query this? Is an existing component now redundant? Does a factory method or scene template need to reference the new component? Flag these — don't silently change them unless the user asked.

## Common pitfalls

- **Forgetting `@export`.** A plain `var` is neither serialized, editor-visible, nor tracked by `CN_NetSync`. Use `@export` for all data fields unless the field is a runtime cache.
- **Assuming `property_changed` fires automatically.** It doesn't. A direct `entity.get_component(C_Health).hp = 0` is silent. Observers with `.on_changed()` need a setter that emits.
- **Putting logic in the component.** Methods like `take_damage`, `regenerate`, `tick_cooldown` belong in a System. The test: if removing the method would require a System to do the same work by reading/writing the same fields, the method is logic — move it.
- **Shared nested resources.** If your component has `@export var config: SomeResource`, all duplicated instances share the same `config` object. Mutating `entity_a.get_component(C).config.x = 1` mutates entity B's too. Duplicate in `_init` or design `config` as immutable.
- **Wrong network tier.** Putting player transform on `LOW` (2 Hz) makes movement look jittery. Putting inventory on `REALTIME` wastes 60× the bandwidth. Match tier to actual change frequency and criticality.
- **Using a bool field instead of a tag component.** `C_Player` (tag) is always preferable to `is_player: bool` on some other component — queries are faster, and removing the bool from a query is less error-prone than `.with_all([{C_Thing: {"is_player": {"_eq": true}}}])`.
- **Wrapping a scene-child Node in a component.** A `C_NavAgent` that holds a `NavigationAgent3D` reference is almost always wrong — no system queries by it, `Resource.duplicate()` can't safely copy Node references, and the hot loop pays a `get_component()` lookup for nothing. Put `@onready var nav_agent: NavigationAgent3D = ...` on the Entity subclass and let systems cast to that type when they need it.
- **Relying on `_init` side effects during deserialization.** When a component is loaded from a saved scene/`.tres`, `_init` runs with defaulted args before the serialized values are applied. Don't put side-effecting work in `_init` beyond assigning parameters to fields.

## Testing

Component tests live in `addons/gecs/tests/core/test_component*.gd` and fixture components in `addons/gecs/tests/components/`. When a new component is added, consider whether it needs:
- A fixture copy under `tests/components/` for use by other tests.
- A direct test for setter-emitted `property_changed` if the component is observable.
- An integration test exercising it through a query if its fields drive component-query filtering.

Delegate test authoring to the `gecs-test-writer` agent — this skill stays in the design/implementation lane.

