Hosting bs in JavaScript
Browser and Node games use the VM through the wasm package built from crates/bs-wasm. This
guide walks through its API. embedding.md explains what every host does and
why; this page shows how in JavaScript. examples/wasm is a small
browser host.
- Building the package
- Loading the module
- Making a VM
- Loading
- Spawning
- The frame loop
- Bullets
- Lasers
- Events
- Hit tests
- Targets, inputs and the beat clock
- Snapshots
- Determinism checks
- A complete program
Building the package
wasm-pack build crates/bs-wasm --target web --release --out-dir /path/to/game/pkg --out-name bsA relative --out-dir is taken from the crate's folder, not the current one. The package has
bs.js, bs_bg.wasm, and bs.d.ts with the TypeScript types for everything below. The web
target works in Node too.
Two cargo features change what's in the package. They go after --, as in the testbed's
npm run wasm. fmt, on by default, adds format(src, width), the formatter bs fmt uses;
--no-default-features leaves it out for a smaller module. dodge adds the dodge bot the
testbed runs in a worker.
Loading the module
In a browser, init fetches bs_bg.wasm from beside bs.js:
import init, { Flag, Vm } from './pkg/bs.js';
await init();In Node, read the bytes and pass them in:
import { readFile } from 'node:fs/promises';
import init, { Flag, Vm } from './pkg/bs.js';
await init({ module_or_path: await readFile(new URL('./pkg/bs_bg.wasm', import.meta.url)) });Making a VM
const vm = new Vm(seed);
vm.setBounds(-32, -32, 416, 480);
vm.setInput('rank', 0.5);
vm.setInput('difficulty', 2);The seed here is a 32-bit unsigned integer; Rust and C take 64 bits. The VM gets the default limits, and there's no way to change them from JavaScript. The standard library is already loaded.
A Vm lives in wasm memory, not on the JavaScript heap. Call vm.free() when done with it. The
garbage collector frees one eventually, but late, and a rewind history of snapshots adds up.
Vm also has [Symbol.dispose], for using.
Loading
load returns a LoadResult. It has ok, and either the names defined and any warnings,
or the errors. Each error and warning has a message, line and col:
const result = vm.load(src);
if (result.ok) {
for (const w of result.warnings) console.warn(`${path}:${w.line}:${w.col}: ${w.message}`);
} else {
for (const e of result.errors) console.error(`${path}:${e.line}:${e.col}: ${e.message}`);
}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, with their parameter counts.
Spawning
const boss = vm.spawn('boss', 192, 352, new Float64Array(), 'boss');
if (!boss.ok) for (const e of boss.errors) console.error(e.message);
vm.spawn('fairy', 96, 400, new Float64Array([250]), 'fairy');spawn(name, x, y, args, kind) returns a SpawnResult: ok and the new actor's id, or the
errors. args holds one number per positional parameter. Leave kind out for emitter. The
script starts on the next tick.
vm.setPosition(boss.id, x, y); // false if the actor is gone
vm.kill(boss.id);eval(src, x, y) runs source at once, as a REPL would, and returns an EvalResult.
The frame loop
Tick at a fixed 60 Hz from an accumulator, and draw between the last two ticks. The views
bullets() returns go stale at the next call into the VM, so copy the ids and positions before
each tick:
const STEP = 1000 / 60;
let last = performance.now();
let behind = 0;
let prev = { id: new Uint32Array(), x: new Float32Array(), y: new Float32Array() };
function frame(now) {
// After a stall, or a hidden tab, drop the backlog rather than run it all at once.
behind = Math.min(behind + now - last, 250);
last = now;
while (behind >= STEP) {
const b = vm.bullets();
prev = { id: b.id.slice(), x: b.x.slice(), y: b.y.slice() };
vm.setTarget(player.x, player.y, 0, 0);
vm.tick();
afterTick();
behind -= STEP;
}
draw(behind / STEP);
requestAnimationFrame(frame);
}
function draw(alpha) {
const b = vm.bullets();
let j = 0;
// Survivors keep their order and new actors come last, so one pass matches them up.
for (let i = 0; i < b.count; i++) {
while (j < prev.id.length && prev.id[j] !== b.id[i]) j++;
let x = b.x[i], y = b.y[i];
if (j < prev.id.length) {
x = prev.x[j] + (x - prev.x[j]) * alpha;
y = prev.y[j] + (y - prev.y[j]) * alpha;
j++;
}
drawSprite(x, y, b.heading[i], b.kind[i]);
}
}
requestAnimationFrame(frame);afterTick handles the events and does the hit tests, as below. A 2D canvas puts y down, so
draw at height - y.
Bullets
vm.bullets() returns typed-array views straight onto the VM's arrays, with no copying:
count, id, x, y, heading, speed, age, kind, flags, delay and tags (a
BigUint64Array). They're valid until the next call into the VM, so read them, then make
other calls.
const b = vm.bullets();
for (let i = 0; i < b.count; i++) {
const sprite = sprites.get(b.kind[i]);
if (b.flags[i] & Flag.Host) drawEnemy(b.x[i], b.y[i], sprite);
else if (b.delay[i] > 0) drawWarning(b.x[i], b.y[i], sprite, b.delay[i]);
else drawBullet(b.x[i], b.y[i], b.heading[i], sprite);
}The flags are Flag.Host, Flag.Scripted, Flag.CanHit and Flag.Cancellable.
vm.kindName(kind) gives a kind's name. A kind keeps its number for the life of the VM, so
build a map from kind numbers to sprites as kinds first appear, outside the loop over the views,
since kindName is a call into the VM too. vm.tagNames() gives the names behind the bits in
tags.
Lasers
vm.lasers() returns views the same way: count, id, kind, width, phase, progress,
flags, tags, pointsStart, pointsLen, and points, the x, y pairs of every laser
together.
const l = vm.lasers();
for (let i = 0; i < l.count; i++) {
const start = 2 * l.pointsStart[i];
const points = l.points.subarray(start, start + 2 * l.pointsLen[i]);
if (l.phase[i] === LaserPhase.Warn) drawTelegraph(points, l.progress[i]);
else if (l.phase[i] === LaserPhase.Active) drawBeam(points, l.width[i]);
else drawFadingBeam(points, l.width[i], 1 - l.progress[i]);
}The phases are LaserPhase.Warn, Active and Fade: 0, 1 and 2. A straight laser has two points, origin then tip.
A curvy one has up to 257, tail to head. rendering.md covers drawing kinds,
warnings and laser phases.
Events
vm.events() returns the queued events as an array of VmEvents, oldest first, and empties
the queue. Each has a type:
for (const e of vm.events()) {
switch (e.type) {
case 'emit':
playCue(e.name, e.x, e.y, e.value);
break;
case 'cancelled':
items.push({ x: e.x, y: e.y });
break;
case 'despawned':
if (enemies.has(e.id)) enemyGone(e.id, e.reason);
break;
case 'error':
console.error(`${e.line}:${e.col}: ${e.message}`);
break;
}
}Emit events carry the name, and a value when the script gave one. Spawned and cancelled
events carry the kind by name. enemies is the game's set of the ids it spawned: bullets and
lasers get despawned events too, and a laser that fades out is 'finished' like an enemy whose
script ended.
A 'dropped' entry first in the array counts events lost to the queue limit.
vm.setSpawnEvents(true) turns on 'spawned' events.
Hit tests
Test after each tick, against the tick's positions. kill is a call into the VM, so collect the
ids first:
const b = vm.bullets();
const struck = [];
for (let i = 0; i < b.count; i++) {
if (!(b.flags[i] & Flag.CanHit)) continue;
const dx = b.x[i] - player.x, dy = b.y[i] - player.y;
const reach = radii.get(b.kind[i]) + PLAYER_RADIUS;
if (dx * dx + dy * dy < reach * reach) struck.push(b.id[i]);
}
for (const id of struck) vm.kill(id);radii maps kind numbers to hit radii, filled in as kinds first appear; the program at the end
shows how. Lasers test the same way against their polylines, with width[i] / 2 as the radius
and Flag.CanHit to see whether they're active. The program has the segment test too.
Targets, inputs and the beat clock
// One player, moving (vx, vy) units per tick, for `(aim :lead ...)`.
vm.setTarget(x, y, vx, vy);
// Two players, as x, y, vx, vy for each. Each shooter aims at the nearer one.
vm.setTargets(new Float64Array([x1, y1, 0, 0, x2, y2, 0, 0]));
vm.clearTargets();
vm.setInput('boss-hp', hp / maxHp);
vm.clearInput('boss-hp');
vm.setBeat(140, songStartTick);
vm.clearBeat();
vm.cancel(['phase-one']); // the cancellable shots tagged 'phase-one
vm.cancel([]); // every cancellable shot: a bomb
vm.clear(); // every actor, with no eventscancel returns how many actors it removed. clearBounds turns culling off.
Snapshots
vm.snapshot() returns a new Vm, a full copy that shares the compiled code. Going back means
using the copy, and freeing the VM it replaces:
const saved = vm.snapshot();
// ... later
vm.free();
vm = saved.snapshot();Byte snapshots restore on any platform, into a VM with the same sources loaded in the same order:
const bytes = vm.snapshotBytes(); // a Uint8Array
// ... later, in this VM or another
const restored = vm.restoreBytes(bytes);
if (!restored.ok) console.error(restored.errors[0].message);The testbed's rewind, in demo/src/sim.ts, keeps a snapshot() every
15 ticks and the inputs of every tick, and replays from the nearest one.
Determinism checks
vm.stateHash() returns the hash of the whole simulation as 16 hex digits. simVersion() is
the simulation version to store with replays, and vm.tickCount() counts the ticks run.
A complete program
This runs the pattern from embedding.md for ten seconds in Node,
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 byte snapshot at tick 300 and
restores it at the end. Save it as headless.mjs beside the pkg folder.
// A headless bs host on Node: runs a boss and a fairy for ten seconds against a player who
// sweeps left and right, and reports what happened.
import { readFile } from 'node:fs/promises';
import init, { Flag, Vm } from './pkg/bs.js';
const PATTERN = `
(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 = 2.5;
// Hit radii are the host's choice, by kind.
const radius = (name) => (name === 'orb' ? 6 : 3);
// The squared distance from (px, py) to laser i's polyline.
function distance2(l, i, px, py) {
const p = l.points;
const first = l.pointsStart[i], end = first + l.pointsLen[i];
let best = Infinity;
for (let k = first; k + 1 < end; k++) {
const ax = p[2 * k], ay = p[2 * k + 1];
const vx = p[2 * k + 2] - ax, vy = p[2 * k + 3] - ay;
const len2 = vx * vx + vy * vy;
const t = len2 > 0 ? Math.min(1, Math.max(0, ((px - ax) * vx + (py - ay) * vy) / len2)) : 0;
const dx = ax + vx * t - px, dy = ay + vy * t - py;
best = Math.min(best, dx * dx + dy * dy);
}
return best;
}
function fail(errors) {
for (const e of errors) console.error(`${e.line}:${e.col}: ${e.message}`);
process.exit(1);
}
await init({ module_or_path: await readFile(new URL('./pkg/bs_bg.wasm', import.meta.url)) });
const vm = new Vm(1);
vm.setBounds(-32, -32, 416, 480);
vm.setInput('rank', 0.5);
const loaded = vm.load(PATTERN);
if (!loaded.ok) fail(loaded.errors);
for (const [name, x, y, args] of [['boss', 192, 352, []], ['fairy', 96, 400, [250]]]) {
const spawned = vm.spawn(name, x, y, new Float64Array(args), name);
if (!spawned.ok) fail(spawned.errors);
}
// Hit radii by kind number, filled in as kinds first appear.
const radii = new Map();
let hits = 0, items = 0, saved = null;
for (let t = 0; t < 600; t++) {
// The player sweeps right for two seconds, then waits for two. Inputs for a tick go in
// before it runs.
const px = 132 + Math.min(t % 240, 120), py = 64;
vm.setTarget(px, py, 0, 0);
vm.setInput('boss-hp', 1 - t / 600);
vm.tick();
for (const e of vm.events()) {
if (e.type === 'emit') console.log(`tick ${vm.tickCount()}: ${e.name} ${e.value ?? 0}`);
else if (e.type === 'cancelled') items++;
else if (e.type === 'error') console.error(`${e.line}:${e.col}: ${e.message}`);
}
// kindName is a call into the VM, which makes the views stale, so look up new kinds first.
let b = vm.bullets();
const unseen = [...new Set(b.kind)].filter((kind) => !radii.has(kind));
if (unseen.length > 0) {
for (const kind of unseen) radii.set(kind, radius(vm.kindName(kind)));
b = vm.bullets();
}
// Collect the hits first: kill is a call into the VM too.
const struck = [];
for (let i = 0; i < b.count; i++) {
if (!(b.flags[i] & Flag.CanHit)) continue;
const dx = b.x[i] - px, dy = b.y[i] - py;
const reach = radii.get(b.kind[i]) + PLAYER_RADIUS;
if (dx * dx + dy * dy < reach * reach) struck.push(b.id[i]);
}
for (const id of struck) vm.kill(id);
hits += struck.length;
// A laser stays where it is, and hits on every tick the player is in it.
const l = vm.lasers();
for (let i = 0; i < l.count; i++) {
const reach = l.width[i] / 2 + PLAYER_RADIUS;
if (l.flags[i] & Flag.CanHit && distance2(l, i, px, py) < reach * reach) hits++;
}
if (vm.tickCount() === 300) saved = vm.snapshotBytes();
}
const counts = `${vm.bullets().count} bullets, ${vm.lasers().count} lasers`;
console.log(`${vm.tickCount()} ticks: ${counts}, ${hits} hits, ${items} items`);
console.log(`hash ${vm.stateHash()}`);
const restored = vm.restoreBytes(saved);
if (!restored.ok) fail(restored.errors);
console.log(`rewound to tick ${vm.tickCount()}, hash ${vm.stateHash()}`);
vm.free();Run it with node headless.mjs. 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 78cb6be0deb0404dThese are the same lines the Rust and C versions print: the simulation is the same in wasm as natively.