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

Try it
(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

Syntax

; A comment runs to the end of the line.
(fire :dir 90 :speed 2.5 :kind 'big-blue)

nil and false are false. Everything else is true, 0 included.

Firing

fire makes a bullet. Every argument is optional:

Try it
(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:

Try it
(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:

Try it
(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

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

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:

Try it
(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:

Try it
(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:

Try it
(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.

Try it
(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:

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.

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

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:

Try it
(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:

Try it
(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:

Try it
(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:

: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:

Try it
(defn main []
  (forever
    (dotimes [i 6]
      (fire :dir (* i 60) :speed 1 :motion 'polar :spin 2
            :kind 'orb))
    (wait 30)))

Lasers

Try it
(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

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

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:

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

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:

The examples show most of this at work, one idea per file.