✨ 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:
@@ -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)
|
||||
Reference in New Issue
Block a user