Perro scripts are Rust files placed in res/**.rs. A behavior script is a small Rust module that usually starts with a single import (use perro_api::prelude::*;) and declares a SelfNodeType, optional #[State] data, the lifecycle hooks (via lifecycle!), and behavior methods (via methods!). Outside those Perro entry points, script code is normal Rust: import your own modules, define structs, use traits, call functions, and split code across files as usual.
Borrow model in practice: Perro centralizes runtime mutation through scoped closures, so most borrow trouble disappears. Main case to avoid is nesting runtime-context operations before prior runtime borrows end.
Core Signatures
// lifecyclefn on_init(&self, ctx: &mut ScriptContext<'_, RT, RS, IP>)fn on_all_init(&self, ctx: &mut ScriptContext<'_, RT, RS, IP>)fn on_update(&self, ctx: &mut ScriptContext<'_, RT, RS, IP>)fn on_fixed_update(&self, ctx: &mut ScriptContext<'_, RT, RS, IP>)fn on_removal(&self, ctx: &mut ScriptContext<'_, RT, RS, IP>) // state / node accesswith_state!(ctx.run, StateType, node_id, |state| -> V { ... }) -> Vwith_state_mut!(ctx.run, StateType, node_id, |state| -> V { ... }) -> Option<V>with_node!(ctx.run, NodeType, node_id, |node| -> V { ... }) -> Vwith_node_mut!(ctx.run, NodeType, node_id, |node| -> V { ... }) -> Option<V>
Current Script Shape
Base script shape:
- single import:
use perro_api::prelude::*; - define
SelfNodeType to declare which node type this script expects - optional
#[State] struct holding per-node instance data lifecycle! for frame/fixed/init/removal hooksmethods! for reusable behavior functions
use perro_api::prelude::*; type SelfNodeType = Node2D; #[derive(Clone, Copy, Variant)]struct AimPoint { target: Vector2,} impl Default for AimPoint { fn default() -> Self { Self { target: Vector2::ZERO, } }} #[State]struct PlayerState { #[default = 100] health: i32, #[default = 220.0] speed: f32, #[default = AimPoint::default()] aim: AimPoint,} lifecycle!({ fn on_update( &self, ctx: &mut ScriptContext<'_, RT, RS, IP>, ) { let dt = delta_time!(ctx.run); let right = key_down!(ctx.ipt, KeyCode::KeyD) as i32; let left = key_down!(ctx.ipt, KeyCode::KeyA) as i32; let jumped = key_pressed!(ctx.ipt, KeyCode::Space); let axis = (right - left) as f32; let speed = with_state!(ctx.run, PlayerState, ctx.id, |state| state.speed); with_node_mut!(ctx.run, SelfNodeType, ctx.id, |node| { node.position.x += axis * speed * dt; if jumped { node.position.y -= 24.0; } }); self.tick_health(ctx, -1); }}); methods!({ fn tick_health( &self, ctx: &mut ScriptContext<'_, RT, RS, IP>, delta: i32, ) { with_state_mut!(ctx.run, PlayerState, ctx.id, |state| { state.health += delta; }); }});
Still Rust
Perro does not turn scripts into a second language. A script file is Rust with a few engine entry points added on top. Use modules, structs, enums, traits, impl blocks, generics, helper functions, and crate dependencies from deps.toml like normal Rust code.
Registration happens when a scene assigns script = "res://path/file.rs". The Perro compiler finds that script, compiles the Rust module, wires its #[State], lifecycle!, and methods!, and uses Variant conversions for cross-script variables, method args, return values, and script_vars.
State
#[State] marks a struct as the per-node persistent state for that script. Per-node instances in scenes instantiate this state. Use #[default = ...] to provide scene or runtime initial values for fields. Fields must be Variant-safe or wrap types that implement conversion to Variant.
Access state from lifecycle/methods with with_state!and with_state_mut!. For dynamic access from the engine or editors use get_var! / set_var!.
Behavior stays on the script type and uses &self; data stays in the runtime-owned #[State] struct. This keeps script code from owning or holding runtime data directly.
Use &self for behavior.
Use with_state_mut! for data writes.
Variant Types
Custom structs and enums used inside #[State], or passed/returned from methods!, must derive Variant. The Variant derive implements conversion between Rust types and Perro's runtime Variant representation so values serialize into scenes, pass through script boundaries, and appear in script_vars. For generic/complex types prefer simple wrapper structs that derive Variant.
use perro_api::prelude::*; #[derive(Clone, Copy, Variant)]struct OrbitGoal { axis: Vector3,} #[derive(Clone, Copy, Variant)]enum Team { Player, Enemy,} #[State]struct SpinnerState { #[default = OrbitGoal { axis: Vector3::new(0.0, 1.0, 0.0) }] orbit_goal: OrbitGoal, #[default = Team::Player] team: Team,}
Note: derive macros require types to be Copy/Clone when used as compact state fields; prefer small POD-like types for state.
Lifecycle
Lifecycle hooks receive ScriptContext. Use ctx.run, ctx.res, ctx.ipt, and ctx.id.
on_initinit one script instance
on_all_initrun after all scripts init for that frame
on_updaterun each frame
on_fixed_updaterun fixed step
on_removalrun before script/node removal
Methods
methods! defines reusable script behavior. Call local behavior directly from the same script.
Use call_method! only for dynamic or cross-script calls. Required leading args are &self and ScriptContext.
Borrow-checker errors around script calls usually mean ctx.run is still borrowed through another runtime macro, or your own local data is borrowed too long.
Store delta time before a state/node closure.
End one ctx.run block before the next runtime call.
project.toml
project.toml configures project metadata and engine defaults: project name, main_scene, icon/splash, graphics/runtime/physics defaults, and platform flags. Paths that start with res:// resolve into the res/asset tree. Perro reads project.toml at build/run to populate runtime settings and packaging metadata.
[project]name = "MyGame"main_scene = "res://main.scn"icon = "res://icon.png"startup_splash = "res://icon.png" [graphics]virtual_resolution = "1920x1080"vsync = falsemsaa = trueocclusion_culling = "gpu"particle_sim_default = "gpu" [runtime]target_fixed_update = 60 [physics]gravity = -9.81coef = 1.0
deps.toml
Use deps.toml to declare additional crates your scripts need. Perro merges deps.toml entries into the generated .perro/scripts/Cargo.toml when you runperro check, perro dev, orperro build. Do not override the engine-managedperro_api or perro_runtime entries.
# deps.toml# Merged into .perro/scripts/Cargo.toml on perro check/dev/build.[dependencies]serde = { version = "1", features = ["derive"] }rand = "0.9"
Scene Authoring
Scenes are .scn files describing a tree of node instances. Each scene entry is a named block that can set optional parent, attach a script, and provide script_vars (which initialize #[State]fields). Inside the entry place the concrete node type block (for example a Sprite2D block that nests aNode2D base block for transform fields).
@root = Player [Player]script = "res://scripts/player.rs"script_vars = { health = 125 speed = 260.0 aim = { target = (12, 0) }} [Node2D] position = (64, 128) rotation = 0.0 scale = (1, 1) z_index = 0 visible = true [/Node2D][/Player]
Scenes Inside Scenes
root_of imports another scene root as a base template. Instance fields override template fields, script_varsmerge by key, and __unset__ removes inherited vars.
[Player]root_of = "res://shared/player_base.scn"script_vars = { speed = 9.5 } [Node2D] position = (16, 48) [/Node2D][/Player]
Runtime node creation (`create_node!`)
Use create_node! from scripts to instantiate nodes at runtime. The macro accepts a node type and init values and returns the created NodeID. This mirrors scene-time construction but runs inside script code. Use the runtime context to attach the node to a parent or to set initial state.
Built-In Nodes
Nodes are data-only. Rendering, physics, resources, input, and script dispatch live in engine systems and contexts.
2D
Node2D, Camera2D, Sprite2D, CollisionShape2D, StaticBody2D, Area2D, RigidBody2D
3D
Node3D, Camera3D, MeshInstance3D, MultiMeshInstance3D, CollisionShape3D, StaticBody3D, Area3D, RigidBody3D, Skeleton3D, ParticleEmitter3D
Lights
AmbientLight3D, Sky3D, RayLight3D, PointLight3D, SpotLight3D
UI
UiBox, UiPanel, UiButton, UiLabel, UiLayout, UiHLayout, UiVLayout, UiGrid