Deterministic mutation hooks for ECS#87
Conversation
Two bevy_ecs primitives for deterministic mutation pipelines: ObserverSet (ordered observer dispatch) and restricted component access (#[component(restricted)] + RestrictedMut<T>).
|
Can you spell out what the use case is for restricted mutability when compared to making the component's fields private and providing mutation metbods? |
|
It is not clear to me how Also, I assume that immutable components aren't sufficient because inserting components requires you to have ownership of a full component value, even if the data is expensive or impossible to clone? But the RFC needs to state why they can't be used. |
| The two primitives are independent: neither depends on the other, and either | ||
| could land first. They are presented together because they share one motivation | ||
| (making deterministic mutation pipelines practical), are designed to compose, | ||
| and are each individually small. Because they are independent, bundling them | ||
| imposes no merge-order constraint between implementation PRs. An author who | ||
| preferred could split them into two cross-linked standalone RFCs without | ||
| changing their substance; they are kept together here because they are easier to | ||
| evaluate as two halves of the same problem. |
There was a problem hiding this comment.
two halves of the same problem
You should explain more about why they are so deeply correlated. From what I see you never mention how one complements the other, or how a certain goal cannot be reached without both.
|
|
||
| ### AI assistance | ||
|
|
||
| AI was used to make holistic design decisions. |
There was a problem hiding this comment.
I'm kind of confused about that – shouldn't language models be even worse for large-scale design decisions than for generating language? And isn't the cost of a bad large-scale design decision higher than for a bad implementation too, making it even more important to actually think things through?
| Observers for the same event can be ordered using the same vocabulary as | ||
| systems. | ||
|
|
||
| 2. Cross-bucket observer ordering. |
| 5. A safe write gate for restricted components. | ||
|
|
||
| Components that opt into restricted access cannot be mutated through ordinary | ||
| safe in-place mutable ECS access such as [`Query`][query]`<&mut T>`, | ||
| [`Query`][query]`<`[`Mut`][mut]`<T>>`, | ||
| typed world/entity mutable access, or safe untyped mutable access by | ||
| [`ComponentId`][component-id]. |
There was a problem hiding this comment.
This only explains the limitations. What are the advantages that you gain by using restricted access that you cannot have otherwise? What do you gain by preventing the use of Query<&mut T>? I don't see this mentioned anywhere else in the RFC, and that's the most critical motivation for the feature.
|
|
||
| 7. A narrow engine surface that libraries can build on. | ||
|
|
||
| Bevy provides ordering and access control. Library policy remains downstream. |
| A downstream library can wrap this further. In the example below `MutationLog` and | ||
| `HealthChanged` are illustrative application-owned types, not Bevy APIs: | ||
|
|
||
| ```rust | ||
| fn apply_logged_damage( | ||
| damaged: Query<(Entity, &IncomingDamage)>, | ||
| mut health: RestrictedMut<Health>, | ||
| mut log: ResMut<MutationLog>, | ||
| ) { | ||
| for (entity, damage) in &damaged { | ||
| let result = health.modify(entity, |health| { | ||
| let before = health.current; | ||
| health.current = health.current.saturating_sub(damage.amount); | ||
| before | ||
| }); | ||
|
|
||
| if let Ok(before) = result { | ||
| log.push(HealthChanged { | ||
| entity, | ||
| before, | ||
| after_damage: damage.amount, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| ``` |
There was a problem hiding this comment.
What is the advantage of having to use modify here? If another downstream crate did the following instead, wouldn't the log get out of sync?
fn apply_logged_damage_bad(
damaged: Query<(Entity, &IncomingDamage)>,
mut health: RestrictedMut<Health>,
) {
for (entity, damage) in &damaged {
let result = health.modify(entity, |health| {
let before = health.current;
health.current = health.current.saturating_sub(damage.amount);
before
});
}
}Or, what is wrong with the current approach of doing this?
fn apply_logged_damage(
mut query: Query<(Entity, &mut Health, &IncomingDamage)>,
mut log: ResMut<MutationLog>,
) {
for (entity, mut health, damage) in &mut query {
let before = health.current;
health.current = health.current.saturating_sub(damage.amount);
log.push(HealthChanged {
entity,
before,
after_damage: damage.amount,
});
}
}| The apply observer mutates through `RestrictedMut<RoundState>`: | ||
|
|
||
| ```rust | ||
| fn apply_command( | ||
| commands_to_apply: Query<(Entity, &ValidatedCommand)>, | ||
| mut round_state: RestrictedMut<RoundState>, | ||
| ) { | ||
| for (entity, command) in &commands_to_apply { | ||
| let _ = round_state.modify(entity, |state| { | ||
| state.turn += command.turn_delta; | ||
| }); | ||
| } | ||
| } | ||
| ``` |
There was a problem hiding this comment.
This is already possible with mutable queries though.
| Component hooks observe structural changes, but they do not cover every in-place | ||
| mutation through safe mutable ECS access. |
There was a problem hiding this comment.
This sounds like you want to observer in-place mutations with observers, but the RFC doesn't actually do that.
| be added later if reflection-driven tooling has a clear use case; see | ||
| [Reflection and scenes](#reflection-and-scenes) for the scene-loading implication. | ||
|
|
||
| [Unsafe APIs][unsafe-world-cell] remain unsafe escape hatches. |
There was a problem hiding this comment.
bevy_ecs' unsafe functions aren't escape hatches for component mutability. Trying to use them that way either fails or is unsound.
|
I would argue using LLMs for large-scale design decisions and writing RFC text is far beyond the simple autosuggest and other small-scale uses allowed by the AI Policy Therefore, i would like to nominate this for closure. The label doesn't yet exist for this repo, so i have to state it like this. Besides the AI Policy concerns, i've had a brief look at the contents and its proposing somewhat duplicated functionality without explaining the differences and besides that, i don't think this is the right way forward for how a solution for this problem should be designed. |
RFC: Deterministic ECS mutation primitives
This RFC proposes two
bevy_ecsprimitives for deterministic mutation pipelines:ObserverSetand ordered observer dispatch, matching the familiar.before/.after/.in_set/.chain()scheduling vocabulary.#[component(restricted)]andRestrictedMut<T>, an opt-in safe write gate that prevents ordinary safe in-place mutable ECS access from bypassing a mediated mutation path.Rendered RFC:
Related Bevy work:
Important provenance note for review:
bevy#24370is currently closed, and its discussion includes Bevy AI-policy concerns. This RFC is intended to discuss the ECS design. It is not intended to bypass provenance requirements for any implementation PR. Any reopened or successor implementation PR must independently satisfy Bevy's contribution policy.The RFC deliberately does not propose upstreaming
MutationLog,SyncBarrier, networking, save/load, undo, replay, or audit policy. Those remain library concerns built on top of the two proposed engine-level enforcement hooks.Specific design points where review would be especially useful:
#[derive(Component)] #[component(restricted)]as recommended here, or the prototype's#[derive(RestrictedAccess)];RestrictedMut<T>::get_mutshould be included in v1 or whether the API should start with closure-stylemodifyplus iteration;ComponentIdpaths returningMutUntyped.