Hosting bs in Rust

A Rust game uses the VM directly, through the bs-core crate. This guide walks through its API. embedding.md explains what every host does and why; this page shows how in Rust. examples/rust-macroquad is a small windowed host.

Setup

bs-core isn't on crates.io. Depend on it by path from a checkout of the repo (a git dependency works the same way):

[dependencies]
bs-core = { path = "../bs/crates/bs-core" }

It's no_std with alloc, and its only dependency is libm, so it builds for any target with an allocator, wasm included. It does no I/O: the game reads the files and hands over the text.

Vm isn't Send, since it shares compiled code between snapshots through Rc. Keep it on one thread. In Bevy that means a non-send resource.

Making a VM

use bs_core::{Limits, Vm};

let mut vm = Vm::new(seed);

// Or with limits other than the defaults.
let limits = Limits { max_actors: 8192, ..Limits::default() };
let mut vm = Vm::with_limits(seed, limits);

The seed is a u64 and drives every random draw. The standard library is already loaded.

Then set the cull bounds and any inputs the scripts read:

use bs_core::Bounds;

vm.set_bounds(Some(Bounds::new(-32.0, -32.0, 416.0, 480.0)));
vm.set_input("rank", 0.5);
vm.set_input("difficulty", 2.0);

Loading

load takes source text and returns Result<Loaded, Vec<Error>>. Loaded has the names defined and any warnings. An Error has a message and a span with line and col, and displays as line:col: message:

let src = std::fs::read_to_string(path)?;

match vm.load(&src) {
    Ok(loaded) => {
        for warning in &loaded.warnings {
            eprintln!("{path}:{warning}");
        }
    }

    Err(errors) => {
        for error in &errors {
            eprintln!("{path}:{error}");
        }
    }
}

A failed load changes nothing. For hot reload, run the same code again when the file changes: the VM keeps running the old definitions if the new ones don't load.

vm.names() lists the functions defined so far, the standard library's included, with how many positional parameters each takes. A debug menu can offer them as things to spawn.

Spawning

let boss = vm.spawn("boss", 192.0, 352.0, &[], Some("boss"))?;
let fairy = vm.spawn("fairy", 96.0, 400.0, &[250.0], Some("fairy"))?;

spawn(name, x, y, args, kind) returns Result<ActorId, Error>. args holds one f64 per positional parameter. kind is None for emitter. The script starts on the next tick. ActorId wraps the u32 ids in the output arrays: ActorId(b.id) makes one from an array entry, and .0 goes the other way.

use bs_core::ActorId;

vm.set_position(boss, x, y); // false if the actor is gone
vm.kill(fairy);              // likewise

eval(src, x, y) runs source at once, as a REPL would, and returns an EvalOutcome.

The frame loop

Tick at a fixed 60 Hz from an accumulator, and draw between the last two ticks. This keeps the last tick's ids and positions for that:

use bs_core::{Target, Vm};

const STEP: f64 = 1.0 / 60.0;

struct Game {
    vm: Vm,
    behind: f64,

    // The previous tick's ids and positions, for drawing between ticks.
    prev_ids: Vec<u32>,
    prev_x: Vec<f32>,
    prev_y: Vec<f32>,
}

impl Game {
    fn frame(&mut self, dt: f64, player: Target) {
        // After a stall, drop the backlog rather than run it all at once.
        self.behind = (self.behind + dt).min(0.25);

        while self.behind >= STEP {
            let b = self.vm.bullets();
            self.prev_ids.clear();
            self.prev_ids.extend_from_slice(b.ids);
            self.prev_x.clear();
            self.prev_x.extend_from_slice(b.x);
            self.prev_y.clear();
            self.prev_y.extend_from_slice(b.y);

            self.vm.set_targets(&[player]);
            self.vm.tick();
            self.after_tick();
            self.behind -= STEP;
        }

        self.draw((self.behind / STEP) as f32);
    }

    fn draw(&self, alpha: f32) {
        let b = self.vm.bullets();
        let mut j = 0;

        // Survivors keep their order and new actors come last, so one pass matches them up.
        for i in 0..b.len() {
            while j < self.prev_ids.len() && self.prev_ids[j] != b.ids[i] {
                j += 1;
            }

            let (x, y) = if j < self.prev_ids.len() {
                let (px, py) = (self.prev_x[j], self.prev_y[j]);
                j += 1;
                (px + (b.x[i] - px) * alpha, py + (b.y[i] - py) * alpha)
            } else {
                (b.x[i], b.y[i])
            };

            draw_sprite(x, y, b.heading[i], b.kind[i]);
        }
    }
}

after_tick handles the events and does the hit tests, as below. The macroquad example uses the same accumulator, without the cap or the interpolation.

Bullets

vm.bullets() returns Bullets, a struct of slices: ids, x, y, heading, speed, age, kind, flags, delay and tags. Iterating it gives a Bullet per actor, with the same fields and is_host() and can_hit():

for b in vm.bullets() {
    let name = vm.symbol_name(b.kind);

    if b.is_host() {
        draw_enemy(b.x, b.y, name);
    } else if b.delay > 0.0 {
        draw_warning(b.x, b.y, name, b.delay);
    } else {
        draw_bullet(b.x, b.y, b.heading, name);
    }
}

The flag constants are Bullets::HOST, SCRIPTED, CAN_HIT and CANCELLABLE, for tests like b.flags & Bullets::CANCELLABLE != 0. symbol_name gives a kind's name. A kind keeps its number for the life of the VM, so cache what it maps to, in a HashMap<u32, Sprite> say, rather than comparing strings every frame. vm.tag_names() gives the names behind the bits in tags.

Lasers

vm.lasers() returns Lasers, with slices ids, kind, width, phase, progress, flags, tags, points_start, points_len and points. polyline(i) gives laser i's points as x, y pairs:

use bs_core::Lasers;

let lasers = vm.lasers();
for i in 0..lasers.len() {
    let points = lasers.polyline(i);
    let width = lasers.width[i];

    match lasers.phase[i] {
        Lasers::WARN => draw_telegraph(points, lasers.progress[i]),
        Lasers::ACTIVE => draw_beam(points, width),
        _ => draw_fading_beam(points, width, 1.0 - lasers.progress[i]),
    }
}

A straight laser has two points. A curvy one has at most bs_core::MAX_SEGMENTS + 1, which is 257. rendering.md covers drawing kinds, warnings and laser phases.

Events

vm.events() returns the queued Events, oldest first. vm.clear_events() empties the queue:

use bs_core::Event;

for event in vm.events() {
    match event {
        Event::Emit { name, x, y, value, .. } => {
            play_cue(vm.symbol_name(*name), *x, *y, *value);
        }

        Event::Cancelled { x, y, .. } => items.push((*x, *y)),
        Event::Despawned { id, reason, .. } if enemies.contains(id) => enemy_gone(*id, *reason),
        Event::Error { error, .. } => eprintln!("{error}"),
        _ => {}
    }
}
vm.clear_events();

enemies is the game's set of the ActorIds it spawned. Bullets and lasers get despawned events too, and a laser that fades out is DespawnReason::Finished like an enemy whose script ended, so check the id.

vm.events() borrows the VM, so a response that changes it, like spawning or killing, has to wait until the loop ends: collect what to do first. events_dropped() says how many events were lost to the queue limit since the last clear. set_spawn_events(true) turns on Event::Spawned. DespawnReason::name() gives a reason as a lowercase word, for logs.

Hit tests

Test after each tick, against the tick's positions. Collect the ids that hit, then kill them, since kill needs the VM the bullet slices borrow:

let struck: Vec<u32> = vm
    .bullets()
    .iter()
    .filter(|b| b.can_hit())
    .filter(|b| {
        let (dx, dy) = (f64::from(b.x) - px, f64::from(b.y) - py);
        let reach = radius(vm.symbol_name(b.kind)) + PLAYER_RADIUS;
        dx * dx + dy * dy < reach * reach
    })
    .map(|b| b.id)
    .collect();

for &id in &struck {
    vm.kill(ActorId(id));
}

Lasers test the same way against their polylines, with width[i] / 2 as the radius and flags[i] & Bullets::CAN_HIT to see whether they're active. The program at the end has the segment test.

Targets, inputs and the beat clock

use bs_core::{Beat, Target};

// One player, moving (vx, vy) units per tick, for `(aim :lead ...)`.
vm.set_targets(&[Target::new(x, y, vx, vy)]);

// Two players. Each shooter aims at the nearer one.
vm.set_targets(&[Target::at(x1, y1), Target::at(x2, y2)]);

vm.set_input("boss-hp", hp / max_hp);
vm.clear_input("boss-hp");

vm.set_beat(Some(Beat { bpm: 140.0, offset: song_start_tick }));
vm.set_beat(None);

vm.cancel(&["phase-one"]); // the cancellable shots tagged 'phase-one
vm.cancel(&[]);            // every cancellable shot: a bomb
vm.clear();                // every actor, with no events

set_bounds(None) turns culling off. cancel returns how many actors it removed.

Snapshots

A clone is a snapshot. It shares the compiled code with the original, so it's cheap, and going back is swapping it in:

let saved = vm.clone();
// ... later
vm = saved.clone();
vm.clear_events();

Byte snapshots restore on any platform, into a VM with the same sources loaded in the same order:

let mut bytes = vec![0; vm.snapshot_size()];
let len = vm.write_snapshot(&mut bytes)?;
bytes.truncate(len);

// ... later, in this VM or another
vm.restore(&bytes)?;

write_snapshot fails if the buffer is too small, and doesn't allocate. restore returns an Error and changes nothing if the bytes are damaged, from another SIM_VERSION or snapshot format, or from a different program.

Determinism checks

vm.state_hash() hashes the whole simulation. bs_core::SIM_VERSION is the simulation version to store with replays. TraceHasher builds the trace hash that bs test and the conformance cases pin: fold the events after every host call and tick with events, the results of calls with load, spawn and the rest, and a checkpoint wherever the actors should be pinned. It needs spawned events on.

Freeing

Drop the Vm. A clone is a VM of its own, and drops separately.

A complete program

This runs the pattern from embedding.md for ten seconds, headless, against a player who sweeps across the bottom of the field. It handles events, hit-tests bullets and lasers, kills the bullets that hit, takes a snapshot at tick 300 and rewinds to it at the end.

//! A headless bs host: runs a boss and a fairy for ten seconds against a player who sweeps left
//! and right, and reports what happened.

use bs_core::{ActorId, Bounds, Bullets, Event, Target, Vm};

const PATTERN: &str = r#"
(defn fairy [dir]
  (forever
    (fan 3 :dir dir :speed 2 :kind 'rice)
    (wait 30)))

(defn boss []
  (emit 'phase 1)
  (until (< (input 'boss-hp 1) 0.5)
    (forever
      (ring 12 :dir (* (age) 3) :speed 1.5 :kind 'orb :tags 'phase-one)
      (wait 12)))
  (cancel :tags 'phase-one)
  (emit 'phase 2)
  (forever
    (laser :dir (aim) :warn 30 :active 40 :fade 10 :kind 'beam)
    (fan 5 :speed 3 :kind 'rice)
    (wait 60)))
"#;

const PLAYER_RADIUS: f64 = 2.5;

/// Hit radii are the host's choice, by kind.
fn radius(kind: &str) -> f64 {
    match kind {
        "orb" => 6.0,
        _ => 3.0,
    }
}

/// The squared distance from (px, py) to a polyline of x, y pairs.
fn distance2(points: &[f32], px: f64, py: f64) -> f64 {
    let mut best = f64::INFINITY;

    for pair in points.windows(4).step_by(2) {
        let (ax, ay) = (f64::from(pair[0]), f64::from(pair[1]));
        let (vx, vy) = (f64::from(pair[2]) - ax, f64::from(pair[3]) - ay);
        let len2 = vx * vx + vy * vy;

        let t = if len2 > 0.0 {
            (((px - ax) * vx + (py - ay) * vy) / len2).clamp(0.0, 1.0)
        } else {
            0.0
        };

        let (dx, dy) = (ax + vx * t - px, ay + vy * t - py);
        best = best.min(dx * dx + dy * dy);
    }

    best
}

fn main() {
    let mut vm = Vm::new(1);
    vm.set_bounds(Some(Bounds::new(-32.0, -32.0, 416.0, 480.0)));
    vm.set_input("rank", 0.5);

    if let Err(errors) = vm.load(PATTERN) {
        for error in errors {
            eprintln!("{error}");
        }
        return;
    }

    let spawned = vm
        .spawn("boss", 192.0, 352.0, &[], Some("boss"))
        .and_then(|_| vm.spawn("fairy", 96.0, 400.0, &[250.0], Some("fairy")));
    if let Err(error) = spawned {
        eprintln!("{error}");
        return;
    }

    let (mut hits, mut items) = (0, 0);
    let mut saved = None;

    for t in 0..600 {
        // The player sweeps right for two seconds, then waits for two. Inputs for a tick go in
        // before it runs.
        let (px, py) = (132.0 + f64::from((t % 240).min(120)), 64.0);
        vm.set_targets(&[Target::at(px, py)]);
        vm.set_input("boss-hp", 1.0 - f64::from(t) / 600.0);
        vm.tick();

        for event in vm.events() {
            match event {
                Event::Emit { name, value, .. } => {
                    let name = vm.symbol_name(*name);
                    let value = value.unwrap_or(0.0);
                    println!("tick {}: {name} {value}", vm.tick_count());
                }

                Event::Cancelled { .. } => items += 1,
                Event::Error { error, .. } => eprintln!("{error}"),
                _ => {}
            }
        }
        vm.clear_events();

        // Collect the hits first: killing changes the VM, which the bullet view borrows.
        let struck: Vec<u32> = vm
            .bullets()
            .iter()
            .filter(|b| b.can_hit())
            .filter(|b| {
                let (dx, dy) = (f64::from(b.x) - px, f64::from(b.y) - py);
                let reach = radius(vm.symbol_name(b.kind)) + PLAYER_RADIUS;
                dx * dx + dy * dy < reach * reach
            })
            .map(|b| b.id)
            .collect();

        for &id in &struck {
            vm.kill(ActorId(id));
        }
        hits += struck.len();

        // A laser stays where it is, and hits on every tick the player is in it.
        let lasers = vm.lasers();
        for i in 0..lasers.len() {
            let reach = f64::from(lasers.width[i]) / 2.0 + PLAYER_RADIUS;
            let can_hit = lasers.flags[i] & Bullets::CAN_HIT != 0;
            if can_hit && distance2(lasers.polyline(i), px, py) < reach * reach {
                hits += 1;
            }
        }

        if vm.tick_count() == 300 {
            saved = Some(vm.clone());
        }
    }

    let (bullets, lasers) = (vm.bullets().len(), vm.lasers().len());
    let (tick, hash) = (vm.tick_count(), vm.state_hash());
    println!("{tick} ticks: {bullets} bullets, {lasers} lasers, {hits} hits, {items} items");
    println!("hash {hash:016x}");

    if let Some(saved) = saved {
        vm = saved;
        vm.clear_events();

        let (tick, hash) = (vm.tick_count(), vm.state_hash());
        println!("rewound to tick {tick}, hash {hash:016x}");
    }
}

It prints:

tick 1: phase 1
tick 302: phase 2
600 ticks: 27 bullets, 1 lasers, 72 hits, 173 items
hash b90b48c2cbbfbfe8
rewound to tick 300, hash 78cb6be0deb0404d

The simulation is the same on every platform, so the C and JavaScript versions of this program print the same lines.