vsrg 0.6.0

Data structures for vertical scrolling rhythm games
Documentation

vsrg

vsrg logo

Rust crate providing data structures for vertical scrolling rhythm games.

Stability

This crate is currently in its very early stage. We plan to battle-test it by using it to build our own commercial rhythm game, so expect occasional breaking changes until the API stabilizes.

We expect to release v1.0 around the same time as the public release of our rhythm game.

What is this crate for?

This crate is intended to help you build rhythm-game gameplay systems and editors without starting from low-level timing, scrolling, and note-storage primitives yourself. See the feature list below.

vsrg does not try to be a full game engine. You can use only the parts you need, such as the math and rhythm modules, or you can use the note-storage system to build larger chart and editor.

We are unsure about Bevy support because we do not use Bevy ourselves. Issues and pull requests related to Bevy integration are welcome.

Key features

Timing and events

  • Beat-time, clock-time, tempo, and scroll-position newtypes.
  • Conversion between beat time and clock time with variable BPM over time.
  • Scroll-position calculation with variable scroll speed over time.
  • Tempo and scroll-speed changes with eased transitions.
  • Linear, quadratic, cubic, and other easing utilities.

Note storage

  • Short-note and long-note storage using a struct-of-arrays layout.
  • Fast queries for notes inside timing and scroll-position windows.
  • Generated game-specific storage types through generate_notes_storage!.
  • Editing methods for adding, removing, replacing, and batching note changes.
  • Helpers for note relationships such as chords, endpoints, parent-child links, and chains.

Installation

Add the crate with Cargo:

cargo add vsrg

Or add it manually to Cargo.toml:

[dependencies]

vsrg = "0.1"

Optional features

[dependencies]

vsrg = { version = "0.1", features = ["serde", "glam", "lut_integration"] }

Available features:

  • glam: implements easing support for supported glam vector types.
  • serde: enables serialization and deserialization for supported types.
  • lut_integration: enables integration of advanced easing curves using bundled lookup tables. This increases binary/static data size.

Quick start

See the crate documentation for a fuller example. We plan to add more example code to this repository later.

A typical setup looks like this:

  1. Define your game's note types, such as tap notes and hold notes.
  2. Implement ShortNoteData or LongNoteData for those note types.
  3. Use generate_notes_storage! to generate strongly typed storage.
  4. Create tempo and scroll-speed tracks.
  5. Load notes into storage.
  6. Query that storage during gameplay and mutate it from your editor.
use vsrg::{generate_notes_storage, ClockTime, Time};
use vsrg::notes::{
    GroupId, LongNoteData, LongNoteTypeStorage, ShortNoteData, ShortNoteTypeStorage,
};
use soa_rs::{SoaClone, Soars};

#[derive(Debug, Clone)]
struct TapNote {
    time: ClockTime,
    lane: u16,
}

#[derive(Debug, Clone)]
struct HoldNote {
    time: ClockTime,
    duration: ClockTime,
    lane: u16,
}

#[derive(Clone, Soars, SoaClone, Default)]
struct NoteState {
    completed: bool,
    highlighted: bool,
}

#[derive(Clone, Soars, SoaClone)]
struct NoteValue {
    lane: u16,
}

impl ShortNoteData for TapNote {
    type ValueData = NoteValue;
    type RuntimeData = NoteState;

    fn time(&self) -> Time {
        Time::Clock(self.time)
    }

    fn group_id(&self) -> &GroupId {
        &GroupId::Main
    }

    fn value_data(&self) -> NoteValue {
        NoteValue { lane: self.lane }
    }

    fn reconstruct<'a>(time: Time, data: <NoteValue as Soars>::Ref<'a>, group: GroupId) -> Self {
        assert_eq!(group, GroupId::Main);
        let Time::Clock(time) = time else {
            panic!("expected clock time")
        };
        Self { time, lane: *data.lane }
    }
}

impl LongNoteData for HoldNote {
    type ValueData = NoteValue;
    type RuntimeData = NoteState;

    fn start_time(&self) -> Time {
        Time::Clock(self.time)
    }

    fn duration(&self) -> Time {
        Time::Clock(self.duration)
    }

    fn group_id(&self) -> &GroupId {
        &GroupId::Main
    }

    fn value_data(&self) -> NoteValue {
        NoteValue { lane: self.lane }
    }

    fn reconstruct<'a>(
        time: Time,
        duration: Time,
        data: <NoteValue as Soars>::Ref<'a>,
        group: GroupId,
    ) -> Self {
        assert_eq!(group, GroupId::Main);
        let (Time::Clock(time), Time::Clock(duration)) = (time, duration) else {
            panic!("expected clock time and duration")
        };
        Self { time, duration, lane: *data.lane }
    }
}

generate_notes_storage! {
    enum MyNoteType,
    enum MyNote,
    enum MyNoteSnapshot,
    enum MyNoteRef,
    enum MyNoteMut,
    struct MyNotesStorage {
        note_type Tap(TapNote) => taps: ShortNoteTypeStorage<TapNote>,
        note_type Hold(HoldNote) => holds: LongNoteTypeStorage<HoldNote>,
    }
}

See the crate documentation for a fuller example that includes note groups, tempo tracks, scroll-speed tracks, rendering queries, judgement queries, and editor updates.

Concepts

VSRG primer: how scrolling works

In a typical VSRG, each note has a Time, which has two jobs:

  • Tells when the player should hit the note (needed for judgement)
  • And determines where the note appears on the scrolling field (needed for rendering notes).

The latter requires converting from the note's time to scroll position. With a constant scroll speed, the formula is simple:

note_position(note, current_music_time) = (note.time - current_music_time) * scroll_speed

Which can be split into two steps:

scroll_position(time) = time * scroll_speed
note_position(note, current_music_time) = scroll_position(note.time) - scroll_position(current_music_time)

If scroll speed can change over time, then the scroll_speed itself becomes a function of time. Calculating position from speed is a classic physics problem, the position is calculated by integrating scrolling speed over time:

scroll_position(time) = ∫ from t=0 to t=time of scroll_speed(t)*dt

The important thing is that once you can calculate scroll_position from time, then the same formula for note_position over time stays the same. Combining them together:

scroll_position(time) = ∫t=0 -> t=time scroll_speed(t)*dt
note_position(note, current_music_time) = scroll_position(note.time) - scroll_position(current_music_time)

Because a note's time does not change during gameplay, you can easily cache scroll_position(note.time) at level load (and/or after editing the note). Only scroll_position(current_music_time) must be recomputed every frame, which can then be reused to calculate the position of every notes at once.

This crates already handles the scroll_position calculation, and caches scroll_position(note.time) per note for you. Rendering code can simply query visible notes in a range of scroll_position, then subtract scroll_position(current_time) to get each note's offset, like so:

fn render_notes<G: NoteGroup>(
    notes: &MyNotesStorage,
    groups: &NoteGroups<G>,
    scroll_tracks: &ScrollSpeedTracks,
    current_time: ClockTime,
) {
    // Notes in the range `scroll_position(current_time)` to `scroll_position(current_time) + render_distance`
    // is rendered.
    let render_distance = ScrollPosition::new(10.0).expect("render distance is not NaN");

    // See note groups explanation below
    for group in notes.taps().scroll_sorted_groups() {
        let group_properties = groups.get_group_or_main(group.group_id());
        let scroll_track = scroll_tracks.get_track_or_main(group_properties.scroll_track_id());
        let current_position = scroll_track.calculate_scroll_position(current_time);
        let visible_range = Interval::new(current_position, current_position + render_distance);

        for i in query_intersecting(group.scroll_position(), visible_range) {
            let value = group.value_data().get(i).expect("component arrays are aligned");
            let state = group.runtime_data().get(i).expect("component arrays are aligned");
            let y = group.scroll_position()[i] - current_position;

            draw_tap_note(*value.lane, y, state);
        }
    }

    for group in notes.holds().scroll_sorted_groups() {
        let group_properties = groups.get_group_or_main(group.group_id());
        let scroll_track = scroll_tracks.get_track_or_main(group_properties.scroll_track_id());
        let current_position = scroll_track.calculate_scroll_position(current_time);
        let visible_range = Interval::new(current_position, current_position + render_distance);

        for i in group.query_scroll_intersecting(visible_range) {
            let value = group.value_data().get(i).expect("component arrays are aligned");
            let state = group.runtime_data().get(i).expect("component arrays are aligned");

            // Hold notes a long notes which is has a time interval instead of a single time point,
            // thus its scroll position is also an interval.
            let y_start = group.scroll_position()[i].start() - current_position;
            let y_end = group.scroll_position()[i].end() - current_position;

            draw_hold_note(*value.lane, y_start, y_end, state);
        }
    }
}

With some creative thinking, the same approach can be used to create other rhythm games format such as cytus, osu or others, simply by applying scroll_position not to the note's position but perhaps note size for example.

Note groups

The scrolling examples above mostly assume that every note in the chart shares the same tempo and scroll-speed changes, but modern rhythm games sometimes allow multiple notes with different scroll peeds to appear at the same time, or attach other shared properties to subsets of notes.

This crate represents them as note groups. A note group is a collection of notes that share the same group properties. At minimum, those properties select the tempo track used to convert beat time into clock time, and the scroll-speed track used to convert clock time into scroll position.

Your game can add more group properties on top of that, such as note color, note size, lane layout, visibility rules, or any other data that should be shared by many notes instead of duplicated into every note.

Tempo, beat-time and clock-time

Clock time is the ground truth during gameplay. Judgement should compare input timestamps against note timestamps in clock time, and scroll speed is also parametrized over clock time.

However, many rhythm game formats define note timing in beats instead of seconds. Beat time is useful because it can be represented precisely in musical terms and usually gives a better chart-editing workflow. This crate supports both units through the Time enum. A note can be authored in beat time or clock time, and a game format may choose to allow both or restrict itself to only one.

When beat time is used, the note group's tempo track converts it into clock time before judgement, indexing, and scroll position calculation.

Note value and note state

This crates make the distinction between a note's value and a note's state:

  • A note's value data is the minimum data needed to define the note itself, i.e the part you would normally write into a serialized chart format. Since time, duration, group id, and cached positions are stored separately by the note storage, value data usually only needs game-specific fields such as lane, color, damage type, or sound effect id.

  • A note's runtime state is data that changes while the chart is being played. Judgement and rendering systems can use it for fields such as whether a note has already been hit, whether it is currently being held, animation state, or temporary editor selection state.

Changing runtime state is cheap because it does not affect where the note is stored or how it is indexed. Changing note value data is a structural edit because depending on what changed, storage may need to reindex notes, recalculate cached timing or scroll-position data, or update note relationships.

Struct-of-arrays storage

Notes are stored in a struct-of-arrays layout instead of one large array of note objects. This makes common hot-loop operations faster because gameplay and rendering systems can access only the fields they need, and it has better memory access patterns.

Floating-point invariants

Types such as BeatTime, ClockTime, Tempo, and ScrollPosition are newtypes around f64. They enforce invariants so they can implement ordering (which allows sorting). In particular, NaN values are rejected when constructing these types, and if a math operation would produce NaN, vsrg uses infinity instead, with the expectation that downstream code can safely ignore invalid or unreachable results.

Performance guidance

  • Avoid storing expensive-to-clone values in note value data or runtime data.
  • Prefer compact data in note components for acceptablecheap cloning. If large shared data is needed, consider storing it behind Rc<[T]> (or Arc<[T]> if needed).
  • Prefer direct per-note-type queries in gameplay and rendering hot loops to avoid cloning, over querying for all note types at once:
// Prefer this: query and borrow only the data needed by the hot loop.
for group in notes.taps().scroll_sorted_groups() {
    for i in group.query_scroll_intersecting(visible_range) {
        let value = &group.value_data()[i];
        draw_tap_note(*value.lane, group.scroll_position()[i]);
    }
}

// Over this: query all note types at once, then branch on each returned note enum.
// This is fine in editor code that runs infrequently.
for note in notes.query_scroll_intersecting(visible_range) {
    match note {
        MyNoteRef::Tap(note) => {
            draw_tap_note(*note.value_data.lane, note.scroll_position);
        }
        MyNoteRef::Hold(note) => {
            draw_hold_note(
                *note.value_data.lane,
                note.scroll_position.start(),
                note.scroll_position.end(),
            );
        }
    }
}

Contributing

Contributions are welcome. Before opening a pull request, please run:

cargo fmt

cargo test --all-features

When changing behavior, please add or update tests where appropriate.

License

All source code is licensed under the MIT License.