Skip to content

Archetype Invariants #1481

Open
Open

Description

@alice-i-cecile

Introduced in #1312. The complete design will be found at RFC #5.

The Basics

An archetype is the set of components that are found together on a single entity. The set of archetypes present in our World is the union of the archetypes of every entity in the world.

Archetype invariants are rules that limit which components can coexist, limiting the possible archetypes that can co-occur. Because of the flexible power of commands.spawn and commands.insert, the components that an entity could have is unknowable at compile time, preventing us from .

We can use this information to allow for more granular component access in ways that would otherwise result in inconsistent runtime behavior, or verify that certain logical rules are followed during execution.

Due to the overhead of verifying these rules against the World's archetypes, this is likely best done only during debug mode, or perhaps by post-hoc inspection of logs.

Use Cases

  1. Reducing false positives in system scheduling ambiguity detection (Ambiguous system ordering #1312)
  2. Assumption checking for safety
  3. More granular overlapping queries (to expand on Less permissive component conflict allowance in system queries #1320)

API for specifying archetype invariants

Primitives:

  • forbidden(my_bundle): an entities archetype cannot have have the specific combination of components listed in the bundle as a subset
  • 'A.always_with(B)': entities that have component A always also have component B
  • `A.only_with(my_bundle)': component A never occurs with components other than those in the bundle

Derived:

  • inseparable(my_bundle): entities that have either component A or B never occur without each other. Equivalent to combining A.always_with(B) with the reverse for every pairwise combination
  • disjoint(my_bundle): entities can only have at most one component in the bundle. Equivalent to creating a forbidden archetype invariant for each pairwise combination
  • bundle versions of all of the primitives, where A and B can be replaced by tuples of components (bundles) instead

Activity

  1. added
    C-FeatureA new feature, making something new possible
    C-UsabilityA targeted quality-of-life change that makes Bevy easier to use
    A-ECSEntities, components, systems, and events
    on Feb 19, 2021
  2. alice-i-cecile commented on Feb 27, 2021

    @alice-i-cecile
    MemberAuthor

    A new thing that I want: element_of(bundle), which allows for "any one of the underlying components" and can be dropped in anywhere you could put a component. This enables enum-like component groups in a really nice way.

    For context, I want this for overriding behavior in a checked and elegant way.

  3. alice-i-cecile commented on Mar 13, 2021

    @alice-i-cecile
    MemberAuthor

    As an interesting twist: a user asked in #1635 to enforce that a resource must always exist. In theory, because #1525 stores resources as components on a singleton entity, we could provide a wrapper to ensure that, by tying it to a Resource marker component or the like.

    While I think that archetype invariants are the right tool for ultimately solving the other requests in #1635, I'm much less convinced on this use case. It feels like it's exploiting a quirk of the implementation details, and that systems panicking when they request a resource that doesn't exist gets us the desired behavior already.

  4. alice-i-cecile commented on Mar 25, 2021

    @alice-i-cecile
    MemberAuthor

    From @BoxyUwU, archetype invariants should only be checked once all commands are flushed, in order to allow for comfortable gradual creation / modification of new entities.

  5. alice-i-cecile commented on Mar 25, 2021

    @alice-i-cecile
    MemberAuthor

    Once all archetype invariants are known, we need to check that no contradictory (impossible to satisfy) rules exist.

  6. BoxyUwU commented on Mar 25, 2021

    @BoxyUwU
    Member

    archetype invariants should only be checked once all commands are flushed

    we actually need to check invariants basically after any time we hand out an &mut World as that would be enough to violate invariants and cause unsoundness if we rely on invariants for query disjointedness.

    so this basically means that

    • Exclusive systems need invariant checking afterwards
    • Applying a command takes &mut World which means commands cannot rely on invariants for soundness unless we check invariants after EVERY command is ran (this is extremely undesirable as it would destroy the ability to build entities across multiple commands)
    • All API's on world that require invariants to hold (such as running systems) must either check invariants themselves or have users check it (it would be really easy to forget to do this so we should we try leverage the type system for this. i.e. a CheckedWorld struct which is just a wrapper around a borrow of World that exposes all the APIs that want invariants to hold)
  7. concave-sphere commented on Apr 8, 2021

    @concave-sphere

    These "invariants" could be viewed as a bottom-up "proto-schema". It might be worth comparing to how traditional database schemas are put together:

    • The database is broken into tables
    • Tables are broken into columns
    • Some columns of a table are the "primary key"
    • Columns might have constraints on them (like "always present")
    • Various additional finer grained functionality

    Two things that jump out at me here are (1) the entire schema is explicitly defined in one place, not scattered around the code base, and (2) the definition includes a basic hierarchy that can be used to organize thinking about the system.

    Purely as a thought experiment, here is an attempt at describing this in Bevy ECS terms. Parts of this are probably contrary to Bevy's design goals, but maybe some ideas can be pulled from it.

    1. A "table" is a value that implements the trait Table.
    2. Every spawn command now takes a type defining the table to spawn inside: commands.spawn::<MyTable>().insert(...).
    3. An additional QueryFilter is added: Table<MyTable>. Query, the scheduler, the ambiguity checker, etc understand that different Tables are disjoint.
    4. A "column" is a component.
    5. The "primary key" is Entity.
    6. Additional constraints are created when the Table is defined.

    A rough realization of this in Rust:

    trait Table { ... }
    trait Column<Table: Table> { ... }
    
    /// See below.
    trait SubTable {
      type Parent;
    }

    The Column trait opts in a column to being usable with a particular table. The type signatures of spawn and insert require the inserted component to be a column of the table. The Bundle trait also is parameterized by a table, and all fields of a derived Bundle must implement Column<Table>. There's no way to stop a type from implementing both Table and Column, but doing so is going to be extremely confusing at best and should be avoided.

    To ensure a single definition of a Table, it is added from AppBuilder like this:

    app.define_table::<MyTable>().unwrap()
       .add_column::<MyColumn>()
       // Sub tables allow modules to have private Column types
       // that are inserted into the schema in a well defined way and
       // don't collide with the Columns of the parent table.
       //
       // A sub table is a collection of columns and constraints that
       // are logically separate from the main table.  Specifically, the
       // sub table can only add constraints that involve its own
       // columns.  Sub tables can NOT have multiple "rows" per
       // Entity -- they just add "normal" columns to the Entity that
       // are kept separate from the parent schema.
       //
       // The sub table *as a whole* is either present or not-present
       // for a given Entity.  Invoking `insert::<MySubTable>` on an
       // `EntityCommands` causes the sub table to be inserted.
       // Must-exist constraints are triggered at this time.  If the
       // sub-table is required for a given `Entity`, the parent table
       // should assert a must-exist constraint with the sub-table's
       // type (if known).
       //
       // `add_sub_schema_bundle` on `TableBuilder<MyTable>` invokes
       // the callback with a `SchemaBuilder`.  Any tables defined by
       // the callback will become sub-tables of `MyTable`.
       .add_sub_schema_bundle(|builder| foreign_package::define_table(builder))
       .add_constraint(Constraint::always_present::<(MyColumn, MyOtherColumn)>())
       .insert_table();
    
    trait SchemaBuilder {
      fn define_table<Table>(&mut self) -> Result<impl TableBuilder<Table>, anyhow::Error>;
      fn add_column<C: Column>(self) -> Self;
      fn add_sub_schema(self, cb: FnOnce<impl TableDefiner>) -> Self;
      fn add_constraint(self, c: Constraint<Table>) -> Self;
      fn insert_table(self) -> Result<(), anyhow::Error>;
    }

    Constraints are defined like this:

    impl Constraint<Table> {
      // This type is always present for any Entity in the table.
      //
      // ColumnSet is a trait that's implemented for anything that is
      // Column or a tuple of ColumnSets.
      fn always_present<CS: ColumnSet<Table>>();
    
      // More constraints can go here.  Constraints always involve columns of `Table`.
      // Cross `Table` constraints are not supported.
    }

    A table can only be defined once: invoking define_table twice for the same type will return an Err. The table can't be used until it is inserted (e.g, spawn will fail with an error that the table isn't defined).

    I'm not sure that's implementable, but I've spent too much time on it already.

  8. alice-i-cecile commented on Apr 8, 2021

    @alice-i-cecile
    MemberAuthor

    @concave-sphere this is fascinating. I agree with your intuition that they feel like bottom-up schema. I'll come back to this in depth as I work on an RFC for these.

  9. added
    S-Needs-Design-DocThis issue or PR is particularly complex, and needs an approved design doc before it can be merged
    on Apr 23, 2021
  10. alice-i-cecile commented on May 21, 2021

    @alice-i-cecile
    MemberAuthor

    What if you would store invariant components in separate tables. That way you'd never have to table-move them and you vastly reduce the amount of components to move on add/remove ops. I'd expect many of the components (pos,rot,transform,mesh,material,...) to be static anyway
    It'd be a pretty easy thing to add into bevy's current design: in addition to the current table ptr & table index, just add an invariant_table_ptr and an invariant_index

    From @SanderMertens on Discord.

  11. alice-i-cecile commented on Jan 10, 2022

    @alice-i-cecile
    MemberAuthor

    When combined with command error handling, we could use this to reliably swap out incompatible sets of components. Instead of panicking, we could handle the result of an insertion error by removing the offending components.

    What I really want is negative or exclusive members in bundles, to make it easier when changing the state of an entity, like moving it to a different area that switches cameras and disables physics.
    So some markers are added, others are removed in one direction, and the opposite in the other.

  12. arialpew commented on May 12, 2022

    @arialpew

    When you start to model something with marker components, you inevitably fall into this issue.

    Markers components like Dead and Alive can be represented as an enum and it's often the first intuitive way of doing it.

    Then, you are probably going to use markers components, you flatten the game state.

    It make easy for Bevy to filter components at runtime and parallelize systems with fine-grained query. But there's a cost, by using marker components instead of enum, you make illegal state representable in your own logic.

    Not a trivial issue to solve. You really need theses markers components because it's a major point of an ECS and the concept is easy to understand, but at the end you need to be carefull about what you do with your game state and your code.

    You can introduce illegal state like a Player that is Swimming and Dead when you expected a Player that is Idle and Dead.

  13. sixfold-origami commented on Jun 22, 2022

    @sixfold-origami
    Contributor

    I started working on a basic prototype for this (with help from @alice-i-cecile), but came to an impasse trying to initialize components by TypeId. I'll probably take another crack at it soon with a different approach, but if I don't, here's what I have:
    https://github.com/plof27/bevy/tree/basic-archetype-invariants

    Some things we worked out:

    1. Archetype invariants should live on the world: Different worlds may have radically different archetypes, and app-less worlds should also be supported.
    2. I think that including helper methods on ArchetypeInvariant to construct common invariants is a nice design. These would then be accompanied by add_archetype_invariant methods on both World and App. The app method would add the invariant to all worlds by default.
    3. The archetype invariants stored on the world should be append-only, and should not be modified at runtime. Since we do not store which archetypes currently have entities, it is impossible to know which archetypes would need to be re-checked if/when a new invariant is added. Furthermore, it is also impossible to know which old, invalid archetypes are no longer a concern. (The prototype handles this by rechecking all archetypes whenever a new invariant is added, and discourages users from adding new invariants at runtime.)
    4. Storing invariants purely at the type level causes unacceptable amounts of API mess, since the types need to be generic over both single components and bundles of components. It also causes problems when storing a list of invariants, as we must resort to dynamic dispatch.
    5. Storing invariants as just ComponentIds means that the user needs to pass &mut World whenever they initialize an archetype invariant, since we cannot ensure that the invariant's components have been intialized in the world before the invariant is added. This causes problems with the app builder API, in addition to being messy.
    6. Storing TypeIds causes problems because we cannot initialize components from TypeIds, and we can't guarantee that all components will be initialized before the invariants are first checked.
    7. Since Archetypes store their components as a list (not really but it's basically a list) of ComponentIds, the archetype invariant system needs to use ComponentIds at some level. This means that dynamic component support will likely come for free if the architecture is designed with it in mind. (This is why the prototype exposes the UntypedArchetypeInvariant struct.)
    8. If at all possible, archetype invariants should be stored in a structured form. This way, they can be used as constraints when optimizing other parts of the engine. In particular, these are appealing as method for ensuring safe parallelization of system parameters that otherwise appear to conflict.
    9. The structure shown in my branch does not cover all possible logical statements that can be made about archetypes, but it does cover the vast majority of common cases. In the future, I would add a Custom variant to ArchetypeStatement that allows the user to provide a custom function, as a safety valve.
    10. The most natural way to validate invariants is to simply check all the new archetypes whenever the world's archetypes are modified. The best way to cache which archetypes have already been checked is to store a cursor pointing to the most recently checked archetype in the archetype list. This relies on the fact that the archetype list is append-only.
    11. The efficiency of checking multiple invariants can likely be improved non-trivially using the following method for each archetype to check:
      1. For each unique ComponentId in the invariant list, check if the archetype has this component. Store the results in a boolean array or as bits in an unsigned integer
      2. For each invariant, use an AND mask to select the bits (components) that are present in the predicate and consequence (separately)
      3. Do the appropriate operation to determine if the value of the predicate and consequence
      4. Compute (!predicate || consequence)
    12. Pruning redundant or tautological invariants is likely not worth the effort, since removing them is very unlikely to reduce the number of set containment checks we will need to do.
    13. Archetype invariants should simply panic if they fail at runtime, because they will likely be used for soundness. Before panicking, however, the system should output a list of all the invariants that the archetype violated (for debugging). These can just be checked sequentially and naively: performance does not matter if you are about to panic anyway.
    14. Certain reasonable archetype invariants cause weird problems with transitivity when chaining entity commands. For example, if {A} and {A, B, C} are valid, but {A, B} is not, adding B followed by C will cause the invariants to panic, but adding {B, C} simultaneously will not. We came to the conclusion that handling this case would be quite difficult without knowing which archetypes are currently in use. Furthermore, disallowing transitive invalidation improves safety. So, instead of handling this in the archetype invariant system, we propose two complementary strategies to mitigate this:
      1. Automatically merge chained entity commands. See Chained EntityCommands create useless temporary archetypes #5074
      2. Warn users of this danger, and instruct them to perform their operations in an order that will not break their invariants. It is relatively straightforward to prove that this is always (ish) possible, although it may be slower in some cases.

    Open questions

    1. We're still unsure of how best to store the invariant information. As described above, all the approaches considered thus far (raw types, TypeIds, ComponentIds) have non-trivial problems or limitations.
    2. The exact list of invariant construction helper methods still needs to be worked out.
    3. Some of the helper methods will likely be bijective (e.g. A <=> B), but the current API design doesn't have a nice way to generate and add multiple invariants at once.
    4. How can we ensure that the invariants will be checked every time the archetypes are modified? As far as I know, there isn't a universal modification hook for archetypes. We could manually add the check wherever &mut Archetypes appears, but this will likely cause bugs in the future. (The likely solution here is to add a Mut-style smart pointer.)
    5. Is there a stronger way to ensure archetype invariants are not added at runtime? Reverting back to AppBuilder would solve this 🙃
  14. 10 remaining items

  15. tbillington commented on Feb 9, 2025

    @tbillington
    Contributor

    Should/would you be able to share the same identity between multiple components?

  16. ElliottjPierce commented on Apr 1, 2025

    @ElliottjPierce
    Contributor

    Some thoughts on general designs here:

    First, I think the easiest part is to add:

    #[derive(Component)]
    #[require(RigidBody)]
    #[archetype(needs(RigidBody), never(Enemy))]
    struct Player;

    I think that part is pretty uncontroversial, and we can implement it pretty quickly. When we create an Archetype we can just return a result based on if the included component's invariants are upheld. We can cache that result in Edges, and if an operation creates an invalid archetype, we can just return an error.

    I think we should start there. That also unblocks #18566. Taking advantage of these invariants to speed up queries, reduce access lists, etc, is really good (obviously), but I think those are separate issues for now.

    Then, we can look at something like:

    #[derive(Component)]
    #[archetype(every(Player), unite(OtherInputData)]
    struct PlayerInputData {
        // ...
    }

    This specifies that a Player can not be spawned without PlayerInputData and that OtherInputData and PlayerInputData are inseparable. (every is the inverse of needs , and unite is the combination of every and needs).

    This is a bit more controversial, as downstream crates can add invariants to components. For example, if one crate inserts component A in a system, but another crate makes it invariant that A has a B, then the utility crate may break due to something it can not control. Proper error handling would help here, but this is still controversial.

    But, this doesn't need any additional design. every and unite can reuse the infrastructure from needs.

    Finally, regarding identity and element_of:

    I think this can really naturally be part of #18540 (component groups). Specifically, a component group can be made unique such that only one component of that group may be on an archetype at any time.

    For example, Identity can be a unique component group:

    #[component_group(unique)]
    trait EntityKind {
        fn name(&self) -> &str;
        fn debug(&self, data: &EntityRef);
    }
    
    type Identity = dyn EntityKind;
    
    #[derive(Component, EntityKind)]
    #[group(Identity)]
    struct Player;

    Or, as another example, bevy-trait-query has Single which has better performance than &dyn MyTriat, but it assumes that only one of the entity's components implements the trait. This would allow that to be enforced at the archetype level for even more performance wins.

    This is easily the most controversial and hardest to design part of this. But I don't think designing this part should block needs and never IMO.

  17. Zeenobit commented on Apr 1, 2025

    @Zeenobit
    Contributor

    With component requirements, enforcing archetype requirements can be done much more easily without any traits or new macro magic. I've been playing with #[require(Expect<T>)] from my own library:
    Zeenobit/moonshine_util@3ae98ff

    It's just a component that checks the existence of another component via a hook.

    I imagine the same pattern could be extended to support things like #[require(Never<T>)].

  18. ElliottjPierce commented on Apr 1, 2025

    @ElliottjPierce
    Contributor

    It's just a component that checks the existence of another component via a hook.

    Hooks are a viable path to this, but they are much slower than just caching the result in the archetype edges. I'm not opposed to this if we want archetype invariants that depend on the value of some components. But if we only want to enforce this at the component id/archetype id level, we can do this much faster without hooks.

  19. Zeenobit commented on Apr 1, 2025

    @Zeenobit
    Contributor

    I'm just weary of additional API complexity. It'd be a lot better if we could define archetype invariants within the same require clause/API rather than creating yet another new macro-driven syntax.

    If we could optimize this at the archetype level and still keep it within the scope of the require API, it'd be the best option in my opinion.

  20. ElliottjPierce commented on Apr 1, 2025

    @ElliottjPierce
    Contributor

    I'm just weary of additional API complexity.

    I agree but I think it's worth it.

    If we could optimize this at the archetype level and still keep it within the scope of the require API, it'd be the best option in my opinion.

    100% Agree here. That's the plan with #18566. But I also think we need the new macro syntax in addition for users that need it. Adding extra invariants could improve query performance in the long run, so allowing users to specify invariants beyond just requirements is important. And also, some requied components will only be required on creation. We might not want an invariant for all of them, but that's a discussion for #18566.

  21. andriyDev commented on Jun 5, 2025

    @andriyDev
    Contributor

    First, the hook approach is the easy way out but provides very little of the benefit of archetype invariants here. One thing I would like to see as a result of Archetype Invariants is being able to show that Query<&Transform, With<A>> and Query<&mut Transform, With<B>> are disjoint (and so can be put into one system), given the archetype invariant A is mutually exclusive with B.

    I think we should split this feature into three PRs (or more):

    1. Archetype Manipulation: Make inserting and removing bundles verify the invariants. Include an API with silly names so we're forced to change it (e.g., add_really_required_component) - sidestep bikeshedding and just get the invariants applied and checked. This API should be like the runtime required components API - sidesteps any derive macro bikeshedding, and it's something we want anyway for dynamic components.
    2. API bikeshedding + derive macro stuff.
    3. Use archetype invariants for system access.

    I suspect these are all doable and don't have significant blockers. The main thing that's not clear to me is whether an archetype invariant should be a trait or not. Something I read above made me think that was the suggestion. I'm not sure if that's necessary though. I feel like we only need to handle a few cases for invariants:

    On Insert of component A, an invariant does one of:

    • remove component B (i.e., A is mutually exclusive with B).
    • error if has component B (i.e., you can't insert A on an entity that already has B)
    • error if missing component B (i.e., if you insert A you must also insert B).
    • insert B default initialized (required components today!)

    On remove of component A:

    • remove B (i.e., A and B are "entangled")

    I think this is all the primitives we need to support pretty much all invariants. We'd need a little bit of verification on these "actions" in order for them to apply constraints to system access (e.g., required components today does not allow us to assume that A and B are always together, since a user can remove B afterwards).

  22. SoyokazeRoom201 commented on Feb 23, 2026

    @SoyokazeRoom201

    Hiiiii!!

    I have searched lots of materials. This is a particularly complex chain of design and implementation IMO.

    1. everything-as-entities(include Component): If we want to achieve kindly entity and archetype invariant, we need everything-as-entities it can be directly implemented through relation, and users do not need two sets of languages. Implementation will also be much much simpler.
    2. Linter, The main obstacle and controversy I have searched is runtime panic or warn. Either way is unacceptable to users. Linter is crucial for checking constraints(require/forbid).
    3. Having a linter cannot also be guaranteed that everything is fine. The issue lies in when, not if. It is necessary to upgrade the archetype state change to form of transactional, which depends on the successful implementation of the above two. Only in this way can users use these features with peace of mind.

    Only when all the above are successfully implemented, this feature can be safely released.

  23. alice-i-cecile commented on Apr 4, 2026

    @alice-i-cecile
    MemberAuthor

    My current analysis here, prompted by #23657, for posterity:

    To me, the hard problems here:

    a) performance
    b) when do we enforce this (e.g. can we rely on this for soundness)
    c) integration with query overlap detection
    d) integration with scheduling and ambiguity checking

  24. added
    X-Needs-SMEThis type of work requires an SME to approve it.
    S-Needs-GoalThis should have a C-Goal and should not continue until it has one
    and removed
    S-Needs-Design-DocThis issue or PR is particularly complex, and needs an approved design doc before it can be merged
    on Apr 4, 2026
  25. moved this to Needs SME Triage in ECSon Apr 4, 2026
  26. SoyokazeRoom201 commented on Apr 5, 2026

    @SoyokazeRoom201

    My current analysis here, prompted by #23657, for posterity:

    To me, the hard problems here:
    a) performance
    b) when do we enforce this (e.g. can we rely on this for soundness)
    c) integration with query overlap detection
    d) integration with scheduling and ambiguity checking

    Hi!!!! refer to #23658, some insights:

    a) already have benches, but currently there are only very naive tests. However, based my personal analysis, I believe that the performance cost is very low. (only my personal view)
    b) If only analysis is conducted without any restrictions(refuses), then options c) and d) cannot be achieved. The guarantee of soundness is in the return of Result Err, which prevents any downstream code from continuing to write Components to the Table.
    c) The TODOs in the draft PR illustrate the possible directions, using the Without list injection method to the FilteredAccess. In short, Components stores a mutex list and injects it directly during registeration phase. In terms of mutex list implementation, this is a roughly SAT solver, but it has a polynomial complexity. There is a performance cost during the registration process, while the benefit is the runtime cost.
    d) This is the same concept as c) (for me). If it is ensured that the queries refer to different archetypes(in code, it sames both call the same API is_ruled_out_by), then this situation will never occur.

    But more significant problems were exposed:

    • non trasactional: Chained EntityCommands create useless temporary archetypes #5074, I think the core issue is not performance optimization, but transactional semantics consistent with the other database language. In short, it should be either all successful or all failed. But this is not the breaking of an invariant; rather, it will lead to unexpected behaviors for users.
    • fallable API: When introducing the concept of "failure", I think it is necessary to remind the users of something.

    Another tradeoff that has to be made is the side-effects vs the guarantee of invariants. When side-effects(any replacement, insertion, remove) are introduced, maintaining invariants becomes impossible, at least it is an extremely complex problem with no guarantee of performance. This does not mean we do not allow the side-effects; instead, it means ensuring that they are orthogonal to verification. That is to say, side effects come first, and verification comes second. Eventually, verification may still fail, but invariants are guaranteed. This explains why the existing required components can never prevent removal.

    Hope this will be of some help!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-ECSEntities, components, systems, and eventsC-FeatureA new feature, making something new possibleC-UsabilityA targeted quality-of-life change that makes Bevy easier to useS-Needs-GoalThis should have a C-Goal and should not continue until it has oneX-Needs-SMEThis type of work requires an SME to approve it.

    Type

    No type

    Projects

    • Status
      Needs SME Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions