Perro scripts are authored directly in Rust. This page breaks down the script example so each part is clear: node target type, state, lifecycle entry points, methods, and runtime access patterns.

Full Example

use perro_context::prelude::*;use perro_core::prelude::*;use perro_ids::prelude::*;use perro_modules::prelude::*;use perro_scripting::prelude::*; type SelfNodeType = Node2D; #[State]pub struct ExampleState {    #[default = 5]    count: i32,} const SPEED: f32 = 5.0; lifecycle!({    fn on_init(&self, ctx: &mut RuntimeContext<'_, R>, self_id: NodeID) {        let count = with_state!(ctx, ExampleState, self_id, |state| state.count)            .unwrap_or_default();        log_info!(count);    }     fn on_all_init(&self, _ctx: &mut RuntimeContext<'_, R>, _self_id: NodeID) {}     fn on_update(&self, ctx: &mut RuntimeContext<'_, R>, self_id: NodeID) {        let dt = delta_time!(ctx);        self.bump_count(ctx, self_id);         let _ = with_node_mut!(ctx, SelfNodeType, self_id, |node| {            node.position.x += dt * SPEED;        }).unwrap_or_default();    }     fn on_fixed_update(&self, _ctx: &mut RuntimeContext<'_, R>, _self_id: NodeID) {}     fn on_removal(&self, _ctx: &mut RuntimeContext<'_, R>, _self_id: NodeID) {}}); methods!({    fn bump_count(&self, ctx: &mut RuntimeContext<'_, R>, self_id: NodeID) {        let _ = with_state_mut!(ctx, ExampleState, self_id, |state| {            state.count += 1;        }).unwrap_or_default();    }     fn test(&self, ctx: &mut RuntimeContext<'_, R>, self_id: NodeID, param1: i32, msg: &str) {        log_info!(param1);        log_info!(msg);        self.bump_count(ctx, self_id);    }});

1. Imports + SelfNodeType

Prelude imports give access to runtime types and helpers. SelfNodeType sets the node type this script is attached to.

use perro_context::prelude::*;use perro_core::prelude::*;use perro_ids::prelude::*;use perro_modules::prelude::*;use perro_scripting::prelude::*; type SelfNodeType = Node2D;

2. State + Constants

#[State] defines script-owned data. Constants stay in plain Rust and are used by lifecycle and method logic.

#[State]pub struct ExampleState {    #[default = 5]    count: i32,} const SPEED: f32 = 5.0;

Fields with #[default = ...] are initialized with that value when the script instance is created.

In with_state! and with_state_mut!, the second argument is your state type (here ExampleState). That is how runtime access is typed.

#[State]pub struct ExampleState {    #[default = 5]    count: i32,    #[default = true]    enabled: bool,} let count = with_state!(ctx, ExampleState, self_id, |state| state.count)    .unwrap_or_default(); with_state_mut!(ctx, ExampleState, self_id, |state| {    if state.enabled {        state.count += 1;    }}).unwrap_or_default();

3. Lifecycle Block

Engine entry points live in one place. Use on_init for setup, on_update for per-frame behavior, on_fixed_update for fixed-step logic, and on_removal for cleanup.

lifecycle!({    fn on_init(&self, ctx: &mut RuntimeContext<'_, R>, self_id: NodeID) {}    fn on_all_init(&self, _ctx: &mut RuntimeContext<'_, R>, _self_id: NodeID) {}    fn on_update(&self, ctx: &mut RuntimeContext<'_, R>, self_id: NodeID) {}    fn on_fixed_update(&self, _ctx: &mut RuntimeContext<'_, R>, _self_id: NodeID) {}    fn on_removal(&self, _ctx: &mut RuntimeContext<'_, R>, _self_id: NodeID) {}});

on_init

Runs when this script instance is created. Use it for one-time setup and initial state reads.

on_all_init

Runs after every script has completed on_init. Use it when setup depends on other scripts already existing.

on_update

Runs every frame. Put most gameplay behavior here (movement, checks, interactions, effects).

on_fixed_update

Runs on a fixed timestep. Use it for deterministic logic and simulation-style updates.

on_removal

Runs when the script is detached or the node is removed. Use it for cleanup and teardown.

4. Methods Block

Put regular script behavior here. For local behavior, call methods directly with self.method(...).

methods!({    fn bump_count(&self, ctx: &mut RuntimeContext<'_, R>, self_id: NodeID) {        with_state_mut!(ctx, ExampleState, self_id, |state| {            state.count += 1;        });    }     fn test(&self, ctx: &mut RuntimeContext<'_, R>, self_id: NodeID, param1: i32, msg: &str) {        log_info!(param1);        log_info!(msg);        self.bump_count(ctx, self_id);    }});

Runtime Access Patterns

State and node mutations are done in-place through closure helpers. This keeps data access explicit while still feeling ergonomic in normal Rust code. For local script behavior, prefer direct method calls over self-targeted indirection.

with_state!(ctx, ExampleState, self_id, |state| state.count).unwrap_or_default();with_state_mut!(ctx, ExampleState, self_id, |state| { state.count += 1; }).unwrap_or_default(); with_node!(ctx, SelfNodeType, self_id, |node| node.position.x).unwrap_or_default();with_node_mut!(ctx, SelfNodeType, self_id, |node| { node.position.x += 1.0; }).unwrap_or_default(); self.bump_count(ctx, self_id);

Read vs Mutate Access

with_state! and with_node! are read access helpers. with_state_mut! and with_node_mut! allow mutation. Mutable closures can also return values when you need computed results back, or return nothing if you only need in-place mutation.

Reads

let count = with_state!(ctx, ExampleState, self_id, |state| {    state.count}).unwrap_or_default(); let x = with_node!(ctx, SelfNodeType, self_id, |node| {    node.position.x}).unwrap_or_default();

Mutate + Return One Value

let new_count = with_state_mut!(ctx, ExampleState, self_id, |state| {    state.count += 1;    state.count}).unwrap_or_default(); let next_x = with_node_mut!(ctx, SelfNodeType, self_id, |node| {    node.position.x += 10.0;    node.position.x}).unwrap_or_default();

Mutate Without Returning Data

with_state_mut!(ctx, ExampleState, self_id, |state| {    state.count += 1;}).unwrap_or_default(); with_node_mut!(ctx, SelfNodeType, self_id, |node| {    node.position.x += 1.0;}).unwrap_or_default();

Mutate + Return Tuple

let (count, doubled) = with_state_mut!(ctx, ExampleState, self_id, |state| {    state.count += 1;    (state.count, state.count * 2)}).unwrap_or((0, 0)); let (x, y) = with_node_mut!(ctx, SelfNodeType, self_id, |node| {    node.position.x += 4.0;    node.position.y += 2.0;    (node.position.x, node.position.y)}).unwrap_or((0.0, 0.0));