Embedding bs

bs runs bullet patterns. The game around it does everything else: the window, input, drawing, sound, collision and score. This page covers what every host does, whatever its language. The host guides walk through the real API for each one:

rendering.md covers drawing what the VM puts out.

A host in outline

  1. Make a VM with a seed.
  2. Load the pattern source, and show any errors.
  3. Set the cull bounds and any inputs, such as rank.
  4. Spawn an entry function for each enemy as it appears.
  5. Once per 60 Hz tick, set the targets and any inputs that changed, then tick.
  6. After each tick, handle the events and clear them, then hit-test the player against the bullets and lasers.
  7. Draw.

The rest of this page fills in those steps.

Ticks and frames

The VM has no clock. Time passes when the host calls tick, and a tick is 1/60 of a second: scripts wait in ticks, and speeds are in units per tick. So the host decides when time moves. Pausing, slowdown and fast-forward are all a matter of calling tick less or more often.

Displays don't all run at 60 Hz, so tick from an accumulator rather than once per frame:

behind = min(behind + frame_seconds, 0.25)   -- after a stall, drop the backlog
while behind >= 1/60:
    set targets and inputs
    tick
    handle events, hit-test
    behind -= 1/60
draw, with alpha = behind * 60

The cap matters in a browser, where a hidden tab gets no frames. Without it, the first frame back runs every tick it missed.

Interpolation. The output arrays hold positions at the end of the last tick. On a 60 Hz display, draw those. On a faster one, bullets visibly step, so draw between the last two ticks instead: keep the previous tick's positions, and draw each actor at prev + (cur - prev) * alpha. Actors keep their ids from tick to tick, and the arrays are in spawn order, so one pass matches them up:

j = 0
for i in 0 .. current.count:
    while j < previous.count and previous.id[j] != current.id[i]:
        j += 1
    if j < previous.count:
        draw at lerp(previous[j], current[i], alpha)
        j += 1
    else:
        draw at current[i]        -- new this tick

This works because actors that survive a tick stay in the same order, and new ones come after them. Copy the ids and positions before each tick, since the arrays get reused. Skip interpolation for a frame after a restore. Interpolation is only for drawing: hit tests use the tick's own positions (see Hit testing).

Coordinates

y points up. Angles are in degrees, counterclockwise from +x, so 90 is up and 270 is down. Most screens put y down, so draw at field_height - y. What a unit is, is up to the host. The testbed uses one per pixel of a 384 by 448 field. See Units in the guide.

Loading scripts

load compiles source and adds its definitions. A load may only hold defn and def forms. It returns the names it defined and any warnings, or a list of errors.

bs check file.bs reports the same errors and warnings from the command line.

Hot reload

Loading a file again replaces the definitions it names, while the game runs. A reload with errors changes nothing, so the old code keeps running. Names the new version no longer defines stay defined.

A function that's running carries on with the code it started with. The new definition takes over the next time it's called. So a forever loop in an entry function keeps its old body until that enemy is spawned again, while the functions it calls change at once. Either put the work in functions the loop calls, or respawn the enemy after a reload.

A reload changes the program, even when the text is the same, so byte snapshots taken before it won't restore after it. See Snapshots.

eval

eval is for consoles and REPLs. It loads any definitions in the source, then runs the other forms at once as a new actor at a given point. It returns the value printed, or the actor's id if the script waits. Games rarely need it.

Spawning

spawn starts a function as a new actor: by name, at a position, with numbers for its parameters, and with a kind for the host to draw it by. The script starts on the next tick. When it ends, the actor is removed, with a despawned event whose reason is finished.

main is only a convention. A game usually defines one entry function per enemy type, or per boss phase, and spawns each by name when that enemy appears:

(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)))

This is the pattern every host guide's complete program runs.

The spawned actor is in the bullet arrays with the HOST flag, and never has CAN_HIT. Draw it as the enemy, or skip it if the game draws its enemies itself.

Moving enemies. A script can move its own actor with move-to, change-speed and the rest. A host actor starts facing 270, straight down, at speed 0. If the game moves the enemy instead, call set_position before each tick. That stops any move-to in progress, since the host has the last word. The arrays show the new position after the next tick.

Killing enemies. When the player destroys an enemy, kill it. Its script stops, and anything attached to it with 'vanish goes with it, which includes its lasers unless they were fired :attach false. Bullets it already fired keep flying. To clear those too, tag them in the script and cancel the tag.

Targets

set_targets sets what (aim) points at: the player, or several players. Each target has a position and a velocity in units per tick.

Inputs

Inputs are named numbers. Scripts read them with (input 'name default). The host sets them with set_input and unsets them with clear_input. They hold until changed, and scripts see a change from the next tick.

Input Scripts read it with Meaning
rank bare rank difficulty within a run, 0 to 1 by convention; 0 when unset
difficulty (by-diff easy normal hard lunatic) 0 to 3, truncated and clamped; normal when unset
anything else (input 'name default) whatever the game needs: boss health, the phase, the player's power

Boss health is the common one. The host sets it every tick, and the boss script above ends its first phase when it drops below half.

There's no built-in input for the field size. The testbed sets field-w and field-h, and its examples read them. A game can do the same.

Bounds and culling

set_bounds sets a rectangle. At the end of each tick, any actor outside it is removed, with a despawned event whose reason is cull. Without bounds nothing is culled: bullets that leave the screen fly on until they reach the actor cap. Always set bounds.

The beat clock

set_beat(bpm, offset) starts a clock for patterns that follow music. Beat 0 falls on tick offset, and the beats come bpm times a minute. Scripts read it with (beat) and (wait-beats n). For a song that starts playing at tick T, pass T plus the time of its first beat, in ticks.

Without a clock, (beat d) gives d and (wait-beats n t) waits t ticks, so scripts that may run without one give those fallbacks. Without a fallback, both are runtime errors.

The clock is an input like the others. Changing it changes what happens, so replays record it.

Reading the output

After each tick, the VM publishes its actors as parallel arrays, in spawn order. A host call that adds or removes actors (spawn, eval, kill, cancel, clear, a restore) publishes them again. The arrays stay valid until the next call that changes the VM, so read or copy what's needed first.

Bullets

Every live actor that isn't a laser is in the bullet arrays, the host's own actors included. Each array has one entry per actor.

Field Type Meaning
ids u32 the actor's id, for kill and set_position
x, y f32 position
heading f32 degrees, in [0, 360)
speed f32 units per tick
age f32 ticks since it spawned
kind u32 a symbol; look its name up with symbol_name
flags u32 the bits below
delay f32 ticks before it can hit, 0 once it can
tags u64 bit n is set for the nth tag name
Flag Value Set when
HOST 1 the host spawned it, with spawn or eval
SCRIPTED 2 its script is still running
CAN_HIT 4 it was fired, and any :delay is over
CANCELLABLE 8 it was fired, and not with :cancellable false

A kind is a symbol: a small number that stands for a name like rice. A symbol keeps its number for the life of the VM, so look each name up once and cache whatever it's drawn with.

Tag names come from tag_names, in the order scripts first used them. A VM holds at most 64.

Lasers

Lasers aren't in the bullet arrays. They have arrays of their own:

Field Type Meaning
ids u32 the laser's actor id
kind u32 a symbol; laser unless the script gave one
width f32 the beam's full width, in units
phase u8 0 warn, 1 active, 2 fade
progress f32 how far through its phase, from 0 up to 1
flags u32 CAN_HIT and CANCELLABLE, as for bullets
tags u64 as for bullets
points_start, points_len u32 where the laser's points are in points
points f32 x, y pairs for all the lasers together

Laser i's polyline is points_len[i] points starting at point points_start[i], so its first x is points[2 * points_start[i]]. A straight laser has two points, from its origin to its tip. A curvy laser has :segments + 1, from the tail to the head.

CAN_HIT is set only while the laser is active and any :delay is over. In every other way a laser is an actor: it has an id, gets spawned and despawned events, and can be killed, cancelled and culled. When it finishes fading it's despawned, with reason finished.

Drawing all of this, from kinds to sprites to laser phases, is in rendering.md.

Events

Events queue up as the VM runs, oldest first, until the host clears them. Handle and clear them after each tick.

Event Fields What hosts do with it
spawned id, kind Off by default; turn it on with set_spawn_events. Firing sounds, or tracking actors by id.
despawned id, reason, x, y Effects where it went; noticing that an enemy's script ended.
cancelled id, kind, x, y Leave an item or a score bonus where the bullet was.
emit id, name, x, y, value Whatever the script means by it: a sound, a screen shake, a phase change, a spell card name.
error id, message, line, col Log it. That actor has stopped.
Despawn reason Why
vanish the script ran (vanish), or it was attached with 'vanish to an actor that went
cull it left the bounds
kill the host called kill
finished a host actor's script ended, or a laser finished fading
error a runtime error stopped it

Hit testing

bs doesn't do collision. It hands out positions, and the game decides what hits.

What a hit does is up to the game. Most games remove the bullet with kill and leave lasers alone. Collect the ids first and kill afterwards, since kill changes the arrays being read. A death or a bomb usually clears the screen with cancel. It removes every cancellable fired actor, or those with given tags, and sends a cancelled event for each, for the items.

Grazing is the same test with a bigger radius.

Snapshots

A snapshot is the whole simulation at one tick: every actor and thread, the random state, the tick count, the seed, and the targets, inputs, bounds, beat clock and limits. Restoring one puts the VM back exactly. There are two kinds:

Clone Byte snapshot
Rust vm.clone() write_snapshot, restore
C none bs_write_snapshot, bs_restore
JavaScript vm.snapshot() snapshotBytes, restoreBytes
What it is a second VM, sharing the compiled code a flat, checksummed, little-endian buffer
Going back swap the copy in restore into any VM with the same program loaded, on any platform
Good for rewind in memory saving to disk, sending over the network

"The same program" means the same sources, loaded in the same order. Comments and whitespace don't count, but any reload does, even of the same text. The seed and limits come from the snapshot. Restoring clears the event queue. A Rust clone keeps the events the original had, so clear them.

A snapshot holds the VM, not the game. Save the score, lives, the player's position and anything else the game keeps alongside it. A host variable that feeds an input, such as boss health, needs saving too, or the next tick sets the input from the unrewound value.

snapshot_size gives the bytes the current state needs, which grows with the live actors. Writing a byte snapshot doesn't allocate, so a ring of buffers set up front works for rollback.

Determinism

The same seed, the same sources loaded in the same order, and the same host calls between the same ticks give the same simulation on every platform, to the bit. Replays, rollback and the golden tests all rest on it. For a host, that means:

Limits

Limits keep a runaway script from taking the game with it. They're set when the VM is made, and they're part of its state, so snapshots carry them.

Limit Default Past it
steps_per_tick 100,000 the actor is stopped with an error
tick_steps 1,000,000 the actor running then is stopped, and the ones after it skip their scripts until the next tick
max_actors 65,536 fire is a runtime error, and spawn fails
max_call_depth 64 runtime error
max_threads 256 another par or race branch is a runtime error
max_events 4096 the oldest events are dropped
max_list_len 65,536 a longer list is a runtime error
max_list_values 1,048,576 runtime error

A runtime error stops only the actor that hit it. Some limits are fixed: 64 tag names per VM, 256 segments per curvy laser, and 65,536 live actors whatever max_actors says.

The calls side by side

What Rust C JavaScript
make, free Vm::new, Vm::with_limits, drop bs_vm_new, bs_vm_free new Vm, free
load load bs_load, bs_errors, bs_warnings load
spawn spawn bs_spawn spawn
tick tick bs_tick tick
targets set_targets bs_set_targets setTarget, setTargets, clearTargets
inputs set_input, clear_input bs_set_input, bs_clear_input setInput, clearInput
bounds set_bounds bs_set_bounds, bs_clear_bounds setBounds, clearBounds
beat clock set_beat bs_set_beat, bs_clear_beat setBeat, clearBeat
output bullets, lasers bs_bullets, bs_lasers bullets, lasers
events events, clear_events bs_events, bs_clear_events events
names symbol_name, tag_names bs_symbol_name, bs_tag_name kindName, tagNames
remove kill, cancel, clear bs_kill, bs_cancel, bs_clear kill, cancel, clear
move set_position bs_set_position setPosition
snapshots clone, write_snapshot, restore bs_write_snapshot, bs_restore snapshot, snapshotBytes, restoreBytes
checks state_hash, SIM_VERSION bs_state_hash, bs_sim_version stateHash, simVersion

Lua calls the C functions through the FFI.