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:
- Rust, with
bs-coreas a crate - C and C++, through the C API in
bs.h - JavaScript, through the wasm package, in a browser or Node
- Lua, in LÖVE through LuaJIT's FFI
rendering.md covers drawing what the VM puts out.
- A host in outline
- Ticks and frames
- Coordinates
- Loading scripts
- Spawning
- Targets
- Inputs
- Bounds and culling
- The beat clock
- Reading the output
- Events
- Hit testing
- Snapshots
- Determinism
- Limits
- The calls side by side
A host in outline
- Make a VM with a seed.
- Load the pattern source, and show any errors.
- Set the cull bounds and any inputs, such as
rank. - Spawn an entry function for each enemy as it appears.
- Once per 60 Hz tick, set the targets and any inputs that changed, then tick.
- After each tick, handle the events and clear them, then hit-test the player against the bullets and lasers.
- 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 * 60The 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 tickThis 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.
- Errors carry a line and a column, both starting at 1. Line 0 means there's no position,
as for a spawn error. Show them as
line:col: message. A load that fails changes nothing. - Warnings point at code that loads but is probably a mistake, like a
letname nothing uses. They don't stop the load. Show them while developing. - Several files can go into one VM, and each load adds to what's there. Inside a load, definitions can come in any order. Across loads they can't: a load can only call functions defined in it or in an earlier load. Load shared helpers first.
- The standard library (
ring,fanand the rest) is in every new VM before the first load. defruns when the file loads, outside any actor, so it can't read inputs, aim or wait.
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.
- Arguments are numbers, one for each positional parameter, and the count must match. Keyword parameters take their defaults. Anything else a script needs comes through inputs.
- The kind is a name for the host, such as
"boss". It defaults toemitter, and the VM doesn't use it. - Errors come back from the call, with no position: an unknown name, a name that isn't a function, the wrong number of arguments, or the actor cap.
- The id comes back too. Ids are generational: once an actor is gone, its id never matches
another one, so
killandset_positionon a stale id just return false. - Randomness for each spawned actor is seeded from the VM's seed and the order of host spawns. Spawn enemies in a different order and they draw different numbers.
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.
(aim)aims at the target nearest the shooter.(aim :target i)picks one by its place in the list.(aim :lead s)uses the velocity to aim where the target will be. Pass the player's real movement per tick, or zeros.- With no targets,
(aim)points straight down, and(target-x)and(target-y)are runtime errors. - Targets keep their values until they're set again. Set them before every tick, from where the player is on that 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.
- Culling goes by an actor's position alone. Leave a margin at least as big as the largest sprite, or bullets vanish while still half on screen. The testbed uses 32 units.
- Host actors are culled too. An enemy spawned outside the bounds, to fly in, is removed at the end of its first tick, so the margin has to cover where enemies enter.
- A laser is culled by its origin, not its beam.
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 |
- A cancelled actor gets a cancelled event instead of a despawned one, never both.
- A runtime error gives an error event, then a despawned event with reason
error. The rest of the VM carries on. clearremoves every actor without any events.- An emit carries the actor's position, so an effect can play where the enemy is. Its value is optional, so check whether it's there.
- Spawned events are off by default because a busy pattern fires hundreds of bullets a second.
- The queue holds 4096 events by default. Past that, the oldest are dropped, and the host can read how many went. Events are output only, so losing some changes nothing in the simulation.
Hit testing
bs doesn't do collision. It hands out positions, and the game decides what hits.
- Radii are the host's. Keep a hit radius for each kind. Shmup hitboxes are usually smaller than the sprite.
- Only test what can hit. Check
CAN_HIT. It leaves out host actors, shots still in their:delay, and lasers that aren't active. - Bullets are circles. One hits when its distance to the player is less than its radius plus the player's.
- Lasers are thick polylines. One hits when the player is within
width / 2plus the player's radius of any of its segments. - Test right after each tick, against the tick's positions, not interpolated ones. Then the hits are the same in a replay.
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.
- Rewind. Keep a snapshot every few ticks, and the inputs of every tick. To go back to tick t, restore the latest snapshot at or before t, then replay the recorded inputs up to t. The testbed keeps one every 15 ticks, for ten seconds back (demo/src/sim.ts).
- Rollback netcode. Each peer runs the same VM. When a late input arrives for an earlier tick, restore the snapshot from that tick and run forward again with the corrected inputs. Compare state hashes between peers now and then to catch a desync early.
- Save states, and bug reports that replay exactly.
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:
- Drive the VM from ticks, not frames. Set the targets and inputs for a tick before it runs, never from interpolated positions or frame times.
- Record what goes in. A replay is the seed, the sources, and for each tick the host calls made before it: loads, targets, inputs, spawns, kills, cancels, positions, bounds and the beat clock.
- Check the version.
SIM_VERSIONgoes up whenever the same inputs could give different results. Store it with replays and refuse ones from another version. Byte snapshots already refuse. - Check the state hash. It covers the whole simulation, and two VMs with the same history hash the same. Hashing every tick in a debug build finds the tick where two runs part.
- Output doesn't count. Reading the arrays and events, clearing events, the event queue's size, and turning spawned events on or off change nothing.
- Hits can feed back. If a hit kills a bullet or changes an input, it's part of the history. It stays deterministic as long as it's worked out the same way from the tick's arrays.
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.