Add plugin skeleton with versioned skill directories, TODO SKILL.md scaffolds with goals for skill-creator, and copy over bevy-skill-research reports from spacecraft into docs/workpad/research/.
10 KiB
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.
// 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<Assets<MyCustomMaterial>> will panic in headless mode (tests, dedicated servers) because the Assets<T> resource for custom render types doesn't exist without RenderPlugin.
// PANICS in headless: Assets<MyMaterial> doesn't exist
fn my_system(mut materials: ResMut<Assets<MyMaterial>>) { ... }
// CORRECT: gracefully skip render logic
fn my_system(materials: Option<ResMut<Assets<MyMaterial>>>) {
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).
// 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<MouseButton> Fires Through UI
The trap: Res<ButtonInput<MouseButton>> 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.
// WRONG: fires when clicking UI panels too
fn world_click(mouse: Res<ButtonInput<MouseButton>>) {
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 camerasAmbientLight— 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<Entity> is redundant — Bevy's relationship hooks handle synchronization automatically.
// WRONG: manual push in an observer
fn on_add_child(trigger: On<Add, MyChildOf>, 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.
// 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.
// 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:
- Move the system to
PostUpdate(afterTransformSystem::TransformPropagate) - Accept the one-frame lag (often fine for gameplay)
- Compute global position manually from the
Transformchain (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:
fn ui_system(mut state: ResMut<MyState>) {
if ui.button("Spawn").clicked() {
state.spawn_requested = true;
}
}
fn handle_spawns(mut state: ResMut<MyState>, 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.
// 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
// 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<T> for server-side rate limiting that must be independent of client state.
Summary: Top Gotcha Categories
- Silent failures (invisible meshes, empty queries, missing components) — Bevy often succeeds silently when something is misconfigured
- Schedule ordering (stale GlobalTransform, spawn timing, command flush order)
- Cache/dedup surprises (asset cache poisoning, state transition re-triggering)
- API renames that compile but misbehave (EventWriter→MessageWriter, Parent→ChildOf, group(2)→group(3))
- Shared-world entity leakage (server systems matching client entities in host mode)