| Header | Link |
|---|---|
| Purpose | Purpose |
| Why Use Query | Why Use Query |
| Use Cases | Use Cases |
| Example | Example |
| Reference | Reference |
The query system finds sets of nodes by runtime state β tag, name, concrete type, base type, render layer, or spatial region β instead of hardcoding scene paths. Reach for it whenever gameplay needs "all the X" or "the X near Y" rather than a fixed reference: every awake enemy, unclaimed pickups in a room, or the current boss. Queries return NodeID handles; you then act through the normal node and script APIs.
| Situation | Choice | Why | Tradeoff |
|---|---|---|---|
| Manager processes every alive spawned enemy | tag query | Membership changes as nodes spawn, die, or lose tags | Results are a snapshot; each returned ID may become stale later |
| Actor always targets one scene object | injected NodeID, not query |
Dependency is fixed and should fail visibly at authoring time | Scene must wire the ref |
| System owns nodes only inside one room/wave | in_subtree(parent) |
Bounds search to the structural owner | Reparenting changes membership |
| Area damage selects nearby targets | spatial within[...] + tag/type filters |
Expresses both location and role in one selection | Spatial bounds are broad-phase selection; later logic may need exact checks |
| Hot loop repeats one query shape | build/reuse NodeQuery |
Avoids rebuilding query description | Runtime still evaluates current scene membership |
| One match is enough | query_first! |
Stops at the API's first match | Ordering is not a stable gameplay contract; use a fixed ref or explicit ranking when identity matters |
lifecycle!({
fn on_update(&self, ctx: &mut ScriptContext<'_, API>) {
query_each!(ctx.run, all(tags["enemy"], tags["alive"]), |id| {
call_method!(ctx.run, id, method!("on_player_seen"), params![]);
});
}
});Dynamic follow-up calls only reach pub members on the matched scripts:
call_method! needs a pub fn and get_var! / set_var! a pub state field
β see method visibility and
state visibility.
Perro query system returns NodeID lists from scene graph filters.
Use it when direct refs are not enough and you want dynamic lookup.
Runtime module: ctx.run.NodeQuery().
The query! and query_first! macros route to NodeQuery.
Reusable query type: NodeQuery.
This works on top of Perro's object-centric node model. Queries do not replace nodes or script state. Queries help find nodes, then you act through normal node/script APIs.
- Find nodes by name, tag, concrete type, or base type.
- Build dynamic groups without hardcoding scene paths.
- Chain query ->
NodeID->with_node!/with_node_mut!/ script macros. - Keep logic data-driven while runtime access stays ID-based.
query!(ctx.run, expr) -> Vec<NodeID>query!(ctx.run, expr, in_subtree(parent_id)) -> Vec<NodeID>query!(ctx.run, &node_query) -> Vec<NodeID>query!(ctx.run, &node_query, in_subtree(parent_id)) -> Vec<NodeID>query_iter!(ctx.run, expr) -> impl Iterator<Item = NodeID>query_each!(ctx.run, expr, |id| { ... })query_map!(ctx.run, expr, |id| value) -> Vec<T>query_first!(ctx.run, expr) -> Option<NodeID>query_first!(ctx.run, expr, in_subtree(parent_id)) -> Option<NodeID>query_expr!(expr) -> QueryExprquery_builder!(expr) -> NodeQueryquery_builder!(expr, in_subtree(parent_id)) -> NodeQuery
Use query! when the matched IDs are the thing you want.
It returns Vec<NodeID>.
Pick it when you need to sort, count, store, diff, reuse, or loop more than once.
let enemies = query!(ctx.run, all(tags["enemy"], not(tags["dead"])));
if enemies.len() > 20 {
signal_emit!(ctx.run, signal!("too_many_enemies"));
}Use query_iter! when iterator adapters make the code smaller or clearer.
It exists so query results can flow into normal Rust iterator chains.
Pick it for take, filter, filter_map, map, find, any, all, or collect.
let first_three = query_iter!(ctx.run, all(tags["pickup"], not(tags["claimed"])))
.take(3)
.collect::<Vec<_>>();Use query_each! when each match triggers an action and you do not need a result list.
It exists to remove boilerplate for query -> for loop -> side effect.
Pick it for calling methods, setting vars, adding tags, moving nodes, or sending signals per node.
query_each!(ctx.run, all(tags["enemy"], tags["awake"]), |id| {
call_method!(ctx.run, id, method!("on_alarm"), params![]);
});Use query_map! when each matched node becomes one output value.
It exists for query -> transform -> collect.
Pick it for collecting positions, names, script vars, distances, or optional lookups.
let enemy_positions = query_map!(ctx.run, all(tags["enemy"], base_type[Node3D]), |id| {
get_global_pos_3d!(ctx.run, id)
});Use query_first! when one match is enough.
It exists for singleton-style lookup and fallback target lookup.
Pick it for player, camera, boss, selected node, current objective, or first available interactable.
if let Some(target) = query_first!(ctx.run, any(name["Boss"], tags["primary_target"])) {
set_var!(ctx.run, target, var!("tracked"), variant!(true));
}Use query_expr! when you need a QueryExpr value.
It exists so you can compose or conditionally add filters in normal Rust code.
let hidden_filter = query_expr!(not(tags["hidden"]));
let query = NodeQuery::new().where_expr(hidden_filter);Use query_builder! when the same filter is reused.
It exists to turn macro syntax into a reusable NodeQuery.
Pick it for shared helper functions, systems that run every frame, and filters with optional subtree overrides.
let actors = query_builder!(all(base_type[Node3D], tags["actor"]));
let room_actors = query!(ctx.run, &actors, in_subtree(room_root_id));Most helper macros run the same runtime query first.
That query builds an owned Vec<NodeID>.
query!returns thatVec.query_iter!turns thatVecintoVec::into_iter().query_each!loops over that iterator.query_map!maps that iterator into a newVec<T>.query_first!uses a first-match runtime path and avoids building the full result list when the runtime supports it.
Use these helpers to pick the clearest gameplay code shape.
Do not pick query_iter! expecting a streaming scene scan or zero allocation.
Why not make query_iter! fully borrowed?
Because gameplay usually does more runtime work inside the loop.
A borrowed iterator would keep ctx.run borrowed for the whole scan, so this would fail:
for id in query_iter!(ctx.run, all(tags["enemy"])) {
call_method!(ctx.run, id, method!("tick"), params![]);
}Current owned query_iter! releases the runtime borrow before the loop body.
That keeps normal script code usable.
For hot paths, prefer one of these:
- Cache stable
NodeIDs after scene load or spawn. - Re-run queries only when tags, scene chunks, or spawned groups change.
- Use
in_subtree(...)to shrink the scanned node set. - Use rare tags like
boss,quest_target, oractive_enemyto narrow candidates. - Add a dedicated runtime API later if a system needs true early-exit or no-alloc traversal.
Boolean forms:
all(expr1, expr2, ...)any(expr1, expr2, ...)not(expr)
Scope form:
in_subtree(parent_id)
Predicate forms:
name["Player", "Boss"]tags["enemy", "alive"]node_type[Camera3D, MeshInstance3D]base_type[Node3D]layers[1, 2, 3]mask[1]within[origin, size](global-space box;Vector2pair for 2D nodes,Vector3pair for 3D nodes)
- Query filters current runtime node set.
- Return value is
NodeIDhandles only. - Query execution belongs to
ctx.run.NodeQuery(), notctx.run.Nodes(). - You still choose typed access after query:
with_node!for exact typewith_base_node!for base-type access- script access macros for state/method vars
query_each!(ctx.run, all(tags["enemy"], not(tags["dead"])), |id| {
let _ = with_base_node_mut!(ctx.run, Node3D, id, |node| {
node.transform.position.y += 0.1;
});
});let target = query_first!(ctx.run, any(name["Boss"], tags["primary_target"]));
if let Some(id) = target {
set_var!(ctx.run, id, var!("alert"), variant!(true));
}let local_hits = query!(
ctx.run,
all(base_type[Node3D], tags["interactable"]),
in_subtree(zone_root_id)
);Use query_builder! when several systems share the same filter or when gameplay options add extra predicates.
fn actor_query(include_sleeping: bool) -> NodeQuery {
let mut q = query_builder!(all(
base_type[Node3D],
tags["actor"],
layers[1]
));
if !include_sleeping {
q = q.where_expr(query_expr!(not(tags["sleeping"])));
}
q
}
let actors = actor_query(false);
let all_actors = query!(ctx.run, &actors);
let room_actors = query!(ctx.run, &actors, in_subtree(room_root_id));- Passing
&actorsreuses the query without cloning. in_subtree(...)onquery!overrides scope for that call only.- Use this for target systems, editor/tool panels, optional filters, and room-local scans.
let q = NodeQuery::new().where_expr(query_expr!(all(name["Player"])));
let ids = ctx.run.NodeQuery().query(&q);Use query_iter! when iterator shape makes the operation clearer.
This is useful for caps, maps, and chained ID lookups.
let closest_three = query_iter!(ctx.run, all(tags["pickup"], not(tags["claimed"])))
.take(3)
.collect::<Vec<_>>();Use query_map! when the output is data derived from each node.
let enemy_positions = query_map!(ctx.run, all(tags["enemy"], base_type[Node3D]), |id| {
get_global_pos_3d!(ctx.run, id)
});query_each!(ctx.run, all(tags["ally"], tags["alive"]), |id| {
call_method!(ctx.run, id, method!("on_team_buff"), params![5.0_f32]);
});let layer_one = query!(ctx.run, all(base_type[Node2D], layers[1]));
let gameplay = query!(ctx.run, all(base_type[Node3D], layers[1, 2, 3]));
let not_layer_one = query!(ctx.run, all(base_type[Node2D], mask[1]));layers[...]matches 2D/3D nodes whoserender_layersintersects any listed layer.mask[...]rejects 2D/3D nodes whoserender_layersintersects any listed layer.layers[1]means only nodes on render layer 1.layers[1, 2, 3]means nodes on any of layers 1, 2, or 3.mask[1]means all nodes except ones on layer 1.- Combine with
base_type[Node2D]orbase_type[Node3D]to avoid non-spatial nodes.
Use within[origin, size] to match nodes whose global position lies inside an axis-aligned box.
originis the box center in global space.sizeis the full box extent along each axis (half on each side oforigin).- Box edges are inclusive.
- A
Vector2pair matches only 2D nodes; aVector3pair matches only 3D nodes. - Non-spatial nodes (UI, resource, base nodes) never match
within[...], and always matchnot(within[...]).
// All living enemies inside a 10x10x10 box around the player.
let player_pos = get_global_pos_3d!(ctx.run, player_id);
let nearby = query!(ctx.run, all(
tags["enemy"],
not(tags["dead"]),
within[player_pos, Vector3::new(10.0, 10.0, 10.0)]
));
// 2D pickups inside a screen-space region.
let hits = query!(ctx.run, all(
tags["pickup"],
within[Vector2::new(640.0, 360.0), Vector2::new(200.0, 200.0)]
));
// Builder form.
let q = NodeQuery::new()
.tags(["enemy"])
.within(player_pos, Vector3::new(10.0, 10.0, 10.0));
let ids = ctx.run.NodeQuery().query(&q);- Core node/script storage is flat and ID-indexed, so post-query operations stay cheap.
- Query cost depends on match set size and predicate complexity.
- Literal
tags["enemy"]values hash at compile time; dynamic tag expressions hash at runtime. - Literal
node_type[...]andbase_type[...]predicates compile into growable type bitmasks. - Literal
layers[...]andmask[...]predicates compile intoBitMasklayer masks. - Queries with
within[...]snapshot global node positions once up front, so the scan itself stays read-only and parallel-safe. Queries withoutwithin[...]pay nothing for this. - The spatial snapshot refreshes dirty global transforms once, then reads the clean transform cache directly (parallel fill on large scenes, reused buffers, only the dimensions the query tests, subtree-only fill for
in_subtreescopes). - A
within[...]clause also narrows the base-type mask toNode2DorNode3Dautomatically, so non-spatial nodes are pruned before predicate eval. - Type-only boolean groups use mask algebra:
allintersects,anyunions, andnotcomplements. - Runtime query planning reorders predicates by estimated cost and uses tag indexes and type masks when possible.
- Indexed tag candidate sets are intersected smallest-to-largest before full predicate eval.
- Mixed queries like
all(tags["rare"], name["Boss"])scan rare tag candidates, not the full scene. - Large full-tree scans can split work across workers.
- Use the query benchmark when changing query planner/index code:
cargo bench -p perro_runtime --bench query_hotpaths- For hot loops:
- cache stable
NodeIDs when safe - refresh cache on scene changes or lifecycle events
- prefer narrower predicates + subtree limits
- cache stable
- Query miss => empty
VecorNone. - Follow-up ops can fail if target node/script no longer exists.
- This keeps failure tied to actual scene/runtime state, not borrow timing.