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.
- name
ecs-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.
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
Entityhandles, never pointers or references into component storage. - Name systems by the transformation they perform:
apply_velocity,tick_cooldowns,despawn_dead. A system calledupdate_playeris an object in disguise. - For Bevy, check
Cargo.tomlfor the version first. Bevy breaks APIs every release; 0.17 renamed buffered events to messages (Message,MessageReader,MessageWriter) and observers takeOn<E>. Follow the migration guide for the exact version in use.
ECS or not
| Situation | Choose |
|---|---|
| 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 replay | ECS (world state is plain data) |
| Small game, tens of unique objects, heavy UI | Engine’s native objects or nodes |
| Team unfamiliar with ECS on a deadline | Objects with composition (child components), migrate hot paths later |
| Only one system is slow | Data-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.
PositionandVelocityseparate;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 (BevyChildOf/Childrenand 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 useParamSet. - Gameplay simulation in
FixedUpdate; rendering-side interpolation and input collection inUpdate. .chain()or.before()/.after()for real data dependencies; group related systems inSystemSets 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() }); });Commandsare 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 withif hp.current != new { hp.current = new; }orset_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
| Need | Mechanism |
|---|---|
| System B reads what A wrote this frame | Order 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 entity | Observers / 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
Entitymust be checked withget()/Okbefore use; neverunwrap()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
- Compile with ambiguity detection on; no conflicting-access errors or ambiguity warnings for gameplay schedules.
- Unit test systems in isolation: build a
World, spawn entities, run the system once, assert component values (Bevy:world.run_system_once(system)via theRunSystemOncetrait). - Benchmark with the target entity count (e.g. 10k, 100k); compare frame time before and after the redesign with the engine profiler or Tracy.
- 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.