Scaffold bevy plugin with bevy-0.19 and bevy-upgrade skills

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/.
This commit is contained in:
2026-07-07 17:52:00 +02:00
parent 8545738a05
commit 325e278af1
13 changed files with 1211 additions and 0 deletions
@@ -0,0 +1,252 @@
# 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<Assets<MyCustomMaterial>>` will panic in headless mode (tests, dedicated servers) because the `Assets<T>` resource for custom render types doesn't exist without `RenderPlugin`.
```rust
// 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)`.
```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<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.
```rust
// 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 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<Entity>` is redundant — Bevy's relationship hooks handle synchronization automatically.
```rust
// 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.
```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<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.
```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<T>` 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)