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

wasm-pack build crates/bs-wasm --target web --release --out-dir /path/to/game/pkg --out-name bs

A 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 events

cancel 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 78cb6be0deb0404d

These are the same lines the Rust and C versions print: the simulation is the same in wasm as natively.