0.24.0 - Codegen removed, typed scripts, script systems, Rune
See the migration guide for breaking changes.
Codegen is gone: 4x faster builds
The codegen project, its pinned nightly toolchain and the cargo expanded binding crates are removed (see the RFC). The bindings went from 4.3MB of generated source to 0.23MB written by hand:
BMS crates, default features + lua54 | 0.23 | 0.24 | |
|---|---|---|---|
cargo build | 209s | 51s | -76% |
cargo check | 19.1s | 9.0s | -53% |
| slowest crate | bevy_reflect_bms_bindings, 193s | bevy_mod_scripting_bevy_math_bindings, 43s |
Non-incremental dev builds of the workspace crates only (dependencies cached), mean of 2 runs, 16 threads. Changing a binding is an ordinary edit, with no nightly toolchain or regeneration step.
Hand-written Bevy bindings
The Bevy bindings are written, documented and tested by hand, one crate per category, designed for scripts rather than mirrored from Rust:
local Vec3 = import_namespace("math").Vec3
local Transform = import_namespace("transform").Transform
local KeyCode = import_namespace("input").KeyCode
local Color = import_namespace("color").Color
local ui = import_namespace("ui")
local t = Transform.from_translation(Vec3.new(1, 2, 3)):looking_at(Vec3.zero(), Vec3.unit_y())
t.translation = 2 * t.translation -- numbers work on either side of operators
if KeyCode.just_pressed(KeyCode.Space) then -- no `ButtonInput<KeyCode>` resource lookups
local node = ui.Node.new({ width = ui.Val.px(100) })
local red = Color.hex("#ff0000"):mix(Color.named("rebecca_purple"), 0.5)
end
- Covers math, ecs, time, transform, color, input, window, asset, camera, render, sprite, text and ui, plus new bindings for gizmos (
Gizmos.line(a, b, color)), animation players, audio playback settings and states (world.get_state,world.set_next_state). - Things which panic in Rust are errors or
nilin scripts:normalizeof a zero vector, integer division by zero,Entity.from_bitsof an invalid id,Dir3.newof a zero vector. - No generic type paths:
world.time(),world.virtual_time(),Window.primary(),world.find_entity_by_name(name). - Everything without a binding stays reachable through reflection: fields,
Type.new{..}, variants. - The examples in the binding docs run as tests, in every language.
Everything is imported
Scripts get three globals: import_namespace, export_namespace and register_callback. Types live in short category namespaces (math, ecs, transform, ui, …) instead of globals which shadowed each other, and so does world:
local bms = import_namespace("bms")
local world = bms.world
bms.log_info("hi from " .. tostring(bms.current_entity()))
Exposing your own functions is one attribute, no globals registry:
#[script_bindings(remote, namespace = "my_game")]
impl NamespaceMembers {
/// `import_namespace("my_game").spawn_enemy(5)` in scripts
fn spawn_enemy(ctxt: FunctionCallContext, hp: usize) -> Result<(), InteropError> { .. }
}
Types, constructors and enums
A type is a value now (ScriptValue::Type), not an allocated StaticTypeRef reference. What you import from a namespace is the type: it is its registration wherever one is expected, and it carries the type’s static functions, the shared new constructor and its enum variants. Calling Vec3.new{} no longer allocates the type and clones it back out on every call.
local time = import_namespace("time")
local Timer, TimerMode = time.Timer, time.TimerMode
world.add_default_component(entity, Transform) -- the type as a registration
local timer = Timer.from_seconds(2, TimerMode.Repeating)
local image = Image.load("player.png") -- on every asset type, a typed `Handle<Image>`
if event.key_code == KeyCode.KeyA then end -- values compare against variants directly
local eased = EaseFunction.Linear:sample(0.5) -- variants have their enum's functions
- Every reflected value has
==(throughreflect_partial_eq) and:clone(), and operators a type doesn’t define fall back to the ones shared by all references. #vgoes through the type’slenfunction, so types can override it.- Asset types get
load(path),add_asset(value)andget_asset(handle), next toworld.load_asset,world.add_asset,world.is_loaded,world.load_stateandworld.asset_path. #[reflect(NoTypeFunctions)]hides the shared type functions (i.e.new) from a type.- One function per name, taking a
Unionfor different operand types (Union<R<Vec3>, f32>borrows): overloads, and the failed attempts they cost on every call, are gone. #[derive(ScriptType)]implements everything a type needs to be used directly in function signatures.
Scripts your editor understands
The Lua language server files describe what scripts actually get back. Functions are generic in the type they are given, and generic types are declared once in terms of their parameters:
local transform = world.get_component(entity, Transform) -- transform.Transform?
local image = world.load_asset("player.png", Image) -- asset.Handle<render.Image>
local state = world.get_state(GameState) -- my_game.GameState?
local t = world.get_type_by_name("bevy_transform::components::transform::Transform") -- the Transform type
- A file per namespace, classes named after their namespace (
math.Vec3), no type globals. - Type tables and values are separate classes:
Transform.from_xyzcompletes on the type, fields on the value, and passing a resource where a component is expected is flagged. - Enum variants extend their enum, so
Timer.from_seconds(2, TimerMode.Repeating)type-checks, andMyEnum.Variant:new{..}checks its fields. - Container fields are typed as what reading them gives:
integer[] | ReflectReference. - The Lua tests of BMS are type-checked against the generated files in CI, with zero diagnostics.
Declare your own generic types with app.register_generic_type::<Handle<A>>("asset", &["A"], docs), and generic functions with TypeOf<R, A> / ValueOf<A> in their signature:
fn get_component(
ctxt: FunctionCallContext,
entity: V<Entity>,
registration: TypeOf<V<ScriptComponentRegistration>, A>,
) -> Result<Option<ValueOf<A>>, InteropError>
The mdbook pages got the same treatment, and a rework: generic types show their layout and functions once, generics no longer vanish as HTML tags, rustdoc links resolve, and summaries are readable.
Script systems
Script systems grew into something you can build a mod’s logic on.
Parameters and queries
Systems declare what they read and write, so Bevy runs them in parallel with everything they don’t conflict with:
bms.system_builder("steer_enemies", attachment)
:query(world.query()
:component(Transform) -- read + write
:component_read(game.Enemy) -- read only, readers run in parallel
:optional_read(game.Shield) -- nil for entities without it
:changed(game.Target)) -- only entities whose target changed since the last run
:resource_read(game.Difficulty)
function steer_enemies(enemies, difficulty)
for _, result in ipairs(enemies) do
local entity, transform, enemy, shield = result[1], result[2], result[3], result[4]
end
end
added/changedfilters, relative to the system’s last run (or the last update inworld.query()).- Queries of a system may share components if
with/withoutmake them disjoint, like in Rust. - Query results are plain lists instead of allocated values.
Ordering and sets
Order against systems, other scripts’ systems, and sets, including Rust sets:
local propagate = import_namespace("transform.TransformSystems").Propagate
local ai = world.register_new_system_set("script.my_mod", "ai") -- other scripts import it from `script.my_mod`
builder:in_set(ai)
:before_set(propagate)
:after_script_system("on_physics", other_attachment)
The bindings register the main Bevy sets, expose your own with:
app.register_script_system_set("my_game.GameSystems", "Combat", GameSystems::Combat);
Run conditions
Evaluated in Rust, without calling into the script:
local c = bms.conditions
builder:run_if(c.any_with_component(game.Enemy))
:run_if(c.not_(c.resource_exists(game.Paused)))
:run_if(c.on_timer(0.5))
resource_exists, resource_changed, resource_added, any_with_component, on_timer, run_once, and the combinators not_, and_, or_.
Lifecycle
- A system is identified by its script and callback label: adding it again replaces it, and it is removed when its script unloads or reloads.
- Systems can be added to schedules which are running (i.e. from
on_script_loaded), the change is applied once the schedule finishes. - Errors are sent as
ScriptErrorMessages like those of other callbacks, and invalid parameters are reported once instead of every frame. - A non-exclusive system can only touch what it declared, on the entities its queries match.
See Script Systems.
Determinism
Given the same inputs, scripts load in the same order and callbacks reach scripts in the same order, on every run and machine:
- Script contexts and their residents are kept in insertion order instead of randomly seeded maps, so callback order, reload order and the “first” script of a shared context are stable.
- Attachments and detachments are one ordered
ScriptLifecycleMessagestream: removing and re-inserting a script component in the same frame no longer ends up unloaded, and a detachment waits for its attachment whose script is still loading. - The pipeline budget counts steps (
tick_budget) rather than wall clock time. - Lua’s
math.randomis seeded with0in new contexts. - Broadcast callbacks are no longer delivered twice to loaded scripts while another script is loading.
See Determinism for what BMS can’t control.
Rune
Rune is the second supported language, behind the rune feature. It replaces Rhai, which is removed:
pub fn on_script_loaded() {
let bms = import_namespace("bms");
let world = bms.world;
for result in world.query().component(life_state_type(world)).build() {
let cells = result[1].cells;
cells[0] += 255;
}
}
Operators, indexing and member lookups go through one language agnostic dispatch module, so every language resolves v.x, v:method() and 2 * v by the same rules, and adding a language is mostly converting values, see Necessary Features.
Faster interop: method calls at less than half the cost
Method calls from Lua cost less than half of what they did (cargo bench, medians of 3 runs on the same machine):
| 0.23 | 0.24 | ||
|---|---|---|---|
v:length() | 1.45µs | 0.67µs | -54% |
v:normalize() (returns a value) | 2.49µs | 1.04µs | -58% |
a + b | 1.57µs | 1.02µs | -35% |
v.x | 554ns | 426ns | -23% |
v.x = 2 | 484ns | 519ns | +7% |
| calling an empty function | 309ns | 178ns | -42% |
- Values returned to scripts are owned by the references to them and freed with the last one: the allocator, its lock and the garbage collection system are gone.
v:methodreuses one Lua function per registered function, and call locations are only looked up for errors.- Claiming the whole world (every asset access,
world.spawn, …) no longer scans every component’s claims.
Soundness
The world access machinery was reviewed and tightened:
- Borrowed arguments (
R<T>,M<T>) are tied to the function call, keeping them past it no longer compiles. - Claims are released when callbacks fail or panic, can’t overflow, and hold across guards for values reachable from several of them.
- Script systems declare exactly what they access to Bevy: written components and resources used to be declared as reads, letting writers run in parallel.
- Reading an asset no longer takes it mutably, or marks it modified (i.e. re-uploading an
Imageevery frame). - Constructors which trust the caller with a type and component id pairing are
unsafe, and registrations can’t be forged through reflection. - Converting
-1to aNonMaxU32from a script was undefined behaviour, it is an error now.
Other changes
- Cyclic Lua tables are an error instead of a stack overflow.
- Lua functions called from Rust return all of their values intact.
- Indexes are normalised when values are created, so values pass between 0 and 1 indexed languages correctly.
DynamicScriptFunctions compare equal to their clones.- The function registry is not locked while functions run, which could deadlock with a writer on another thread.
- Android and iOS builds are checked, and the script tests run on an emulator, in CI.
mlua0.12.- Only the top level
CHANGELOG.mdis maintained, it covers every crate.