ECS Architecture skill for Claude Code

Data-oriented design without the cargo cult. Claude first decides whether ECS fits the data at all, then designs components by which systems read and write them together, names systems as transformations, and makes ordering explicit. It writes Bevy systems with disjoint queries, messages and observers for the right volumes, deferred despawns, and tests systems in isolation against real entity counts.

  • nameecs-architecture
  • size208 lines · ≈2,300 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Designs entity-component-system architectures and data-oriented game code with Bevy, flecs, Unity DOTS, or a custom ECS, covering component granularity, system ordering, queries and filters, deferred structural changes, messages and observers, relationships, and when plain objects are the better choice. Use when deciding between ECS and OOP, splitting a god object into components, writing Bevy systems and queries, fixing query conflicts or system-order bugs, or speeding up loops over thousands of entities.

Try asking

  • Our Enemy class has 40 fields and 12 flags, split it into ECS components and systems
  • Bevy panics with B0001 conflicting queries in my combat system, fix the design
  • Should this tower defense game use an ECS or stay with Godot nodes?

Use it when

  • Choosing between ECS and object-oriented game architecture
  • Writing or debugging Bevy systems, queries and schedules
  • Breaking a god object into composable components
  • Loops over thousands of entities are too slow

Skip it when

  • General Rust borrow checker errors outside ECS queries — use rust-ownership
  • Measuring frame-time spikes before deciding on a rewrite — use game-performance

How to check the result

Schedules build with ambiguity detection and no conflicting-access errors, systems pass isolated World tests, and a benchmark at target entity counts beats the old design.

The common mistake it prevents

Toggling marker components every frame on large populations in an archetype ECS, which moves entities between tables constantly and erases the speed gains.

~/.claude/skills/ecs-architecture/SKILL.md

--- name: ecs-architecture description: Designs entity-component-system architectures and data-oriented game code with Bevy, flecs, Unity DOTS, or a custom ECS, covering component… license: MIT ---

Done well means: components are plain data with no behavior, each system does one transformation over a clearly named query, ordering between systems is explicit where it matters, structural changes are deferred, and the design was chosen because the data shape called for it rather than because ECS is fashionable.

Ground rules

  • Decide ECS vs objects before writing code (table below). Retrofitting ECS into an engine built around scene-graph objects (Godot nodes, Unity GameObjects) doubles the complexity unless the hot path really needs it.
  • Components hold data; systems hold logic. A component with methods beyond trivial constructors or getters is a smell.
  • Entities are IDs (index + generation). Store Entity handles, never pointers or references into component storage.
  • Name systems by the transformation they perform: apply_velocity, tick_cooldowns, despawn_dead. A system called update_player is an object in disguise.
  • For Bevy, check Cargo.toml for the version first. Bevy breaks APIs every release; 0.17 renamed buffered events to messages (Message, MessageReader, MessageWriter) and observers take On<E>. Follow the migration guide for the exact version in use.

ECS or not

SituationChoose
Thousands of similar entities updated every frame (bullets, units, particles, boids)ECS / data-oriented arrays
Many orthogonal features combined freely (burning + frozen + flying + shielded)ECS: compose by adding components
Simulation needs determinism, save/load of whole world, or replayECS (world state is plain data)
Small game, tens of unique objects, heavy UIEngine’s native objects or nodes
Team unfamiliar with ECS on a deadlineObjects with composition (child components), migrate hot paths later
Only one system is slowData-oriented arrays for that system; keep the rest as is

Workflow

- [ ] 1. List the entity kinds and the data each needs; find shared fields
- [ ] 2. Split data into components by "which systems read/write it together"
- [ ] 3. List systems as data transformations with their read and write sets
- [ ] 4. Decide ordering constraints and where structural changes happen
- [ ] 5. Choose communication: direct component writes, messages, or observers
- [ ] 6. Implement; enable ambiguity/conflict detection; profile with real entity counts

Component design

  • Granularity rule: fields that are always read and written together belong in one component; fields used by different systems belong in different components. Position and Velocity separate; Health { current, max } together.
  • Tag (marker) components for categories: Player, Enemy, Dead. Queries filter on them.
  • Avoid per-frame tag toggling on large populations in archetype ECSs (Bevy, flecs, DOTS): adding or removing a component moves the entity between tables. For flags that flip every frame use a field or sparse-set storage (Bevy: #[component(storage = "SparseSet")]).
  • Never store Vec<Entity> children manually when the ECS provides relationships (Bevy ChildOf/Children and custom relationships, flecs pairs); you will desync them.
  • Shared immutable config (weapon definitions) → resource or asset referenced by handle, not copied into each entity.

Bevy patterns

use bevy::prelude::*;

#[derive(Component, Default)]
struct Velocity(Vec3);

#[derive(Component)]
struct Health { current: f32, max: f32 }

impl Default for Health {
    fn default() -> Self { Self { current: 100.0, max: 100.0 } }
}

#[derive(Component)]
#[require(Transform, Velocity, Health)]   // spawning Enemy inserts these defaults
struct Enemy;

#[derive(Message)]
struct Damage { target: Entity, amount: f32 }

fn apply_velocity(time: Res<Time>, mut q: Query<(&mut Transform, &Velocity)>) {
    let dt = time.delta_secs();
    for (mut transform, vel) in &mut q {
        transform.translation += vel.0 * dt;
    }
}

fn apply_damage(mut msgs: MessageReader<Damage>, mut q: Query<&mut Health>) {
    for d in msgs.read() {
        if let Ok(mut hp) = q.get_mut(d.target) {
            hp.current -= d.amount;
        }
    }
}

fn despawn_dead(mut commands: Commands, q: Query<(Entity, &Health), Changed<Health>>) {
    for (e, hp) in &q {
        if hp.current <= 0.0 {
            commands.entity(e).despawn();       // deferred until the next sync point
        }
    }
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_message::<Damage>()
        .add_systems(FixedUpdate, (apply_velocity, (apply_damage, despawn_dead).chain()))
        .run();
}

Rules for Bevy specifically:

  • Two queries in one system that touch the same component mutably conflict (error B0001). Make them disjoint with With<A>/Without<A> filters or use ParamSet.
  • Gameplay simulation in FixedUpdate; rendering-side interpolation and input collection in Update.
  • .chain() or .before()/.after() for real data dependencies; group related systems in SystemSets and order the sets. Turn on ambiguity detection while developing: app.edit_schedule(FixedUpdate, |s| { s.set_build_settings(ScheduleBuildSettings { ambiguity_detection: LogLevel::Warn, ..default() }); });
  • Commands are applied at sync points, so an entity spawned in one system is not visible to a later system in the same set unless ordered after an apply point (Bevy inserts these automatically between ordered systems that use commands).
  • Changed<T> / Added<T> filters skip unchanged entities; mutable deref marks a component changed even if the value is identical, so guard writes with if hp.current != new { hp.current = new; } or set_if_neq.
  • Observers react to entity events immediately (on spawn, on component add, custom triggers). Use them for rare reactions (death VFX, achievement); use messages for high-volume per-frame streams.
  • Organize by feature plugins (impl Plugin for CombatPlugin) that register their own components, messages, and systems.

flecs (C++) sketch

flecs::world ecs;
struct Position { float x, y; };
struct Velocity { float x, y; };

ecs.system<Position, const Velocity>("Move")
   .each([](Position& p, const Velocity& v) { p.x += v.x; p.y += v.y; });

ecs.entity("ship").set(Position{0, 0}).set(Velocity{1, 0});
while (ecs.progress(1.0f / 60.0f)) {}

flecs pairs ((ChildOf, parent), (Likes, bob)) model relationships natively, and pipelines/phases (OnUpdate, PostUpdate) order systems.

Unity DOTS note

Entities 1.x: IComponentData structs, ISystem with [BurstCompile], IJobEntity for parallel work, EntityCommandBuffer for structural changes from jobs, bakers to convert authoring GameObjects. Hybrid projects keep UI and one-off objects as GameObjects.

Custom ECS (minimal, when no library fits)

  • Start with structure-of-arrays storage per component plus a sparse set (entity index -> dense index) for O(1) add/remove; archetype tables only if profiling shows multi-component iteration dominates.
  • Generational IDs: { index: u32, generation: u32 }; bump generation on despawn so stale handles fail lookups instead of aliasing a new entity.
  • Never mutate storage structure while iterating; queue spawns/despawns and flush after each system or phase.
  • Do not write your own if Bevy ECS (usable standalone as bevy_ecs), flecs, EnTT (C++), or bitECS/miniplex (TypeScript) fit the stack.

Communication between systems

NeedMechanism
System B reads what A wrote this frameOrder B after A; plain component data
Many-to-many notifications, high volume (damage, hits)Messages / event buffers drained each frame
React once when something happens to an entityObservers / hooks (on add, on remove, custom trigger)
Global singleton data (score, config, RNG)Resource
Rare cross-cutting command (load level)State transitions or a command message

Gotchas

  • Hidden ordering bugs: two systems writing the same component in an undefined order produce frame-to-frame flicker or nondeterminism in replays.
  • Despawning an entity referenced elsewhere: every stored Entity must be checked with get()/Ok before use; never unwrap() a lookup of a remembered entity.
  • Giant “update everything” systems lose parallelism; the scheduler can only run systems in parallel when their access sets do not conflict.
  • Over-splitting into one-field components with every system querying eight of them costs readability without speed gains.
  • RNG in systems: use a seeded RNG resource per simulation, not thread_rng(), if replays or networking need determinism.

Verification

  1. Compile with ambiguity detection on; no conflicting-access errors or ambiguity warnings for gameplay schedules.
  2. Unit test systems in isolation: build a World, spawn entities, run the system once, assert component values (Bevy: world.run_system_once(system) via the RunSystemOnce trait).
  3. Benchmark with the target entity count (e.g. 10k, 100k); compare frame time before and after the redesign with the engine profiler or Tracy.
  4. Replay test for deterministic sims: same seed and inputs produce an identical world hash.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc