Hosting bs in C and C++

C and C++ games use the C API in crates/bs-ffi. This guide walks through it. embedding.md explains what every host does and why; this page shows how in C. examples/c-raylib is a small windowed host.

Building and linking

Build the library with cargo:

cargo build --release -p bs-ffi

That gives a static library, target/release/libbs_ffi.a, and a shared one, libbs_ffi.so (.dylib on macOS; bs_ffi.lib and bs_ffi.dll with MSVC). Add crates/bs-ffi/include to the include path and include bs.h, which includes bs_api.h. Both work from C99 and from C++, where bs.h wraps the declarations in extern "C".

The static library holds Rust's standard library, which needs a few system libraries. On Linux:

cc -std=c99 -Wall -I bs/crates/bs-ffi/include game.c bs/target/release/libbs_ffi.a \
    -lpthread -ldl -lm

cargo rustc --release -p bs-ffi --crate-type staticlib -- --print native-static-libs prints the exact list for the platform at hand. The raylib example's CMakeLists.txt runs cargo as a build step and links the static library:

cmake -S examples/c-raylib -B examples/c-raylib/build
cmake --build examples/c-raylib/build
examples/c-raylib/build/bs_raylib examples/01-spiral.bs

The rules

The comment at the top of bs.h sets out rules that hold for every function:

Functions that can fail return BS_OK, BS_ERROR (read why with bs_errors) or BS_INVALID (a NULL pointer, or for inputs, a name that isn't UTF-8).

The code below passes string literals with this macro, from the raylib example:

// Every string here is NUL-terminated, so it goes in with BS_NUL_TERMINATED as its length.
#define STR(s) (s), BS_NUL_TERMINATED

Making a VM

bs_vm *vm = bs_vm_new(seed, NULL);

// Or with limits other than the defaults.
bs_limits limits = bs_default_limits();
limits.max_actors = 8192;
bs_vm *vm = bs_vm_new(seed, &limits);

The seed is a uint64_t and drives every random draw. The standard library is already loaded. Then set the cull bounds and any inputs the scripts read:

bs_set_bounds(vm, -32, -32, 416, 480);
bs_set_input(vm, STR("rank"), 0.5);
bs_set_input(vm, STR("difficulty"), 2);

Loading

if (bs_load(vm, src, src_len) != BS_OK) {
    size_t count;
    const bs_error *errors = bs_errors(vm, &count);

    for (size_t i = 0; i < count; i++) {
        const bs_error *e = &errors[i];
        fprintf(stderr, "%s:%" PRIu32 ":%" PRIu32 ": %s\n", path, e->line, e->col, e->message);
    }
}

size_t nwarnings;
const bs_error *warnings = bs_warnings(vm, &nwarnings);

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. The errors and warnings stay valid until the next load, eval, spawn or restore.

Spawning

uint32_t boss;
if (bs_spawn(vm, STR("boss"), 192, 352, NULL, 0, STR("boss"), &boss) != BS_OK) {
    // bs_errors says why: an unknown name, the wrong number of arguments, the actor cap.
}

double dir = 250;
bs_spawn(vm, STR("fairy"), 96, 400, &dir, 1, STR("fairy"), NULL);

The arguments are an array of double, one per positional parameter. A NULL kind means emitter. The last argument receives the actor's id, and may be NULL. The script starts on the next tick.

bs_set_position(vm, boss, x, y); // false if the actor is gone
bs_kill(vm, boss);               // likewise

bs_eval(vm, src, len, x, y, &result) runs source at once, as a REPL would. result.kind says whether it only defined things, ended with a printed value, or is still running as actor.

The frame loop

Tick at a fixed 60 Hz from an accumulator, and draw between the last two ticks. The bullet view is gone after the next tick, so copy the ids and positions first:

#define STEP (1.0 / 60.0)

// The previous tick's ids and positions, for drawing between ticks.
typedef struct {
    uint32_t *ids;
    float *x, *y;
    size_t count, capacity;
} previous;

static void remember(previous *p, bs_bullet_view b) {
    if (b.count > p->capacity) {
        p->capacity = 2 * b.count;
        p->ids = (uint32_t *)realloc(p->ids, p->capacity * sizeof *p->ids);
        p->x = (float *)realloc(p->x, p->capacity * sizeof *p->x);
        p->y = (float *)realloc(p->y, p->capacity * sizeof *p->y);
    }

    p->count = b.count;
    if (b.count == 0) return;

    memcpy(p->ids, b.ids, b.count * sizeof *p->ids);
    memcpy(p->x, b.x, b.count * sizeof *p->x);
    memcpy(p->y, b.y, b.count * sizeof *p->y);
}

static double behind;
static previous prev;

void frame(bs_vm *vm, double dt, bs_target player) {
    // After a stall, drop the backlog rather than run it all at once.
    behind = behind + dt < 0.25 ? behind + dt : 0.25;

    while (behind >= STEP) {
        remember(&prev, bs_bullets(vm));
        bs_set_targets(vm, &player, 1);
        bs_tick(vm);
        after_tick(vm);
        behind -= STEP;
    }

    float alpha = (float)(behind / STEP);
    bs_bullet_view b = bs_bullets(vm);
    size_t j = 0;

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

        float x = b.x[i], y = b.y[i];
        if (j < prev.count) {
            x = prev.x[j] + (x - prev.x[j]) * alpha;
            y = prev.y[j] + (y - prev.y[j]) * alpha;
            j++;
        }

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

after_tick handles the events and does the hit tests, as below. The raylib example ticks once per frame with the frame rate capped at 60, which is the simple version of the same loop.

Bullets

bs_bullets(vm) returns a bs_bullet_view: a count and parallel arrays ids, x, y, heading, speed, age, kind, flags, delay and tags.

bs_bullet_view b = bs_bullets(vm);

for (size_t i = 0; i < b.count; i++) {
    const char *kind = bs_symbol_name(vm, b.kind[i]);

    if (b.flags[i] & BS_FLAG_HOST) {
        draw_enemy(b.x[i], b.y[i], kind);
    } else if (b.delay[i] > 0) {
        draw_warning(b.x[i], b.y[i], kind, b.delay[i]);
    } else {
        draw_bullet(b.x[i], b.y[i], b.heading[i], kind);
    }
}

The flags are BS_FLAG_HOST, BS_FLAG_SCRIPTED, BS_FLAG_CAN_HIT and BS_FLAG_CANCELLABLE. bs_symbol_name returns a string that lives as long as the VM. Kinds are small numbers that keep their meaning for the life of the VM, so an array indexed by kind makes a good cache of sprites. bs_tag_count and bs_tag_name(vm, bit) give the names behind the bits in tags.

Lasers

bs_lasers(vm) returns a bs_laser_view: a count, parallel arrays ids, kind, width, phase, progress, flags, tags, points_start and points_len, and the shared points buffer of x, y pairs, point_count pairs long.

bs_laser_view l = bs_lasers(vm);

for (size_t i = 0; i < l.count; i++) {
    const float *points = l.points + 2 * l.points_start[i];
    uint32_t n = l.points_len[i];

    switch (l.phase[i]) {
    case BS_LASER_WARN:
        draw_telegraph(points, n, l.progress[i]);
        break;
    case BS_LASER_ACTIVE:
        draw_beam(points, n, l.width[i]);
        break;
    default:
        draw_fading_beam(points, n, l.width[i], 1 - l.progress[i]);
        break;
    }
}

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

bs_event_view events = bs_events(vm);

for (size_t i = 0; i < events.count; i++) {
    const bs_event *e = &events.events[i];

    switch (e->type) {
    case BS_EVENT_EMIT:
        play_cue(bs_symbol_name(vm, e->symbol), e->x, e->y, e->has_value, e->value);
        break;
    case BS_EVENT_CANCELLED:
        drop_item(e->x, e->y);
        break;
    case BS_EVENT_DESPAWNED:
        if (is_enemy(e->id)) enemy_gone(e->id, e->reason);
        break;
    case BS_EVENT_ERROR:
        fprintf(stderr, "%" PRIu32 ":%" PRIu32 ": %s\n", e->line, e->col, e->message);
        break;
    }
}

bs_clear_events(vm);

type decides which fields mean something: symbol is the kind for spawned and cancelled events and the name for emits, reason is a BS_DESPAWN_ value, and message, line and col belong to errors. The array stays valid until the next call that changes the VM.

is_enemy stands for the game's own check of the ids it spawned. Bullets and lasers get despawned events too, and a laser that fades out is BS_DESPAWN_FINISHED like an enemy whose script ended.

events.dropped counts events lost to the queue limit since the last clear. bs_set_spawn_events(vm, true) turns on BS_EVENT_SPAWNED.

Hit tests

Test after each tick, against the tick's positions. bs_kill changes the VM, which invalidates the view, so collect the ids first:

bs_bullet_view b = bs_bullets(vm);
uint32_t *struck = (uint32_t *)malloc((b.count + 1) * sizeof *struck);
size_t nstruck = 0;

for (size_t i = 0; i < b.count; i++) {
    if (!(b.flags[i] & BS_FLAG_CAN_HIT)) continue;

    double dx = b.x[i] - player.x, dy = b.y[i] - player.y;
    double reach = radius(bs_symbol_name(vm, b.kind[i])) + PLAYER_RADIUS;
    if (dx * dx + dy * dy < reach * reach) struck[nstruck++] = b.ids[i];
}

for (size_t i = 0; i < nstruck; i++) bs_kill(vm, struck[i]);
free(struck);

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

Targets, inputs and the beat clock

// One player, moving (vx, vy) units per tick, for `(aim :lead ...)`.
bs_target player = {x, y, vx, vy};
bs_set_targets(vm, &player, 1);

// Two players. Each shooter aims at the nearer one.
bs_target players[2] = {{x1, y1, 0, 0}, {x2, y2, 0, 0}};
bs_set_targets(vm, players, 2);

bs_set_input(vm, STR("boss-hp"), hp / max_hp);
bs_clear_input(vm, STR("boss-hp"));

bs_set_beat(vm, 140, song_start_tick);
bs_clear_beat(vm);

const char *tags[] = {"phase-one"};
bs_cancel(vm, tags, NULL, 1); // the cancellable shots tagged 'phase-one
bs_cancel(vm, NULL, NULL, 0); // every cancellable shot: a bomb
bs_clear(vm);                 // every actor, with no events

bs_cancel takes an array of tag names and an array of their lengths, which may be NULL when the names are NUL-terminated. It returns how many actors it removed. bs_clear_bounds turns culling off.

Snapshots

The C API has byte snapshots. They restore on any platform, into a VM with the same sources loaded in the same order:

size_t size = bs_snapshot_size(vm);
uint8_t *saved = (uint8_t *)malloc(size);
size_t saved_len = bs_write_snapshot(vm, saved, size);

// ... later, in this VM or another
if (bs_restore(vm, saved, saved_len) != BS_OK) {
    // bs_errors says why: damaged bytes, another version, or a different program.
}

bs_write_snapshot returns 0 when the buffer is too small, and doesn't allocate, so a ring of buffers kept from one frame to the next works for rewind and rollback. There's no in-memory clone as in Rust and JavaScript.

Determinism checks

bs_state_hash(vm) hashes the whole simulation. bs_sim_version() is the simulation version to store with replays, and bs_tick_count(vm) counts the ticks run.

Freeing

bs_vm_free(vm) frees the VM and every string and array it handed out. NULL is fine.

C++

The API is plain C, so C++ calls it as is. A unique_ptr frees the VM, and a string_view has the pointer and length a string argument wants:

#include <memory>
#include <string_view>

#include "bs.h"

using Vm = std::unique_ptr<bs_vm, decltype(&bs_vm_free)>;

Vm make_vm(uint64_t seed) {
    return Vm(bs_vm_new(seed, nullptr), bs_vm_free);
}

bool load(bs_vm *vm, std::string_view src) {
    return bs_load(vm, src.data(), src.size()) == BS_OK;
}

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 byte snapshot at tick 300 and restores it at the end. It compiles as C99 and as C++.

// 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.

#include <inttypes.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include "bs.h"

// Every string here is NUL-terminated, so it goes in with BS_NUL_TERMINATED as its length.
#define STR(s) (s), BS_NUL_TERMINATED

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

#define PLAYER_RADIUS 2.5

// Hit radii are the host's choice, by kind.
static double radius(const char *kind) {
    return strcmp(kind, "orb") == 0 ? 6.0 : 3.0;
}

// The squared distance from (px, py) to a polyline of `count` x, y pairs.
static double distance2(const float *points, uint32_t count, double px, double py) {
    double best = INFINITY;

    for (uint32_t k = 0; k + 1 < count; k++) {
        double ax = points[2 * k], ay = points[2 * k + 1];
        double vx = points[2 * k + 2] - ax, vy = points[2 * k + 3] - ay;
        double len2 = vx * vx + vy * vy;

        double t = len2 > 0 ? ((px - ax) * vx + (py - ay) * vy) / len2 : 0;
        t = t < 0 ? 0 : t > 1 ? 1 : t;

        double dx = ax + vx * t - px, dy = ay + vy * t - py;
        if (dx * dx + dy * dy < best) best = dx * dx + dy * dy;
    }

    return best;
}

static void print_errors(bs_vm *vm) {
    size_t count;
    const bs_error *errors = bs_errors(vm, &count);

    for (size_t i = 0; i < count; i++) {
        const bs_error *e = &errors[i];
        fprintf(stderr, "%" PRIu32 ":%" PRIu32 ": %s\n", e->line, e->col, e->message);
    }
}

int main(void) {
    bs_vm *vm = bs_vm_new(1, NULL);
    bs_set_bounds(vm, -32, -32, 416, 480);
    bs_set_input(vm, STR("rank"), 0.5);

    double fairy_dir = 250;
    if (bs_load(vm, STR(PATTERN)) != BS_OK ||
        bs_spawn(vm, STR("boss"), 192, 352, NULL, 0, STR("boss"), NULL) != BS_OK ||
        bs_spawn(vm, STR("fairy"), 96, 400, &fairy_dir, 1, STR("fairy"), NULL) != BS_OK) {
        print_errors(vm);
        bs_vm_free(vm);
        return 1;
    }

    size_t hits = 0, items = 0;
    uint8_t *saved = NULL;
    size_t saved_len = 0;

    for (int 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.
        int sweep = t % 240 < 120 ? t % 240 : 120;
        bs_target player = {132.0 + sweep, 64.0, 0, 0};
        bs_set_targets(vm, &player, 1);
        bs_set_input(vm, STR("boss-hp"), 1.0 - t / 600.0);
        bs_tick(vm);

        bs_event_view events = bs_events(vm);
        for (size_t i = 0; i < events.count; i++) {
            const bs_event *e = &events.events[i];

            switch (e->type) {
            case BS_EVENT_EMIT:
                printf("tick %" PRIu64 ": %s %g\n", bs_tick_count(vm),
                       bs_symbol_name(vm, e->symbol), e->has_value ? e->value : 0.0);
                break;
            case BS_EVENT_CANCELLED:
                items++;
                break;
            case BS_EVENT_ERROR:
                fprintf(stderr, "%" PRIu32 ":%" PRIu32 ": %s\n", e->line, e->col, e->message);
                break;
            }
        }
        bs_clear_events(vm);

        // Collect the hits first: bs_kill changes the VM, which invalidates the view.
        bs_bullet_view b = bs_bullets(vm);
        uint32_t *struck = (uint32_t *)malloc((b.count + 1) * sizeof *struck);
        size_t nstruck = 0;

        for (size_t i = 0; i < b.count; i++) {
            if (!(b.flags[i] & BS_FLAG_CAN_HIT)) continue;

            double dx = b.x[i] - player.x, dy = b.y[i] - player.y;
            double reach = radius(bs_symbol_name(vm, b.kind[i])) + PLAYER_RADIUS;
            if (dx * dx + dy * dy < reach * reach) struck[nstruck++] = b.ids[i];
        }

        for (size_t i = 0; i < nstruck; i++) bs_kill(vm, struck[i]);
        hits += nstruck;
        free(struck);

        // A laser stays where it is, and hits on every tick the player is in it.
        bs_laser_view l = bs_lasers(vm);
        for (size_t i = 0; i < l.count; i++) {
            const float *points = l.points + 2 * l.points_start[i];
            double reach = l.width[i] / 2.0 + PLAYER_RADIUS;
            bool can_hit = l.flags[i] & BS_FLAG_CAN_HIT;
            if (can_hit && distance2(points, l.points_len[i], player.x, player.y) < reach * reach)
                hits++;
        }

        if (bs_tick_count(vm) == 300) {
            size_t size = bs_snapshot_size(vm);
            saved = (uint8_t *)malloc(size);
            saved_len = bs_write_snapshot(vm, saved, size);
        }
    }

    printf("%" PRIu64 " ticks: %zu bullets, %zu lasers, %zu hits, %zu items\n",
           bs_tick_count(vm), bs_bullets(vm).count, bs_lasers(vm).count, hits, items);
    printf("hash %016" PRIx64 "\n", bs_state_hash(vm));

    if (saved && bs_restore(vm, saved, saved_len) == BS_OK) {
        printf("rewound to tick %" PRIu64 ", hash %016" PRIx64 "\n", bs_tick_count(vm),
               bs_state_hash(vm));
    } else {
        print_errors(vm);
    }

    free(saved);
    bs_vm_free(vm);
    return 0;
}

Build and run it from the repo root:

cargo build --release -p bs-ffi
cc -std=c99 -Wall -Wextra -I crates/bs-ffi/include headless.c target/release/libbs_ffi.a \
    -lpthread -ldl -lm -o headless
./headless

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 version prints: the simulation is the same everywhere.