Skip to content

Script cookbook ​

Every recipe is a finished script. The code on this page is pulled straight from the tests (crates/kvas_engine/tests/cookbook.rs): it is compiled and run in CI, so it cannot go stale.

To use a recipe: copy the struct into <project>/scripts/src/, add the type to register_scripts!(...) and drag the script onto an object. Number fields that default to 0 are set in the Inspector (speed and stop_distance for Chaser).

Spinning with an Inspector setting ​

A field declared in fields() shows in the Inspector and is saved in the scene.

rust
/// Spins the object around Y. `speed` is shown in the Inspector and saved in the scene.
#[derive(Default)]
pub struct Spinner {
    speed: f32,
}

impl Script for Spinner {
    fn update(&mut self, ctx: &mut Ctx) {
        ctx.rotate(Vec3::new(0.0, self.speed * ctx.dt(), 0.0));
    }

    fn fields(&mut self) -> Vec<Field<'_>> {
        vec![Field::f32("speed", &mut self.speed)]
    }
}

A chaser ​

Walks to the object named Player, faces it and stops at a distance. look_at_flat changes only the heading; move_towards returns true when the point is reached.

rust
/// Walks towards the object named "Player" and stops at `stop_distance`.
#[derive(Default)]
pub struct Chaser {
    speed: f32,
    stop_distance: f32,
}

impl Script for Chaser {
    fn update(&mut self, ctx: &mut Ctx) {
        let Some(player) = ctx.find("Player") else { return };
        let Some(target) = ctx.position_of(player) else { return };
        ctx.look_at_flat(target);
        if ctx.distance_to(player).is_some_and(|d| d > self.stop_distance) {
            ctx.move_towards(target, self.speed);
        }
    }

    fn fields(&mut self) -> Vec<Field<'_>> {
        vec![Field::f32("speed", &mut self.speed), Field::f32("stop_distance", &mut self.stop_distance)]
    }
}

Patrol between points ​

Keeps the route and the index of the next point. State (next) is reset by a hot reload - that is fine.

rust
/// Walks between the points of `route` in a loop.
#[derive(Default)]
pub struct Patrol {
    route: Vec<Vec3>,
    next: usize,
    speed: f32,
}

impl Script for Patrol {
    fn start(&mut self, ctx: &mut Ctx) {
        if self.route.is_empty() {
            // Default route: a square around the start point.
            let o = ctx.position();
            self.route = vec![o + Vec3::X * 4.0, o + Vec3::new(4.0, 0.0, 4.0), o + Vec3::Z * 4.0, o];
        }
    }

    fn update(&mut self, ctx: &mut Ctx) {
        let target = self.route[self.next % self.route.len()];
        ctx.look_at_flat(target);
        if ctx.move_towards(target, self.speed.max(1.0)) {
            self.next += 1;
        }
    }
}

A ball spawner ​

Timer::repeating counts the interval, Rng gives repeatable randomness, despawn_after cleans up.

rust
/// Spawns a small physical ball every `interval` seconds at a random spot around the object.
#[derive(Default)]
pub struct BallSpawner {
    rng: Rng,
    timer: Option<Timer>,
}

impl Script for BallSpawner {
    fn start(&mut self, _ctx: &mut Ctx) {
        self.rng = Rng::new(7);
        self.timer = Timer::repeating(0.5);
    }

    fn update(&mut self, ctx: &mut Ctx) {
        let Some(timer) = self.timer.as_mut() else { return };
        if timer.tick_secs(ctx.dt()).just_finished() {
            let at = ctx.position() + self.rng.on_circle(3.0) + Vec3::Y * 4.0;
            let ball = ctx.spawn_primitive("Ball", "sphere", at);
            ctx.set_color(ball, [self.rng.f32(), self.rng.f32(), self.rng.f32(), 1.0]);
            ctx.despawn_after(ball, 10.0);
        }
    }
}

A third-person camera ​

Exponential smoothing 1 - exp(-k * dt) feels the same at 30 and 144 FPS.

rust
/// Smoothly follows the object named "Player" from behind and above.
#[derive(Default)]
pub struct FollowCamera {
    offset: Vec3,
}

impl Script for FollowCamera {
    fn start(&mut self, _ctx: &mut Ctx) {
        if self.offset == Vec3::ZERO {
            self.offset = Vec3::new(0.0, 4.0, 7.0);
        }
    }

    fn update(&mut self, ctx: &mut Ctx) {
        let Some(player) = ctx.find("Player") else { return };
        let Some(target) = ctx.position_of(player) else { return };
        let wanted = target + self.offset;
        let here = ctx.position();
        // Exponential smoothing is frame-rate independent: the same feel at 30 and 144 FPS.
        let smooth = here.lerp(wanted, 1.0 - (-6.0 * ctx.dt()).exp());
        ctx.set_position(smooth);
        ctx.look_at(target);
    }
}

A coin ​

Checks what is nearby by the player tag (add the tag in the Inspector or with ctx.add_tag).

rust
/// A coin: the player collects it by touching it.
#[derive(Default)]
pub struct Coin;

impl Script for Coin {
    fn update(&mut self, ctx: &mut Ctx) {
        ctx.rotate(Vec3::new(0.0, 120.0 * ctx.dt(), 0.0));
        let hits = ctx.overlap_sphere(ctx.position(), 0.8);
        if hits.into_iter().any(|e| ctx.has_tag(e, "player")) {
            ctx.hud_message("+1 coin", 1.0);
            ctx.despawn_self();
        }
    }
}

Shooting with a ray ​

raycast returns a RayHit. Push the hit body through its RigidBody component.

rust
/// Press the left mouse button to shoot a ray forward and push what it hits.
#[derive(Default)]
pub struct Shooter {
    force: f32,
}

impl Script for Shooter {
    fn update(&mut self, ctx: &mut Ctx) {
        if !ctx.mouse_pressed(MouseButton::Left) {
            return;
        }
        let origin = ctx.position();
        let direction = ctx.forward();
        if let Some(hit) = ctx.raycast(origin, direction, 50.0) {
            ctx.hud_message("hit!", 0.5);
            // Apply the impulse to the hit object through the world.
            if let Some(body) = ctx.get_mut_of::<RigidBody>(hit.entity) {
                body.impulse += direction * self.force.max(5.0);
            }
        }
    }
}

Messages: a button and a door ​

Scripts do not know each other: the button broadcasts a signal, the door listens. Signals are delivered next frame.

rust
/// A door that opens when somebody broadcasts the "open_door" signal.
#[derive(Default)]
pub struct Door {
    open: bool,
}

impl Script for Door {
    fn update(&mut self, ctx: &mut Ctx) {
        if !ctx.signals("open_door").is_empty() {
            self.open = true;
        }
        let target_y = if self.open { 3.0 } else { 0.0 };
        let y = ctx.transform().position.y;
        ctx.transform_mut().position.y = approach(y, target_y, 2.0 * ctx.dt());
    }
}

/// A button that sends the signal after being touched.
#[derive(Default)]
pub struct DoorButton;

impl Script for DoorButton {
    fn update(&mut self, ctx: &mut Ctx) {
        if ctx.action_pressed("Interact") {
            let _ = ctx.emit("open_door", json!({ "from": "button" }));
        }
    }
}

Vertex deformation and shared variables ​

make_editable turns a built-in primitive into an editable mesh, editable(e) gives access to the vertices: wave, twist, taper, bend, noise_displace, smooth, inflate. set_global / global_f32 / add_global are variables shared by all scripts.

rust
/// Wobbles the vertices of a sphere like jelly and counts frames in a shared variable.
#[derive(Default)]
pub struct Jelly {
    ready: bool,
}

impl Script for Jelly {
    fn update(&mut self, ctx: &mut Ctx) {
        let me = ctx.entity;
        if !self.ready {
            self.ready = ctx.make_editable(me);
        }
        let t = ctx.elapsed();
        if let Some(mesh) = ctx.editable(me) {
            // Start from the original shape every frame, then bend it: cheap and drift-free.
            let mut fresh = kvas_engine::mesh_edit::EditableMesh::from_mesh(&kvas_engine::mesh::MeshData::primitive("sphere").unwrap()).unwrap();
            fresh.wave(Vec3::Y, Vec3::X, 0.1, 0.6, t);
            fresh.taper(Vec3::Y, 1.0, 1.0 + 0.2 * (t * 3.0).sin());
            mesh.vertices = fresh.vertices;
            mesh.changed();
        }
        // Any script can read this: `ctx.global_f32("jelly_frames", 0.0)`.
        ctx.add_global("jelly_frames", 1.0);
    }
}

How to test a script without the editor ​

Scripts are ordinary types, so they run inside a test - exactly what this cookbook does:

rust
let host = ScriptHost::from_defs(vec![ScriptDef::of::<Chaser>("Chaser")]).unwrap();
let mut runtime = Runtime::new(scene, Some(host));
for _ in 0..240 {
    runtime.tick(1.0 / 60.0);
}
assert!(runtime.scene.world.get::<Transform>(chaser).unwrap().position.x > 5.0);

Kvas Engine