Scripts
Back to DocsPerro 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));