Skip to content

A pattern engine in Rust

Written 2026-10-27 from the project's own docs and code.

Fboard's music comes from fboard_music, a Rust crate that reads patterns written in a subset of Strudel's language and turns them into scheduled sounds. Gameplay drives which section plays, how many layers are in, and the tempo. This page describes how the crate is built.

What the engine does is working and tested. What it plays is not settled: the current score is a placeholder, and no musical direction has been chosen for the game yet.

Design constraints

  • No engine, no audio device. The crate depends only on serde, serde_json, ron and hound (a WAV reader/writer). It produces a list of voices (sample playbacks with timing, pitch, gain and pan). The game schedules those on the Kira audio library, and an offline renderer mixes the same voices into a buffer for video captures and tests.
  • Same text as Strudel. Every pattern in the score pastes into the Strudel REPL for auditioning, so Strudel is the authoring tool and the game is the player.
  • Nothing of Strudel ships. Strudel is AGPL-licensed, so the crate is an independent implementation written from the published TidalCycles/Strudel model, not a port.
  • Unsupported means error. "Anything outside the subset is a loud parse error, never a silent difference."

Modules

Module Role
time Exact rational time. One cycle is one bar.
pattern Patterns as functions from a time span to the events in it, plus the operations on them.
mini The mini-notation parser ("bd [sd sd] <hh oh>*2 bd(3,8)").
expr The expression parser (stack(s("bd*2"), note("c3 e3").s("bass")).slow(2)).
bank Sounds: built-in synthesized instruments plus a curated folder of samples.
voice Turns one event's controls into concrete sample playbacks.
score The score file, sections and layers, and the conductor that steers them from gameplay.
render Scheduling on a musical clock, and the offline mixer.
sim Optional (feature sim): reads gameplay state from the simulation crate.

Exact time

Musical subdivisions like thirds and fifths of a bar can't be represented exactly as floats, and small errors accumulate into drift. So time is a reduced fraction:

pub struct Time {
    num: i64,
    den: i64,
}

impl Time {
    pub fn new(num: i64, den: i64) -> Time {
        assert!(den != 0, "zero denominator");
        let g = gcd(num, den).max(1);
        let s = if den < 0 { -1 } else { 1 };
        Time { num: s * num / g, den: s * den / g }
    }

    /// Start of the cycle containing this time.
    pub fn sam(self) -> Time {
        Time::int(self.num.div_euclid(self.den))
    }
}

Comparisons cross-multiply in i128 so they can't overflow. sam ("start of cycle", a TidalCycles term) uses Euclidean division so negative times round down correctly. A unit test checks that 1/3 + 1/3 + 1/3 == 1. Floats from user input (.late(0.25)) are converted to the nearest fraction with a denominator up to a million, using continued fractions.

Patterns are functions

Following the Tidal/Strudel model, a pattern isn't a list of notes. It's a function you query with a time span; it returns the events (called haps) active in that span. Each hap has a whole (the event's full extent) and a part (the piece of it inside the query). An event starts in the span when its whole begins where its part does.

pub struct Hap<T> {
    /// The event's full extent; `None` for continuous values.
    pub whole: Option<Span>,
    /// The fragment of it inside the queried span.
    pub part: Span,
    pub value: T,
}

type Query<T> = Arc<dyn Fn(Span) -> Vec<Hap<T>> + Send + Sync>;

pub struct Pattern<T> {
    query: Query<T>,
}

Because a pattern is just a shared closure, transformations wrap one closure in another. Making a pattern faster means querying the inner pattern over a stretched span and squeezing the answers back:

pub fn fast(&self, factor: Time) -> Pattern<T> {
    if factor == Time::ZERO {
        return silence();
    }
    if factor < Time::ZERO {
        return self.fast(-factor).rev();
    }
    self.with_time(move |t| t * factor, move |t| t / factor)
}

pub fn slow(&self, factor: Time) -> Pattern<T> {
    if factor == Time::ZERO { silence() } else { self.fast(Time::ONE / factor) }
}

pub fn late(&self, by: Time) -> Pattern<T> {
    self.with_time(move |t| t - by, move |t| t + by)
}

Using Arc (a shared, reference-counted pointer) makes cloning a pattern cheap, and the Send + Sync bounds let patterns move between threads.

The supported subset

Mini-notation: sequences, rests (~ or -), [ ] subsequences, < > one-per-cycle alternation, , layering, *n and /n (a number or a bracketed pattern), @n weight, _ elongation, !n and ! replication, ? and ?p random drop, (k,n,rot) Euclidean rhythms, and words like bd:3, c#4 and 0.5.

Expressions:

  • sources and controls: s/sound, n, note, gain, velocity, speed, pan, attack, release, clip/legato, begin;
  • combinators: stack, cat/slowcat, seq/fastcat, silence;
  • methods: any control, plus fast, slow (a number or mini-notation), rev, late, early, degradeBy.

Anything else is an error that names the function.

Randomness: ? and degradeBy drop events pseudo-randomly. The random value is a hash of the event's start time and a seed, so the same pattern always drops the same events. That's deterministic, which tests and replays need, but it is deliberately not Strudel's random sequence, so a pattern using these will sound different in the game than in the REPL.

Parity with real Strudel

The claim "same text as Strudel" is tested, not assumed. A script in the Strudel spike runs Strudel's own pattern engine (@strudel/core 1.2.6) over a list of expressions and writes every event's start, end and controls to tests/strudel_golden.json, with times as exact fractions:

{ "code": "s(\"bd sd\")",
  "haps": [ { "b": [0, 1], "e": [1, 2], "v": { "s": "bd" } },
            { "b": [1, 2], "e": [1, 1], "v": { "s": "sd" } }, ... ] }

tests/strudel_parity.rs parses each expression with the Rust engine, collects onsets over the same four cycles, sorts both lists and requires them to be identical. It collects every mismatch rather than stopping at the first, and reports the first differing event of each.

The fixture covers 39 expressions and 438 events, from s("bd sd") through alternation, layering, Euclidean rhythms, fast("<1 2>"), rev, late/early and chained controls. All match. Random operators are left out on purpose, for the reason above. When the supported subset grows, the fixture is regenerated. The general technique is described in Testing and verification.

Sounds

A pattern names sounds with s(...). They come from two places:

  • Built-in synthesized instruments, so the score plays with nothing else installed: drums kick, snare, hat, openhat, clap; pitched bass, lead, pad; and guitar and mute, distorted power chords where the note is the chord's root.
  • A curated sounds folder (empty by design until sounds whose licenses allow shipping are chosen). A folder of WAVs is a one-shot bank (s("name:2") plays the third file), and a <name>.instrument.json file is a pitched multisample instrument in the region-map format the Strudel spike emits from SFZ. A curated sound with a built-in's name replaces the built-in. WAV only; other formats must be converted first.

The voice module resolves instrument notes the way an SFZ player does (key and velocity ranges, round robin, crossfades, tuning), ported from the spike's own JavaScript. Sample playback follows Strudel's sampler: with no clip, release or loop, the whole sample plays; otherwise it holds for the event's length (times clip) and then releases.

Score and conductor

A score is a RON file of sections, each made of layered patterns:

(
    bpm: 112.0,
    tempo_lift: 0.06,              // up to +6% tempo at full intensity
    quantize: 2,                   // section changes land on 2-bar lines
    sections: [
        (name: "cruise", layers: [
            (name: "drums", pattern: "s(\"kick snare kick snare\")"),
            (name: "lead", pattern: "note(\"a4 c5\").s(\"lead\")", from: 0.6),
        ]),
    ],
    stingers: [(cue: "banked", pattern: "note(\"c5 e5 g5\").s(\"lead\")", quantize: 0.25)],
)

(That's the documentation example from score.rs, not the game's score.) One cycle is one bar of four beats, matching Strudel at setcps(bpm / 240).

Each frame, the game fills in MusicInputs: the scene (menu, riding, paused, results), the rider's speed, whether they're in the air, grinding or bailed, the combo multiplier, and time left on a timed run. A function maps these to a preferred section and an intensity between 0 and 1. The conductor then:

  • queues section changes for the next quantize line, so they land on the bar grid;
  • raises intensity quickly and lets it fall slowly;
  • fades each layer in or out by intensity (a layer can come in at, say, 0.6 and optionally leave again above some level);
  • lifts the tempo slightly at high intensity (the game's score allows up to +5%);
  • plays stingers, short cues for events like a bail or a banked combo, on their next quantize line.

The numbers in the gameplay-to-music mapping are recorded in the code as first guesses, to be tuned by ear.

Scheduling and playback

The musical clock runs at 3840 ticks per bar, a number divisible by 2, 3, 4, 5, 6, 8 and 16, so common subdivisions land exactly on ticks. In the game, voices are scheduled about 120 ms ahead on a Kira clock, so they land on the beat regardless of frame rate, and Kira's internal buffer is set to 64 frames (about 1.5 ms timing resolution). Each layer plays on its own Kira sub-track, which is where intensity fades are applied.

The offline renderer mixes the same scheduled voices deterministically. A command-line example renders each section of a score at low and high intensity to WAV files and prints levels per section and per layer.

What's verified and what isn't

From the project's handoff notes:

  • Verified: cargo test --workspace passes, including 22 music tests, the Strudel parity test and 3 audio tests added with the engine. A capture of a recorded gameplay run with the engine providing the soundtrack measured 124 s long, mean −26.4 dB, peak −6.6 dB, and no gaps over 0.75 s below −45 dB.
  • Not verified: at the time of the notes, nobody had listened to the output. The live Kira path (clock scheduling, layer fades, a pause low-pass filter) runs without warnings but was only exercised muted; only its tick arithmetic is unit-tested. The offline capture applies layer gains per voice at trigger time, so its fades are coarser than the live ones.

Levels were set by measurement, not by ear. The score that currently ships in the game is a placeholder pending a musical direction.

See also