| name | bevy-018 |
| description | Project-local cheat sheet for Bevy 0.18 API specifics. Use whenever writing or editing Bevy code in this repo — the Bevy ecosystem moves fast and most online tutorials/skills target older versions (0.14–0.16) that will mislead. Also covers the voxel-meshing crate split (block-mesh vs fast-surface-nets). |
| user-invocable | false |
Bevy 0.18 — survival notes for this project
Bevy ships breaking changes every ~3 months. Anything you remember from training data or read on a blog is probably wrong. When uncertain, read the cached source at ~/.cargo/registry/src/index.crates.io-*/bevy_*-0.18.1/ — it is authoritative.
If you make an API decision based on this file and the build fails, treat that as a sign this file is stale. Update it after fixing.
Module path map (stuff that moved)
bevy_internal re-exports each sub-crate as a top-level module of bevy::. Useful paths:
| Item | 0.18 path | Older path you might remember |
|---|
Mesh, Indices, PrimitiveTopology, Mesh3d, Mesh2d | bevy::mesh::* | bevy::render::mesh::* |
RenderAssetUsages | bevy::asset::RenderAssetUsages | bevy::render::render_asset::* |
MeshMaterial3d, StandardMaterial | bevy::pbr::* (in prelude) | mostly the same |
Camera3d, Camera2d | bevy::camera::* (in prelude) | bevy::core_pipeline::* |
DirectionalLight, PointLight, AmbientLight | bevy::light::* (in prelude) | bevy::pbr::* |
MouseMotion, MouseButton, AccumulatedMouseMotion | bevy::input::mouse::* (in prelude) | same |
Window, PrimaryWindow, CursorOptions, CursorGrabMode | bevy::window::* | same module, but see CursorOptions split below |
The bevy::prelude is generous — most everyday types are in it. When in doubt, just use bevy::prelude::*; and add module-qualified imports for the rest.
Events → Messages (renamed in 0.17, sticks in 0.18)
"Buffered events" are now called "messages." EventReader no longer compiles.
| 0.16 and earlier | 0.17+ |
|---|
Event trait, #[derive(Event)] | Message trait, #[derive(Message)] |
EventReader<T> | MessageReader<T> |
EventWriter<T> | MessageWriter<T> |
Events<T> resource | Messages<T> resource |
app.add_event::<T>() | app.add_message::<T>() |
World::send_event / Commands::send_event | World::write_message / Commands::write_message |
#[derive(Event)] and Trigger/observer pattern are now reserved for entity-targeted events (different concept). Don't conflate the two.
For mouse motion specifically, prefer Res<AccumulatedMouseMotion> (resource summing this frame's delta) over MessageReader<MouseMotion> — fewer lifetimes, simpler code, same data.
Naming collision with lightyear (this repo): lightyear::prelude::Message is its own networked-message trait and lightyear_messages exports a #[derive(Message)] proc-macro that conflicts with bevy_ecs's identically-named derive when both are in scope. Since this project uses lightyear::prelude::* everywhere, don't try to use bevy's local Message/MessageWriter/MessageReader for in-process buffered events here — even if you fully-qualify the trait, the derive name collides. For in-process spawn/event queues use a plain Resource with a Vec<T> and drain it from a system (see worldspace_toast.rs — PendingToasts is the template).
Window split: CursorOptions is its own component
Window.cursor_options no longer exists. The window entity carries both Window and a separate CursorOptions component.
fn lock_cursor(mut w: Query<&mut Window, With<PrimaryWindow>>) {
w.single_mut().unwrap().cursor_options.grab_mode = CursorGrabMode::Locked;
}
use bevy::window::{CursorOptions, CursorGrabMode, PrimaryWindow};
fn lock_cursor(mut c: Query<&mut CursorOptions, With<PrimaryWindow>>) {
if let Ok(mut c) = c.single_mut() {
c.grab_mode = CursorGrabMode::Locked;
c.visible = false;
}
}
When customizing the primary window, set WindowPlugin { primary_cursor_options: Some(CursorOptions { .. }), .. } — it's a sibling of primary_window, not nested inside it.
Input feature flags (0.18)
Mouse, keyboard, gamepad, and touch are opt-in features of the bevy crate. The default feature set (["2d", "3d", "ui"]) does not enable them.
bevy = { version = "0.18", features = ["mouse", "keyboard"] }
Without these, input plugins won't register at runtime. Types still compile (they live in bevy_input unconditionally), so this fails silently.
Bundles → required components (since 0.15)
SpriteBundle, Camera3dBundle, MaterialMeshBundle, PbrBundle, etc. are gone. Spawn the components directly; required-components fill in the rest.
commands.spawn(PbrBundle { mesh, material, transform, ..default() });
commands.spawn((
Mesh3d(mesh_handle),
MeshMaterial3d(material_handle),
Transform::from_xyz(0.0, 0.0, 0.0),
));
Mesh3d and MeshMaterial3d are tuple-struct wrappers around handles, not bundles.
Time API
Time::delta_seconds() → Time::delta_secs() (since ~0.16). Same for elapsed_secs, etc. The _seconds variants are gone, not deprecated.
Query::single returns Result
Query::single() and Query::single_mut() return Result<_, QuerySingleError>. Use let Ok(x) = q.single() else { return; }; or let-else — never .unwrap() in shipped code.
Mesh construction
use bevy::asset::RenderAssetUsages;
use bevy::mesh::{Indices, Mesh, PrimitiveTopology};
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::RENDER_WORLD | RenderAssetUsages::MAIN_WORLD,
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_indices(Indices::U32(indices));
Note: in 0.18, Assets<Mesh> retains RENDER_WORLD-only meshes after data extraction — you don't need to keep MAIN_WORLD set unless you actually read mesh data CPU-side.
Custom Material shaders: bind group is 3, not 2
Pre-0.17 cheat sheets and most blog posts say "material bindings live at @group(2)." In 0.18 the index is 3 (MATERIAL_BIND_GROUP_INDEX = 3 in bevy_pbr). Hardcoding @group(2) builds fine but blows up at first draw with:
Shader global ResourceBinding { group: 2, binding: 0 } is not available in the pipeline layout
Storage class Storage { access: StorageAccess(LOAD) } doesn't match the shader Uniform
…because group 2 is now the mesh storage bind group. Fix: use the shader-def Bevy injects per material, which is set to the correct index automatically.
#import bevy_pbr::forward_io::VertexOutput
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> color: vec4<f32>;
@fragment
fn fragment(_in: VertexOutput) -> @location(0) vec4<f32> {
return color;
}
Bevy's preprocessor replaces #{MATERIAL_BIND_GROUP} at compile time. This is the only correct way — there is no const value you can @group(N) directly.
Also note: Bevy uses reversed-Z depth. Default depth_compare is CompareFunction::GreaterEqual — closer to camera = larger depth value. To pick out fragments BEHIND existing geometry (X-ray / blueprint effects), flip to Less, not Greater.
bevy_ui: BorderRadius is a Node field, not a Component
The standalone BorderRadius component is gone in 0.18 — BorderRadius is now a pub field on Node. Spawning (Node { .. }, BorderRadius::all(Val::Px(6.0))) fails the Bundle check (BorderRadius no longer derives Component):
commands.spawn((Node { .. }, BorderRadius::all(Val::Px(6.0))));
commands.spawn(Node {
border_radius: BorderRadius::all(Val::Px(6.0)),
..default()
});
BackgroundColor and BorderColor are still standalone components — only BorderRadius was folded into Node. The asymmetry is jarring but real.
SystemParam, Bundle, and tuple caps in Bevy 0.18
Three distinct ceilings will bite you when systems and spawns grow:
-
A single system can take at most 16 system params. Adding a 17th gives a cryptic no method named "in_set" found for fn item … error at the call site — the compiler is really saying "IntoSystem/SystemParam doesn't reach 17," but it surfaces on .in_set / .run_if / .chain rather than on the system definition. Fix: combine related params under a #[derive(SystemParam)] struct, or — usually simpler — extract gating logic into a run_if so the gated param isn't needed inside the system at all.
-
commands.spawn(...) tuples cap at 15 elements. Adding a 16th component gives (..., ..., ...) is not a Bundle. Fix: nest into tuple-of-tuples — spawn((inner_group_of_components, outer_components...)) — Bundle is implemented recursively, so nested tuples count as one element of the outer. The NPC spawn paths in npc.rs::spawn_initial_npc_on_first_connect and server.rs::spawn_loaded_npc use this workaround.
-
add_systems(Update, (a, b, c, …)) tuples cap at ~5–8 systems before the trait-resolution chain explodes. The threshold isn't fixed — it depends on each system's signature complexity (more params per system = lower system count). If one of your systems has many params, the whole tuple's trait resolution gets monstrous and fails. Fix: split into multiple .add_systems(Update, …) calls. The server's Simulation set in server.rs uses this workaround on purpose.
Caps 1 and 3 surface as the same E0599 "no method named in_set found" pattern, with the long type name written to target/debug/deps/…long-type-….txt. Cap 2 surfaces as (...) is not a Bundle. When you see either, count params/tuple size before chasing other leads.
bevy_ui: Text and ImageNode constructors
Both are real components in 0.18, with Text::new(impl Into<String>) and ImageNode::new(Handle<Image>). Each auto-inserts a Node via required-components, but if you want sizing/layout you spawn an explicit Node alongside them:
commands.spawn((
ImageNode::new(asset_server.load("ui/icon.png")),
Node { width: Val::Px(28.0), height: Val::Px(28.0), ..default() },
));
commands.spawn((
Text::new("Select"),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::WHITE),
));
TextFont and TextColor are separate sibling components on the text entity — they're required-components of Text, so you only need to spawn the ones you want to override.
DirectionalLight as component
commands.spawn((
DirectionalLight {
illuminance: 10_000.0,
shadows_enabled: true,
..default()
},
Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -0.8, 0.4, 0.0)),
));
Transform provides direction; the component itself is just lighting parameters.
Voxel meshing crate split
This bites every voxel project. There used to be one crate; now there are two:
| Crate | Algorithms |
|---|
block-mesh (0.2) | greedy_quads, visible_block_faces — faceted/blocky output |
fast-surface-nets (0.2) | surface_nets — smooth output, what we want |
For block-junk we use fast-surface-nets. Both crates re-export ndshape (ConstShape3u32 etc.) so we still have one ndshape version.
use fast_surface_nets::{surface_nets, SurfaceNetsBuffer};
use fast_surface_nets::ndshape::{ConstShape, ConstShape3u32};
type ChunkShape = ConstShape3u32<34, 34, 34>;
let sdf: Vec<f32> = ;
let mut buffer = SurfaceNetsBuffer::default();
surface_nets(&sdf, &ChunkShape {}, [0; 3], [33; 3], &mut buffer);
f32 implements SignedDistance directly. For memory-tight chunks later, Sd8/Sd16 from fast_surface_nets are i8/i16 fixed-point alternatives.
Skinned-mesh animation (glTF)
Bevy 0.18 animation rests on three asset types and three components. Skim the cached examples in ~/.cargo/registry/src/index.crates.io-*/bevy-0.18.1/examples/animation/ (especially animated_mesh_control.rs and animation_graph.rs) before writing new animation code — the API has churned every minor version.
Asset types (bevy_animation + bevy_gltf):
AnimationClip — one named clip; the glTF loader produces one per gltf.animations[i].
AnimationGraph (asset) — a petgraph DAG of clip + blend + add nodes. Construct with AnimationGraph::from_clips([clip_handles]) → (graph, Vec<AnimationNodeIndex>), or new() + add_clip(handle, weight, parent).
Gltf (asset) — top-level handle to a loaded glb. Has named_animations: HashMap<Box<str>, Handle<AnimationClip>> and named_scenes, so you can look up clips by their Blender name instead of guessing indices. Load it as asset_server.load::<Gltf>("path.glb"), but you have to wait for it to finish loading before reading those fields.
Components:
SceneRoot(Handle<Scene>) on the entity — Bevy walks the glTF hierarchy and spawns the skinned mesh + bones as children.
AnimationPlayer — auto-inserted on a descendant of the scene root once it finishes loading (specifically, on the entity that owns the animation targets). Don't insert it manually. Watch Added<AnimationPlayer> to find it.
AnimationGraphHandle(Handle<AnimationGraph>) and AnimationTransitions — insert these alongside the AnimationPlayer once it appears, then drive playback via the transitions component.
Retargeting across separate .glb files works as long as the bone hierarchy/names match. Clips are keyed by AnimationTargetId, a stable UUID hashed from the bone path within the glTF. KayKit-style packs that ship one *_Rig_Medium_*.glb of animations + multiple character .glbs sharing the same skeleton just work — load clips from the animation file, scene from the character file.
Minimal wiring pattern (cribbed from animated_mesh_control.rs):
let (graph, indices) = AnimationGraph::from_clips([
asset_server.load(GltfAssetLabel::Animation(0).from_asset(anim_glb)),
asset_server.load(GltfAssetLabel::Animation(1).from_asset(anim_glb)),
]);
let graph_handle = graphs.add(graph);
commands.spawn(SceneRoot(asset_server.load(GltfAssetLabel::Scene(0).from_asset(char_glb))));
fn setup_player_once_loaded(
mut commands: Commands,
library: Res<AnimationLibrary>,
mut new_players: Query<(Entity, &mut AnimationPlayer), Added<AnimationPlayer>>,
) {
for (e, mut player) in &mut new_players {
let mut transitions = AnimationTransitions::new();
transitions.play(&mut player, library.idle, Duration::ZERO).repeat();
commands.entity(e).insert((
AnimationGraphHandle(library.graph.clone()),
transitions,
));
}
}
transitions.play(&mut player, library.walk, Duration::from_millis(200)).repeat();
Gotchas:
AnimationTransitions owns clip switching. Don't call player.play() directly when transitions are present — the example file warns the two will fight.
- The
AnimationPlayer lives on a descendant entity, not on the entity that owns SceneRoot. If you want to drive transitions from elsewhere (e.g. a system that watches the parent's AvatarVelocity), cache the player entity on the parent the moment it appears.
- glTFs typically place the model origin at the feet; our
AvatarPose.translation is the eye position. Offset the scene's Transform by -(EYE_OFFSET_FROM_CENTRE + PLAYER_HALF_EXTENTS.y) when attaching, or you get floating-shoulder characters.
- Spawning a SceneRoot is asynchronous — the children (and the auto-added
AnimationPlayer) don't exist on the same frame. Any startup logic that touches them must wait for Added<AnimationPlayer>.
- Iterator items:
Children::iter() yields Entity by value, not &Entity. Don't write for &child in children.iter() — it won't compile. Just for child in children.iter().
- Required-component trap when swapping renderables:
Mesh3d auto-inserts Transform + GlobalTransform + Visibility + InheritedVisibility + ViewVisibility via required components. If you replace a Mesh3d insert with something that doesn't require Transform (e.g. moving the mesh into a child SceneRoot and leaving only a marker on the parent), the parent loses its Transform silently. Sync systems querying &mut Transform skip the entity, child transforms inherit from nothing, and everything renders at world origin. Always insert Transform::default() + Visibility::default() explicitly on parent entities that need to be positioned but don't bear a renderable.
Cross-file animation retargeting (separate character + animation glbs)
KayKit-style asset packs ship a character glb (Knight.glb) with the skinned mesh + skeleton but zero animation tracks, and bundles of animation clips in separate "rig" glbs (Rig_Medium_*.glb) against the same skeleton. The intuitive setup — spawn the character scene, build an AnimationGraph from the rig's clip handles, attach it — silently produces a body whose parts all collapse to the origin. The Bevy 0.18 loader only inserts AnimationPlayer, AnimationTargetId, and AnimatedBy when the file being loaded contains its own animations (see bevy_gltf/src/loader/mod.rs:1016-1027 and 1487-1495). With no AnimationPlayer driving bones, bone transforms stay at identity; with no AnimationTargetId on the bones, the rig clip's curves have nothing to bind to even if you insert an AnimationPlayer manually.
Symptom signature: skinned mesh upright (not lying down — that'd be a coordinate-system issue) with all body parts at the same world position. That's bind-pose-relative meshes drawn without the inverse-bind correction that the bone hierarchy normally cancels out.
Fix: replay the loader's rigging pass yourself in a SceneInstanceReady observer. Walk the scene's entity tree, build a Vec<Name> path from the scene-root node down (inclusive of its own name), and on each named entity insert AnimationTargetId::from_names(path.iter()) + AnimatedBy(rig_root). Insert AnimationPlayer + AnimationGraphHandle on the rig_root entity (the direct child of the SceneRoot bearer, matching the glb's top-level scene node). Retargeting then works because AnimationTargetId is hashed from the bone-name chain — character and rig glbs computing the same chain get the same UUID and the clip's curves bind to your bones. See client.rs::setup_npc_skeleton_anim for the in-repo implementation.
AnimationTargetId and AnimatedBy are not in the bevy prelude — import explicitly: use bevy::animation::{AnimatedBy, AnimationTargetId};.
One more wrapper trap when reaching into a scene: Bevy's glTF loader spawns an unnamed coordinate-system-conversion entity (world_root_id) above the glTF's named top-level nodes. So SceneRoot bearer → first child is the unnamed wrapper, not the named glTF root. Search descendants by Name (name.as_str() == "Rig_Medium") rather than positional children.iter().next() — the wrapper count can change with Bevy versions or convert_coordinates settings.
bevy_egui: interactive UI must run in EguiPrimaryContextPass
bevy_egui's pointer/click event collection only happens inside the EguiPrimaryContextPass schedule. A Window::show(...) call placed in regular Update (or FixedUpdate, or one of the project's GameSet::* sets) will render the window and its buttons, but every button(...).clicked() returns false — the input never reaches egui. Symptom: panel visible, cursor releases as expected, clicks land silently with no log spam to suggest a wiring problem.
Read-only egui systems (like debug_overlay_ui) get away with it because they don't read input. Anything with buttons, sliders, or text fields must live in EguiPrimaryContextPass:
app.add_systems(
bevy_egui::EguiPrimaryContextPass,
my_panel_ui.run_if(in_state(AppState::InGame)),
);
In-repo: menu.rs::pause_menu_ui and debug.rs::debug_panel_ui both use this schedule. Other system ordering (GameSet::Input etc.) still applies to the input handlers (keyboard toggles, etc.) — only the egui UI itself needs the special schedule.
Texture/material learnings from the procedural-texture build (2026-06)
embedded_asset! in a library crate works like in a binary: the path is
relative to the invoking file's directory, and the URL is
embedded://<crate_name_snake_case>/<path-from-src>. E.g. src/render.rs in
block-junk-textures calling embedded_asset!(app, "render/chunk_material.wgsl")
embeds src/render/chunk_material.wgsl as
embedded://block_junk_textures/render/chunk_material.wgsl.
AsBindGroup texture without a sampler is valid — #[texture(100, dimension = "2d_array")] with no #[sampler] pairs fine with a shader that
only uses textureLoad. Nearest pixel fetch with manual wrap (varying used
extents inside one fixed-size array slot) is exactly the textureLoad use case.
- Storage-buffer bindings must be non-empty.
ShaderStorageBuffer::from(vec![])
builds a zero-size buffer wgpu rejects at bind time. Push a dummy element when
a table can legitimately be empty.
AccumulatedMouseScroll exists as a resource (sibling of
AccumulatedMouseMotion) — no MessageReader<MouseWheel> needed for zoom.
- Ambient light is the
GlobalAmbientLight resource (set .brightness).
- Srgb vs blend math: storing color tiles as
Rgba8Unorm (NOT …Srgb) keeps
shader-side layer blending in display space, byte-matching CPU previews; convert
to linear once at the end (srgb_to_linear) before handing PBR the base color.
bevy_egui 0.39 specifics
EguiContexts::ctx_mut() returns Result — let Ok(ctx) = … else { return }.
egui::ImageButton is deprecated: use
egui::Button::image(SizedTexture::new(id, size)).selected(bool).
- CPU-generated preview images don't need bevy assets:
ctx.load_texture(name, ColorImage::from_rgba_unmultiplied(…), TextureOptions::NEAREST)
returns an egui-managed TextureHandle (Send+Sync, fine inside a Local).
- Closing a
menu_button popup after a click: ui.close().
- Reading
ctx.wants_pointer_input() / wants_keyboard_input() from a plain
Update system (to gate camera orbit / shortcuts) works; only interactive
widget code must live in EguiPrimaryContextPass.
- Styling (egui 0.33):
Rounding is now CornerRadius (u8 fields, e.g.
CornerRadius::same(8)); the window field is visuals.window_corner_radius.
Widget tiers live at visuals.widgets.{noninteractive,inactive,hovered,active}
with bg_fill/weak_bg_fill/bg_stroke/fg_stroke/corner_radius. Frames
build as egui::Frame::default().fill(..).stroke(..).corner_radius(..) .inner_margin(Margin::same(8)). Two-skin pattern used in this repo
(ui_theme.rs): set the in-game skin once on the context via
ctx.set_style, and give dev/debug windows a per-window
egui::Window::frame(dev_frame()) plus
ui.style_mut().override_text_style = Some(TextStyle::Monospace) inside.
- egui's bundled fonts have thin symbol coverage — arrows ↑↓, triangles
◀▶, ✕, ⌘, die faces render as tofu. This repo vendors DejaVu Sans as a
fallback: call
block_junk_textures::egui_fonts::install(ctx) once per
context (feature egui-fonts; the game does it in
menu.rs::setup_egui_context, the studio in studio_ui). DejaVu covers
BMP symbols but NOT emoji-plane glyphs (🎲 U+1F3B2 etc.) — pick BMP icons.
The egui dep there must track bevy_egui's pin (0.39 → egui 0.33) or the
Context types won't unify.
When a check fails
The cached crate source at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/<crate>-<version>/ is the source of truth. Before guessing at an API:
grep -nE "pub mod|pub use|pub fn|pub struct" $(find ~/.cargo/registry/src -name "<crate>-<version>" -type d)/src/lib.rs
Verify, then update this file with what you found.