name: bevy-architecture description: Architecture method for Bevy (Rust ECS) game projects, grounded in how Bevy 0.18+ actually works rather than forcing DDD/Clean Architecture onto an ECS. Use this skill whenever designing, scaffolding, structuring, refactoring, or reviewing any Bevy codebase; deciding where a system/component/resource should live; choosing between Messages, Observers, or direct mutation; modeling components and game state; setting up schedules, states, assets, config, saves, determinism, networking, or AI. Trigger it for questions like "how should I structure my Bevy game", "where does this system go", "is this Bevy code over-engineered", or any Bevy folder/module/plugin layout decision, even when the word "architecture" is not used.
Bevy Architecture
Domain-first method for long-lived Bevy games. It assumes Bevy 0.18+, Rust, and ECS-first thinking. Folders express intent; crates and dependency direction enforce it.
Every rule carries its reason. A rule without a stated why gets misapplied. An absolute with no named exception is a defect in this skill.
Normative keywords
- MUST / MUST NOT — non-negotiable; violating it is a defect.
- SHOULD / SHOULD NOT — strong default; deviate only with a stated reason matching a named exception.
- MAY — a real option, no default preference.
- CANNOT — enforced by the compiler, the ECS, or physics. Not a choice.
Do not invent exceptions to a SHOULD beyond those named here.
North Star (priority order; higher wins on conflict)
- Simulation correctness — illegal game states are hard to represent; the sim is reproducible where the design needs it.
- Comprehensibility — a name tells you where its behavior lives.
- Changeability — features can be added, tuned, removed without ripple.
- Agent navigability — predictable paths, narrow public surfaces, colocated behavior.
- Performance — exploited where measured, never pre-optimized. ECS gives locality and parallelism for free.
You MUST NOT optimize for: engine-independence, maximum abstraction, or speculative multiplayer/modding before it exists.
ECS reality (read first)
- One
World, shared storage. Module privacy hides items, not component access. Real isolation = crate boundaries, not folders. - Data/behavior separation is already given: components = data, systems = behavior. You do not police it; you use it.
- Cross-cutting components (
Transform,Health) are unavoidable — they need an explicit owner (see Layout). - Decoupling is a trade: a Message swaps a compile-time dependency for a timing/ordering one. Choose it deliberately.
1. Layout
Top-level folders MUST be gameplay capabilities, never ECS layers.
src/
├── app/ # composition root: plugins, schedules, states, error handler. No gameplay.
├── domain/ # cross-cutting GAME components + identities (the shared vocabulary)
├── shared/ # business-free utilities: math, rng, diagnostics helpers
├── movement/ # ── features ──
├── combat/
├── ai/
└── main.rs
- The ONLY non-feature top-level folders allowed are
app/,domain/,shared/. Any rootcomponents/,systems/, orresources/folder is a defect — it scatters one feature across the tree. shared/MUST be business-free.domain/MUST be business but feature-neutral.domain/MUST be sub-grouped by capability from day one (domain/combat/,domain/characters/,domain/economy/,domain/world/). A flatdomain/becomes a junk drawer every feature depends on.- Inside a feature, group by sub-capability and colocate tests:
combat/ ├── plugin.rs # the feature's PUBLIC API ├── components.rs # components private to combat ├── attack/{intent.rs, resolve.rs, tests.rs} └── damage/{apply.rs, tests.rs}
2. Plugin = public API
- Each feature MUST expose exactly one
Plugin(CombatPlugin) that registers its systems, messages, events, observers, sets, resources. - Other code MUST depend only on what
plugin.rs/mod.rsre-exports. Reaching intocombat::damage::apply::internalis a defect.
3. Dependencies
Allowed direction (arrows point at what is depended on):
app → feature → domain → shared
- Features MAY depend on
domain/andshared/. - Features SHOULD NOT depend on each other directly. Cross-feature behavior MUST go through Messages on
domain/-owned types — this keeps the graph pointing only atdomain/shared, prevents feature⇄feature spaghetti, and breaks cycles by construction. - Exception: a feature MAY read another feature's public component/message types only when those types live in
domain/(so the dependency is ondomain, not the feature). - The dependency graph MUST be acyclic and MUST NOT point "upward". Enforce with a fitness check (§17).
4. Crate split (on a trigger, not day one)
- Start single-crate. Promote a boundary to a crate only when it needs policing.
- The decisive trigger is the sim/presentation split: extract a
simcrate so game logic CANNOT linkbevy_render. - Use Bevy 0.18
2d_api/3d_apifeature collections: they give Bevy's API without the renderer, making the wall a compile error, not a hope.
5. Components, entities, resources
- Component = per-entity data. Entity = a thing in the world. Resource = single global instance.
- The player/enemies/projectiles MUST be entities, never resources. A resource MAY hold their
Entityid (PlayerEntity(Entity)), not their state. - A type MUST be a resource only if two could never exist; otherwise it is an entity.
- Use
#[require(...)]to make illegal entities hard to spawn. Keeprequiregraphs shallow and acyclic — cycles panic at startup. - Prefer small marker components (
Enemy,Hostile) over boolean fields when the marker drives query filtering. Use Bevy'sDisabledto exclude entities, not customis_activeflags.
6. Type-driven modeling (from day one, not an escalation)
- Identities MUST be newtypes, not bare primitives — the compiler catches "wrong
u32passed". - Rule-bearing values SHOULD be value objects that make illegal states unrepresentable and own their pure, local invariants.
- Pure, World-free methods (
is_dead(),apply_damage(&mut self, n)) MUST live on the type. Re-derivingcurrent <= maxin every system is the real anti-pattern. - What MUST NOT live on a component: anything needing
World, queries, spawn/despawn, sending messages, or cross-entity coordination. The line is World access, not "any method".
#[derive(Component, Clone, Copy)]
pub struct Health { current: u32, max: u32 }
impl Health {
pub fn new(max: u32) -> Self { Self { current: max, max } } // invalid Health CANNOT be built
pub fn is_dead(&self) -> bool { self.current == 0 } // pure, local → belongs here
pub fn apply_damage(&mut self, n: u32) { self.current = self.current.saturating_sub(n); }
}
- Model exclusive states as typed unions (
Holstered | Drawing | Ready | Firing), not contradictory booleans.
7. Functional core, imperative shell
- Game decisions SHOULD be pure free functions taking plain data, returning plain data. The system is a thin shell: read queries → call core → write results.
- Why: the core is trivially unit/property-testable and deterministic; the shell is what headless sim tests exercise.
8. Communication: Messages vs Observers vs direct
| Tool | Bevy concept | Timing | Ordering | Use for |
|---|---|---|---|---|
| Direct write | system mutates components | this frame | by schedule | same feature, immediate invariant |
| Message | Message + MessageWriter/Reader |
next time reader runs | by your schedule | cross-feature facts, fan-out, determinism-friendly |
| Observer | Event + Observer/On<E> |
immediate | CANNOT be ordered | entity-lifecycle reactions, targeted hooks, propagation |
Decision rule:
- Same feature, immediate → direct mutation. Do not message yourself.
- Another feature reacts, next-tick consistency is fine → Message (the default cross-feature channel).
- Immediate, entity-targeted reaction needed → Observer. If reaction order matters, use Messages and order the reader systems — observers CANNOT be ordered.
Naming:
- Messages and Events MUST be past-tense facts (
DamageApplied,EnemyKilled). Never vague mutations (EntityUpdated). - Intent/request types SHOULD be suffixed
Intent/Request(AttackIntent). They MUST NOT be suffixedCommand—Commandis reserved for Bevy'sCommandsqueue.
Canonical flow: Input → Intent (Message) → sim systems → Fact (Message) → presentation. Observers are a side-channel, not the spine.
9. Scheduling, SystemSets, determinism
Schedule split:
- Gameplay simulation (physics, combat, movement, AI, economy) MUST run in
FixedUpdate. - Frame-rate work (input sampling, UI, camera, presentation, animation) runs in
Update. Startupis for one-time init only.
SystemSet convention (every feature follows the same shape — AI-friendly and orderable):
#[derive(SystemSet, Clone, PartialEq, Eq, Hash, Debug)]
pub enum CombatSet { Intake, Resolve, Apply, EmitFacts }
- Each feature MUST define one such pipeline enum and assign its systems to a phase. Order phases with
.chain(). - Why: uniform phases make ordering explicit, cross-feature ordering legible, and the layout predictable.
Fixed timestep is NOT determinism. If the design needs reproducibility, declare a tier in an ADR:
- T0 — none needed.
- T1 (single-machine replay/test): order systems via SystemSets; enable schedule ambiguity detection in CI; thread one seeded RNG resource (no global RNG in sim); do not rely on incidental iteration order or randomly-seeded hash maps.
- T2 (cross-machine lockstep): all of T1 plus floating-point determinism (fixed-point or
libmpath). Adopt only for lockstep netcode.
Most projects need T1 at most.
Input→FixedUpdate seam: input is sampled at frame rate, consumed at fixed rate. You MUST buffer input in Update into Intent messages that FixedUpdate drains — raw per-frame input dropped/double-counts.
Gate systems with run_if run conditions, not early return — the condition is visible to the scheduler.
10. States
- Use
Statesfor major modes (Loading | MainMenu | Playing | GameOver). - Use
SubStatesfor modes that exist only under a parent (e.g.PausedunderPlaying) — removed automatically when the parent leaves. - Use
ComputedStatesfor derived read-only views, to avoid duplicating "are we roughly here" checks. - Attach
StateScoped(or#[states(scoped_entities)]) so mode entities despawn on exit. - You SHOULD NOT explode into tiny state flags. If a distinction gates one system, use a marker or run condition.
11. Layers
Direction MUST be one-way:
infrastructure → simulation → presentation
- Presentation MAY read simulation via
Changed<T>and fact Messages. Simulation MUST NOT read presentation components or assets. - Enforce with the
simcrate split (§4): then "sim reads a sprite" is a compile error. - Input is presentation-adjacent; it produces
Intentand lives inUpdate, never in sim logic.
12. Assets
- Load handles once at startup/loading and store them in typed per-feature resources. Systems read the resource.
- You MUST NOT call
asset_server.load(...)ad hoc inside gameplay systems — scattered handles cause duplicate loads and untracked readiness.
#[derive(Resource)]
pub struct CombatAssets { pub hit_sound: Handle<AudioSource> }
13. Config
- Config files (RON/TOML) MUST be deserialized once at the edge into typed domain structs.
- Simulation MUST consume typed config (
EnemyStats,WeaponConfig), never file formats — same parse-don't-validate boundary as §6.
14. Persistence / saves
- Principle: decouple the persistence schema from the runtime layout. It is NOT "never serialize the
World". - Player progression SHOULD use hand-authored, versioned
SaveGamestructs with explicit migration. - Levels/prefabs/editor data MAY use reflection scenes (
DynamicScene) with registered types. - Persisted types are a public contract: changing one requires a version bump + migration, never a silent field edit.
15. Networking
- Authority MUST be server-side; client state is never trusted. Clients send input/intent, run prediction + presentation.
- Pick the model in an ADR — it sets the determinism tier: state replication (forgiving, T1, more bandwidth) vs lockstep (cheap bandwidth, requires T2).
- Do not build networking abstractions before a model is chosen.
16. AI
- There is no quality ranking among techniques. Choose by problem shape:
- Behavior Trees — authored, inspectable, sequenced behavior.
- Utility AI — many agents picking among options with smooth tradeoffs; weak at long sequences.
- GOAP — agent plans its own action sequence; powerful, harder to debug/scale.
- A project MAY mix them. The AI feature consumes facts and emits
Intentlike any feature — it MUST NOT be a privileged subsystem reaching across boundaries.
17. Errors, assertions, observability
Errors
- Systems/observers SHOULD return
Result<(), BevyError>and use?for should-never-fail accesses (query.single()?). - Develop with the panicking handler; ship with a logging handler (
set_error_handler). - Expected absence (no target this frame) is control flow (
let Ok(x) = .. else { return Ok(()) }), not anErr.
Assertions / invariants
- Invariants that the type system CANNOT express SHOULD be enforced with
debug_assert!in the sim path (zero release cost), and withassert!for setup/config validation that must hold in release.
debug_assert!(health.current <= health.max);
assert!(total_weight > 0.0, "loot table must have positive weight");
Observability (first-class, not an afterthought — large games are undebuggable without it)
- Hot systems and core decision functions SHOULD carry tracing spans:
#[tracing::instrument(skip_all)]. - Key facts SHOULD emit structured logs:
info!(attacker = ?e, damage = n, "attack resolved"). - Each feature SHOULD expose diagnostics/metrics from the start; performance problems found late are expensive.
18. Testing
- Functional-core unit tests — pure decision fns; no Bevy, instant; most logic coverage lives here.
- Headless sim tests — build an
App, add the plugin, insert inputs,app.update()N times, assert on components/resources/messages. The backbone of gameplay testing. - Property tests — for combat/economy/crafting/loot invariants, run against the core.
- Tests MUST colocate with the code they cover; only cross-feature behavior goes in a top-level
tests/.
19. Governance
- Encode load-bearing rules as fitness functions (CI), so the architecture polices itself:
- dependency direction is acyclic and never upward (§3);
- the
simcrate's tree contains nobevy_render(§11); - schedule ambiguity gate for sim schedules (§9, T1);
- spawning each archetype does not panic (catches
requirecycles).
- Record each considerable decision as an ADR; mark superseded ones, never delete. Seed ADRs for: determinism tier, networking model, persistence strategy, any feature promoted to a crate.
- Maintain
ARCHITECTURE.mdandAGENTS.mdpointing tools at the layout and these rules.
20. Evolution path (earn every abstraction)
Stay at the earliest stage that meets current needs; advance only on a concrete trigger.
- Foundation — single crate; feature folders;
domain/+shared/; newtypes + typed components; FixedUpdate/Update split; SystemSet pipelines; states. Most prototypes stay here. - Determinism (T1) — SystemSet ordering, ambiguity gate, seeded RNG when replays/tests/balance need reproducibility.
- Crate split — extract
sim(nobevy_render) when the layer wall must be compiler-enforced or build/ownership demands it. - Enforcement — add fitness functions when boundaries drift under churn.
- Networking / lockstep (T2) — deterministic FP + netcode model only when multiplayer is committed.
Flag over-engineering (a sim crate for a jam game; lockstep FP with no multiplayer) as readily as under-engineering. When unsure, pick the lower stage and name the trigger that would justify advancing.
Review checklist (when reviewing a Bevy codebase)
- Layout — top-level folders are capabilities? Only
app/domain/sharedare non-features?domain/sub-grouped, not a junk drawer? - Dependencies — acyclic, never upward? Features talk via Messages on domain types, not direct feature→feature deps? Plugin is the only public surface?
- Types — identities are newtypes? Rule-bearing values are value objects with pure local methods? Illegal states unrepresentable? No World access on components?
- Communication — Messages for facts, Observers for immediate reactions, direct for same-feature? Facts past-tense? No
Commandsuffix collision? - Scheduling — sim in
FixedUpdate? Uniform SystemSet pipeline per feature? Determinism tier declared and its requirements met? Input buffered across the seam? - Layers — one-way infra→sim→presentation? Enforced by crate split where claimed?
- Assets/config — handles in typed resources (no ad-hoc loads)? Config parsed once at the edge into typed structs?
- Robustness —
Result/?in fallible systems?debug_assert!for non-type invariants? Spans + structured logs on hot paths and key facts? - Governance — fitness functions enforce the rules? ADRs for the dominating decisions? Tests colocated, core unit-tested, sim tested headlessly?
- Restraint — any abstraction that has not earned its place? Could it use fewer concepts?