Skip to content

Latest commit

Β 

History

History
260 lines (202 loc) Β· 8.93 KB

File metadata and controls

260 lines (202 loc) Β· 8.93 KB

Script Authoring Guide

Purpose

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.

AI Agents: Start With Perro State

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 as self.internal_method(ctx) within a script.
  • Use get_var! / set_var! for dynamic access to pub state fields and call_method! for dynamic calls to pub fn methods in methods!.
  • End each state-access closure before another ctx.run call. 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, or thread_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.

Choose Script State

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 NodeID or Option<NodeID>
  • per-instance assets as typed IDs such as TextureID or MeshID

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.

Choose A Node

ctx.id is the node that owns the current script instance.

Use the narrowest stable way to find another node:

  1. Store a fixed scene dependency as a state NodeID.
  2. Derive a structural dependency from the parent or children.
  3. 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.

Choose Typed Or Dynamic Script Access

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.

Choose A Timer

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.

Keep Runtime Borrows Short

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![]);
}

Script Boundaries

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].

See Also