The bs guide
bs is a small language for shmup bullet patterns. A pattern is a script: it fires bullets, waits, and fires more, and each bullet can run a script of its own. Scripts run on a deterministic VM, so the same script, seed and inputs give the same bullets on every machine. That's what makes replays, rollback and golden tests possible.
This guide covers the whole language. On bs.ell.dev/guide, every
example with a main has a link that opens it in the testbed, where you can change it and see
the result straight away.
- A first pattern
- Units
- Syntax
- Firing
- Bullets with scripts
- Names, choices and functions
- Loops and lists
- Doing things at once
- The standard library
- Transforms
- Placing shots
- Lasers
- Moving the enemy
- Talking to the host
- Randomness
- When something goes wrong
- BulletML
- Tools
A first pattern
(defn main []
(forever
(ring 12 :speed 2)
(wait 20)))The host spawns an actor, here the enemy, and runs its main. (ring 12 :speed 2) fires 12
shots spaced evenly round a circle. (wait 20) pauses the script for 20 ticks, a third of a
second, and forever repeats the two.
Units
- Angles are in degrees. 0 points right, 90 up, 180 left and 270 down, so angles grow counterclockwise and y points up.
- Time is in ticks, 60 a second. A wait can be a fraction of a tick: a loop that fires and then waits 0.5 fires twice a tick, and each shot starts exactly where it would have been.
- Speed is in units per tick. What a unit is, is up to the host. In the testbed it's a pixel of the game's own resolution, on a field 448 units tall.
Syntax
; A comment runs to the end of the line.
(fire :dir 90 :speed 2.5 :kind 'big-blue)- Calls:
(name args...). The first item says what to call, and the rest are its arguments. - Named arguments:
:dir 90. They come after the positional ones, in any order. - Symbols:
'big-blueis a symbol, a name used as a value. Kinds, tags and event names are all symbols. - Lists:
[1 2 3]. - Other values: numbers (
3,-2.5,.5,1e3),true,falseandnil. Commas count as whitespace. - What's left out: strings, maps and assignment. Values never change once made.
nil and false are false. Everything else is true, 0 included.
Firing
fire makes a bullet. Every argument is optional:
(defn main []
(forever
(fire :dir (aim) :speed 4 :kind 'needle-cyan)
(wait 3)))| Key | Default | What it does |
|---|---|---|
:dir |
(aim) |
direction in degrees |
:speed |
1 | units per tick |
:kind |
'bullet |
what the host draws; the testbed's Kinds tab lists its sprites |
:run |
none | a script for the new bullet (see below) |
:at |
[0 0] |
where it starts, relative to the shooter |
:delay |
0 | ticks before it can hit, for warnings |
:tags |
none | a symbol or a list of them, for cancel |
:cancellable |
true | whether cancel can remove it |
:attach |
nil | keep it moving with its shooter |
:motion |
'linear |
'polar or 'wave for a curved path, with :spin, :amp and :period |
(aim) is the direction from the shooter to the player.
Bullets with scripts
:run gives the new bullet a function to run. It starts on the next tick:
(defn stop-and-aim []
(change-speed 0 :over 30)
(wait 40)
(change-dir (aim))
(change-speed 4 :over 20))
(defn main []
(forever
(ring 16 :speed 3 :kind 'small-red :run stop-and-aim)
(wait 60)))A bullet's script steers it with these:
| Form | What it does |
|---|---|
(change-speed v :over n) |
brings the speed to v over n ticks |
(change-dir d :over n) |
turns to face d over n ticks, the short way round |
(turn rate :for n) |
turns by rate degrees every tick, for n ticks or for good |
(accel vx vy :over n) |
adds a velocity on top of heading and speed: gravity, wind |
(move-to x y :over n) |
glides to a point |
(vanish) |
removes the bullet |
:over and :for work in the background: the script carries on at once, so wait if you want
the change to finish first. Leave :over out and the change happens at once.
A script can ask where it is and how it's moving with (x), (y), (heading), (speed) and
(age), the ticks since it was fired.
fn makes a function on the spot. It keeps the values of the names it uses, so each bullet
below remembers its own i:
(defn main []
(forever
(dotimes [i 8]
(fire :dir (* i 45) :speed 2 :kind 'rice
:run (fn []
(wait (* i 5))
(turn 3 :for 30))))
(wait 60)))A bullet keeps flying after its script ends. The enemy itself is different: when the script it was spawned with ends, it's removed.
Names, choices and functions
(def gap 12)
(defn main []
(let [kind (if (> rank 0.5) 'big-red 'big-blue)
n (cond (< rank 0.3) 8
(< rank 0.7) 12
:else 16)]
(forever
(ring n :kind kind)
(wait gap))))defnames a value. It runs once, when the file loads.letnames values for its body. Each one can use the names before it.if,when,cond,and,oranddowork as you'd expect.condtakes pairs, and:elseis always true.defnnames a function. Definitions can come in any order, and loading a file again replaces them, even in scripts that are already running. That's what makes live coding work.
A function can't call itself, directly or through others. Use loop and recur for that kind
of repetition (see below).
Keyword parameters
Parameters after the positional ones can be named, each with a default:
(defn sweep [n :from 200 :to 340 :kind 'rice]
(dotimes [i n]
(fire :dir (lerp from to (/ i (- n 1))) :speed 3
:kind kind)
(wait 2)))
(defn main []
(forever
(sweep 12)
(sweep 12 :from 340 :to 200 :kind 'needle-orange)
(wait 30)))A default is worked out when the function is called, and only if the caller left the key out,
so it can use the parameters before it, or (aim). A key the function doesn't have is an error
when the file loads, at the line that passes it.
Passing keys on
& opts at the end of the parameters takes every named argument the function has no parameter
for. & opts at the end of a call passes them on. Here petals passes anything it doesn't use
itself on to fire:
(defn petals [n :speed 2 & opts]
(dotimes [i n]
(fire :dir (* i (/ 360 n)) :speed speed & opts)))
(defn main []
(forever
(petals 10 :kind 'big-pink :delay 20)
(wait 40)))The keys still get checked. Calling (petals 10 :knid 'big-pink) is an error at load time,
because nothing petals passes its keys on to takes :knid. Inside the function, opts is a
list of [key value] pairs, or nil when there are none.
Loops and lists
| Form | What it does |
|---|---|
(dotimes [i n] ...) |
runs the body n times, with i from 0 |
(forever ...) |
runs the body until the actor goes |
(loop [a 0 b 1] ... (recur new-a new-b)) |
a loop that carries values from one pass to the next |
(doseq [x xs] ...) |
runs the body once for each item of a list |
loop is how a pattern keeps state, since nothing can be reassigned:
(defn main []
(loop [a 0 step 1]
(fire :dir a :speed 2 :kind 'small-cyan)
(fire :dir (+ a 180) :speed 2 :kind 'small-magenta)
(wait 1)
(recur (+ a step) (+ step 0.05))))Lists hold any values, other lists included. Binding forms can take them apart: [n speed kind] below binds the three items of each row, and _ skips one.
(def waves
[[12 1.5 'big-blue]
[18 2 'big-cyan]
[24 2.5 'small-cyan]])
(defn main []
(forever
(doseq [[n speed kind] waves]
(ring n :speed speed :kind kind)
(wait 14))
(wait 30)))The list functions:
nth,count,first,rest,last,empty?conj,concat,reverse,rangemap,filter,reducerand-nth, which picks an item at random
Each makes a new list rather than changing one. The functions you give map, filter and
reduce run straight away, so they can't wait.
Doing things at once
par runs its forms side by side and finishes when they all have. race finishes when the
first one does, and stops the rest.
(defn spiral [step kind]
(loop [a 0]
(fire :dir a :speed 2 :kind kind)
(wait 2)
(recur (+ a step))))
(defn main []
(par (spiral 11 'small-cyan)
(spiral -11 'small-magenta)))(until test body...)runs the body until the test is true, checking once a tick.(wait-until test)waits for it.
These are threads in the VM's sense, not the operating system's. They take turns in a fixed order, so they're as deterministic as everything else.
A phase that ends on a timer or on the boss's health is a race or an until:
(defn phase-one []
(forever (fan 5 :kind 'rice) (wait 20)))
(defn phase-two []
(forever (ring 24 :kind 'big-red) (wait 30)))
(defn main []
(until (> (age) 240) (phase-one))
(phase-two))The last shot
Each thread remembers the direction and speed of its last shot. (last-dir) and
(last-speed) read them, which makes sweeps easy to write without a loop variable:
(defn main []
(forever
(fire :dir (+ (last-dir 0) 13) :speed 2.5 :kind 'rice)
(wait 2)))Before the first shot, (last-dir) is (aim) and (last-speed) is 1. (last-dir d) gives d
instead. set-last-dir and set-last-speed set them without firing, which the BulletML
converter needs.
The standard library
These are written in bs and loaded into every VM. You can read them in
crates/bs-core/std and redefine any of them.
| Call | Fires |
|---|---|
(ring n :dir 0 :speed 2 :kind 'bullet) |
n shots evenly round a circle, the first at :dir |
(fan n :dir (aim) :step 15 :speed 2 :kind 'bullet) |
n shots :step degrees apart, centred on :dir |
(spread n :dir (aim) :width 60 :speed 2 :kind 'bullet) |
n shots across an arc :width degrees wide |
(polygon sides :per-side 5 :dir 90 :speed 2 :kind 'bullet) |
the outline of a polygon that keeps its shape as it grows |
(rose n :petals 4 :dir 0 :speed 2 :kind 'bullet) |
a ring that opens into petals |
(stream n :dir (aim) :speed 2 :gap 4 :kind 'bullet) |
n shots in a line, :gap ticks apart |
(burst n :dir (aim) :width 60 :speed [1 3] :kind 'bullet) |
n shots at random directions and speeds |
Each one passes any other key on to fire, so (ring 12 :run spin :tags 'soft) works.
Transforms
mirror, rotate, offset and speed-scale change everything their body fires:
(defn sweep []
(dotimes [i 12]
(fire :dir (+ 200 (* i 6)) :speed 3 :kind 'rice)
(wait 2)))
(defn main []
(forever
(par (sweep) (mirror (sweep)))
(wait 30)))| Form | What it does |
|---|---|
(mirror body...) |
flips left and right |
(rotate deg body...) |
turns everything by deg |
(offset x y body...) |
fires from a point x, y away |
(speed-scale k body...) |
multiplies speeds by k |
They nest. They also carry into the scripts of the bullets fired inside them, so a mirrored
bullet that turns left turns right instead. Questions are answered inside the transform too:
an aimed shot stays aimed when mirrored, and (+ (aim) 20) comes out on the other side.
Placing shots
:at [x y] starts a shot x, y away from the shooter: a ring of guns, or the end of a
wing. Under a transform the offset turns with it.
:attach keeps a shot moving with whatever fired it:
true: it follows, and lets go when the shooter goes.'vanish: it goes when the shooter does.'detach: the same astrue.
:delay n keeps a shot from hitting for its first n ticks, and the host can draw it as a
warning until then. Fire a line of delayed shots, then the real thing, and the player gets
a telegraph.
:tags and cancel: (cancel) removes every cancellable bullet, and
(cancel :tags ['soft]) only those tagged 'soft. It returns how many went. In the testbed,
cancelled bullets turn into items, as they do in most shmups when a phase ends.
:motion gives a shot a curved path, worked out from its age alone:
'polarcircles the point it was fired from. It moves outward at:speedand spins at:spindegrees a tick.'waveflies along:dirand sways:ampunits to each side, every:periodticks.
(defn main []
(forever
(dotimes [i 6]
(fire :dir (* i 60) :speed 1 :motion 'polar :spin 2
:kind 'orb))
(wait 30)))Lasers
(defn main []
(forever
(laser :dir (aim) :warn 40 :active 60 :fade 15 :width 12
:kind 'laser-cyan)
(wait 120)))A laser goes through three phases: a warning line, the beam, then the fade. It can only hit while the beam is on.
| Key | Default | What it does |
|---|---|---|
:dir, :length, :width |
(aim), 512, 8 |
the beam |
:warn, :active, :fade |
30, 60, 15 | ticks in each phase |
:turn |
0 | degrees it turns each tick while active |
(curvy-laser :curve 'head) draws a trail that follows a moving head. 'head flies at
:speed turning :turn a tick, 'wave sways like a wave shot, and 'polar spins like a polar
one. :segments and :window set how long and smooth the trail is.
Lasers take the same placement keys as fire. They follow their shooter unless you pass
:attach false, and vanish with it.
Moving the enemy
(defn main []
(let [w (input 'field-w 384)
home (y)]
(forever
(move-to (rand (* w 0.25) (* w 0.75))
(+ home (rand -20 20))
:over 40)
(wait 40)
(fan 7 :kind 'big-orange)
(wait 20))))move-toworks in field coordinates, and(x)and(y)are where the actor is now.(target-x),(target-y)and(target-count)ask about the player, or players: the host can set several targets.(aim :target i)picks one.(aim :lead s)aims where the player will be, for a shot at speed s.
Talking to the host
Inputs. The host sets named numbers, and scripts read them with (input 'name default):
the boss's health, the phase, anything. rank, a difficulty from 0 to 1, is one of them, and
reads as a plain name. (by-diff easy normal hard lunatic) picks one of four values by the
difficulty input.
The beat clock. When the host sets a tempo, (wait-beats 1) waits for the next beat and
(beat) says which beat it is now, so patterns can follow music. Give both a fallback for when
there's no clock: (wait-beats 1 30) waits 30 ticks then.
Events. (emit 'name) or (emit 'name value) sends the host an event, with the actor's
position: a sound cue, a screen shake, a phase change.
In the testbed:
var1tovar8, the difficulty and the beat clock are in the settings menu.(input 'field-w)and(input 'field-h)give the field size. The width follows the window, so keep positions relative to the enemy or tofield-w.- Emitted names show up in the console.
Randomness
(rand) gives a number from 0 up to 1, (rand a b) one from a up to b, and (rand-nth xs) a
random item. Each actor draws from its own stream, seeded from the VM's seed and the order
actors were fired in. So a pattern looks the same every time for the same seed, and changing
one bullet's script doesn't change what the others draw. The testbed's seed box picks the
seed.
When something goes wrong
- Load errors come with a line and column, and the load changes nothing: the old
definitions stay. Loads also warn about likely mistakes, like a
letname nothing uses, or a call to a parameter that shares a builtin's name. - Runtime errors stop the actor that hit them, and the rest carry on.
- A runaway loop can't freeze the game. Each actor gets a step budget per tick, 100,000 by default. An actor that goes over is stopped with an error.
BulletML
bs convert pattern.xml turns a BulletML file into bs. The testbed has the whole BulletML
example corpus converted, in sections by game. About 97% of those files match libbulletml
exactly over 600 ticks.
Tools
The bs command line, built from crates/bs-cli, has these:
| Command | What it does |
|---|---|
bs check file.bs |
loads a file and reports errors and warnings |
bs run file.bs |
runs it for a while and reports what happened |
bs fmt file.bs |
lays it out the way the examples are written |
bs serve dir/ |
serves a folder to the testbed, which reloads a file when you save it (open the testbed with ?serve) |
bs dodge file.bs |
searches for a path through the pattern, and reports the moments with none |
bs test |
checks patterns against recorded traces |
For other editors:
editors/vscode-bshas syntax highlighting for VS Code.editors/tree-sitter-bsis a tree-sitter grammar.
The examples show most of this at work, one idea per file.