Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 + lua540.230.24
cargo build209s51s-76%
cargo check19.1s9.0s-53%
slowest cratebevy_reflect_bms_bindings, 193sbevy_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 nil in scripts: normalize of a zero vector, integer division by zero, Entity.from_bits of an invalid id, Dir3.new of 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 == (through reflect_partial_eq) and :clone(), and operators a type doesn’t define fall back to the ones shared by all references.
  • #v goes through the type’s len function, so types can override it.
  • Asset types get load(path), add_asset(value) and get_asset(handle), next to world.load_asset, world.add_asset, world.is_loaded, world.load_state and world.asset_path.
  • #[reflect(NoTypeFunctions)] hides the shared type functions (i.e. new) from a type.
  • One function per name, taking a Union for 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_xyz completes 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, and MyEnum.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 / changed filters, relative to the system’s last run (or the last update in world.query()).
  • Queries of a system may share components if with / without make 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 ScriptLifecycleMessage stream: 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.random is seeded with 0 in 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.230.24
v:length()1.45µs0.67µs-54%
v:normalize() (returns a value)2.49µs1.04µs-58%
a + b1.57µs1.02µs-35%
v.x554ns426ns-23%
v.x = 2484ns519ns+7%
calling an empty function309ns178ns-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:method reuses 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 Image every frame).
  • Constructors which trust the caller with a type and component id pairing are unsafe, and registrations can’t be forged through reflection.
  • Converting -1 to a NonMaxU32 from 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.
  • mlua 0.12.
  • Only the top level CHANGELOG.md is maintained, it covers every crate.