🤖 Flesh out bevy-0.19 and bevy-upgrade skills from research

Replace scaffold placeholders with full skill content:
- bevy-0.19: ECS cheat sheet, critical traps table, architecture guide, and 7 reference files
- bevy-upgrade: version migration workflow, transition references for 0.15→0.19, and 3 per-bump reference files
This commit is contained in:
2026-07-07 17:52:10 +02:00
parent 325e278af1
commit c661027d19
14 changed files with 927 additions and 53 deletions
@@ -0,0 +1,71 @@
# Camera, Rendering, Assets
## Camera setup
`Camera3d` is a marker with `#[require(...)]` pulling in `Camera`, `DebandDither`, `CameraRenderGraph`, `Projection`, `Tonemapping`, `ColorGrading`, `Exposure`. Spawning bare `Camera3d` yields a working 3D camera.
- **0.18**: `RenderTarget` is a separate required component, no longer a `Camera` field.
- **0.19**: `Hdr` moved from `bevy_render` to `bevy_camera`; screen-space specular transmission fields moved into a `ScreenSpaceTransmission` component.
### Multiple cameras / ordering
- `Camera.order: isize` — higher renders later (on top).
- Every camera with `is_active: true` renders independently.
- Overlay cameras need `ClearColorConfig::None` or they wipe the layer below.
### Camera gotchas
- Default far plane is **1000 units** — anything beyond is silently culled.
- Forward is **-Z**.
- Viewport coordinates are **physical** pixels; cursor position is **logical**.
## Coordinate system
Right-handed:
| Axis | Bevy | GLTF models |
|------|------|-------------|
| Forward | -Z (`Transform::forward()`) | +Z (`Transform::back()`) |
| Up | +Y | +Y |
| Right | +X | -X from camera perspective |
GLTF fix: orient with `look_to(-direction, Vec3::Y)` or treat `transform.back()` as the model's visual forward.
**0.18**: `GltfPlugin`'s `use_model_forward_direction` was replaced by `convert_coordinates` with `rotate_scene_entity` and `rotate_meshes` flags.
## Meshes & materials
- `StandardMaterial` needs **normals**; a mesh without them spawns fine and renders invisible. Programmatic meshes: `.with_computed_flat_normals()` (or insert `ATTRIBUTE_NORMAL`).
- Back-face culling is on by default — wrong winding = invisible faces.
- GLTF scenes spawn via **scene labels** (`"model.glb#Scene0"`), not the raw asset handle.
- Visibility inherits: parents without `Visibility`/`InheritedVisibility` make children invisible — common with hand-built hierarchies around loaded scenes.
- **0.18**: mesh accessors became `try_*` returning `Result<_, MeshAccessError>`; `MaterialPlugin` fields (`prepass_enabled`, `shadows_enabled`) became `Material` trait methods.
- **0.19**: GLTF material loading returns `GltfMaterial` — use the `#Material0/std` label for a `StandardMaterial`. `Assets::get_mut` returns mutation-tracked `AssetMut<A>`. `Scene`/`SceneRoot` renamed to `WorldAsset`/`WorldAssetRoot`.
## Custom material shaders — bind groups
Material bindings are `@group(3)` (0.18+), not `@group(2)`:
| Group | Contents |
|-------|----------|
| 0 | view |
| 1 | globals/lights |
| 2 | mesh (storage buffer) |
| 3 | material (`AsBindGroup`) |
`@group(2)` for material uniforms produces the confusing `Storage class Storage doesn't match the shader Uniform` error — group 2 is the mesh storage buffer.
Import: `use bevy::shader::ShaderRef;` (moved out of `bevy::render::render_resource` in 0.18).
## Lighting
`AmbientLight` split (0.18):
- `GlobalAmbientLight`**resource**, all cameras.
- `AmbientLight`**component** on a camera entity, per-camera.
## Assets
- Assets are not ready right after `asset_server.load()`. Gate on `AssetEvent::LoadedWithDependencies` or `AssetServer::is_loaded_with_dependencies()`.
- **Cache poisoning**: the server dedups by path. A `load_with_settings` that strips data (e.g. `load_meshes = RenderAssetUsages::empty()`) poisons the cache; a later `load()` of the same path returns the stripped asset — invisible models, zero errors. Never mix stripped and rendered loads of the same path in one process; stripped loads belong to headless tools only.
- **0.18**: `LoadContext::path()` returns `AssetPath`, not `Path`; image `reinterpret_*` return `Result`.
- **0.19**: `AssetPath::resolve()`/`resolve_embed()` take `&AssetPath`; the string-taking variants are `resolve_str()`/`resolve_embed_str()`.
@@ -0,0 +1,87 @@
# ECS Core Reference
## Components
`#[derive(Component)]` on structs/enums; requires `Send + Sync + 'static`.
Storage:
- Table (default) — fast iteration.
- `#[component(storage = "SparseSet")]` — fast insert/remove; use for tag-like components that churn.
Attributes:
- `#[component(immutable)]` (0.18+) — only `&T` access, never `&mut T`. Good for identity-like data.
- `#[require(B, C)]` — auto-insert `B` and `C` when this component is added. Constructor forms: `B(value)`, `C { field: val }`, function calls, `= expr`. This replaced `#[derive(Bundle)]` (removed 0.16+); spawn with tuples instead.
- Hooks: `#[component(on_add = f)]`, `on_insert`, `on_replace`, `on_remove`, `on_despawn`. Hook signature: `fn(DeferredWorld, HookContext)`. Hooks run synchronously inside the ECS operation.
- `#[component(clone_behavior = Ignore)]` — skip during entity clone.
## Resources
| Param | Access |
|-------|--------|
| `Res<T>` | shared read |
| `ResMut<T>` | exclusive write |
| `Option<Res<T>>` / `Option<ResMut<T>>` | may not exist (mandatory for render resources in headless-capable systems) |
| `Local<T>` | per-system private state, default-initialized, persists across runs |
| `NonSend<T>` / `NonSendMut<T>` | `!Send` resources (main-thread only) |
World/App: `init_resource::<R>()`, `insert_resource(value)`, `remove_resource::<R>()`.
**0.19**: Resources implement `Component` and live on dedicated entities. Consequences:
- `Query<Entity>` (and other very broad queries) match resource entities — filter with `Without<IsResource>`.
- A type cannot derive both `Component` and `Resource`.
## Systems
Any `fn(params...)` where every param is a `SystemParam`.
- **Fallible** (0.18+): `fn system(...) -> Result` (alias for `Result<(), BevyError>`). Errors are logged; the system runs again next tick. `?` converts anything convertible to `BevyError`. Prefer this over `unwrap()`.
- **Exclusive**: `fn system(world: &mut World)` — full access, blocks parallelism; use sparingly.
Valid `SystemParam` types include: `Query`, `Res`, `ResMut`, `Commands`, `Local`, `MessageReader`, `MessageWriter`, `ParamSet`, `Single`, `Populated`, `SystemName`, `SystemChangeTick`, `RemovedComponents<T>`, `DeferredWorld`, `&World`, `Option<P>`, `Result<P, SystemParamValidationError>`.
- `Single<D, F>` — resolves to exactly one entity or the system doesn't run correctly (panics); the param-level version of `query.single()`.
- `Populated<D, F>` — the system is skipped when the query is empty. Cheap way to avoid per-frame no-op runs.
## Queries
`Query<D, F>``D: QueryData`, `F: QueryFilter` (default `()`).
Data: `&T`, `&mut T`, `Entity`, `Has<T>`, tuples.
Filters:
| Filter | Matches |
|--------|---------|
| `With<T>` / `Without<T>` | presence / absence |
| `Added<T>` | first tick after the component was added |
| `Changed<T>` | first tick after add or mutable deref |
| `Spawned` | first tick after entity spawn |
| `Or<(A, B)>` | either |
Note `Changed<T>` triggers on `&mut` deref even without an actual value change — deref only when writing.
Methods: `.iter()` / `.iter_mut()`, `.get(entity)` / `.get_mut(entity)`, `.single()` (Result-returning; panicking use is a bug magnet), `.par_iter()` / `.par_iter_mut()`.
Reusable shapes: `#[derive(QueryData)]`, `#[derive(QueryFilter)]`.
## Commands
| Call | Effect |
|------|--------|
| `commands.spawn((A, B))` | spawn with component tuple, returns `EntityCommands` |
| `commands.spawn_empty()` | empty entity, chain `.insert(...)` |
| `commands.spawn_batch(iter)` | batch spawn |
| `commands.entity(e).insert(c)` | add/replace component |
| `commands.entity(e).insert_if_new(c)` | add only if absent |
| `commands.entity(e).remove::<T>()` | remove component |
| `commands.entity(e).despawn()` | despawn entity **and descendants** |
| `commands.queue(cmd)` | custom `Command` impl |
Commands are deferred to the next sync point — a spawn is not queryable in the same system.
## Ordering
- `(a, b).chain()` — sequential.
- `.before(x)` / `.after(x)` — explicit constraints against systems or sets.
- `#[derive(SystemSet)]` + `app.configure_sets(Update, SetA.before(SetB))` — named groups; the scalable way to order across plugins.
- Systems with overlapping mutable access and no ordering run nondeterministically; Bevy warns about ambiguities. Order anything where results depend on it.
@@ -0,0 +1,105 @@
# Events, Messages, Observers & Hooks
0.18 split the old single event system into **Messages** (pull, buffered) and **Events** (push, observed). They are different traits with different derives — using the wrong one won't compile, and porting old code mechanically to the wrong side is an architecture bug.
## Messages — pull-based, double-buffered
The renamed successor of `EventWriter`/`EventReader`. Use for decoupled many-to-many data flow where several systems batch-process the same stream.
```rust
#[derive(Message)]
struct Collision { a: Entity, b: Entity }
fn produce(mut writer: MessageWriter<Collision>) {
writer.write(Collision { a, b }); // also: write_batch(iter), write_default()
}
fn consume(mut reader: MessageReader<Collision>) {
for c in reader.read() { /* each reader has its own cursor */ }
}
```
- Double-buffered: a message survives one `update()` cycle and is dropped on the next. A reader that runs before the writer in the same frame sees it next frame.
- Multiple readers run concurrently; multiple writers of the same type cannot.
- Reader extras: `len()`, `is_empty()`, `clear()`.
- Run condition: `on_message::<M>()`.
## Events — push-based, immediate, observed
```rust
#[derive(Event)]
struct Speak { text: String }
// Global observer
app.add_observer(|on: On<Speak>| println!("{}", on.text));
// Entity-scoped observer — fires only for this entity
commands.entity(id).observe(|on: On<Hit>| { /* ... */ });
// Trigger
commands.trigger(Speak { text: "hi".into() });
world.trigger_ref(&mut event); // inspect the event after observers mutate it
```
Observer param `On<'w, 't, E, B>` (`B: Bundle` filters by component):
- `event()` / `event_mut()` — event data (also `Deref<Target = E>` for direct field access)
- `trigger()` / `trigger_mut()` — trigger context
- `observer()` — the observer entity
Observers run immediately during the trigger, not at a schedule point.
## EntityEvent — entity-targeted
```rust
#[derive(EntityEvent)]
struct Hit {
#[event_target]
entity: Entity,
damage: f32,
}
```
`#[entity_event(propagate)]` makes the event bubble up the `ChildOf` chain — useful for UI-like hit forwarding.
**0.19**: `EntityComponentsTrigger` gained archetype fields — destructure with `..`:
`let EntityComponentsTrigger { components, .. } = e.trigger();`
## Lifecycle events
Built-in events observers can watch per component:
| Event | Fires | Order |
|-------|-------|-------|
| `Add` | component added where absent | first |
| `Insert` | any insert (add or replace) | after `Add` |
| `Replace` | component removed, regardless of replacement | before `Remove` |
| `Remove` | component removed | after `Replace` |
| `Despawn` | entity despawned (per component) | last |
```rust
world.add_observer(|on: On<Add, Health>| { /* entity just gained Health */ });
```
## Component hooks — synchronous, inline
For callbacks that must be atomic with the ECS operation itself (not deferred, not scheduled):
```rust
#[derive(Component)]
#[component(on_add = init_health_bar)]
struct Health(f32);
fn init_health_bar(world: DeferredWorld, ctx: HookContext) { /* ... */ }
```
Available: `on_add`, `on_insert`, `on_replace`, `on_remove`, `on_despawn`.
## Choosing the mechanism
| Mechanism | Model | Reach for it when |
|-----------|-------|-------------------|
| Messages | pull, buffered, scheduled | several systems independently process a stream; frame-batched work |
| Event + observer | push, immediate | something must react *now* (gameplay reactions, structural responses) |
| EntityEvent | push, targeted, can propagate | the reaction belongs to a specific entity |
| Component hook | synchronous, inline | the response must not be separable from the ECS operation |
| `Changed<T>` / `Added<T>` | per-tick polling | cheap frame-wise checks inside a normal system |
@@ -0,0 +1,65 @@
# Hierarchy & Relationships
## ChildOf / Children (0.16+ model)
- `ChildOf(pub Entity)` lives on the **child** and points at the parent. (`Parent` no longer exists.)
- Get the parent via `child_of.parent()` — the old `Deref` was removed.
- `ChildOf` implements `Relationship`; `Children` implements `RelationshipTarget`. They auto-sync via hooks: adding `ChildOf(p)` to a child inserts/updates `Children` on `p`; removing it updates the parent. Never maintain `Children` manually.
- Despawning a parent despawns all descendants — `despawn()` is recursive by default (`despawn_recursive` is gone).
## Spawning children
```rust
// Direct: ChildOf in the spawn tuple
commands.spawn((Turret, ChildOf(ship)));
// Closure for several children
commands.entity(ship).with_children(|spawner| {
spawner.spawn(Turret);
let ship = spawner.target_entity(); // was parent_entity()
});
// Attach existing entities
commands.entity(ship).add_child(turret);
commands.entity(ship).add_children(&[a, b]);
commands.entity(ship).insert_children(index, &[a, b]);
```
## Querying
- `Children` derefs to `[Entity]` and implements `IntoIterator` yielding `Entity`.
- Iterate: `for child in &children` — not `children.iter().copied()`.
- Methods: `len()`, `is_empty()`, `contains()`, `first()`, `last()`, `sort_by()`.
- Relationship-generic query extensions: `query.related::<ChildOf>(entity)`, `query.relationship_sources::<Children>(entity)`.
## Detach vs despawn (0.18 names)
| Method | Effect |
|--------|--------|
| `detach_child(e)` | unparent one child, keep it alive |
| `detach_children(&[..])` | unparent several |
| `detach_all_children()` | unparent all |
| `despawn_related::<Children>()` | despawn the children themselves |
The pre-0.18 names (`remove_child`, `remove_children`, `clear_children`) are gone.
## Custom relationships
`#[relationship]` / `#[relationship_target]` derive pairs get the same auto-sync as `ChildOf`/`Children`. Do **not** write observers that manually push/remove entities in the target collection — the hooks already do it, and manual sync causes duplicates or desync.
## Transform propagation
- Child `Transform` is parent-relative; `GlobalTransform` is computed world-space through the `ChildOf` chain.
- Propagation runs in **`PostUpdate`** (`TransformSystem::TransformPropagate`). A system in `Update` reading `GlobalTransform` sees last frame's value.
- Options: schedule the reader in `PostUpdate` after propagation; accept the one-frame lag (often fine); or (rarely) walk the `Transform` chain manually.
## Quick corrections
| Stale | Correct |
|-------|---------|
| `Parent` | `ChildOf(entity)` |
| `*child_of` | `child_of.parent()` |
| `children.iter().copied()` | `for child in &children` |
| `despawn_recursive()` | `despawn()` |
| `parent_entity()` (in spawner) | `target_entity()` |
| `remove_children` / `clear_children` | `detach_children` / `detach_all_children` |
@@ -0,0 +1,71 @@
# Input
## Keyboard — `Res<ButtonInput<KeyCode>>`
| Method | Purpose |
|--------|---------|
| `pressed(k)` | held this frame |
| `just_pressed(k)` / `just_released(k)` | edge detection |
| `any_pressed(iter)` / `any_just_pressed(iter)` / `all_pressed(iter)` | multi-key checks |
| `get_pressed()` / `get_just_pressed()` | iterate held / newly pressed |
| `clear_just_pressed(k)` | consume the press |
`ButtonInput<Key>` exists for logical/locale-aware keys (text-ish input); `KeyCode` is physical layout.
## Mouse
Buttons: `Res<ButtonInput<MouseButton>>` — same API. Variants: `Left`, `Right`, `Middle`, `Back`, `Forward`, `Other(u16)`.
Motion and scroll:
| Type | Kind | Purpose |
|------|------|---------|
| `MouseMotion` | Message | raw per-event `delta: Vec2` — read with `MessageReader<MouseMotion>` |
| `AccumulatedMouseMotion` | Resource | frame total, reset each frame — usually what you want |
| `MouseWheel` | Message | scroll with `MouseScrollUnit` (Line/Pixel) |
| `AccumulatedMouseScroll` | Resource | frame total scroll |
Cursor position — from the `Window` component, logical pixels, origin top-left:
```rust
fn cursor(windows: Query<&Window>) -> Result {
if let Some(pos) = windows.single()?.cursor_position() { /* ... */ }
Ok(())
}
```
**UI click-through trap**: `ButtonInput<MouseButton>` is raw winit input — it fires even when the click landed on a UI panel. World-click handlers must verify the click wasn't consumed by UI (e.g. a full-screen background `Interaction` node that only receives clicks passing through all UI).
## Gamepad
- Buttons: `Res<ButtonInput<GamepadButton>>` — same pressed/just_pressed API; analog buttons also map to `[0.0, 1.0]`.
- Axes: `Res<Axis<GamepadAxis>>``get(axis) -> Option<f32>` in `[-1.0, 1.0]`.
- Messages: `GamepadButtonStateChangedEvent`, `GamepadButtonChangedEvent`, `GamepadAxisChangedEvent`, `GamepadConnectionEvent`.
- Deadzones/thresholds: `GamepadSettings` component.
## Touch
- `TouchInput` message with `TouchPhase` (Began/Moved/Ended).
- `Touches` resource — active-touch queries, analogous to `ButtonInput`.
- `ForceTouch` for pressure-capable devices.
- Requires the `touch` feature flag.
## Feature gating (0.18+)
Input sources (`mouse`, `keyboard`, `gamepad`, `touch`, `gestures`) are feature-gated. They're on by default via `bevy_window`/`bevy_gilrs`, but with `default-features = false` they must be listed explicitly — missing input with no errors usually means a missing feature.
## Action mapping
Bevy has no built-in action-mapping layer. The community standard is `leafwing-input-manager`; check the project's `Cargo.toml` before hand-rolling bindings.
## Pattern
```rust
fn movement(keys: Res<ButtonInput<KeyCode>>) {
let mut dir = Vec3::ZERO;
if keys.pressed(KeyCode::KeyW) { dir.z -= 1.0; } // forward is -Z
if keys.pressed(KeyCode::KeyS) { dir.z += 1.0; }
if keys.pressed(KeyCode::KeyA) { dir.x -= 1.0; }
if keys.pressed(KeyCode::KeyD) { dir.x += 1.0; }
}
```
@@ -0,0 +1,86 @@
# Plugins, App, States, Schedules
## Plugin trait (`bevy::app::Plugin`)
Required:
- `fn build(&self, app: &mut App)` — runs immediately on `add_plugins`.
Optional (defaults provided):
- `fn ready(&self, _: &App) -> bool` — default `true`; override for async init.
- `fn finish(&self, _: &mut App)` — runs after all plugins report `ready()`, before the first schedule tick. The idiomatic place for cross-plugin setup that needs another plugin's resources to exist.
- `fn cleanup(&self, _: &mut App)` — runs after `finish()`; remove temp resources.
- `fn name(&self) -> &str` — defaults to type name; used for the uniqueness check.
- `fn is_unique(&self) -> bool` — default `true`; return `false` for multi-instance plugins.
Any `fn(&mut App)` auto-implements `Plugin`.
## Plugin dependencies
There is no formal depends-on declaration. Patterns:
- `app.is_plugin_added::<T>()` to guard or panic early with a clear message.
- Sub-plugins: call `app.add_plugins(ChildPlugin)` inside `build()`. Bevy deduplicates by name; unique plugins panic on double-add.
- Cross-plugin wiring that needs the other plugin's resources → `finish()`.
## Plugin groups
`DefaultPlugins` / `MinimalPlugins` implement `PluginGroup`. Customize via the builder:
```rust
DefaultPlugins
.disable::<AudioPlugin>()
.set(WindowPlugin { /* config */ })
.add_before::<RenderPlugin>(MyPlugin)
```
`.disable::<T>()`, `.enable::<T>()`, `.set(plugin)`, `.add_before::<Target>(p)`, `.add_after::<Target>(p)`.
## App builder key methods
| Method | Purpose |
|--------|---------|
| `add_plugins(P)` | Plugin, plugin group, or tuple of either |
| `insert_resource(R)` | Insert, overwriting existing |
| `init_resource::<R>()` | Insert via `Default`/`FromWorld` if absent |
| `add_systems(schedule, systems)` | Add to a schedule |
| `configure_sets(schedule, sets)` | Order system sets |
| `register_type::<T>()` | Reflection registry |
| `is_plugin_added::<T>()` | Plugin presence check |
| `register_required_components::<T, R>()` | Runtime component requirements |
## States
Three flavors:
- **FreelyMutableState** — changed via the `NextState<S>` resource.
- **SubStates** — exist only while the parent state is in a required variant.
- **ComputedStates** — derived: `fn compute(sources) -> Option<Self>`.
Registration: `app.init_state::<S>()` (default initial) or `app.insert_state(value)`.
Transition schedules: `OnEnter(S)`, `OnExit(S)`, `OnTransition(S)`.
Entity scoping: `DespawnOnEnter(S)`, `DespawnOnExit(S)` (replaced `StateScoped`, 0.16+).
Run condition: `in_state(S)`.
**0.18 behavior change**: `NextState::set()` fires `OnExit`+`OnEnter` even when setting the current state. Use `set_if_neq()` for no-op-on-same. One-time setup in `OnEnter` systems will re-run if any caller `set()`s the active state.
## Schedules
| Phase | Schedules |
|-------|-----------|
| Startup (once) | `PreStartup`, `Startup`, `PostStartup` |
| Per-frame | `First`, `PreUpdate`, `Update`, `PostUpdate`, `Last` |
| Fixed timestep | `FixedFirst`, `FixedPreUpdate`, `FixedUpdate`, `FixedPostUpdate`, `FixedLast` (inside `FixedMain`, driven by `RunFixedMainLoop`) |
| Other | `Main`, `SpawnScene` |
Gameplay simulation that must be framerate-independent goes in `FixedUpdate`. Transform propagation and most engine bookkeeping run in `PostUpdate`.
## Run conditions (`common_conditions`)
- `in_state(s)`
- `resource_exists::<R>()`, `resource_added::<R>()`, `resource_changed::<R>()`, `resource_equals(val)`
- `any_with_component::<T>()`, `any_component_removed::<T>()`
- `on_message::<M>()` (renamed from `on_event`, 0.18)
- `run_once()`, `not(cond)`, `condition_changed(cond)`, `condition_changed_to(cond)`
Combine with `.and(cond)` / `.or(cond)`.
@@ -0,0 +1,93 @@
# Production Gotchas
Battle-tested traps from shipped Bevy code. Most are silent — no error, no warning, wrong behavior. Grouped by category; scan the headers when debugging "it just doesn't work".
## Silent failures
### Asset cache poisoning via `load_with_settings`
`AssetServer` dedups by path. Loading a GLTF with stripped settings (e.g. `load_meshes = RenderAssetUsages::empty()` for hierarchy extraction) caches the stripped version; a later plain `load("model.glb#Scene0")` returns it — invisible models, zero errors, order-dependent.
Rule: a process that renders an asset must never also `load_with_settings`-strip that asset. Stripped loads are for headless servers/tools only.
### Mesh without normals
Spawns successfully, renders invisible. Use `.with_computed_flat_normals()` or insert `ATTRIBUTE_NORMAL` on programmatic meshes.
### Visibility hierarchy
Parents lacking `Visibility`/`InheritedVisibility` make all children invisible regardless of the children's settings. Sneaky in hand-assembled hierarchies around loaded scenes.
### Spawning from non-standard schedules
Plugins (particles, audio, …) often hook the standard `Update`/`Startup` cycle. Entities spawned from exclusive or custom schedules (e.g. a UI pass) can exist in queries yet be ignored by the plugin — all components present, no errors, nothing happens.
Fix: set a flag in the odd schedule, do the actual spawn in `Update`:
```rust
fn ui_system(mut state: ResMut<MyState>) {
if clicked { state.spawn_requested = true; }
}
fn handle_spawns(mut state: ResMut<MyState>, mut commands: Commands) {
if std::mem::take(&mut state.spawn_requested) {
commands.spawn(/* ... */);
}
}
```
## Scheduling & state
### `GlobalTransform` stale in `Update`
Propagation runs in `PostUpdate`; `Update` readers see last frame. Move the reader to `PostUpdate` after `TransformSystem::TransformPropagate`, or accept the lag deliberately.
### `State::set()` re-fires same-state transitions (0.18)
`next_state.set(Playing)` while already `Playing` fires `OnExit(Playing)` + `OnEnter(Playing)`. One-time setup in `OnEnter` (cameras, level loads) re-runs. Use `set_if_neq()` when re-firing isn't wanted.
## Headless / server contexts
### Render resources must be `Option`
`ResMut<Assets<MyMaterial>>` panics in headless runs (tests, dedicated servers) — the resource doesn't exist without `RenderPlugin`:
```rust
fn my_system(materials: Option<ResMut<Assets<MyMaterial>>>) {
let Some(mut materials) = materials else { return; };
// ...
}
```
Any system that can run both rendered and headless needs this.
## UI interaction
### Raw mouse input fires through UI
`Res<ButtonInput<MouseButton>>` is winit-level; it fires for clicks consumed by UI panels. World-click handlers need a UI-consumption check (e.g. a full-screen low-z `Interaction` node that receives only clicks passing through all UI).
### `RelativeCursorPosition.normalized` is center-origin
`(0, 0)` is the node's **center**; range is `(-0.5, -0.5)``(0.5, 0.5)`. For `[0, 1]` UV: `normalized + Vec2::splat(0.5)`.
## Relationships
Custom `#[relationship]` / `#[relationship_target]` pairs auto-sync via hooks. Do not write observers that manually push/remove entities in the target `Vec<Entity>` — that duplicates what the macro does and desyncs.
## Physics plugins (Avian / Rapier pattern)
- **`CollidingEntities` is not auto-inserted** with `Collider` (Avian3D 0.5). Without adding `CollidingEntities::default()`, collision queries return nothing while the simulation runs fine internally.
- **Removing `RigidBody` from a parent with `Collider` children** can panic with stale internal-tree indices. Despawn collider children first.
## Shared-world multiplayer (server + client in one process)
- **Server entities carry no visual components.** `SceneRoot`/`Mesh3d`/`Sprite` belong on client-spawned entities only; mixing causes host-vs-remote visual drift and double maintenance.
- **`run_if(is_server)` gates the system, not the query.** In a shared world, server systems still match client replica entities — queries must filter client markers explicitly.
- **Shared components race.** Server and client systems in one World share component instances; a client system resetting a timer changes what the server system reads the same frame. Use `Local<T>` for server-side state that must stay independent.
## Debugging heuristics
1. Invisible things: check normals, visibility hierarchy, far plane (1000), asset cache poisoning, scene-label spawning.
2. Empty query results: check the component is actually inserted (physics markers), spawn schedule, `Without<IsResource>` on broad queries (0.19).
3. Behavior fires unexpectedly / twice: same-state transitions, raw input through UI, ambiguous system order.
4. Compiles-but-wrong after touching old code: stale API from a previous Bevy version — cross-check the bevy-upgrade skill's rename tables.