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.
/// 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.
/// 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.
/// 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.
/// 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.
/// 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).
/// 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.
/// 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.
/// 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.
/// 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:
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);