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
- Making a VM
- Loading
- Spawning
- The frame loop
- Bullets
- Lasers
- Events
- Hit tests
- Targets, inputs and the beat clock
- Snapshots
- Determinism checks
- Freeing
- A complete program
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); // likewiseeval(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 eventsset_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 78cb6be0deb0404dThe simulation is the same on every platform, so the C and JavaScript versions of this program print the same lines.