# Bevy Skill Research: Lessons from Production > **Date**: 2026-07-07 > **Source**: Extracted from a production Bevy 0.18 multiplayer game's errata log (~1500 lines of battle-tested gotchas). All examples below are neutralized — project-specific types replaced with generic equivalents. These are patterns that burned real development time and are likely to recur in any Bevy project. They go beyond "read the docs" — most involve subtle interactions between Bevy subsystems, or behavior that contradicts reasonable assumptions. --- ## 1. Asset Cache Poisoning via `load_with_settings` **The trap**: `AssetServer` deduplicates by path. If you `load_with_settings` a GLTF with `load_meshes = RenderAssetUsages::empty()` (e.g. for hierarchy extraction), then later `load()` the same path for rendering, Bevy returns the cached version — which has empty meshes. Result: invisible models, zero errors. ```rust // POISONED: first load strips mesh data let handle = asset_server.load_with_settings("model.glb", |s: &mut GltfLoaderSettings| { s.load_meshes = RenderAssetUsages::empty(); }); // STALE: reuses cached empty-mesh version, silently invisible let scene = asset_server.load("model.glb#Scene0"); ``` **Fix**: Never use `load_with_settings` with stripped mesh/material data in a process that also renders those assets. Lightweight loads are only safe in headless servers or tools that never render the same asset. **Why LLMs get this wrong**: The cache deduplication is invisible — no errors, no warnings, and it depends on load ordering. --- ## 2. Render-Dependent Resources Must Be Optional in Headless Contexts **The trap**: Systems using `ResMut>` will panic in headless mode (tests, dedicated servers) because the `Assets` resource for custom render types doesn't exist without `RenderPlugin`. ```rust // PANICS in headless: Assets doesn't exist fn my_system(mut materials: ResMut>) { ... } // CORRECT: gracefully skip render logic fn my_system(materials: Option>>) { let Some(ref mut materials) = materials else { return; }; // ... } ``` **Rule**: Any system that may run in both rendered and headless contexts must wrap render-specific resources in `Option<>`. --- ## 3. `RelativeCursorPosition.normalized` Is Center-Origin **The trap**: `RelativeCursorPosition.normalized` returns `(0, 0)` at the **center** of the UI node, not the top-left. Range is `(-0.5, -0.5)` to `(0.5, 0.5)`. ```rust // WRONG assumption: UV origin at top-left let uv = rel_cursor.normalized.unwrap(); // (0,0) = center! // CORRECT: convert to [0,1] UV let uv = rel_cursor.normalized.unwrap() + Vec2::splat(0.5); ``` --- ## 4. Raw `ButtonInput` Fires Through UI **The trap**: `Res>` comes from winit and fires for ALL clicks, including clicks consumed by UI panels. Game world click handlers that read it directly will fire when the user clicks UI buttons. ```rust // WRONG: fires when clicking UI panels too fn world_click(mouse: Res>) { if mouse.just_pressed(MouseButton::Left) { /* fires through UI! */ } } ``` **Fix**: Use a UI-aware click event system. If using bevy_ui, check that the click wasn't consumed by a UI node (e.g. via a full-screen background `Interaction` node at low z-index that only receives clicks that pass through all UI). --- ## 5. `AmbientLight` Split (0.18) `AmbientLight` was split into two types: - `GlobalAmbientLight` — a **resource**, affects all cameras - `AmbientLight` — a **component** on camera entities, per-camera ambient LLMs trained on pre-0.18 code will use the old single `AmbientLight` type incorrectly. --- ## 6. Custom Relationships: Don't Implement Collection Logic Manually **The trap**: When creating custom `#[relationship]` / `#[relationship_target]` pairs, implementing manual add/remove logic for the target's `Vec` is redundant — Bevy's relationship hooks handle synchronization automatically. ```rust // WRONG: manual push in an observer fn on_add_child(trigger: On, mut parent_q: Query<&mut MyParent>) { parent_q.get_mut(trigger.parent).unwrap().children.push(trigger.entity()); } // CORRECT: the relationship macro handles this automatically // Just derive the relationship — no manual sync needed ``` --- ## 7. `State::set()` Always Triggers Transitions in 0.18 **The trap**: In Bevy 0.18, calling `state.set(CurrentState)` (setting to the same value) now triggers `OnExit` + `OnEnter` for that state. Previously this was a no-op. ```rust // 0.18: this triggers OnExit(Playing) + OnEnter(Playing) even if already Playing! next_state.set(GameState::Playing); // If you want the old no-op behavior: next_state.set_if_neq(GameState::Playing); ``` **Impact**: Systems in `OnEnter` that do one-time setup (spawn cameras, load levels) will re-run unexpectedly if anything calls `set()` with the current state. --- ## 8. GlobalTransform Is Stale in `Update` **The trap**: Transform propagation runs in `PostUpdate`. Any system in `Update` that reads `GlobalTransform` gets last frame's value. ```rust // STALE: GlobalTransform hasn't been propagated yet this frame fn targeting(q: Query<&GlobalTransform>) { let pos = q.get(target).unwrap().translation(); // one frame behind } ``` **Options**: 1. Move the system to `PostUpdate` (after `TransformSystem::TransformPropagate`) 2. Accept the one-frame lag (often fine for gameplay) 3. Compute global position manually from the `Transform` chain (rare, fragile) --- ## 9. Spawning from Non-Standard Schedules Can Break Plugin Expectations **The trap**: Some plugins (particle systems, audio, etc.) expect entities to be spawned during the standard `Update`/`Startup` schedule cycle. Spawning entities from exclusive schedules (like `EguiPrimaryContextPass`) can result in entities that exist in queries but are never picked up by the plugin's internal systems. **Symptoms**: Entity has all expected components, no errors logged, but the plugin ignores it completely. **Fix**: Use a flag/resource pattern — set a flag in the non-standard schedule, spawn the entity in `Update`: ```rust fn ui_system(mut state: ResMut) { if ui.button("Spawn").clicked() { state.spawn_requested = true; } } fn handle_spawns(mut state: ResMut, mut commands: Commands) { if state.spawn_requested { state.spawn_requested = false; commands.spawn(MyPluginBundle { ... }); } } ``` --- ## 10. Mesh Without Normals Is Silently Invisible When creating meshes programmatically, forgetting to call `with_computed_normals()` (or manually inserting normals) results in a mesh that spawns successfully but renders as completely invisible. No error, no warning. ```rust // INVISIBLE: no normals let mesh = Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::default()) .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions); // VISIBLE: normals computed let mesh = Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::default()) .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions) .with_computed_flat_normals(); ``` --- ## 11. Custom Material Shader Bind Groups In Bevy 0.18, material bind groups are at `@group(3)`, NOT `@group(2)`: | Group | Contents | |-------|----------| | 0 | View bindings | | 1 | Globals/lights | | 2 | Mesh bindings (storage buffer) | | 3 | Material bindings (`AsBindGroup`) | Using `@group(2)` for material uniforms produces a confusing error: `Storage class Storage doesn't match the shader Uniform` — because group 2 is the mesh storage buffer. --- ## 12. `ShaderRef` Import Path Moved ```rust // WRONG (pre-0.18): use bevy::render::render_resource::ShaderRef; // CORRECT (0.18+): use bevy::shader::ShaderRef; ``` --- ## 13. Physics Plugin Gotchas (Avian3D / Rapier Pattern) ### `CollidingEntities` Must Be Explicitly Added In Avian3D 0.5, `CollidingEntities` is NOT auto-inserted on entities with a `Collider`. You must explicitly add `CollidingEntities::default()`. Without it, collision queries return nothing — the physics simulation still runs internally, but your queries never see the results. ### `RigidBody` Removal Can Panic with Collider Children Removing `RigidBody` from a parent entity that has `Collider` children can trigger stale index panics in the physics engine's internal tree. **Workaround**: Despawn collider children before removing `RigidBody` from the parent. --- ## 14. Visibility Hierarchy Requires Components on Parents If a parent entity lacks visibility components (`Visibility`, `InheritedVisibility`), child entities are invisible regardless of their own visibility settings. This is particularly sneaky when spawning scene hierarchies where intermediate nodes might lack these components. --- ## 15. Server/Client Architecture Traps (Multiplayer-Specific but Broadly Applicable) These apply to any Bevy project with server-authoritative networking, regardless of networking library: ### Server Entities Must Not Carry Visual Components In a shared-world architecture (server + client in same process), server entities should be physics/logic only. Visual components (`SceneRoot`, `Mesh3d`, `Sprite`) belong exclusively on client-spawned entities. Mixing them creates: - Inconsistent visuals between host and remote clients - Dual maintenance burden on spawn code - Visual systems accidentally processing server entities ### Server-Only Systems Still Match Client Entities in Shared World A `run_if(is_server)` gate controls WHETHER a system runs, not WHICH entities it queries. In a shared-world host, server systems will match both server entities and client replica entities unless queries explicitly filter out client markers. ### Shared-World Cooldowns and Timers Race When server and client systems share a World, they share component instances. A client system resetting a timer will affect the server system's read of that same timer in the same frame. Use `Local` for server-side rate limiting that must be independent of client state. --- ## Summary: Top Gotcha Categories 1. **Silent failures** (invisible meshes, empty queries, missing components) — Bevy often succeeds silently when something is misconfigured 2. **Schedule ordering** (stale GlobalTransform, spawn timing, command flush order) 3. **Cache/dedup surprises** (asset cache poisoning, state transition re-triggering) 4. **API renames that compile but misbehave** (EventWriter→MessageWriter, Parent→ChildOf, group(2)→group(3)) 5. **Shared-world entity leakage** (server systems matching client entities in host mode)