Use this page as the default standard for Perro gameplay scripts.
Scripts own behavior for one attached node. #[State] holds data for each
instance of that script. Scene files may override state fields before
on_init runs.
Read this guide and the relevant state, lifecycle, and method examples before choosing a gameplay structure. Use Perro's scripting model rather than assuming it follows another Rust engine's ownership model.
&self in a callback does not prevent mutable gameplay state. The runtime
stores a separate #[State] value for each node using the script and provides
access through ctx.run. Start with imports/helper types, one #[State] root,
lifecycle!, then methods!; no handwritten impl is needed for script
behavior.
use perro_api::prelude::*;
#[State]
struct GameState {
#[default = 100]
pub health: i32,
velocity: Vector2,
}
lifecycle!({
fn on_update(&self, ctx: &mut ScriptContext<'_, API>) {
self.internal_method(ctx);
}
});
methods!({
fn internal_method(&self, ctx: &mut ScriptContext<'_, API>) {
with_state_mut!(ctx.run, GameState, ctx.id, |state| {
state.velocity.x = 0.0;
});
}
pub fn externally_callable_method(
&self,
ctx: &mut ScriptContext<'_, API>,
amount: i32,
) {
with_state_mut!(ctx.run, GameState, ctx.id, |state| {
state.health = (state.health + amount).clamp(0, 100);
});
}
});GameState is only a placeholder name. Match it to the script owner and keep
large roots readable with cohesive nested structs. Derive Variant for nested
types that cross a scene or dynamic script boundary. Keep fixed node trees and
reusable composition in .scn files. Use runtime node creation only for
generated leaf data, debug/tooling nodes, and transient objects with no
reusable topology. Load authored .scn prefabs for projectiles, waves,
enemies, and other gameplay objects. See scene templates
and runtime spawning.
See state for persistent/nested state and ownership rules, lifecycle for callback/helper boundaries, and methods for engine-facing vs pure helpers.
Use these defaults for a first pass:
- Use
with_state!/with_state_mut!for known state types on this node or another node. Use direct calls such asself.internal_method(ctx)within a script. - Use
get_var!/set_var!for dynamic access topubstate fields andcall_method!for dynamic calls topub fnmethods inmethods!. - End each state-access closure before another
ctx.runcall. Copy the needed result out; do not add shared mutable wrappers to work around that borrow. - Keep behavior with its owning node, scene coordination in a controller script, and shared constants or pure helpers in plain Rust modules.
- Do not default to
Mutex,RefCell, orthread_local!for gameplay state. Perro's state and method APIs are enough for most games. Add extra ownership or synchronization machinery only for a concrete need those APIs do not meet, such as a library requirement or actual cross-thread data sharing.
This is not a ban on Rust impl blocks for helper types or on legitimate
threading primitives. Check the relevant Perro docs and examples first; do not
introduce that complexity merely because callbacks take &self.
Put a value in #[State] when it belongs to one script instance and must
survive a callback:
- mutable gameplay values such as health, velocity, and mode
- cached runtime values used by later callbacks
- fixed node dependencies as
NodeIDorOption<NodeID> - per-instance assets as typed IDs such as
TextureIDorMeshID
Keep constants as Rust constants. Keep values used by one callback as local variables. Do not use state as a bag for stateless temporary results.
#[derive(Clone, Default, Variant)]
struct CharacterLook {
portrait: TextureID,
materials: Vec<MaterialID>,
}
#[State]
struct PlayerState {
#[default = 100]
#[expose]
pub health: i32,
#[expose]
#[node_ref(Camera3D)]
pub camera: Option<NodeID>,
#[expose]
pub look: CharacterLook,
velocity: Vector3,
}#[expose] organizes what is visible from the editor inspector. Only pub
state fields may be set through scene script_vars; nested members inside
derived custom types ride their pub root field.
Scene asset strings coerce to their typed resource IDs before on_init:
script_vars = {
health = 125,
camera = @MainCamera,
look = {
portrait = "res://textures/player.png",
materials = ["res://materials/body.rue", "res://materials/trim.rue"]
}
}
This path supports TextureID, MaterialID, MeshID, AnimationID,
AnimationTreeID, NavMeshID, and SoundFontID. Coercion recurses through
options, lists, maps, tuples, and custom #[derive(Variant)] values. An absent
field or decode failure keeps the field default. Asset load failure keeps each
resource module's normal nil/failure behavior. Runtime set_var! stays strict:
it accepts the field's actual Variant type, not a resource path string.
ctx.id is the node that owns the current script instance.
Use the narrowest stable way to find another node:
- Store a fixed scene dependency as a state
NodeID. - Derive a structural dependency from the parent or children.
- Query when membership is dynamic or many nodes match.
Do not search by name every frame for a dependency the scene already knows.
#[node_ref(...)] gives the editor and doctor a type hint; the runtime value
remains a NodeID.
Use with_node! for known typed reads and with_node_mut! for known typed
writes.
let speed = with_node!(ctx.run, CharacterBody3D, ctx.id, |node| {
node.velocity.length()
}).unwrap_or_default();
let camera = with_state!(ctx.run, PlayerState, ctx.id, |state| state.camera).unwrap_or_default();
if let Some(camera) = camera {
with_node_mut!(ctx.run, Camera3D, camera, |node| {
node.fov = 70.0;
});
}Use node-base helpers for shared identity, hierarchy, and transform behavior. Use a concrete node type when editing type-specific fields.
let parent_id = get_node_parent_id!(ctx.run, ctx.id);
with_base_node_mut!(ctx.run, Node2D, parent_id, |base| {
base.position.x += 1.0;
});Treat refs as optional at runtime. A target may be absent, removed, or have the wrong type. Skip work on a missing optional ref; gameplay code need not panic or log every optional miss.
Use with_state! and with_state_mut! when the state type is known. Use a
normal Rust helper or direct method call for behavior inside the same script.
Use get_var!, set_var!, and call_method! when the target script or member
is selected at runtime. Dynamic calls return Variant; decode the expected
type at the call site. They reach only members the target declares pub:
get_var! / set_var! need a pub state field and call_method! a pub fn
β see state visibility and
method visibility.
Use methods for a targeted command with known receiver, arguments, and an optional return value. Use signals first for events, fan-out, and loose or cross-scene coordination.
Use a named timer for one-shot delays and cooldown completion. Connect its finished signal to a method. The runtime stores one active timer per name; starting that name again resets its deadline. Use distinct names for work that must run concurrently.
Keep a state clock only when code needs continuous progress each frame, such as an animation blend or HUD countdown.
Runtime helpers borrow ctx.run for the duration of their closure. Never call
another ctx.run API inside a with_state!, with_state_mut!, with_node!, or
with_node_mut! closure.
Copy or clone the required value out, end the closure, and make the next call:
let emit_phase_two = with_state_mut!(ctx.run, BossState, ctx.id, |state| {
let emit = !state.phase_two && state.health <= 50.0;
state.phase_two |= emit;
emit
}).unwrap_or(false);
if emit_phase_two {
signal_emit!(ctx.run, signal!("boss_phase_two"), params![]);
}Split scripts around behavior ownership, not a fixed size rule. Keep one
cohesive behavior with the node that owns it. Move scene-wide orchestration to
a controller script. Put shared constants, math, and pure transforms in normal
Rust modules without #[State].