The bs cookbook

Common shmup patterns written in bs. Each example is a full program with a main, and on bs.ell.dev each one has a link that opens it in the testbed. The guide covers the language itself.

The enemy starts near the top of the field and the player near the bottom. The field is 448 units tall. Its width depends on the window, so examples that use the side walls read it with (input 'field-w 384). The dodge bot gets through every example here.

Aiming

Aimed and locked streams

The first stream aims every shot, so it follows the player. The second aims once, so every shot goes where the player was and a small step clears it.

Try it
(defn main []
  (forever
    ; Aimed every shot: the stream bends after the player.
    (dotimes [i 12]
      (fire :speed 4 :kind 'needle-cyan)
      (wait 4))
    (wait 30)
    ; Aimed once: the stream goes where the player was.
    (stream 12 :speed 4 :gap 4 :kind 'needle-yellow)
    (wait 30)))

fire aims at the player when it has no :dir. stream works out its :dir once, so every shot in it goes the same way. :gap and :speed control how hard each stream is to avoid.

Odd and even fans

An odd fan puts its middle shot on the player, so they have to move. An even fan puts the player in the middle gap, so standing still is safe. This example alternates the two.

Try it
(defn main []
  (forever
    ; Odd: the middle shot is aimed, so move.
    (fan 5 :step 14 :speed 2.5 :kind 'big-red)
    (wait 30)
    ; Even: the player sits in the middle gap, so stay.
    (fan 6 :step 14 :speed 2.5 :kind 'big-blue)
    (wait 30)))

fan centres its shots on (aim), :step degrees apart. Keep the step wide enough that the player fits between two shots by the time they arrive.

Leading shots

(aim :lead s) aims where the player will be when a shot at speed s reaches them, if they keep moving the same way. Fired with a plain aimed shot, it catches a player who stands still and one who keeps moving. Changing speed or direction gets past both.

Try it
(defn main []
  (forever
    (dotimes [i 6]
      (fire :speed 3 :kind 'needle-purple)
      (fire :dir (aim :lead 3) :speed 3
            :kind 'needle-yellow)
      (wait 8))
    (wait 40)))

Give :lead the same speed the shot flies at. Moving the enemy lists the other queries about the player.

Random spray

Bursts of shots at random angles and speeds, centred on the player. The shots are spread thin enough to get through.

Try it
(def colours ['small-orange 'small-yellow 'small-red])

(defn main []
  (forever
    (dotimes [i 40]
      (burst 2 :width 70 :speed [1.5 3.5]
             :kind (rand-nth colours))
      (wait 3))
    (wait 45)))

burst picks each direction within :width degrees of :dir, the player by default, and each speed from the :speed range. rand-nth picks the colour. Wider ranges spread the shots further.

A wall to weave through

Rows of shots fall across the whole width of the field. Each row is shifted half a step from the last, so the player moves a little for each one.

Try it
(def spacing 24)

; A row of shots across the top of the field, falling.
; shift moves it along by a share of the spacing.
(defn row [shift kind]
  (let [w (input 'field-w 384)]
    (dotimes [i (floor (/ w spacing))]
      (let [wx (* spacing (+ i shift))]
        (fire :at [(- wx (x)) (- 440 (y))] :dir 270
              :speed 2 :kind kind)))))

(defn main []
  (forever
    (row 0.25 'big-orange)
    (wait 24)
    (row 0.75 'big-yellow)
    (wait 24)))

:at places a shot relative to the shooter, so (- wx (x)) turns a field position into an offset. The number of shots in a row comes from field-w, which keeps the spacing the same on any width. spacing sets how much room the player has between shots.

Spirals

Spiral

One shot a tick, each turned a few degrees further than the last. The player moves through the gaps between passes of the arm.

Try it
(defn main []
  (loop [a 0]
    (fire :dir a :speed 2.5 :kind 'rice)
    (wait 1)
    (recur (+ a 11))))

loop and recur carry the angle from shot to shot (Loops and lists). The step sets how tightly it winds: 11 degrees a tick makes a full turn every 33 ticks. A step that divides 360 evenly lines the shots up into straight spokes instead.

Double spiral

Two arms half a turn apart, one colour each.

Try it
(defn main []
  (loop [a 0]
    (fire :dir a :speed 2 :kind 'big-cyan)
    (fire :dir (+ a 180) :speed 2 :kind 'big-pink)
    (wait 2)
    (recur (+ a 7))))

For more arms, fire more shots per step, (/ 360 arms) degrees apart. A smaller step or a shorter wait packs the arms tighter.

Counter-rotating spirals

Two three-armed spirals turning opposite ways. Where the arms cross, the gaps between them keep opening and closing.

Try it
(defn spiral [step kind]
  (loop [a 0]
    (dotimes [arm 3]
      (fire :dir (+ a (* arm 120)) :speed 2 :kind kind))
    (wait 4)
    (recur (+ a step))))

(defn main []
  (par (spiral 9 'needle-cyan)
       (spiral -9 'needle-violet)))

par runs both spirals at once, each in its own thread (Doing things at once). One function makes both spirals. Only the sign of the step and the kind differ.

Rotating rings in two colours

A ring every few ticks, each turned 5 degrees from the last, so the shots line up into curved arms. The colours alternate by ring.

Try it
(def colours ['big-blue 'big-pink])

(defn main []
  (loop [i 0]
    (ring 18 :dir (* i 5) :speed 1.6
          :kind (nth colours (mod i 2)))
    (wait 16)
    (recur (inc i))))

(mod i 2) goes 0, 1, 0, 1, and nth uses it to pick a kind from the list. A turn of half the ring's spacing, 10 degrees for 18 shots, makes each ring fill the gaps of the one before.

Bullet scripts

A bullet fired with :run runs a script of its own, and the script can steer it, fire from it or remove it.

Stop and re-aim

A ring flies out and stops. Then its shots turn toward the player and fly at them one at a time.

Try it
(defn reaim [i]
  (fn []
    (change-speed 0 :over 30)
    (wait (+ 40 (* i 3)))
    (change-dir (aim))
    (change-speed 3 :over 20)))

(defn main []
  (forever
    (let [a (rand 0 360)]
      (dotimes [i 16]
        (fire :dir (+ a (* i 22.5)) :speed 3 :kind 'big-pink
              :run (reaim i))))
    (wait 90)))

reaim takes i and returns a script, so each shot waits a little longer than the one before. change-speed with :over runs in the background, so the script needs its own wait before it turns.

Split

Big shots slow down, then each one bursts into a ring with one shot aimed at the player.

Try it
(defn split []
  (change-speed 0 :over 40)
  (wait 40)
  (ring 10 :dir (aim) :speed 1.5 :kind 'small-orange)
  (vanish))

(defn main []
  (forever
    (fan 3 :step 50 :speed 3 :kind 'huge-orange :run split)
    (wait 70)))

A bullet's script can fire too, and its shots start where the bullet is. (aim) inside the script aims from the bullet, so each ring's first shot goes at the player. vanish removes the big shot once it has split.

Bounce off the walls

A wide spread whose outer shots bounce off the side walls and come back across the field.

Try it
(defn bounce []
  (let [w (input 'field-w 384)]
    (wait-until (or (< (x) 0) (> (x) w)))
    (change-dir (- 180 (heading)))))

(defn main []
  (loop [i 0]
    (spread 8 :dir (+ 270 (* 6 (mod i 2))) :width 160
            :speed 2.5 :kind 'big-cyan :run bounce)
    (wait 30)
    (recur (inc i))))

wait-until checks its test once a tick. Once the shot is past a wall, (- 180 (heading)) flips its direction left to right. It bounces once, then flies on. Every other spread is turned 6 degrees, so the shots don't all fly down the same tracks.

Curving shots

Rings whose shots all turn the same way, so each ring bends into a pinwheel.

Try it
(defn curl [rate]
  (fn [] (turn rate :for 90)))

(defn main []
  (forever
    (ring 6 :speed 2 :kind 'leaf :run (curl 0.7))
    (wait 6)))

turn adds its rate to the heading every tick, for :for ticks. curl returns a script with the rate built in. A negative rate turns the other way. Firing both directions together makes a lattice.

Weak homing

Missiles turn toward the player for a while, then fly straight. A sidestep late in their flight gets away from them.

Try it
(defn seek []
  (dotimes [i 5]
    (change-dir (aim) :over 30)
    (wait 15)))

(defn main []
  (forever
    (ring 6 :dir 30 :speed 2 :kind 'missile :run seek)
    (wait 50)))

change-dir with :over 30 would turn the missile all the way to the player in 30 ticks. The next aim cuts it off after 15, so it only gets halfway round each time. Fewer rounds or a longer :over make the homing weaker.

Gravity

Shots are thrown upward and fall back down in arcs.

Try it
(defn fall []
  (accel 0 -3 :over 40))

(defn main []
  (forever
    (fire :dir (+ 90 (rand -70 70)) :speed (rand 1 2.5)
          :kind 'gem :run fall)
    (wait 3)))

accel adds a velocity on top of the shot's own heading and speed. Here it ramps to 3 units a tick downward over 40 ticks and stays there. Give it an x part, like (accel 0.5 -3 :over 40), for wind.

Speeding up

A fast ring, then a slow ring that speeds up and catches the first one near the player.

Try it
(defn surge []
  (change-speed 4 :over 90))

(defn main []
  (forever
    (ring 24 :dir (rand 0 360) :speed 0.3
          :kind 'needle-orange
          :run surge)
    (ring 24 :dir (rand 0 360) :speed 2.5 :kind 'big-teal)
    (wait 45)))

change-speed with :over ramps the speed in the background. Each ring gets its own random angle, so the two rings' shots don't line up.

Lasers

A laser shows a warning line, fires, then fades. It can only hit while the beam is on.

Sweeping lasers

Two beams start pointing out to the sides and sweep down toward the middle, stopping short of it. Aimed fans come down the space between them.

Try it
(defn beam []
  (laser :dir 190 :turn 0.4 :warn 40 :active 125 :fade 20
         :width 14 :kind 'laser-blue))

(defn main []
  (forever
    (beam)
    (mirror (beam))
    (wait 40)
    (dotimes [i 8]
      (fan 3 :step 10 :speed 2.5 :kind 'big-yellow)
      (wait 15))
    (wait 85)))

:turn turns the beam that many degrees each tick while it's active, so 0.4 for 125 ticks takes it from 190 to 240. mirror makes the second beam from the first. A sweep across the whole field would leave no space, so each beam stops 30 degrees short of straight down.

Laser cage

Two beams come down on either side of the player, then aimed shots fall between them.

Try it
; A bar down the whole height of the field, at field x.
(defn bar [bx]
  (laser :at [(- bx (x)) (- 448 (y))] :dir 270 :length 448
         :width 10 :warn 50 :active 200 :fade 20
         :attach false :kind 'laser-red))

(defn main []
  (let [w (input 'field-w 384)]
    (forever
      (let [px (clamp (target-x) 70 (- w 70))]
        (bar (- px 60))
        (bar (+ px 60)))
      (wait 60)
      (dotimes [i 12]
        (spread 5 :width 40 :speed 2 :kind 'small-yellow)
        (wait 15))
      (wait 60))))

:attach false leaves a beam where it was fired instead of following the boss. (target-x) is the player's x, and clamp keeps the cage inside the field.

Warning line, then beam

Beams aimed at the player. Each one shows a thin warning line for 45 ticks before it fires.

Try it
(defn main []
  (forever
    (dotimes [i 3]
      (laser :warn 45 :active 20 :fade 10 :width 16
             :kind 'laser-cyan)
      (wait 25))
    (wait 60)))

A laser aims at the player unless given a :dir. :warn sets how long the line shows. The 25-tick wait between beams sets the rhythm.

Curvy lasers

Two arcs curl down either side of the boss while a wavy beam snakes toward the player.

Try it
(defn arc []
  (curvy-laser :dir 330 :curve 'head :speed 2.5 :turn -0.8
               :segments 32 :window 50 :width 8
               :attach false :kind 'laser-green))

(defn main []
  (forever
    (arc)
    (mirror (arc))
    (wait 20)
    (curvy-laser :curve 'wave :speed 2.5 :amplitude 30
                 :period 50 :segments 40 :window 60
                 :attach false :kind 'laser-thin)
    (wait 40)))

curvy-laser draws a trail behind a moving head. With 'head the head flies at :speed, turning :turn degrees a tick. With 'wave it sways :amplitude units to each side every :period ticks. :segments and :window set how long and smooth the trail is, and :attach false lets the beams fly off on their own.

Shapes and symmetry

Polygons

Outlines from a triangle up to a hexagon and round again, each turned a little.

Try it
(defn main []
  (loop [sides 3]
    (polygon sides :per-side 6 :dir (* sides 20) :speed 1.5
             :kind 'big-cyan)
    (wait 40)
    (recur (if (= sides 6) 3 (inc sides)))))

polygon fires the corners at :speed and the rest slower, so the outline keeps its shape as it grows. :per-side sets how many shots make each side, and so how wide the gaps are.

Roses

Rings that open into five petals, alternating colours, each turned half a petal from the last.

Try it
(defn main []
  (loop [a 0]
    (rose 40 :petals 5 :dir a :speed 2.5
          :kind 'small-magenta)
    (wait 35)
    (rose 40 :petals 5 :dir (+ a 36) :speed 2.5
          :kind 'small-yellow)
    (wait 35)
    (recur (+ a 15))))

rose fires the tip of each petal at :speed and the shots between petals at a quarter of it. Those slow shots stay near the boss for a long time, so keep the count to about 40 shots a rose.

Mirrored wings

A sweep from one side and its mirror image from the other. Each shot curls a little, and the mirrored shots curl the other way.

Try it
(defn wing []
  (dotimes [i 12]
    (fire :dir (+ 190 (* i 6)) :speed 2.5 :kind 'rice
          :run (fn [] (turn 0.5 :for 60)))
    (wait 3)))

(defn main []
  (forever
    (par (wing) (mirror (wing)))
    (wait 30)))

mirror flips everything fired inside it left to right, including the turns in the bullets' scripts. par fires the two wings together.

Rotational symmetry

One arm, made of a small fan and a slow big shot, repeated five times round the circle and turned a little more each volley.

Try it
(defn arm []
  (fan 3 :dir 0 :step 5 :speed 2 :kind 'needle-violet)
  (fire :dir 0 :speed 1.4 :kind 'big-purple))

(defn main []
  (loop [a 0]
    (dotimes [i 5]
      (rotate (+ a (* i 72)) (arm)))
    (wait 10)
    (recur (+ a 7))))

rotate turns everything fired inside it, so the arm is written pointing at 0 degrees and rotate puts it in place. For n arms, space them (/ 360 n) apart.

Time and phases

Phases on a timer

Three attacks, each running for a set time before the next takes over.

Try it
(defn pinwheel []
  (forever
    (ring 5 :dir (* (age) 3) :speed 2 :kind 'rice)
    (wait 4)))

(defn fans []
  (forever
    (fan 7 :step 9 :speed 2.5 :kind 'big-orange)
    (wait 24)))

(defn rain []
  (forever
    (burst 3 :dir 270 :width 140 :speed [1 2.5]
           :kind 'small-blue)
    (wait 5)))

(def phases
  [[240 pinwheel]
   [180 fans]
   [240 rain]])

(defn main []
  (forever
    (doseq [[ticks attack] phases]
      (race (wait ticks) (attack)))))

race stops all its forms as soon as one ends, so (race (wait ticks) (attack)) runs an endless attack for that many ticks (Doing things at once). The phases are a list of [ticks attack] rows, so reordering or retiming them doesn't touch the code. Bullets from one phase carry over into the next, and A clean break between phases shows how to clear them.

Phases on health

The boss changes attacks as its health drops, and clears the field at each change.

Try it
(defn hp [] (input 'var1 1))

(defn calm []
  (forever
    (ring 12 :dir (* (age) 2) :speed 1.6 :kind 'big-teal)
    (wait 25)))

(defn angry []
  (forever
    (ring 16 :dir (* (age) 2) :speed 2 :kind 'big-red)
    (fan 5 :step 12 :speed 3 :kind 'needle-orange)
    (wait 20)))

(defn desperate []
  (forever
    (burst 4 :width 360 :speed [1.5 3] :kind 'small-red)
    (wait 3)))

(defn main []
  (until (< (hp) 0.6) (calm))
  (cancel)
  (emit 'angry)
  (until (< (hp) 0.25) (angry))
  (cancel)
  (emit 'desperate)
  (desperate))

The host passes health in as an input. In the testbed, var1 in the settings menu stands in for it: 1 is full health, 0 is dead. Until it's set, (input 'var1 1) reads 1 and the first attack keeps running. until stops its body when the test turns true, checking once a tick.

Attack cycle

The boss moves to a new spot, runs an attack, then moves again for the next one.

Try it
(defn reposition []
  (let [w (input 'field-w 384)]
    (move-to (rand (* w 0.25) (* w 0.75)) (rand 330 380)
             :over 40)
    (wait 50)))

(defn spiral-burst []
  (dotimes [i 60]
    (ring 3 :dir (* i 13) :speed 2.2 :kind 'needle-cyan)
    (wait 2)))

(defn aimed-volley []
  (dotimes [i 4]
    (fan 5 :step 10 :speed 3 :kind 'big-yellow)
    (wait 18)))

(defn big-ring []
  (dotimes [i 3]
    (ring 24 :dir (* i 7.5) :speed 1.5 :kind 'big-purple)
    (wait 20)))

(defn main []
  (forever
    (doseq [attack [spiral-burst aimed-volley big-ring]]
      (reposition)
      (attack))))

Functions are values, so a list can hold attacks and doseq can call each in turn. Each attack here ends on its own, which is what lets the cycle move on. move-to works in field coordinates, and field-w keeps the boss away from the walls.

A clean break between phases

At the end of each phase the bullets are cleared, then there's a one-second pause. The big purple shots are fired with :cancellable false, so they stay.

Try it
(defn swirl []
  (forever
    (ring 5 :dir (* (age) 4) :speed 1.5 :kind 'small-cyan)
    (wait 4)))

(defn anchors []
  (forever
    (fire :speed 2 :kind 'huge-purple :cancellable false)
    (wait 40)))

(defn phase [ticks attack]
  (race (wait ticks) (attack))
  (emit 'phase-end (cancel))
  (wait 60))

(defn main []
  (forever
    (phase 240 swirl)
    (phase 240 (fn [] (par (swirl) (anchors))))))

(cancel) removes every cancellable bullet and returns how many went, which emit passes to the host for a score or a sound. The testbed turns cancelled bullets into items. To clear only some, tag shots with :tags and call (cancel :tags 'soft) (Placing shots).

Telegraphs

Marked lines

Three lines of ghost shots appear along the paths the shots will take. After 40 ticks they can hit, and they move along those lines.

Try it
(defn go []
  (wait 40)
  (change-speed 3 :over 10))

(defn marked-line [d]
  (dotimes [i 14]
    (fire :at [(* i 18 (cos d)) (* i 18 (sin d))] :dir d
          :speed 0 :delay 40 :kind 'big-red :run go)))

(defn main []
  (forever
    (let [d (aim)]
      (marked-line d)
      (marked-line (+ d 25))
      (marked-line (- d 25)))
    (wait 80)))

:delay keeps a shot from hitting for that many ticks, and the testbed draws it as a ghost until then. Fired at speed 0 with a script that waits out the delay, the ghosts sit still as a warning and then go.

Safe lanes

Rows of ghosts form at the top of the field with one lane left open, then fall. The open lane moves by one each row, so the player always has time to reach it.

Try it
(def lanes 8)

(defn fall []
  (wait 30)
  (change-speed 2 :over 10))

(defn row [open]
  (let [w (input 'field-w 384)
        lane-w (/ w lanes)]
    (dotimes [i (* lanes 4)]
      (let [wx (* (+ i 0.5) (/ lane-w 4))
            at [(- wx (x)) (- 440 (y))]]
        (when (not= open (floor (/ wx lane-w)))
          (fire :at at :dir 270 :speed 0 :delay 30
                :kind 'small-purple :run fall))))))

(defn main []
  (loop [open 3]
    (row open)
    (wait 45)
    (let [step (rand-nth [-1 1])]
      (recur (clamp (+ open step) 0 (dec lanes))))))

The lane only ever moves one step, and clamp keeps it on the field. More lanes makes narrower ones, and a shorter wait between rows gives less time to move.

Familiars and options

Orbiting options

Three drones circle the boss and follow it as it moves, each firing aimed fans.

Try it
(defn gun []
  (forever
    (wait 40)
    (fan 3 :step 12 :speed 2.2 :kind 'small-yellow)))

(defn main []
  (dotimes [i 3]
    (let [a (* i 120)]
      (fire :at [(* 70 (cos a)) (* 70 (sin a))] :speed 0
            :motion 'polar :spin 1.2 :attach true
            :cancellable false :kind 'drone :run gun)))
  (let [w (input 'field-w 384)]
    (forever
      (move-to (* w 0.3) (y) :over 90)
      (wait 120)
      (move-to (* w 0.7) (y) :over 90)
      (wait 120))))

:motion 'polar with :spin circles a shot round the point it was fired from, and :attach true keeps that point on the boss. :at sets where on the circle each drone starts. They're fired :cancellable false so that a phase change doesn't take them away.

Free drones

Drones fly out to the sides, fire a few rings, then leave. The boss keeps firing meanwhile.

Try it
(defn drone [tx ty]
  (move-to tx ty :over 50)
  (wait 60)
  (dotimes [i 4]
    (ring 12 :dir (* i 7.5) :speed 1.5 :kind 'small-cyan)
    (wait 30))
  (change-dir 90)
  (change-speed 3 :over 40))

(defn main []
  (let [w (input 'field-w 384)]
    (forever
      (doseq [tx [(* w 0.2) (* w 0.8)]]
        (fire :speed 0 :kind 'drone
              :run (fn [] (drone tx 300))))
      (dotimes [i 8]
        (fan 3 :step 15 :speed 2.5 :kind 'needle-violet)
        (wait 30)))))

A drone is a shot with a script, fired at speed 0 with the 'drone kind. move-to glides it to a spot on the field. After its last ring it turns upward and speeds off the top, where it's culled.

Difficulty

Difficulty and rank

Difficulty changes the pattern itself: bigger rings, and an aimed fan from Hard up. Rank changes the speed and timing.

Try it
(defn main []
  (let [n (by-diff 10 14 18 22)
        aimed (by-diff 0 0 3 5)]
    (loop [a 0]
      (let [speed (lerp 1.5 2.4 rank)
            gap (lerp 40 26 rank)]
        (ring n :dir a :speed speed :kind 'big-blue)
        (fan aimed :step 12 :speed (* speed 1.5)
             :kind 'needle-yellow)
        (wait gap)
        (recur (+ a 7))))))

(by-diff easy normal hard lunatic) picks one of four values by the host's difficulty setting. That doesn't change mid-fight, so it's read once. rank runs from 0 to 1 and can change during play, so it's read every volley. (fan 0 ...) fires nothing, which leaves the aimed layer out on Easy and Normal. See Talking to the host.

Randomness that stays fair

Each actor draws from its own random stream, seeded by the VM (Randomness). The same seed plays out the same way every time, so replays work.

Variety without repeats

The boss picks its next attack at random but never the same one twice in a row, and each attack varies its own details.

Try it
(defn petals []
  (rose 32 :petals (rand-nth [3 4 5]) :dir (rand 0 360)
        :speed 2 :kind 'small-magenta))

(defn wave []
  (dotimes [i 20]
    (fire :dir (+ 270 (* 50 (sin (* i 18)))) :speed 2.5
          :kind 'needle-cyan)
    (wait 2)))

(defn volley []
  (fan (rand-nth [3 5 7]) :step 12 :speed 2.5
       :kind 'big-orange))

(def attacks [petals wave volley])

(defn main []
  (loop [prev -1]
    (let [i (rand-nth (filter (fn [j] (not= j prev))
                              (range (count attacks))))
          attack (nth attacks i)]
      (attack)
      (wait 50)
      (recur i))))

filter drops the last attack's index before rand-nth picks from the rest. Inside the attacks, rand-nth and rand vary the petal count, the angle and the size of the fan.

A gap that's always there

Random sprays toward the lower half of the field, always with one gap. The gap moves a little each volley, so the player can always reach it.

Try it
(defn main []
  (loop [gap (mod (aim) 360)]
    (dotimes [i 30]
      (let [d (rand 200 340)]
        (when (> (abs (- d gap)) 12)
          (fire :dir d :speed (rand 2 2.3)
                :kind 'small-red))))
    (wait 20)
    (recur (clamp (+ gap (rand -8 8)) 215 325))))

Random shots can line up into a wall by chance. Leaving out every shot near the gap rules that out. (aim) comes back between -180 and 180, so mod moves it into the 0 to 360 range the other angles use. clamp keeps the gap away from the edges of the spray.

Music

On the beat

A ring on every beat, an aimed pair on the off-beats, and a burst every fourth beat.

Try it
(defn main []
  (par
    ; A ring on every beat, turning by a step each beat.
    (forever
      (ring 20 :dir (* 9 (beat (/ (tick) 30))) :speed 1.6
            :kind 'big-pink)
      (wait-beats 1 30))
    ; An aimed pair on the off-beats.
    (forever
      (wait-beats 0.5 15)
      (fan 2 :step 16 :speed 3 :kind 'needle-yellow)
      (wait-beats 0.5 15))
    ; A burst every fourth beat.
    (forever
      (wait-beats 4 120)
      (burst 16 :width 90 :speed [2 3.5]
             :kind 'small-yellow))))

(wait-beats n) waits for the host's beat clock. A whole number waits n beats from the start of the current one, and a fraction waits for the next step on that grid. The second number is the wait in ticks when there's no clock, and 30 ticks is a beat at 120 bpm. (beat d) is the beat now, or d without a clock. The testbed's beat clock is in the settings menu.

Writing helpers

Passing keys on

A helper that fires two interlocking rings, called twice with different keys.

Try it
; Two rings, the second turned half a step and :gap ticks
; later, so they interlock. Any other key goes to ring.
(defn ring-pair [n :dir 0 :gap 8 & opts]
  (ring n :dir dir & opts)
  (wait gap)
  (ring n :dir (+ dir (/ 180 n)) & opts))

(defn surge []
  (change-speed 3 :over 60))

(defn main []
  (forever
    (ring-pair 16 :dir (age) :speed 0.5 :kind 'big-teal
               :run surge)
    (wait 30)
    (ring-pair 20 :gap 12 :speed 1.5 :kind 'tiny-pink
               :delay 20)
    (wait 40)))

& opts collects the keys ring-pair has no parameter for and passes them on to ring, so a caller can add :run, :delay, :tags or anything else fire takes. A key that nothing takes is an error when the file loads. Keys the helper sets itself aren't passed on, which is why ring-pair takes :dir as a parameter of its own.