Perro

Rust Game Engine

What Perro provides: a Rust-first workflow for building scenes, gameplay, and assets with one consistent runtime model.

Scripting model

Rust scripts with engine context in every hook

Use lifecycle, methods, state, and Variant integration while keeping normal Rust modules and traits.

use perro_api::prelude::*; type SelfNodeType = Node2D; #[derive(Clone, Copy, Variant)]struct MoveTuning {    sprint: f32,} #[State]struct ExampleState {    #[default = 5]    count: i32,     #[default = MoveTuning { sprint: 1.8 }]    tuning: MoveTuning,} lifecycle!({    fn on_update(        &self,        ctx: &mut ScriptContext<'_, RT, RS, IP>,    ) {        let right = key_down!(ctx.ipt, KeyCode::KeyD) as i32;        let left = key_down!(ctx.ipt, KeyCode::KeyA) as i32;        let axis = (right - left) as f32;        let dt = delta_time!(ctx.run);         with_node_mut!(ctx.run, SelfNodeType, ctx.id, |node| {            node.position.x += axis * dt * 5.0;        });    }});

Behavior and data are separated

Script behavior lives on shared script type methods, while each node instance owns distinct runtime state.

Scoped state access prevents borrow traps

with_state! and with_state_mut! scope data borrows so runtime operations can proceed safely after closure ends.

Dynamic script vars stay accessible

get_var! and set_var! expose script_vars values from scene data and runtime calls without coupling behavior ownership.

Cross-script calls stay explicit

call_method! + params! + Variant conversion make runtime dispatch deterministic and inspectable.

methods!({    fn hit_enemy(        &self,        ctx: &mut ScriptContext<'_, RT, RS, IP>,        enemy_id: NodeID,    ) {        call_method!(ctx.run, enemy_id, func!("take_damage"), params![10_i32]);        set_var!(ctx.run, enemy_id, var!("alerted"), variant!(true));        let health = get_var!(ctx.run, enemy_id, var!("health"));         signal_emit!(ctx.run, signal!("enemy_hit"), params![enemy_id, health]);    }});

Global Signal System

Emit named events globally. Connect listeners by signal id and method id.

Emitter
📡
Receiver
📻
methods!({    fn send_ping(        &self,        ctx: &mut ScriptContext<'_, RT, RS, IP>,    ) {        signal_emit!(ctx.run, signal!("ping"));    }});
lifecycle!({    fn on_init(        &self,        ctx: &mut ScriptContext<'_, RT, RS, IP>,    ) {        signal_connect!(ctx.run, ctx.id, signal!("ping"), func!("on_ping"));    }});

Query system

Find nodes by name, tag, type, or base type

name[...]

Match one or more scene node names.

tags[...]

Match nodes carrying runtime tags.

is[...] / is_type[...]

Match exact concrete node types.

base[...] / base_type[...]

Match nodes that inherit from a base type.

let enemies = query!(    ctx,    all(        any(tags["enemy"], name["Boss"]),        base_type[Node3D],        not(tags["dead"])    ),    in_subtree(arena_root)); for id in enemies {    let _ = with_base_node_mut!(ctx, Node3D, id, |node| {        node.position.y += 0.1;    });}