Files
reliquary/plugins/bevy/skills/bevy-0.19/references/production-gotchas.md
T
g4borg c661027d19 🤖 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
2026-07-07 17:52:10 +02:00

4.8 KiB
Raw Blame History

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:

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:

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.