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
- 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
Build the shared library:
cargo build --release -p bs-ffiThat 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_TERMINATEDbs_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:
- Functions and enum constants are fields of
bs:bs.bs_tick(vm),bs.BS_OK. - A Lua string passes as
const char *. Give#sas its length, orNULforBS_NUL_TERMINATED. - C arrays index from 0.
size_tand 64-bit values come back as cdata numbers.tonumberturns them into Lua numbers.- Strings come back as
const char *.ffi.stringcopies one into a Lua string. - Flag tests need
bit.band.
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() endbs_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) -- likewiseThe 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
endplayer 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
endThe 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)
endThe 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) endLasers 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 eventsA 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() endlove.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)
endRun 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.