Hazel

Plugin API status

Every part of Bevy's API a plugin author might reach for, and what happens when they do. This page is the reference; Standalone Plugins is the guide that explains why the boundary is shaped this way.

Meaning
worksUsable today, and the source is character-identical to Bevy.
differsUsable today, but you write something different or it behaves differently.
missingNot available. There is a known mechanism and no blocker — nobody has built it.
neverStructurally blocked. The note says by what.

Read Traps before this page. A missing row costs you a compile error and five minutes. The handful of things that compile and then behave differently from Bevy are what actually cost people an afternoon, and they are listed there.

Systems & scheduling

BevyIn a plugin
Commands as a system paramworks
Query<D, F> as a system param, including two or more Query params in one systemworks
Res / ResMut for a resource the plugin declaredworks
Resworks
Schedules First, PreUpdate, Update, PostUpdate, Lastworks
app.init_resource::() / app.insert_resource(value)works
A system with no parameters — fn tick() {.. }missingTake at least one param, e.g. fn tick(time: Res
#[derive(SystemParam)] for a custom paramdiffersImplement the public unsafe trait SystemParam by hand; there is no derive. Only the fetch half is reachable from outside the crate, so declare can only be a no-op — which limits a custom param to re-wrapping something the per-call struct already carries (the shape Images, Meshes and RemovedComponents use). Bundling a Query or a Res, which is what Bevy's derive is for, cannot be expressed.
Capturing closure as a system — add_systems(Update, move |q: Query<&mut T>| {.. })missingA plain fn or a non-capturing closure. The refusal is a const assert in the ergonomic layer, not the boundary: the callable is rebuilt from nothing, so it must be zero-sized. The per-system opaque word that would carry a boxed capture already round-trips through the host, unused.
add_systems(Update, (a, b, c)) — a tuple of systemsdiffersIdentical spelling for up to 12 systems in one tuple; Bevy takes 20, and a 13th is a trait-bound error. A tuple means 'all of these' and says nothing about order — it accepts no .chain(), .run_if() or set configuration.
chain()missingPut the systems in different schedules (First then Update). add_systems takes a tuple but no configuration on it: the descriptor's flags word is hard-coded to zero and the host rejects anything else. That word is where ordering would land.
run_if(condition)missingAn early return inside the system body. Same reserved flags word; nothing in the descriptor can carry a condition.
before(other) / .after(other)missingPut the two systems in different schedules. Same reserved flags word, and the descriptor has no label field at all, so a plugin has no way to name another system — its own or an engine one — across the boundary.
in_set(S) / #[derive(SystemSet)] / app.configure_sets(..)missingSame reserved flags word; there is no SystemSet derive (Component and Resource are the only two) and no configure_sets on App.
Relative execution order of two plugin systems in the same schedulediffersEvery system is added with no constraints, so Bevy's multi-threaded executor may run two of yours in any order or in parallel — including the members of one add_systems tuple. That is deliberate: each declares real per-query and per-resource access so it can schedule freely. Bevy would let you pin it; here you cannot.
Schedules PreStartup / Startup / PostStartupmissingDo registration in Plugin::build; for a one-time spawn, guard an Update system with a resource flag. There are five schedule constants and startup is not among them, and build is not a substitute for spawning — Commands exists only inside a running system. An unknown schedule id is warned and re-homed to Update.
FixedFirst / FixedPreUpdate / FixedUpdate / FixedPostUpdate / FixedLast / RunFixedMainLoopmissingNot among the five schedules. Appending more is an append-only change, and an unknown value degrades to Update with a warning rather than being dropped.
Other built-in Bevy schedules (SpawnScene, StateTransition, Main)missingSame five-value list; anything else is re-homed to Update with a warning.
A plugin-defined schedule — #[derive(ScheduleLabel)] struct MyScheduleneverA schedule crosses as one u32 resolved by a closed match on the host. There is no encoding for a label the host has no constant for.
Localmissing#[derive(Resource)] + ResMut. There is no SystemParam impl for Local. The per-system storage word that would hold one already round-trips through the host, unused.
ParamSet<(P0, P1, ..)>missingNo impl, and declaring the conflicting params separately does not substitute: Bevy's own access check fires when the system is registered, the host reports an access conflict, and the whole plugin load fails.
Option<Res> — and Bevy's skip-the-system-when-missing behaviourdiffersThe spelling is now Bevy's — Option<Res> and Option<ResMut> are both system params. The behaviour is not: Bevy declines to run a system whose Res is missing, whereas here params resolve per call, so the system runs and the param is None. A bare Res on an absent resource panics on first deref, which costs you that system for the rest of the session — use Option, or is_present().
MessageReader / MessageWriter (Bevy 0.19's rename of EventReader/EventWriter — neither spelling exists here)missingNothing in the per-call struct carries message buffers. The mechanism is established — input, meshes, images, HTTP and removed-components are all per-call sources appended to it — but no message source has been added.
app.add_message::() / app.add_event::()missingNo such method on App, matching the absence of the reader and writer params.
Single<D, F>missingQuery<D, F> plus single(), which now exists (returning Option, not Result). There is no Single impl, and its defining behaviour — skipping the system unless exactly one entity matches — needs run conditions.
Populated<D, F>missingQuery<D, F> plus if q.is_empty() { return; }. No impl, and skip-on-empty needs run conditions.
RemovedComponentsdiffersShipped, and for e in removed.read() reads exactly like Bevy: per-system cursors, and a despawn counts as a removal. The difference is the return type — read() hands back an owned Vec rather than an iterator, because the host cannot share your allocator, so any adapter chain needs .into_iter() first. Declaring it creates no access conflict, as in Bevy.
SystemChangeTickmissingNo impl, and no tick crosses the boundary — the per-call context is two floats. The values exist host-side, since the dispatcher uses Bevy's own change ticks to serve Added/Changed, so exposing them would be a new per-call field.
Deferred (a custom SystemBuffer)missingNo impl. The only deferred buffer here is the Commands sink, for which the host supplies Bevy's own Commands. A plugin-defined buffer needs a second host-called entry point at apply time.
NonSend / NonSendMut / NonSendMarkerneverNonSend names a host Rust type by TypeId. The boundary can only carry a resource the plugin itself described with a descriptor — registration and lookup are both keyed on the id the plugin got back.
&World / World / DeferredWorld / &Archetypes / &Entities, and exclusive systems — fn setup(world: &mut World)neverA plugin links no Bevy, so there is no World type to name. The host pointer in the per-call struct is documented as always null while a system runs, because the world is borrowed by the queries, and the only system entry shape is an extern "C" fn taking that struct.
ApplyDeferred / apply_deferred as an explicitly scheduled systemneverA plugin registers only its own systems, never a host one. Command application is Bevy's, happening after the system that queued it, and the descriptor cannot express a barrier.
Res / ResMut for an engine-owned resourcemissingThere is a lookup-component-id-by-name call but no resource equivalent, so a plugin can only reach a resource it registered itself; adding one mirrors an existing call exactly. The real limit is narrower than 'impossible' but real: only a repr(C) plain-data engine resource could ever be mirrored, the same rule host components already obey.
Res<Time> / Time / Time, time.pause(), set_relative_speedmissingThe shim Time exposes exactly delta_secs() and elapsed_secs() — no clocks, no pause, no relative speed. The values are appendable, but the Bevy spelling never can be: a generic resource has no C representation, the same argument that flattened ButtonInput into Res. Expect a distinct non-generic type. FixedUpdate is not an available schedule either.
Res<State> / ResMut<NextState> / #[derive(States)] / app.init_statemissingHand-roll it: a #[derive(Resource)] struct holding the current state. There is no States trait or derive, and no state machinery on App.
OnEnter(S) / OnExit(S) / OnTransition schedulesneverA schedule is a plain u32 resolved through a closed match. A label parameterised by a plugin-defined state type has no encoding in that word.
Plugin::finish() / cleanup() / name() / is_unique()missingPut everything in build(). The Plugin trait declares build and nothing else, and the single ABI entry point calls it once.
Gizmos (debug drawing)missingNo Gizmos param and no gizmo call in the host interface. Lines and colours are plain data and the interface is append-only, so this is an append, not a design problem.

Queries & filters

BevyIn a plugin
Query<&T>works
Queryworks
Query<Option<&T>>works
Withworks
Withoutworks
q.is_empty()works
Query<&mut T>differsSame declaration, but the item is a bare &mut T, not Mut — no is_changed, set_changed, bypass_change_detection or into_inner, and for mut t in &mut q warns unused_mut. Two write-back divergences: the host compares your staged bytes against a pre-call baseline and skips the write and the change tick when they match, and write-back happens only after your system returns, so a panicking system's writes are discarded.
Query<Option<&mut T>>differsItem is Option<&mut T>, not Option<Mut> — same divergence as &mut T, and the same compare-gated, after-the-call write-back.
Query data / filter tuple arity — Query<(&A, &B, ..), (With, ..)>differsData tuples are 2 to 15 and filter tuples 1 to 15. The top matches Bevy; the bottom does not — Bevy implements from 0, so Query<()> and the one-element Query<(&A,)> compile there and not here. A bare () filter does work.
Or<(With, With)>differs2 to 15 branches; Bevy allows 0 to 15, so only the empty and single-branch spellings are missing. The real limit is what a branch may contain: With, Without and a nested Or only. Added or Changed inside the bracket makes the host refuse the whole system at load with an explanatory error.
q.iter() / q.iter_mut() / for x in &qworks
Query length — q.count()differsSpelled len(), a name Bevy's Query does not have (Bevy's is count()), and q.iter().len() is not a workaround because the iterator is not ExactSizeIterator. The value is post-filter.
q.get(entity) / q.get_mut(entity)differsBoth exist but return Option, where Bevy returns Result — so if let Some(x), and no ? into a Bevy error. They are also O(n): the lookup scans the staged entity ids, because the host hands over a flat array rather than a map. Fine for 'did the thing I spawned match?'; do not put one inside a loop over another query.
q.contains(entity)works
q.iter_many(..)missingIterate and compare the Entity term yourself, or call get per id. The staged view already carries the entity column, so this is a plugin-side addition with no ABI change; iter_many_unique and the _mut forms are the same story.
q.single() / q.single_mut()differsBoth exist but return Option, where Bevy returns Result — deliberate, because a panicking plugin system is disabled for the session, so if let Some(x) is the shape that survives. None covers both zero matches and more than one; there is no single_inner.
q.par_iter()missingAbsent, along with par_iter_mut and par_iter_many. Chunking needs no ABI change — the staged cells are one contiguous buffer — but a plugin has no handle on Bevy's task pool, so it would be your own threads oversubscribing the schedule.
q.transmute_lens::()missingThe query-data trait exposes only a compile-time cell count with no runtime component-to-cell map, so narrowing to a new D has nothing to look up. join and QueryLens likewise.
Query<Ref>missing&T, with no access to the change tick — no Ref type exists and no access kind carries a tick channel. Closer than it was: the dispatcher already reads per-row change ticks to serve the tick filters, so what is left is a wrapper type plus a wider cell.
Query<Mut>missing&mut T — no Mut type exists; &mut T is the item type itself. Same mechanism as Ref, and even with one, set_changed would have to defeat the byte-comparison gate deliberately.
Query<Has>missingOption<&T>, then test for None. An impl over the existing optional cell (null means false) would cover it with no ABI change.
Query<AnyOf<(&A, &B)>>missingQuery<(Option<&A>, Option<&B>), Or<(With, With)>>. Purely a missing impl — it decomposes into optional cells plus an Or bracket, both of which exist.
Added / ChangeddiffersSame spelling, and the tick comparison is Bevy's own against this system's last and current run — but three divergences. Neither may appear inside Or; the host refuses the system at load. Changed fires LESS often than in Bevy, because a plugin's &mut write only bumps the tick when the bytes actually differ and there is no set_changed() — the let _ = &mut *foo; idiom compiles and does nothing. And it is a per-row predicate applied while staging, so it trims the rows you see but not the entities the host walks. Also: a hot reload registers new systems, so Added fires for the whole scene.
SpawnedmissingNo Spawned filter. It would be one more access kind plus entity metadata, but it is the first non-Or term carrying no component id, so the id-resolution path needs a new case. No caller asking.
Allow — bypass DefaultQueryFiltersmissingAbsent from the filter set. The host's query builder has no allow-by-id call, but adding archetypal access is exactly what Allow does — one appended access kind plus a host branch.
QuerymissingNo impl. It is plain-old data, so it fits the cell model; it needs an appended access kind and marshal pair and nothing more.
Query<&Name>missingWith / Without work today as filters — Name is force-registered so the id resolves, and filter terms skip the data gate. Reading it needs two things: a string-backed marshal case, because the default is a verbatim byte copy and Name holds a String, so a raw cell would hand you a pointer into the engine heap; and an opt-in from the crate that owns it.
Reading an entity's children — Query<&Children>missingNothing in the ABI reads hierarchy. It can never be a query CELL — every term has one fixed cell size and cells are raw byte copies, while Children is a Vec — but the two-pass count-then-fill read source that mesh reads use is an established pattern, so the capability is shaped, just unbuilt.
Query / EntityMut / FilteredEntityRefneverA query declares its whole access set at registration and the host's query builder is driven only by declared terms. A plugin has no Rust type for an arbitrary host component, and up-front access declaration is what lets plugin systems schedule in parallel; whole-entity dynamic access has neither.

Components & resources

BevyIn a plugin
#[derive(Component)] on a struct with named fieldsdiffers#[derive(Component, Default)] #[repr(C)] struct Foo { .. }. Default is mandatory, because the editor needs a value to put on the entity when you add the component. The derive also const-asserts the type has no destructor, so a component owning memory is a compile error whose message names the way out. #[repr(C)] is expected, documented, and checked by nothing. Bevy's derive requires none of the three.
#[repr(C)] on a componentdiffersRequired, enforced by nothing — the derive never inspects attributes for repr. Because the schema addresses fields by offset and the host reads and writes through those offsets, a repr(Rust) component still stores, inspects and round-trips correctly. It bites only where the same bytes become a GPU uniform: a post-process or material settings component is uploaded at its declared layout, and Rust's field order is not the shader's.
Field types in a plugin componentdiffersf32 / i32 / u32 / usize / i64 / bool / Vec3 / Quat / Str256 map to a field kind; anything else is silently skipped, as are fields whose name starts with _. Note the Vec2 and Color in the shim are plugin-side maths only and are NOT boundary types, so a field of either is unmapped. An unmapped field still round-trips byte-for-byte through a save, because the writer copies the whole component — but it gets no inspector row, no BSN name, and it resets to default the moment a layout migration runs. No warning at any point.
A component field holding String / Vec / Box / HandleneverStr256 for text; an asset handle for anything large. This is now a compile error rather than a silent leak: the derive const-asserts the type has no destructor and names Str256, a fixed-size array, or plugin-side state keyed by Entity as the ways out. The host refuses a descriptor declaring a destructor independently, on both the component and resource paths, because the ABI is public and a hand-written impl never passes through the derive.
#[derive(Component)] on an enumneverHard compile error: an enum has no stable field layout for the inspector to address by offset. Bevy allows enum components.
#[derive(Component)] on a tuple struct — struct Speed(f32)differsUse named fields. Only named fields are walked, so a tuple struct compiles and registers with an empty field schema: storable, completely uneditable, unreachable from BSN, and no warning.
#[component(name = "CRT")] — inspector labeldiffersA plugin-only key with no Bevy counterpart, and only name is read. It is accepted spelled #[component(..)] or #[resource(..)], so resources get the same label control. An empty name falls back to the last segment of the type path. Bevy's other keys on that attribute — immutable, storage, hook names, clone_behavior — are not honoured.
#[field(min = .., max = .., speed = ..)] / #[field(skip)] — inspector hintsdiffersPlugin-only; Bevy's derive has no inspector metadata. min and max must both be present or neither, or it is a compile error. skip is a bare word and keeps the field's place in the struct while removing its inspector row. Ranges ride a separate call that an older host skips wholesale, which then shows unbounded drags; the host normalises what it does receive — a reversed range is swapped, a zero speed becomes a thousandth of the span.
#[field(default = ..)]differsimpl Default for the whole type; the attribute is parsed and then discarded. The trap: the value is parsed as a float literal BEFORE the key is examined, so #[field(default = 1.0)] compiles and is silently ignored, while #[field(default = Vec3::ZERO)] is a compile error complaining about a float literal. A converted effect keeps compiling and quietly starts at the wrong value.
#[component(storage = "SparseSet")]neverThe host registers every plugin component as table storage literally, and the descriptor has no storage field for a plugin to express a choice through.
#[component(immutable)] / Component::MutabilityneverThe host passes mutable literally in the same registration call, and the plugin-side Component trait has no Mutability associated type to set.
Component hooks — #[component(on_add = ..)] / on_insert / on_remove / on_despawnmissingNo hook entry in the host interface. Bevy's by-id hook registration is reachable for a descriptor-registered component, so this is one appended interface entry plus a thunk — plumbing rather than a new mechanism.
#[require(Transform)]neverThe derive declares only component/resource and field as helper attributes, so #[require] is an unknown-attribute compile error. Bevy's registration path is generic over real Component types; the dynamic by-id form exists but is only reachable from inside a static type's own required-components impl, which a descriptor-registered component does not have.
#[relationship] / #[relationship_target]neverThe derive rejects the attribute, and the host registers every plugin component with no relationship accessor.
#[derive(Resource)]differs#[derive(Resource, Default)] #[repr(C)] struct Score { .. }. It registers through the same descriptor path as a component, so it inherits every restriction: struct only, Default required, the same field kinds, and the same no-destructor compile error, re-checked host-side on its own path. It correctly refuses spawn(MyResource) by not implementing Bundle, and #[resource(name = "..")] sets the inspector label. Registration is idempotent and does not reset a live value, so two systems both taking ResMut are safe.
Scene save/load of plugin components — Bevy's #[reflect(Component)] + register_typediffersNothing to write — every registered plugin component persists automatically. That is the inverse of Bevy's opt-in, on the reasoning that a plugin component is plain data by host enforcement, and there is no transient flag, so a scratch component cannot be excluded. The save writes the whole component as bytes at its live padded size rather than field by field. Plugin RESOURCES persist too, but only through a builder that opts in — a prefab or a delete-undo subtree deliberately leaves them behind.
Loading a scene whose plugin is no longer installeddiffersNothing to write — unresolved bytes are parked on the entity verbatim and re-emitted on the next save, so a round trip through a build without the plugin does not delete your data. When the plugin is back, migration matches by FIELD NAME: renaming a field orphans that field's saved values while the rest of the component still loads, a field whose kind changed is left at the default, and adding, removing or reordering fields is safe. Resolution tries the full type path, then a rename-alias table, then the short name; an ambiguous short name resolves to nothing rather than a guess. A blob whose length does not match the live layout and has no schema to migrate through is skipped with a warning.
Changing a component's or resource's memory layout across a hot reloadneverRestart the editor. The reload is refused with a reason naming what moved — a changed size, a changed field count, a field that moved offset, a changed field kind — and the previous build keeps running. A rename alone is byte-compatible and takes the schema-refresh path instead, because field names are deliberately not part of the comparison. Resources are checked on their own path. Bevy fixes a component's layout permanently at registration.

Commands & entities

BevyIn a plugin
commands.spawn_empty()works
entity_commands.id()works
commands.spawn(bundle)differsIdentical, but any component type you only ever insert and never name in a query needs app.register_component::() in build() first. The insert path stamps an id that is only filled at registration; an unregistered type yields an invalid id, and the host logs that a plugin queued an insert for a component it never registered and drops the command. Query terms resolve their own ids, which is why register_component is documented as optional.
Bundle tuple arity in spawn/insertdiffers1 to 15 elements, matching the top of Bevy's range — the old 8-element cap is gone. One gap is left: there is no Bundle impl for (), so commands.spawn(()) does not compile. Write spawn_empty().
commands.entity(e)differsIdentical acquisition, much smaller handle: id / insert / insert_one / remove / despawn plus the engine-specific make_renderable / set_material / call_service — no insert_if_new, try_insert, with_children, retain, queue or trigger. Domain crates add methods by extension trait, so use renzora_plugin::anim::AnimCommands is part of the spelling. If the entity is gone at apply time every command silently no-ops, where Bevy routes it to the error handler.
entity_commands.insert(bundle)differsIdentical, with the same registration caveat as spawn. The value is byte-copied and forgotten because the host owns those bytes from the push onward — no longer a leak risk, since a component owning memory is now a compile error. Host-side the byte count is checked against the live registered layout and a mismatch is refused with an error rather than written; Transform is the only type field-marshalled rather than copied.
entity_commands.remove::<B: Bundle>()differsremove::<T: Component>() — one component per call; remove::<(A, B)>() does not compile.
entity_commands.despawn()differsIdentical spelling and signature; try_despawn() does not exist. The host maps it to Bevy's try_despawn, so it never warns when the entity is already gone, where Bevy's despawn does. Recursive descendant despawn is preserved, because it is Bevy's own call.
entity_commands.despawn_related::()missingNo method of that name, no per-relationship despawn command, and no way to enumerate children. despawn() is NOT a substitute: it takes the entity as well as its children, where despawn_related keeps it. The only route today is to remember the ids you spawned and despawn each.
entity_commands.insert(single_component)differsinsert(c) works; insert_one(c) is the single-component path the derive routes through — a plugin-only name with no Bevy counterpart. It moves the value and forgets it, because the host owns those bytes from the push onward.
commands.spawn_batch(iter)missingfor b in iter { commands.spawn(b); } — equivalent, but it pushes one entity reservation plus one command each. The command kind is a plain newtype precisely so a batching value can be appended.
commands.get_entity(e) -> Result<EntityCommands, ..>missingThere is no fallible variant: Commands::entity is the only accessor and hands back a handle unconditionally, and the sink has no synchronous existence query. entity() already tolerates a stale id — every apply arm is guarded — so a Result-returning form is an append, not a design change.
entity_commands.insert_if_new / insert_if / try_insert / insert_if_neq / entry()missingtry_insert's semantics are already what plain insert does here: a missing entity is a silent no-op. None of the conditional or entry-style variants exist, and a queued command has no flags field to carry a condition.
entity_commands.remove_by_id / remove_with_requires / retain / clearmissingNo methods. Remove-by-id is exactly what the host already calls internally to service a remove, so exposing it is plumbing; retain and clear need world introspection a plugin has no access to.
entity_commands.add_child(child) / add_children(&[..]) / insert_children / replace_children / remove_childrenmissingNo hierarchy methods and no ChildOf shim, so an existing entity cannot be reparented at runtime — the only parenting that happens is host-side inside the BSN spawner. Name-based id lookup exists, but ChildOf holds an Entity, so it needs an entity-marshalling case rather than the default verbatim byte copy. Nothing in the ABI expresses a sibling index either.
Unparenting — in Bevy 0.19 written entity(child).remove::()missingThe remove machinery would work unchanged — it takes any registered component id — there is simply no ChildOf type in the shim to name as the type parameter.
entity_commands.with_children(|parent| { .. }) / with_child(bundle)differsA Children [ .. ] block inside a bsn! literal, passed to spawn_scene or insert — Scene implements Bundle, so both spellings take it. Nesting exists but is compile-time text: bsn! renders to a static string, so children cannot carry runtime values and no child ids come back — the id you get is the root of the first tree only. The host-side spawner does insert real parent links depth-first.
commands.queue(|world: &mut World| ..) / entity_commands.queue(EntityCommand)neverA queued command is plain-old data deep-copied at push time, because its payload may point at a plugin stack local that is gone by apply time, and there is no World or entity-world shim for a closure to receive. The sink exposes exactly push and reserve-entity.
commands.run_system(id) / register_system / run_system_cachedmissingSystems are registered only during build(), and registration hands back a status rather than a system id, so there is nothing to name later. The descriptor already carries an entry fn pointer, so the raw material is there.
commands.trigger(event) / add_observer / entity.observe(..)missingNo observers, no event type, no add_observer on App. The nearest existing thing is the panel action thunk — a single unmangled fn pointer — and the append-only descriptor design makes adding one an extension rather than a redesign.
commands.write_message(msg)missingcommands.call_service(..) carries opaque bytes to a named consumer instead. No message channel crosses in either direction; the service payload is deliberately uninterpreted so this crate need not learn any domain.
commands.init_resource::() / insert_resource(value)differsapp.init_resource::() / app.insert_resource(value) in Plugin::build. Both exist on App only — no command kind is resource-related — so a running system cannot create or replace a resource wholesale; it can only mutate one it holds via ResMut.
commands.remove_resource::()missingRegistration and insertion are the only resource entries in the host interface; a removal entry would be an append.
commands.run_schedule(label)missingA system's schedule is fixed in its descriptor at registration and never named again, and nothing in a queued command carries a schedule.
clone_and_spawn() / clone_components::(target) / move_components::(target)missingNo clone or move command. All three need host-side copying driven by a bundle type a plugin cannot describe beyond individual registered ids, but a per-id copy command would be an append.
commands.reborrow() / entity_commands.commands()missingCommands and EntityCommands are a raw sink pointer plus an id, so a reborrow is mechanically trivial — it simply is not written.
insert_batch / try_insert_batch / queue_handled / append / get_spawned_entity / unregister_system / log_componentsmissingNone exist. Commands is spawn / spawn_empty / spawn_mesh / spawn_scene / entity / call_service and nothing else. Each of these is either a batching optimisation over commands that do exist or an error-handling variant, and a plugin has one error policy: silently skip a missing entity, log a mismatched or unregistered component.
Calling a domain crate's API directly (play an animation, apply an impulse, fire an HTTP request)differscommands.entity(e).call_service(service, op, payload), normally wrapped by a domain module — and those wrappers are extension TRAITS you must import: AnimCommands and PhysicsCommands on EntityCommands, HttpCommands on Commands, since an HTTP request belongs to the plugin rather than an entity. A plugin cannot link the engine crate that owns the domain, so the request crosses as opaque plain-old data behind a small header and consumers take only their own service id. Nothing draining a service id is valid: leftovers are cleared at end of frame, so the call is discarded rather than queued forever.

Assets & rendering

BevyIn a plugin
Query<Entity, With> / Without — filter on "has a mesh"works
Res — asset_server.load("models/x.glb#Mesh0")neverCreate assets at init through App and keep the opaque handles. There is no load call, no path resolution and no asset-server entry in the host interface, and a plugin never holds a real Handle — it gets an index into a host slot table.
ResMut<Assets> / Assets / Assets as a system paramneverApp::add_mesh / add_mesh_data / add_image / add_material / add_material_shader during build(), then Meshes::write / Images::write from a system. Assets is a generic Rust type with no C representation, and the collections live behind the world, which the host holds only during init.
Handle / Handle / HandlediffersOne non-generic opaque handle — a bare u64 index — covering meshes, materials and images. No type parameter and no ref-counting: it indexes one of three host-side lists, so the same number means different things in different calls and nothing type-checks that. Each entry records the owning plugin, but the write paths do not check it. An unknown index is a logged error, not a panic.
meshes.add(Sphere::new(1.0)) — a built-in primitivediffersapp.add_mesh(sys::Primitive::Sphere, Vec3::splat(1.0)). Six 3D primitives (cuboid, sphere, plane, cylinder, capsule, torus), init-only, dimensions crossing as one Vec3 whose meaning changes per primitive: full extents for cuboid and plane, x = radius for a sphere, x = radius and y = height for cylinder and capsule, x and y = major and minor radius for a torus. No 2D shapes. An unknown primitive is not silent — it logs that this build does not have it and draws a cuboid, because a visible cube beats a missing mesh.
Mesh::new(..).with_inserted_attribute(..) — hand-built geometrydiffersapp.add_mesh_data(&positions, normals, uvs, indices). Init-only, triangle list only, four attributes: position, normal, uv0, index. normals: None runs Bevy's normal computation, uvs: None zeroes them, indices: None means every three positions are a face. Tangents, joints, weights and a second UV set have no way across, and vertex colours exist only on the write path. Anything inconsistent is refused with a log line rather than padded.
assets.get_mut(&mesh_handle) — rewrite a mesh every framediffersMeshes::write(handle, &positions, normals, uvs, indices, colors). Only meshes the plugin created, and the whole mesh is replaced rather than one attribute edited: the host builds a fresh mesh through the same validator and assigns it over the existing asset, so everything already drawing that handle follows. A refusal leaves the previous geometry alone. This is the only path that accepts per-vertex linear-RGBA colours, which the built-in material multiplies into base colour.
meshes.get(&mesh3d.0) — read geometry already in the worlddiffersMeshes::read(entity) -> Option. Copies out rather than borrowing, in two host calls (probe for counts, then fill buffers you own); position, normal, uv0 and index only, with a 16-bit index buffer widened rather than refused. None for the frames before the asset loads, which is a normal early-frame state and not an error. Any entity with a mesh is readable, imported models included.
images.add(Image::new(..))differsapp.add_image(width, height, sys::ImageFormat::Rgba8, &data). Three formats (sRGB RGBA8, linear RGBA8, R32 float), 2D single-layer only, no mip or sampler control, init-only, and the data length must equal width x height x bytes-per-pixel exactly or it is refused rather than padded.
images.get_mut(&handle) then write image.datadiffersImages::write(handle, &pixels) — contents only. The new buffer must be exactly the same byte count, since dimensions and format are frozen at creation, and a mismatch is refused with the previous pixels intact. There is no Images::read.
materials.add(StandardMaterial {.. })differsapp.add_material(color) or app.add_material_pbr(color, metallic, roughness, emissive). Colour crosses as a linear [f32; 4], not a Color. Four fields reach the host and the rest is defaulted — no texture maps, no normal or occlusion or clearcoat, no alpha mode. Init-only and immutable once created: nothing edits a built material, so a parameter that changes needs add_material_shader.
impl Material for MyMaterial + #[derive(AsBindGroup)] — a custom shaded materialdiffersapp.add_material_shader::(id, WGSL, AlphaMode::Opaque, &textures). One shared host material type that every plugin material is an instance of; only the fragment shader is swapped, so the vertex stage is Bevy's and skinning, morph targets and instancing come free. Fragment entry point only, uniform capped at 256 bytes, up to four textures fixed at registration because the layout is per type rather than per instance. Unlike a post-process shader this goes through Bevy's pipeline, so shader imports work and importing the PBR vertex output is required. The uniform bytes come from the FIRST entity anywhere in the world carrying the settings component — one global look, not per-entity.
AlphaMode::Mask(0.3) / Premultiplied / Add / MultiplydiffersThree modes — opaque, mask, blend — and mask carries no threshold; the bridge hard-codes 0.5. Only add_material_shader takes one at all; add_material and add_material_pbr are always opaque. An unknown value is refused with a log line rather than falling back.
commands.spawn((Mesh3d(h), MeshMaterial3d(h), Transform::from_xyz(..)))differscommands.spawn_mesh(mesh, material, transform), or commands.entity(e).make_renderable(..). One fused command instead of a bundle, because the plugin-side Mesh3d is an opaque filter-only marker you cannot construct — mesh, material and transform must be set together. spawn_mesh returns entity commands, so plugin components can be attached in the same breath.
commands.entity(e).insert(MeshMaterial3d(handle)) — reskin existing geometrydifferscommands.entity(e).set_material(handle) — handle only, no generic material type; the host resolves the slot to either a standard or a plugin material and inserts the right component. It exists because make_renderable could not do it: a plugin has no way to re-supply an imported model's mesh. Filter on With first — a material on an entity with no mesh is not an error and draws nothing.
Color::srgb(0.2, 0.6, 0.9) / Color::hsl(..)differsA Color now exists: srgb, hsl, WHITE/BLACK/NONE, linear_rgb(a) and with_alpha, so those call sites compile unchanged. It is a plain red/green/blue/alpha struct storing LINEAR always, not Bevy's colour-space enum — Color::srgb(0.2, ..).red is 0.033 here and 0.2 in Bevy — and there is no Srgba / LinearRgba / Hsla, no to_srgba(), no conversions back. It is plugin-side only: it cannot be a component field and never crosses the boundary, so every API still takes [f32; 4] and you write add_material(Color::srgb(..).to_linear_array()).
A custom render-graph node / ViewNode recording its own draw commandsdiffersapp.add_render_pass(id, FRAGMENT_WGSL, RenderPhase::LdrPost, order, |pass| { pass.set_pipeline(); pass.draw(0..3, 0..1); }). Fullscreen passes only: WGSL crosses as source text, the host owns the pipeline and the single bind group (view texture, then sampler), there are four fixed phases, and set_pipeline takes no argument because there is only ever one. The callback must be a non-capturing closure or fn. In a build with no renderer the registration is warned and dropped, and the plugin's systems still run.
A post-process effect — #[derive(ExtractComponent)] settings on a camera + a ViewNodediffersapp.add_post_process::(id, FRAGMENT_WGSL, RenderPhase::HdrPost, order). The effect is GLOBAL, not per-camera: the host takes the uniform bytes off the first entity anywhere in the world carrying the settings component, so no settings component on anything means the effect is off — which is how you enable it. Ignore the doc comment on add_post_process that says to put one on a camera. The shader is parsed directly to check its uniform against the component's size, so shader imports do not survive here, unlike add_material_shader.
Registering a new render pass, effect or material after startup (hot reload)differsEditing an existing pass or effect's WGSL hot-reloads: the reload validates an effect's uniform against the settings size and overwrites the shader asset, and Bevy recompiles. Adding a NEW pass or effect warns and is skipped — that needs the render sub-app, which a main-world system cannot reach. A custom MATERIAL reloads neither way: its pending list is consumed once at startup and never re-read, so an edited material shader is ignored until restart.
commands.spawn((PointLight {.. }, Transform::from_xyz(..))) — spawn a lightdifferscommands.spawn_scene(bsn! { Transform { translation: Vec3(0.0, 6.0, 0.0) } PointLight { intensity: 400000.0 } }). Reachable only as BSN text resolved at runtime, so a misspelled component or field is a logged warning rather than a compile error. Any field you do not name is filled from the type's reflected default, which is why this works for Bevy's own components; a type registered without one must have every field spelled out.
commands.entity(camera).insert(Bloom { intensity: 0.3 }) / DepthPrepassdifferscommands.entity(e).insert(bsn! { Bloom { intensity: 0.3 } }). Finding the camera is no longer the problem it was: declare a filter-only mirror for Camera3d and query Query<Entity, With>, which needs no permission. What differs: fields you do not name are reset to the type's default rather than left alone, and only the first top-level tree in the literal targets the entity — further trees spawn as separate roots.
commands.entity(camera).insert(Msaa::Sample8) / Tonemapping::AcesFitted — enum-valued render componentsdiffersbsn! { Msaa } — the bare name only, which inserts the type's default. A variant body reaches the deserializer still wrapped in parentheses, so Msaa { Sample8 } and Msaa(Sample8) both arrive as (Sample8), which is rejected — and the enum path swallows that failure with no warning at all, unlike the struct paths. The variant you asked for silently never applies.
Query<&Transform, With> / Query<&DirectionalLight> — find the camera or the sundiffersDeclare the mirror yourself — pub struct Camera3d(()); plus host_component!(Camera3d, "bevy_camera::components::Camera3d") — and use it in With / Without / Added / Changed: filtering on any host component that derives Reflect is free. Reading one as query DATA is refused at load unless the crate that owns it opted in, and only the animation and physics state mirrors are opted in today, so &Camera3d and &DirectionalLight are rejected with an error naming the type. &Transform is the exception: special-cased and field-marshalled.
*visibility = Visibility::Hidden — hide an entityneverWrite empty geometry, or despawn. The plugin-side Visibility is an opaque filter-only marker over a Bevy enum with no plain-data mirror, and even a hand-written mirror would be refused as query data. There is no visibility command either.
Mesh3d(handle) or MeshMaterial3d(handle) named inside a BSN bodyneverTheir only field is a handle, and BSN field values go through a plain deserializer with no handle processor, so there is no way to spell a handle in the grammar — the field fails to deserialize and is skipped, leaving a dangling default. Use spawn_mesh / make_renderable / set_material, which carry handles as command payloads.
Sprite { image,.. } / Mesh2d + MeshMaterial2d — 2D renderingneverSprite is nameable in BSN but its image is a handle, which BSN cannot express, and a sprite with the default handle draws nothing. The only spawn path in the ABI inserts a 3D mesh and material, the primitive set has no 2D shapes, and no 2D component has a mirror. The Vec2 in the shim is plugin-side maths only and never crosses.

BSN scenes

BevyIn a plugin
Bare marker component — Markerworks
Children [.. ] nesting, comma-separated, parenthesised or bareworks
Comments inside the scene — // line and /* block */works
#Key entity name — #PlayerdiffersIt names the entity — a real Name is inserted, before the component list, so a later explicit Name("Foo") still overrides it — but there is no name scope to refer back to. Bevy pairs the name with an entity reference; the tree here carries no reference field.
Named-field braces — Comp { a: 1 }differsSame syntax, three caveats. Unmentioned fields default only if the target has a reflected Default; without one the body must name every field or the whole component is skipped. A misspelled field name is a warning, not the compile error Bevy's macro gives. And for a PLUGIN-OWNED component only f32, i32, bool and string values can be written at all — MyComp { dir: Vec3(1.0, 0.0, 0.0) } drops the field and leaves the default, even though the derive happily declares Vec3 and Quat field kinds.
Tuple component — Comp(1.0), Comp(a, b)differsThe body must supply EVERY positional field: the patch applies only when the count matches, so a short body lands fully default with no diagnostic at all (too many args does warn). Bevy documents Comp(val) as patching with the rest defaulted.
Module-qualified path — mymodule::CompA {.. }differsThe bare short name, or the exact registered type path — nothing in between. Lookup tries the full path then the trailing identifier, so a partial qualification matches neither and the component is dropped with a warning. An ambiguous short name deliberately resolves to nothing rather than a guess.
Omitted fields defaultdiffersTop level only, with three answers depending on the target: plugin components fill from their registered default bytes, engine components from their reflected Default, and nothing below the top level fills at all.
Nested struct value — Node { padding: UiRect { left: Px(4.0) } }differsName every field of the nested value. Patching stops at the top level: each field value is deserialized whole, so an incomplete nested body fails and is dropped with a warning while the rest of the component still lands. Bevy recurses into nested types.
Enum field value — row_gap: Val::Px(6.0)differsWrite row_gap: Px(6.0) — a bare variant with no enum path prefix. Field bodies are deserialized with a grammar whose enum form is Variant(payload), so Val::Px(6.0) reads Val as the variant name, fails, and the field is dropped with a warning while the rest of the component lands.
Generic component — MeshMaterial3d::(handle)differsWrite MeshMaterial3d with no turbofish — the parser tracks angle-bracket depth, and the registered short type path for a generic contains no ::, so the turbofish form matches nothing. Resolving the name still leaves you with a handle field BSN cannot spell.
Other relationship targets — MyRel [.. ]differsAny name not ending in Children is warned and then treated as Children anyway, so the tree spawns and looks structurally right while the entities are parented rather than related through your relationship. Bevy genuinely stores and generates against the relationship path.
#[require(..)] chains firing on insertdiffersEngine components go in through reflection's typed insert, so requires do run — but one component at a time, not as one bundle. Plugin-owned components take the raw-layout path instead, and the derive has no require attribute to declare any.
bsn_list! — multiple root entities sharing one name scopediffersbsn_list! { (..), (..) } spawns, but roots after the first come out parentless and unreferenceable: both macros render to the same static string, every scene is parsed as a list, and on spawn only the first root lands on the reserved entity. Bevy's motivation for the macro — a shared name scope across roots — does not exist here.
Two-way binding a widget field to a plugin resource (Renzora-only, no Bevy equivalent)differsWrite EmberSliderWidget { value: bind(FlockSettings.cohesion) }. Three limits: only TOP-LEVEL fields bind, and a bind(..) nested inside a struct value is left as literal text; resolution happens one frame later, by a polling system, because the widget's subtree does not exist yet at insert time; and only f32, i32 and bool fields of a registered RESOURCE can bind — Vec3, Quat and string are refused with an error, components are not addressable, and an ambiguous resource short name is an error rather than a guess.
commands.spawn_scene(bsn! {.. })differsIdentical call syntax, and like Bevy's it returns entity commands for the root — but a restricted, compile-time-literal subset inside the braces. bsn! renders its tokens to a static string parsed by the host at runtime, so there is no expression interpolation, no template functions, no on(..) entries, and only Children may nest.
entity_commands.apply_scene(bsn! {.. }) — apply a scene onto an existing entitydiffersWrite commands.entity(e).insert(bsn! { .. }). Different method name, and it REPLACES rather than patches: each named component is rebuilt from its default before insertion, so fields the source does not mention are reset. Only the first top-level tree lands on the target entity.
Enum component variant as a scene entry — MyEnum::VariantneverOnly the default variant is reachable. MyEnum::Variant resolves to no registered type and is dropped with a warning, and there is no body spelling that reaches the deserializer's bare-variant form, because the parser only accepts a delimited body — MyEnum(Variant) arrives as a tuple. Worse, the enum path swallows a failed deserialize with no warning at all.
Field-assignment shorthand — Comp { name }neverComp { name: name } is not a substitute either: bsn! has no local variables at all, because it expands to a static string with nothing to capture. The shorthand parses cleanly and is then dropped with no warning, so the field silently keeps its default.
Observers — on(|ev: On<Pointer>| {.. }) / on(my_observer)neverUse Button + PanelActionId { action: 1 } in the BSN plus Panel::new(..).on_action(..) in Rust, with the action carried as a number because the field kinds cannot hold a closure. A closure cannot cross the C ABI inside a string; on(..) parses as a component named on and is dropped with a warning.
~Template — a custom Template impl prefixnever~ is outside the path charset, so the parse hard-errors — and the error is logged host-side and never reaches the plugin, so the whole scene silently fails to spawn from your point of view.
@MySceneComp — include a SceneComponentneverSame as ~: @ is outside the path charset and the parse hard-errors, silently from the plugin's side. There is no scene-component concept on the plugin side — no props, no scene function.
Cached scene asset — :"scene.bsn"neverThe colon IS in the path charset, so this parses as a component literally named ':' (dropped with a warning) and then hard-errors on the string literal that follows. This is a genuine gap, not parity with an unfinished Bevy feature: Bevy 0.19 parses it, generates a cached scene asset, and that asset is a real scene.
Named-entity cross reference — ChildOf(#Parent), Comp(#OtherKey)neverThere is no name scope in the spawner: #Key becomes a Name and nothing more. In a named field the value is dropped with a warning; in a tuple component the failed argument warns and then the whole body is dropped silently by the field-count rule.
Inline asset value — Mesh3d(asset_value(Circle::new(4.0)))neverUse commands.spawn_mesh(mesh_handle, material_handle, transform). BSN carries no Rust-expression channel and no handle spelling — values only ever go through the reflect deserializer — and there is no field kind for a handle either.
template_value(component) / template_value(Comp::from_str(..))neverIt needs a live Rust value, and the expansion is a static string. It parses as a component named template_value, misses both the type registry and the plugin schemas, and is dropped with a warning.
Rust expression value — { 1 + 2 }, { vec![..] }, { bsn!{.. } }neverUnquoted braces are rewritten to parentheses before deserializing, so Bevy's expression braces are reinterpreted as an anonymous struct body: { 1 + 2 } becomes (1 + 2) and is dropped with a warning, and anything that happens to look like a valid struct is accepted with a completely different meaning.

Domains & I/O — animation, physics, HTTP, input, maths, logging, hot reload

BevyIn a plugin
Depending on an ordinary crates.io crate (noise, ttf-parser, serde, ..)works
Vec3 — new, splat, ZERO/ONE/X/Y/Z, dot, cross, length, length_squared, distance, normalize, lerp, clamp_length_max, and + - * / -x += -= *=works
Quat — IDENTITY, from_axis_angle, from_rotation_x/y/z, q * q, q * v, Defaultworks
Transform::IDENTITY, from_xyz, from_translation, with_translation, with_scale, rotate(q)works
transform.rotate_x/y/z(angle)works
transform.forward() / back() / right() / left() / up() / down()differsSame call, but all six return Vec3 where Bevy 0.19 returns Dir3, and there is no Dir3 type in the shim.
Transform::look_to / with_rotation / from_rotation / from_scale / rotate_around / compute_matrix / local_x/y/z / align / mul_transformmissinglooking_at and transform_point did ship, with Bevy's signatures and semantics — looking_at points -Z at the target and returns the transform unchanged in the two degenerate cases rather than producing a NaN basis. Everything else listed is absent, all of it plugin-side code with no ABI cost, though compute_matrix and align additionally want Mat4 and Dir3, which do not exist.
The rest of glam's Vec3 — NEG_X/Y/Z, MIN/MAX/INFINITY/NAN, abs, min/max/clamp, floor/ceil/round, reflect, angle_between, project_onto, to_array, extend/truncate, midpoint, move_towards, is_nan, Div, Index, DisplaymissingAdd, subtract, scale, cross and lerp are all inherent methods or operators on the shim's Vec3 now, so the old advice to re-implement them locally is obsolete. What is left is the long tail listed here, none of which exists. Hand-roll it — it is plugin-side code and costs no ABI.
Quat::conjugate / lerp / dot / to_euler / from_rotation_arc / from_mat3missinginverse, normalize, length and slerp shipped and read like Bevy's (inverse is the unit-quaternion cheap form, normalize returns identity rather than NaN at zero length, slerp takes the shorter arc and falls back to lerp near-parallel). Two things to know: Quat::from_euler exists but takes plain (yaw, pitch, roll), so Bevy's from_euler(EulerRot::YXZ, ..) does not compile; and Quat::from_basis(x, y, z) is a shim-only constructor with no Bevy equivalent.
Vec4, Mat3, Mat4, Affine3A, Dir3, Rect, IVec2/UVec2missingVec2 and Color shipped, both PLUGIN-SIDE ONLY — neither can be a component field and neither ever crosses the boundary. Vec2 has ZERO/ONE/X/Y, new, splat, dot, perp_dot, length, length_squared, distance, normalize_or_zero, lerp, extend and the operators, but no plain normalize. The types listed here still do not exist, so the tuple-and-array idioms stand: the cursor is (f32, f32), mesh UVs are [f32; 2], material colour is [f32; 4].
A String field on a componentdifferspub text: Str256 — 252 bytes inline plus a length, built with Str256::new(s) (None if it does not fit) or new_truncating (cuts at a char boundary). A String field now fails to compile outright, where it used to compile, bit-copy its pointer into host storage, never drop, and serialise into scenes as a meaningless number. One capacity only, because the size has to be recoverable from the field kind alone.
Res<ButtonInput> + keys.pressed(KeyCode::KeyW)differsRes + input.pressed(Key::W) — one param covers keyboard, mouse and cursor, because a generic resource cannot be instantiated across a C ABI. Key names drop Bevy's Key/Digit prefix pattern, and the 84 wire values are frozen and deliberately unequal to Bevy's discriminants, so a Bevy upgrade that inserts a variant cannot remap anyone's input.
Res<ButtonInput>differsinput.mouse_pressed(MouseButton::Left) on the same Res. Five buttons only; MouseButton::Other(u16) has no wire number and is dropped.
window.cursor_position() via Query<&Window, With>differsinput.cursor() -> Option<(f32, f32)> — a tuple, not a Vec2, and always the primary window; a plugin has no window query and no way to name a second window. Absent is NaN on the wire, so forgetting to check gives an obviously wrong number rather than a plausible (0, 0).
Res / Resdiffersinput.cursor_delta() / input.scroll(), both (f32, f32), folded into the same snapshot. The scroll delta is copied verbatim and its unit is never read, so the line-versus-pixel distinction is lost.
Numpad, media, IME and international keysmissingThere is room — 84 keys against a 256-bit set — but the mapper returns nothing for these rather than inventing a wire number that could never be changed.
Gamepad — Query<&Gamepad>, GamepadButton, axes, rumblemissingNothing structurally blocks it: the same flatten-into-one-snapshot trick works and the snapshot is appendable. There is simply no gamepad field and nothing collected.
Touch input (Res)missingSame shape as gamepad: no field, nothing collected, no vocabulary for a touch id.
Text entry — EventReader, logical keys, IME compositionneverThe input snapshot carries three keyboard bit-sets, three mouse bitmasks and six floats, and there is no event stream of any kind on the boundary. The Bevy spelling is what is blocked — a reader is generic over a host type, and a logical key owns a heap string. A frame's typed text as a fixed-size UTF-8 buffer is not blocked by anything, so if it ever arrives it arrives in that shape.
AnimationPlayer::play(node_index)differscommands.entity(e).play_animation("run"), with features = ["anim"] and use renzora_plugin::anim::AnimCommands. Not a component write: it encodes a command onto the generic service channel and names the clip by string, where Bevy names a graph node index.
player.play(n).set_speed(s).repeat()differsplay_animation_with(name, speed, looping) — one flat call instead of a builder chain, and looping is a bool rather than a repeat mode.
AnimationTransitions::play_with_transition(player, node, duration)differscommands.entity(e).crossfade_animation("run", 0.2) — duration is f32 seconds rather than a Duration, the target is a name, and it always loops.
player.stop_all() / pause_all() / resume_all(), active.set_speed(s), active.seek_to(t)differsstop_animation() / pause_animation() / resume_animation() / set_animation_speed(s) / seek_animation(secs) on entity commands — deferred commands rather than method calls on a live player, and speed and seek apply to the whole animator, because the payload carries no node index.
Reading animator state — Query<&AnimationPlayer>differsQuery<&AnimState> after use renzora_plugin::anim::AnimState — a numeric-only mirror (clip and state hashes, state time, time, playing), not Bevy's type. Names arrive as 64-bit hashes, so is_clip("run") works but enumerating or discovering a name does not. It is exposed READ-ONLY: &mut AnimState is refused at registration and the system never runs.
State-machine parameters (Renzora's own animator; no Bevy equivalent)differsset_anim_param(name, f32) / set_anim_bool / set_anim_trigger / set_layer_weight. Write-only, and a name longer than 48 bytes is dropped entirely — not truncated — with an error, because a silently shortened name resolves to no clip and is far harder to trace. Reading a parameter back is impossible: parameters are an unbounded name-to-value map and the state mirror is fixed-size with no reply channel.
Procedural tweens (Renzora-only)differscommands.entity(e).tween_position(target, secs, Easing::OutCubic). The 17 easing ordinals are frozen and must stay aligned with the engine's declaration order, and tween_rotation targets are Euler DEGREES in a Vec3, not a Quat.
Physics forces — avian ExternalForce / ExternalImpulse / LinearVelocitydifferscommands.entity(e).apply_force(v) / .apply_impulse(v) / .set_velocity(v), with features = ["physics"]. Deferred service commands rather than component writes; the bridge re-emits them as the same action a Lua script fires, so a plugin's impulse takes exactly a script's path, including the 2D/3D dispatch.
Kinematic character move-and-slidedifferscommands.entity(e).kinematic_slide(delta, max_slope_degrees) — one fixed operation with a slope angle; no controller config, no ground snapping, no step height.
Reading body state — Query<&LinearVelocity> + collision eventsdiffersQuery<&PhysicsState>, then is_grounded() / is_colliding() / just_entered() / just_exited(). The mirror carries linear velocity, a precomputed speed, the ground normal and four contact flags — but no angular velocity, no collider identity and no contact point, and collision NAMES are deliberately omitted. Read-only: &mut PhysicsState is refused at registration.
Creating a body — RigidBody, Collider, jointsneverConstruct them indirectly through BSN text. The Bevy/avian spelling is blocked: those types are avian's, have no plain-data mirror, and a plugin cannot name them. The physics operation set has four values and none is constructive, so a plugin can only drive a body something else authored.
Spatial queries — raycast, shapecast, overlap testsmissingavian's spatial-query param can never cross, but a request-then-poll reply is a shape this boundary already uses — input, meshes, images, HTTP and removed components are all per-call sources, and polling an HTTP reply by tag is exactly what a raycast reply would look like. Today every physics operation is write-only and there is no reply, so nothing works yet.
HTTP GET/POSTdifferscommands.http_get(TAG, url) / commands.http_post(TAG, url, body), with features = ["http"]. Fire-and-tag, not a future or a callback: a callback pointer would have to survive a hot reload, which is exactly what generation-gating exists to prevent. Not entity-scoped — these hang off Commands, not EntityCommands.
Collecting an HTTP responsediffersfn collect(http: Http) { if let Some(r) = http.poll(TAG) { .. } } — a bare system param, not a Res, polled every frame and delivered exactly once, with a two-pass copy so a failed allocation cannot swallow the response. The body is lossy UTF-8, so binary responses are mangled.
Native file / folder pickerdifferscommands.pick_file(TAG, title, &filter) / pick_folder(TAG, title) / pick_save_path(..), with features = ["dialog"], collected by dialogs.poll(TAG). Fire-and-tag like HTTP. Cancelling REPLIES (DialogResult::Cancelled) rather than going silent, and Unavailable is distinct from it (no windowing / headless) so a plugin can fall back to typing a path; outcome.path() collapses both to None. The picker blocks the editor while open, matching the rest of the editor, but the answer still arrives through the queue on a later frame — never assume which. Filters cross as text ("Images:png,jpg;All:*"); DialogFilter strips the separators out of labels so it cannot mis-split.
A domain that needs the host to answer itdiffersRides SystemCall::replies — the generic reply channel, keyed by (service, tag) — rather than a source of its own. Added in MINOR 4.5 as the counterpart to call_service, which has been generic since 2.4; before it, every answering domain (meshes, images, HTTP) cost a VERSION_MINOR bump. Intended to be the last per-domain source ever added, so a new domain now costs no boundary surface in either direction.
Streaming HTTP (SSE / NDJSON / chunked)differscommands.http_get_stream(TAG, url) / http_post_stream(TAG, url, body), collected with while let Some(c) = http.poll_stream(TAG). One request yields many chunks ending in exactly one terminal chunk (End, or Error with the text in c.data), so poll in a loop and stop at c.is_last(). Chunks are transport-sized, not message-sized — a JSON object can span two, and two can share one — so accumulate and split on your API's own framing. Streams and whole-body responses share a queue but never cross: poll ignores chunks and poll_stream ignores completed bodies.
HTTP verbs beyond GET/POST, custom headers, timeouts, response headersmissingThe status code IS available — the response carries it, with is_ok(). Two verbs exist and anything else is warned, but the engine's own client takes an arbitrary method string, so more verbs are two constants and a match arm. Headers and timeouts need a parameter on the request call and room in its header struct — more work, still no blocker.
info!("spawned {n} boids")works
debug! / trace!differslog(LogLevel::Debug, msg) after use renzora_plugin::sys::LogLevel. Both levels exist on the wire and the host routes them to real debug!/trace!, but only info, warn and error got the macro-and-function pair, so these two are the one place you still write the level out by hand.
Logging before Plugin::build is enteredneverThe interface pointer is stashed at init; before that, the log call sees null and returns, so every level including the macros is a silent no-op.
Hot reload of plugin code while the editor runs (Bevy has no equivalent)differsNothing to write: edit a .rs or .wgsl under plugins/ and save. Components and resources survive untouched, because their ids are name-keyed and the data lives in the host's ECS; panels, render passes, post-process effects and GPU handles are taken back and re-registered by the new build. Systems cannot be removed from a Bevy schedule, so they retire by generation and stay scheduled as no-ops. Post-process and render-pass SHADERS hot-swap; a custom MATERIAL's shader does not — that needs a restart. Every failure mode leaves the running build in place, and a reload leaks one library image and one shadow-copied file by design, because a mapped DLL cannot be replaced on Windows.
Adding a NEW panel, render pass or post-process effect during a reloadneverRestart the editor. Two different reasons: panel registration is an init-time extension on App, so the reload finds no slot for an unknown id and warns once per id; and registering a pass or effect needs the render sub-app, which the main-world reload system cannot reach. Editing an existing one works in both cases — the BSN is reparsed and redrawn, the shader asset is validated and overwritten.
Pre-declared host-component mirrors a plugin can query without setupdiffersTransform (full read/write) plus Mesh3d and Visibility, and behind their features AnimState and PhysicsState. Reading an engine component as DATA is enforced, not merely documented: any data term naming a host component whose owning crate has not opted in fails the whole system at registration with an error naming the type — so &Mesh3d and &Visibility no longer compile-and-lie, and &mut AnimState / &mut PhysicsState are refused because both are exposed read-only. Transform is exempt and specially marshalled. Filtering with With/Without is unrestricted and free. GlobalTransform and Name are force-registered so they are filterable, but cannot get a naive mirror: one's layout varies with the SIMD backend, the other holds a String.
Exposing your own in-tree component to standalone pluginsdiffersFour steps now, not three: make it #[repr(C)] plain data; register_type and register_component it so the id exists before plugins load; call renzora_plugin::host::expose_component_data::(app) — new, and without it every plugin read is refused at registration; and publish the type path so the plugin side can declare host_component!(Mirror, "my_crate::path::MyComponent"). Use the _mut form only if the plugin is meant to own the value; a too-large mirror writes past its staging row, and no size crosses the ABI for either direction to check. Matching is by string through the reflection registry, so a rename or a moved module is silent on both sides and presents as a query that matches nothing forever — which is why both existing bridges assert the two agree at startup.
Depending on a Bevy ecosystem crate (bevy_hanabi, bevy_mod_outline, avian3d, leafwing-input-manager, ..)neverTheir public APIs take real Bevy types; the Transform, Query and App here are shims with no relationship to Bevy's. Anything needing them is an in-tree engine crate compiled against real Bevy — the deliberate split between the two plugin tiers.

Editor panels

BevyIn a plugin
An editor panel (Renzora-only; the in-tree equivalent is App::register_panel_content)differsapp.add_panel(Panel::new("flock", "Flock", bsn!{ .. }).icon("bird").category("Plugins").on_action(f)). The body crosses as a STRING of BSN parsed host-side; a plugin is never handed widget entities. An empty id, empty markup or a duplicate id is refused, so ids must be globally unique across every loaded plugin. An empty icon defaults to a puzzle piece and an empty category to 'Plugins'. Ignore the panel descriptor's own doc comment — it still documents the retired column/slider grammar that BSN replaced.
Panel widget setdiffersBSN naming real components: Node, Text, Children, plus EmberButtonWidget, EmberSliderWidget, EmberToggle, EmberCheckbox, EmberInput, EmberDropdown, EmberTabs, EmberProgress, EmberTable and EmberTimeline (with EmberTrack/EmberClip for timeline rows). The vocabulary is open — the spawner resolves a name against the engine's type registry first and your own component schemas second — but the widgets are Renzora's, not Bevy's, and the body is text rather than Rust.
Button clicks — .observe(on_click) / Trigger<Pointer>differsPanelActionId { action: 1 } on the widget plus .on_action(handler); Action::is("1") matches the number stringified. Two live traps. Dispatch queries the interaction and the action id on ONE entity, and EmberButtonWidget builds its clickable box — the thing carrying Interaction — as a CHILD, so the EmberButtonWidget + PanelActionId pairing printed in the standalone-plugins guide compiles, spawns, looks right and never dispatches: put the action id on Bevy's Button, which requires Interaction. And there is no edge detection — every frame the interaction is pressed fires the handler again, not once per click.
Reading a widget's value in the action handler — Action::valuemissingDocumented as a toggle's 0 or 1 or a slider's position, but the host hard-codes it to 0.0 on every dispatch and never reads the widget, so the field is always 0. Nothing structural blocks it: the float already crosses, and the host already knows how to read a widget's model for binding. Until someone fills it in, use bind(..) and read the resource.
Changing a panel's contents after registrationdifferscommands.set_panel_content("id", &markup) from any system, with markup being BSN source built at run time — a &str, not a Scene, since Scene holds a &'static str. Rides the generic service channel, so it does NOT move VERSION_MINOR. Safe to call every frame: the host compares before parsing, so unchanged markup costs a string comparison and no redraw. An unregistered id is reported and ignored (creating a panel needs &mut App, which a system has not got); malformed BSN keeps the panel that is on screen, as on hot reload. Action thunks are rebound on redraw, so PanelActionId numbers survive.
Panel action handler capturing statemissingA plain fn or a non-capturing closure only: the handler is a const thunk rebuilt from nothing, so a capturing closure is a compile error. The descriptor does carry an opaque per-panel word plumbed end to end and handed back on every dispatch — add_panel simply passes null — so boxing a capture and reading it back is a plugin-crate change with no ABI move. Only FREEING it is unaddressed: there is no unregister hook, so a reload would leak the box.
Two-way binding a widget to plugin statediffersEmberSliderWidget { value: bind(FlockSettings.cohesion), min: 0.0, max: 2.0 } — dragging never calls the plugin; the host reads and writes the resource bytes itself. Only f32, i32 and bool fields bind (an i32 models as a float and rounds on write); anything else, Vec3 and Quat included, is refused with an error naming the kind. Targets must be a registered RESOURCE — components are not addressable — and an ambiguous short name is an error listing the candidates, resolved by qualifying with the crate. Only top-level fields of a component body bind. One undocumented constraint: binding attaches to the widget entity's FIRST CHILD, so it only works on Ember widget components whose insert hook builds exactly that child — a bind on a plain Node has nothing correct to attach to.

214 entries: 26 work identically, 93 differ, 61 are missing, 34 never will be.

Closer, but only in one layer. 26 of 214 rows are now character-identical Bevy (was 22), 93 differ, 61 are missing, 34 never. Nine of the fourteen upward moves are in systems, queries, maths and logging — with the read-only iterator projection, get/single/contains, Option<Res>, RemovedComponents, fixed rotate_x/y/z and real info!/warn!/error! macros, the body of a Bevy system now largely copy-pastes. The edges do not: everything touching the host is still translation — assets, rendering and BSN are 4 works against 34 differs and 15 nevers, components must be repr(C) plain data with a mandatory Default and no destructor, and there is still no ordering, run condition, event, observer, hierarchy or asset-server spelling anywhere. Two rows moved down because they overstated. Rule of thumb: the inside of your systems reads like Bevy; every line where a plugin talks to the engine reads like an FFI.


Roadmap

The missing column above is a backlog, and this is the order it gets worked in. Ordered by how many rows each step deletes, not by how interesting it is.

ABI says what a step costs compatibility: none is plugin-side or host-side only and every built plugin keeps working; MINOR appends to the contract and older plugins keep working; MAJOR refuses every plugin built before it.

Shipped

What it changedABI
Interface table repairedTwo functions had been inserted mid-struct and recorded as appended, so a plugin built against 2.5–2.10 would have called the slot it compiled for and landed in a different function. No field order makes every historical MINOR correct, so all of them are refused by name.MAJOR → 3.0
Every offset-keyed table pinnedInterface, SystemCall, FrameCtx, CommandSink, MeshSource, ImageSource, HttpSource — field names and written types, so a signature change at an unchanged offset fails too. FrameCtx is included because SystemCall embeds it by value, which makes appending one float there an ABI break authored in a different struct.none
set_materialA plugin material can be applied to geometry the plugin did not create — an imported model, a shape the user authored. Before this, make_renderable set mesh, material and transform together, so a custom shader could only ever land on shapes the plugin spawned itself.MINOR 2.12
Custom material path made to workBind group corrected to @group(3); the plugin supplies a fragment only, so Bevy's mesh vertex stage keeps skinning and morph targets; unused texture slots bind a fallback so the bind group matches its layout; the prepass and shadow passes keep Bevy's own fragment.none
rotate_x/y/z correctedThey post-multiplied, so they silently were rotate_local_*. Copied Bevy source spun the wrong way once an object was tilted, with no diagnostic. rotate_local_* added with the old bodies.none
Documented rules made realThree rules the docs stated and nothing enforced are now compile errors: a component owning a String/Vec/Box is refused by the derive; Query::iter() yields a read-only projection so nested iteration cannot alias; and the four structs handed to plugins as a first-field pointer assert that the field is at offset zero.none
Host-mirror data gateReading an engine component as data now requires the owning crate to expose it. Filtering is unrestricted. Without the gate a plugin could name any reflected component and be handed its raw bytes — including Window, which owns a String, and GlobalTransform, whose layout changes with the engine's SIMD backend.none
Interface shape hashThe table now carries a hash of its own field list, checked at load. Appending is invisible to it; inserting, reordering or retyping refuses the plugin with a rebuild message instead of sending its call into a different function. Protects plugins built outside this repository, which the build-time order test cannot.MINOR 3.13
Boundary layouts pinned57 types and 200 fields in sys.rs — every repr(C) struct, every transparent newtype, and every function-pointer alias — diffed against a golden list generated by the same parser the test uses. A new type fails until someone records whether it crosses, because the failure mode of a curated list is omission.none
Boundary-editing ruleWritten into sys.rs's module docs, where someone about to edit it will see it: which types may be appended to, which are frozen outright, and why by-value embedding breaks structs in a different part of the file.none
Two shipped bugs fixedThe host-data allowlist could never match in a release build (it compared a debug-only placeholder string), and every animation command copied three uninitialised bytes into host storage.none
Payloads checked exactlyThe domain bridges accepted any payload at least the right size, so a reordered command passed and was misread silently. Now exact.none
Frozen values pinnedKey's numbering, the SERVICE hashes and the inline capacities are compiled into every shipped plugin, and no layout test can see them.none
Reads and writes separatedWriting a host mirror needs its own opt-in — a mirror larger than the host type writes past its staging row, where reading one merely returns wrong bytes.none
Maths surfaceQuat::inverse/normalize/from_euler/slerp/from_basis, Transform::looking_at/transform_point, and Vec2 and Color which did not exist at all. Vec3 already had operators and the common methods — plugins/hair was duplicating them, and now delegates.none
Syntax parity sweepTuple arities raised to Bevy's — query data and filters to 15, system params and add_systems to 16, bundles to 15, Or to 15. add_systems(Update, (a, b, c)) compiles. info!/warn!/error! exist as macros alongside the functions. Query::single/single_mut/get/get_mut/contains. Option<Res<T>> and Option<ResMut<T>>.none
Added<T> / Changed<T> (4.2)Deletes the hand-rolled signature hashing in hair and text3d and ripple's _ready flag. QueryBuilder has no tick dimension, so the host tests ticks per row — which is what Bevy does too: its own Added/Changed are non-archetypal, so there is no whole-table skip being given up. Refused inside Or, where an empty branch would match every entity in the world.MINOR
Crossing enums newtyped (4.0)SystemStatus, RegisterStatus and InitResult were real enums whose values cross the boundary, which makes an out-of-range value undefined behaviour. Wire-identical fix; the compiler now forces the host to handle a value from a newer plugin.MAJOR
Field offsets bounds-checkedA field claiming an offset past the end of its component is dropped at registration, and two inspector paths no longer read an unsizable field at a guessed four-byte width.none
Contract tidiedStale test references, an orphaned attribute asserting an invariant on the wrong type, one aligned read where every sibling reads unaligned, and const-asserts tying renzora_bsn's hardcoded layout literals to the definitions they describe.none
Textures, geometry read/write, strings, HTTP, physicsSee the version history in Standalone Plugins.MINOR 2.5–2.11

Then — deletes whole classes of workaround

What it unlocksSizeABI
RemovedComponents by ComponentIdDeletes the per-frame liveness sweep in every plugin that owns anything. Both shipped plugins hand-roll one. The param has empty access declarations, so it can never conflict.mediumMINOR
Reflective field patchThe complement to BSN insert, which replaces rather than patches. The only way to nudge one field of a live Camera or PointLight without resetting the rest. Must refuse immutable and relationship components.~80 linesMINOR
Capturing closures as systemsRecovers Commands::queue, add_observer, entity.observe, and on(|ev| …) in BSN. The per-system token already round-trips through the ABI unused — the refusal is a const assert in the ergonomic layer, not a boundary constraint.mediumnone
Syntax-parity sweepTuple arities to Bevy's 15/16, add_systems(Update, (a, b)), info!/warn! as macros, Query::single/get/contains, Option<Res<T>>, Local<T>, and the missing maths (Vec2/Vec4/Color/Dir3, Quat::from_euler, looking_at). This is where "zero syntax changes" is won or lost in an author's first hour, and none of it touches the ABI.mediumnone
Deferred asset creationDeletes the fixed-pool idiom — hair reserves 16 meshes, text3d 64, each with a slot index stored on the component to survive reload. The pattern already exists for entities.mediumMINOR
Run conditions, ordering, system setsSystemDesc.flags is reserved and enforced-zero for exactly this. Ordering between plugin systems is tractable; ordering against host sets needs a curated published map, because the set interner is private.largeMINOR
Schedules by nameStartup, FixedUpdate and the rest. Note Startup is a trap under hot reload — a reloaded plugin's Startup system would never fire, since reload happens mid-frame.smallMINOR
Per-entity plugin materialsToday the settings component is read from the first entity carrying it anywhere in the world and broadcast, so two entities using one plugin material always look the same.one filenone
Reflective mirroringRead any registered host component by type path with no hand-written #[repr(C)] mirror. Kills the mirror-layout hazard permanently instead of guarding it. Placed last deliberately: the largest contained piece of work here, and the steps above deliver most of the practical value first.largeMINOR

Not planned

The 38 never rows, and two things that look like natural next steps and are not:

  • Reflected function calls — "call any engine function by name". Three independent blockers: the feature is not compiled, the registry is created empty with no call sites anywhere in Bevy, and World does not implement Reflect so no function taking it could be registered even if it were.
  • Direct pointers into component storage. Would save the per-entity copy, but loses the comparison against a baseline that stops every plugin system marking everything it merely read as changed. The real cost is the per-cell allocation, which is fixable without this.