Hosting bs in Lua with LÖVE

LÖVE runs on LuaJIT, whose FFI can call the C API directly: it reads the declarations from bs_api.h, and no binding code is needed. This guide walks through the C API as Lua sees it. embedding.md explains what every host does and why, and c.md covers the same functions from C. examples/love is a small LÖVE host. Any LuaJIT program can call the API the same way; plain Lua can't, since it has no FFI.

Setup

Build the shared library:

cargo build --release -p bs-ffi

That gives target/release/libbs_ffi.so (libbs_ffi.dylib on macOS, bs_ffi.dll on Windows). Copy it and crates/bs-ffi/include/bs_api.h into the game's folder, then:

local bit = require("bit")
local ffi = require("ffi")

-- read returns the size too, which cdef would take as a type parameter, so keep only the text.
local header = love.filesystem.read("bs_api.h")
ffi.cdef(header)

local libs = { Windows = "bs_ffi.dll", OSX = "libbs_ffi.dylib" }
local bs = ffi.load(love.filesystem.getSource() .. "/" .. (libs[ffi.os] or "libbs_ffi.so"))
local NUL = bs.BS_NUL_TERMINATED

bs_api.h has no preprocessor lines, so ffi.cdef takes it as it is. bs.h wraps it for C compilers and won't parse.

This loads the library from the game's folder, which works while developing. ffi.load needs a real file on disk, so a packaged .love or fused game ships the library outside the archive and loads it from there. The LÖVE example copies nothing: it runs from the repo root, and reads the header and the library where cargo leaves them.

Through the FFI:

Making a VM

local vm = ffi.gc(bs.bs_vm_new(seed, nil), bs.bs_vm_free)

-- Or with limits other than the defaults.
local limits = bs.bs_default_limits()
limits.max_actors = 8192
local vm = ffi.gc(bs.bs_vm_new(seed, limits), bs.bs_vm_free)

ffi.gc frees the VM when Lua collects it. The seed drives every random draw. The standard library is already loaded. Then set the cull bounds and any inputs the scripts read:

bs.bs_set_bounds(vm, -32, -32, 416, 480)
bs.bs_set_input(vm, "rank", NUL, 0.5)
bs.bs_set_input(vm, "difficulty", NUL, 2)

Loading

local function print_errors()
  local count = ffi.new("size_t[1]")
  local errors = bs.bs_errors(vm, count)

  for i = 0, tonumber(count[0]) - 1 do
    local e = errors[i]
    print(("%s:%d:%d: %s"):format(path, e.line, e.col, ffi.string(e.message)))
  end
end

local src = love.filesystem.read(path)
if bs.bs_load(vm, src, #src) ~= bs.BS_OK then print_errors() end

bs_warnings works the same way, for loads that worked. A failed load changes nothing. For hot reload, check love.filesystem.getInfo(path).modtime now and then, and load the file again when it changes. The VM keeps running the old definitions if the new ones don't load.

Spawning

local id = ffi.new("uint32_t[1]")
if bs.bs_spawn(vm, "boss", NUL, 192, 352, nil, 0, "boss", NUL, id) ~= bs.BS_OK then
  print_errors()
end
local boss = id[0]

local args = ffi.new("double[1]", 250)
bs.bs_spawn(vm, "fairy", NUL, 96, 400, args, 1, "fairy", NUL, nil)

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

bs.bs_set_position(vm, boss, x, y) -- false if the actor is gone
bs.bs_kill(vm, boss)               -- likewise

The frame loop

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

local STEP = 1 / 60
local behind = 0
local prev = { count = 0, capacity = 0 }

-- Keeps the last tick's ids and positions, for drawing between ticks.
local function remember(b)
  local n = tonumber(b.count)
  if n > prev.capacity then
    prev.capacity = 2 * n
    prev.ids = ffi.new("uint32_t[?]", prev.capacity)
    prev.x = ffi.new("float[?]", prev.capacity)
    prev.y = ffi.new("float[?]", prev.capacity)
  end

  prev.count = n
  if n == 0 then return end

  ffi.copy(prev.ids, b.ids, n * 4)
  ffi.copy(prev.x, b.x, n * 4)
  ffi.copy(prev.y, b.y, n * 4)
end

function love.update(dt)
  -- After a stall, drop the backlog rather than run it all at once.
  behind = math.min(behind + dt, 0.25)

  while behind >= STEP do
    remember(bs.bs_bullets(vm))
    bs.bs_set_targets(vm, player, 1)
    bs.bs_tick(vm)
    after_tick()
    behind = behind - STEP
  end
end

function love.draw()
  local alpha = behind / STEP
  local b = bs.bs_bullets(vm)
  local j = 0

  -- Survivors keep their order and new actors come last, so one pass matches them up.
  for i = 0, tonumber(b.count) - 1 do
    while j < prev.count and prev.ids[j] ~= b.ids[i] do j = j + 1 end

    local x, y = b.x[i], b.y[i]
    if j < prev.count then
      x = prev.x[j] + (x - prev.x[j]) * alpha
      y = prev.y[j] + (y - prev.y[j]) * alpha
      j = j + 1
    end

    draw_sprite(x, H - y, b.heading[i], b.kind[i])
  end
end

player is a bs_target made once with ffi.new("bs_target") and updated from the mouse or the ship. after_tick handles the events and does the hit tests, as below. LÖVE puts y down, so draw at H - y, where H is the field's height.

Bullets

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

local kinds = {}

-- A kind keeps its number for the life of the VM, so look each name up once.
local function kind_name(kind)
  local name = kinds[kind]
  if not name then
    name = ffi.string(bs.bs_symbol_name(vm, kind))
    kinds[kind] = name
  end
  return name
end

local b = bs.bs_bullets(vm)
for i = 0, tonumber(b.count) - 1 do
  local kind = kind_name(b.kind[i])

  if bit.band(b.flags[i], bs.BS_FLAG_HOST) ~= 0 then
    draw_enemy(b.x[i], b.y[i], kind)
  elseif b.delay[i] > 0 then
    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)
  end
end

The flags are BS_FLAG_HOST, BS_FLAG_SCRIPTED, BS_FLAG_CAN_HIT and BS_FLAG_CANCELLABLE. bs_symbol_name doesn't change the VM, so it's safe to call while reading the view. bs_tag_count and bs_tag_name(vm, bit) give the names behind the bits in tags.

Lasers

bs.bs_lasers(vm) returns a bs_laser_view: a count, C arrays ids, kind, width, phase, progress, flags, tags, points_start and points_len, and the shared points array of x, y pairs. This draws each one as a line:

local l = bs.bs_lasers(vm)
for i = 0, tonumber(l.count) - 1 do
  local line, first = {}, l.points_start[i]
  for k = first, first + l.points_len[i] - 1 do
    line[#line + 1] = l.points[2 * k]
    line[#line + 1] = H - l.points[2 * k + 1]
  end

  -- A thin line while it warns, the full width once it's active.
  local warn = l.phase[i] == bs.BS_LASER_WARN
  love.graphics.setLineWidth(warn and 1 or l.width[i])
  love.graphics.line(line)
end

The phases are BS_LASER_WARN, BS_LASER_ACTIVE and BS_LASER_FADE. 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

local events = bs.bs_events(vm)
for i = 0, tonumber(events.count) - 1 do
  local e = events.events[i]

  if e.type == bs.BS_EVENT_EMIT then
    play_cue(kind_name(e.symbol), e.x, e.y, e.has_value and e.value or nil)
  elseif e.type == bs.BS_EVENT_CANCELLED then
    items[#items + 1] = { x = e.x, y = e.y }
  elseif e.type == bs.BS_EVENT_DESPAWNED and enemies[e.id] then
    enemy_gone(e.id, e.reason)
  elseif e.type == bs.BS_EVENT_ERROR then
    print(("%d:%d: %s"):format(e.line, e.col, ffi.string(e.message)))
  end
end
bs.bs_clear_events(vm)

symbol is the kind for spawned and cancelled events and the name for emits. kind_name above works for both, since they're all symbols.

enemies is the game's table 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.bs_set_spawn_events(vm, true) turns on spawned events.

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:

local b, struck = bs.bs_bullets(vm), {}
for i = 0, tonumber(b.count) - 1 do
  if bit.band(b.flags[i], bs.BS_FLAG_CAN_HIT) ~= 0 then
    local dx, dy = b.x[i] - player.x, b.y[i] - player.y
    local reach = radius(b.kind[i]) + PLAYER_RADIUS
    if dx * dx + dy * dy < reach * reach then struck[#struck + 1] = b.ids[i] end
  end
end
for _, id in ipairs(struck) do bs.bs_kill(vm, id) end

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 ...)`.
local player = ffi.new("bs_target", x, y, vx, vy)
bs.bs_set_targets(vm, player, 1)

-- Two players. Each shooter aims at the nearer one.
local players = ffi.new("bs_target[2]", { { x1, y1, 0, 0 }, { x2, y2, 0, 0 } })
bs.bs_set_targets(vm, players, 2)

bs.bs_set_input(vm, "boss-hp", NUL, hp / max_hp)
bs.bs_clear_input(vm, "boss-hp", NUL)

bs.bs_set_beat(vm, 140, song_start_tick)
bs.bs_clear_beat(vm)

local tags = ffi.new("const char *[1]", { "phase-one" })
bs.bs_cancel(vm, tags, nil, 1) -- the cancellable shots tagged 'phase-one
bs.bs_cancel(vm, nil, nil, 0)  -- every cancellable shot: a bomb
bs.bs_clear(vm)                -- every actor, with no events

A bs_target passes where the API wants a pointer to one. The strings in a const char * array point into Lua strings, so keep those strings referenced until the call returns. 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. A Lua string holds one well:

local size = bs.bs_snapshot_size(vm)
local buf = ffi.new("uint8_t[?]", size)
local saved = ffi.string(buf, bs.bs_write_snapshot(vm, buf, size))

-- ... later, in this VM or another
if bs.bs_restore(vm, saved, #saved) ~= bs.BS_OK then print_errors() end

love.filesystem.write("save.bin", saved) keeps one on disk.

Determinism checks

bs.bs_state_hash(vm) hashes the whole simulation, as a 64-bit cdata number: bit.tohex(hash, 16) prints it. bs.bs_sim_version() is the simulation version to store with replays, and bs.bs_tick_count(vm) counts the ticks run.

Freeing

ffi.gc frees the VM when it's collected. To free it at once, take the finalizer off and free it by hand: bs.bs_vm_free(ffi.gc(vm, nil)).

A complete program

A small LÖVE game, main.lua, that runs the pattern from embedding.md. The mouse is the player. Holding the left button wears the boss down through the boss-hp input, B bombs, S saves a snapshot and L loads it. Bullets that hit are killed and counted, lasers count a hit for every tick the player is in them, and cancelled bullets count as items. bs_api.h and the library sit beside main.lua.

-- A small bs game in LÖVE. The mouse is the player. Hold the left button to wear the boss down,
-- press B to bomb, S to save a snapshot and L to load it. bs_api.h and the bs-ffi library sit in
-- the game folder.

local bit = require("bit")
local ffi = require("ffi")

-- read returns the size too, which cdef would take as a type parameter, so keep only the text.
local header = love.filesystem.read("bs_api.h")
ffi.cdef(header)
local libs = { Windows = "bs_ffi.dll", OSX = "libbs_ffi.dylib" }
local bs = ffi.load(love.filesystem.getSource() .. "/" .. (libs[ffi.os] or "libbs_ffi.so"))
local NUL = bs.BS_NUL_TERMINATED

local W, H, SCALE, STEP = 384, 448, 1.5, 1 / 60
local PLAYER_RADIUS = 2.5

local 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)))
]]

local vm, behind, hp, hits, items, saved = nil, 0, 1, 0, 0, nil
local player = ffi.new("bs_target")
local kinds = {}

-- Symbol numbers stay the same for the life of a VM, so look each name up once.
local function kind_name(symbol)
  local name = kinds[symbol]
  if not name then
    name = ffi.string(bs.bs_symbol_name(vm, symbol))
    kinds[symbol] = name
  end
  return name
end

-- Hit radii are the host's choice, by kind.
local function radius(kind)
  return kind_name(kind) == "orb" and 6 or 3
end

local function print_errors()
  local count = ffi.new("size_t[1]")
  local errors = bs.bs_errors(vm, count)
  for i = 0, tonumber(count[0]) - 1 do
    local e = errors[i]
    print(("%d:%d: %s"):format(e.line, e.col, ffi.string(e.message)))
  end
end

-- The squared distance from (px, py) to laser i's polyline.
local function distance2(l, i, px, py)
  local best, p = math.huge, l.points
  local first = l.points_start[i]
  for k = first, first + l.points_len[i] - 2 do
    local ax, ay = p[2 * k], p[2 * k + 1]
    local vx, vy = p[2 * k + 2] - ax, p[2 * k + 3] - ay
    local len2 = vx * vx + vy * vy
    local t = len2 > 0 and ((px - ax) * vx + (py - ay) * vy) / len2 or 0
    t = math.min(1, math.max(0, t))
    local dx, dy = ax + vx * t - px, ay + vy * t - py
    best = math.min(best, dx * dx + dy * dy)
  end
  return best
end

local function handle_events()
  local events = bs.bs_events(vm)
  for i = 0, tonumber(events.count) - 1 do
    local e = events.events[i]
    if e.type == bs.BS_EVENT_EMIT then
      print(("%s %g"):format(kind_name(e.symbol), e.has_value and e.value or 0))
    elseif e.type == bs.BS_EVENT_CANCELLED then
      items = items + 1
    elseif e.type == bs.BS_EVENT_ERROR then
      print(("%d:%d: %s"):format(e.line, e.col, ffi.string(e.message)))
    end
  end
  bs.bs_clear_events(vm)
end

local function hit_test()
  -- Collect first: bs_kill changes the VM, and that invalidates the view.
  local b, struck = bs.bs_bullets(vm), {}
  for i = 0, tonumber(b.count) - 1 do
    if bit.band(b.flags[i], bs.BS_FLAG_CAN_HIT) ~= 0 then
      local dx, dy = b.x[i] - player.x, b.y[i] - player.y
      local reach = radius(b.kind[i]) + PLAYER_RADIUS
      if dx * dx + dy * dy < reach * reach then struck[#struck + 1] = b.ids[i] end
    end
  end
  for _, id in ipairs(struck) do bs.bs_kill(vm, id) end
  hits = hits + #struck

  local l = bs.bs_lasers(vm)
  for i = 0, tonumber(l.count) - 1 do
    local reach = l.width[i] / 2 + PLAYER_RADIUS
    local active = bit.band(l.flags[i], bs.BS_FLAG_CAN_HIT) ~= 0
    if active and distance2(l, i, player.x, player.y) < reach * reach then hits = hits + 1 end
  end
end

function love.load()
  love.window.setMode(W * SCALE, H * SCALE)

  vm = ffi.gc(bs.bs_vm_new(1, nil), bs.bs_vm_free)
  bs.bs_set_bounds(vm, -32, -32, W + 32, H + 32)
  bs.bs_set_input(vm, "rank", NUL, 0.5)

  local dir = ffi.new("double[1]", 250)
  if bs.bs_load(vm, PATTERN, #PATTERN) ~= bs.BS_OK
      or bs.bs_spawn(vm, "boss", NUL, W / 2, 352, nil, 0, "boss", NUL, nil) ~= bs.BS_OK
      or bs.bs_spawn(vm, "fairy", NUL, 96, 400, dir, 1, "fairy", NUL, nil) ~= bs.BS_OK then
    print_errors()
    love.event.quit(1)
  end
end

function love.update(dt)
  -- bs is y-up; the screen is y-down.
  local mx, my = love.mouse.getPosition()
  player.x, player.y = mx / SCALE, H - my / SCALE
  if love.mouse.isDown(1) then hp = math.max(0, hp - dt / 10) end

  -- Tick at a fixed 60 Hz, and drop the backlog after a long stall.
  behind = math.min(behind + dt, 0.25)
  while behind >= STEP do
    bs.bs_set_targets(vm, player, 1)
    bs.bs_set_input(vm, "boss-hp", NUL, hp)
    bs.bs_tick(vm)
    handle_events()
    hit_test()
    behind = behind - STEP
  end
end

function love.keypressed(key)
  if key == "b" then
    bs.bs_cancel(vm, nil, nil, 0)
    handle_events()
  elseif key == "s" then
    local size = bs.bs_snapshot_size(vm)
    local buf = ffi.new("uint8_t[?]", size)
    saved = ffi.string(buf, bs.bs_write_snapshot(vm, buf, size))
  elseif key == "l" and saved then
    if bs.bs_restore(vm, saved, #saved) ~= bs.BS_OK then print_errors() end
  end
end

function love.draw()
  local b = bs.bs_bullets(vm)
  for i = 0, tonumber(b.count) - 1 do
    local host = bit.band(b.flags[i], bs.BS_FLAG_HOST) ~= 0
    local r = host and 12 or radius(b.kind[i])
    love.graphics.setColor(host and { 1, 0.4, 0.4 } or { 0.8, 0.9, 1 })
    love.graphics.circle("fill", b.x[i] * SCALE, (H - b.y[i]) * SCALE, r * SCALE)
  end

  local l = bs.bs_lasers(vm)
  for i = 0, tonumber(l.count) - 1 do
    local line, first = {}, l.points_start[i]
    for k = first, first + l.points_len[i] - 1 do
      line[#line + 1] = l.points[2 * k] * SCALE
      line[#line + 1] = (H - l.points[2 * k + 1]) * SCALE
    end
    -- A thin line while it warns, the full width once it's active.
    local warn = l.phase[i] == bs.BS_LASER_WARN
    love.graphics.setColor(1, 0.3, 0.3, warn and 0.5 or 1)
    love.graphics.setLineWidth(warn and 1 or l.width[i] * SCALE)
    love.graphics.line(line)
  end

  love.graphics.setColor(1, 1, 1)
  love.graphics.circle("fill", player.x * SCALE, (H - player.y) * SCALE, PLAYER_RADIUS * SCALE)
  local hud = "hp %.2f  hits %d  items %d  bullets %d"
  love.graphics.print(hud:format(hp, hits, items, tonumber(b.count)), 10, 10)
end

Run it with love . from the game's folder.

Loading a snapshot rewinds the VM but not hp, which lives in Lua. On the next tick the boss reads the current hp again: the rewind covers the bullets, not the fight. A real game saves its own state alongside the snapshot.