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

Handles (T1d)

An ink Value::Handle is an opaque {kind, id} token — a name for a host resource (an entity, a timer, an audio instance), never a live pointer. The script world holds only the token; dereferencing happens host-side, inside a binding, against a registry bevy-brink owns. See docs/t1d-spec.md §4 for the full design; this page covers the bevy-brink side of it.

HandleKind — the two-halved trait

Each kind of host resource a story can hold a handle to implements one trait, split by which side of the save boundary the knowledge lives on:

#![allow(unused)]
fn main() {
extern crate bevy_ecs;
extern crate bevy_brink;
extern crate serde;
use bevy_ecs::world::World;
use bevy_brink::HandleKind;
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Clone)]
struct TimerSaveKey { remaining_secs: f32 }

struct TimerState { remaining_secs: f32 }

struct Timer;

impl HandleKind for Timer {
    const KIND: &'static str = "Timer";
    type Resource = TimerState;
    type SaveKey = TimerSaveKey;

    fn save_key(&self, _world: &World, res: &TimerState) -> Option<TimerSaveKey> {
        Some(TimerSaveKey { remaining_secs: res.remaining_secs })
    }

    fn resolve(&self, _world: &mut World, key: &TimerSaveKey) -> Option<TimerState> {
        // Timers are resumable: `resolve` doesn't look one up, it rebuilds
        // one from the recipe `save_key` captured.
        Some(TimerState { remaining_secs: key.remaining_secs })
    }
}
}

SaveKey is a reconstruction recipe, not just a foreign key — pick a point on the spectrum per kind: identity lookup (an NPC GUID, an asset path), reconstruction (the timer above — resumable), or deliberate ephemerality. Returning None from save_key for a particular live resource means “this one is meaningless across sessions” — an implementor choice, never a category the spec assigns. resolve returning None means the recipe no longer names anything live (e.g. the NPC despawned) — a normal, expected outcome, never a fault.

Registering a kind

#![allow(unused)]
fn main() {
extern crate bevy_app;
extern crate bevy_ecs;
extern crate bevy_brink;
extern crate serde;
use bevy_app::App;
use bevy_ecs::world::World;
use bevy_brink::{HandleKind, BrinkHandleAppExt};
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Clone)]
struct TimerSaveKey { remaining_secs: f32 }
struct TimerState { remaining_secs: f32 }
struct Timer;
impl HandleKind for Timer {
    const KIND: &'static str = "Timer";
    type Resource = TimerState;
    type SaveKey = TimerSaveKey;
    fn save_key(&self, _world: &World, res: &TimerState) -> Option<TimerSaveKey> {
        Some(TimerSaveKey { remaining_secs: res.remaining_secs })
    }
    fn resolve(&self, _world: &mut World, key: &TimerSaveKey) -> Option<TimerState> {
        Some(TimerState { remaining_secs: key.remaining_secs })
    }
}
fn demo(app: &mut App) {
app.register_handle_kind::<(), Timer>(Timer);
}
}

This inserts Timer’s HandleRegistry<Timer> resource (opaque u64 token ids, live-resource storage) and indexes it under "Timer" in the app’s type-erased HandleKinds<()> — the index everything below (is_valid, save/load, GC) dispatches through when it only has a runtime Value::Handle to work from, not a static K.

Mint a token from inside any binding that has the resource to hand and the loaded Program (to resolve Timer’s name id):

let value = registry.mint_value(program, TimerState { remaining_secs: 30.0 });

mint_value returns None if this compile never interned "Timer" — a kind name only exists in a program’s name table if the source graph actually references Handle<Timer> somewhere (a typed binding signature or annotation).

is_valid — the standard world-query binding

bevy-brink registers is_valid(h) automatically on every BrinkPlugin<M> — no wiring needed. It’s an ordinary bind_brink_query binding (per spec, not a language intrinsic): call it from ink like any other EXTERNAL. Dead, unregistered-kind, and non-handle arguments all just return falseis_valid never faults.

Dead handles and declared failure values

Dereferencing a dead handle is never UB and never a turn fault. A binding looks its token up in the typed HandleRegistry<K> and, on a miss, returns whatever failure value it has chosen to declare — Value::Null, a sentinel int, whatever fits the binding’s contract:

fn npc_name(In((flow, args)): In<BrinkQueryInput>, registry: Res<HandleRegistry<Npc>>, mut commands: Commands) -> Value {
    let Some((_, id)) = args.first().and_then(Value::as_handle) else { return Value::Null };
    match registry.get_or_dead::<()>(id, &mut commands, flow) {
        Some(npc) => Value::String(npc.name.clone().into()),
        None => Value::Null, // the declared failure value for this binding
    }
}

get_or_dead is opt-in telemetry: a dead lookup fires BrinkDeadHandleDeref<M> (entity, kind, id) at the flow entity, so a host can log/count misses without that changing what the binding returns. Use plain HandleRegistry::get for a silent lookup.

Save/load and the rehydration report

bevy-brink persists the token → SaveKey table beside the ink SaveState, and keeps token ids stable across a load — the ink state (which only ever holds {kind, id}) is untouched; only the registry’s right-hand side (the live resources) rebinds:

// Alongside BrinkGlobals::save_state:
let handle_save = save_handles::<()>(app.world());

// Alongside BrinkGlobals::load_state, after the ink SaveState is loaded:
let report = load_handles::<()>(
    app.world_mut(),
    &program,
    &script_save_state,
    &handle_save,
    RehydrationPolicy::Lenient,
)?;

load_handles walks every Value::Handle reachable from script_save_state (including tokens nested in arrays/maps/records/closures) and buckets each one into the returned RehydrationReport:

BucketMeaning
reboundresolved to a live resource, same token id
dead_by_resolvea registered kind, a persisted recipe, but resolve returned None — normal
dead_ephemerala registered kind with no persisted entry — the kind chose ephemerality for this token
dead_by_unregistered_kindthe kind isn’t registered at all — suspicious (integration drift)

Never-fail-load holdsload_handles always returns Ok under the production-default [RehydrationPolicy::Lenient], even with unregistered kinds present (they land in dead_by_unregistered_kind for you to log). RehydrationPolicy::StrictKinds is the dev/CI knob: an unregistered kind fails the whole call loudly instead, so a registration that drifted out of sync with a save file surfaces immediately rather than silently dropping state.

Registry GC at -> DONE

Script state is fully enumerable, so bevy-brink computes the live handle-token set — every token reachable from the shared World’s globals plus every flow’s own local state — at every -> DONE a flow reaches, and drops each registered kind’s unreachable registry entries. No script-side destructors exist or are needed; this is wired automatically by BrinkPlugin<M> (see gc_on_turn_done). A dev-only HandleRetentionMetrics<M> resource tracks each kind’s live count and last-sweep drop count — a diagnostic, not a semantic.

EntityMapper integration

For a kind whose Resource is Entity, cross-references inside a SaveKey may point at entities by their old session’s id. resolve can consult and populate the app-wide HandleEntityRemap resource (implements bevy_ecs::entity::EntityMapper, reset at the start of every load_handles call) to translate between old and new Entity ids as it reconstructs scene-based resources.