Skip to content

jengine-rhythm

Rhythm games: a beat clock locked to the music, note charts from data tables, hit windows, holds and scoring.

KindProject package: linked into the builds of the projects that list it
Sourcepackages/jengine-rhythm
Version0.1.0
Entry pointjengine_rhythm::RhythmPackage
Script namespaceRhythm
Listed byprojects/arcade-classics, projects/engine-test-lab

List it in the top-level "packages" array of project.jengine:

{
"packages": [
{ "name": "jengine-rhythm", "source": { "type": "path", "path": "../../packages/jengine-rhythm" } }
]
}

Then rebuild the editor viewport for the project (jengine viewport-build projects/<name>, or the editor’s Rebuild viewport banner). Native and web builds link it on their own.

A scene that uses its components must be a v2 scene ("version": 2): a v1 scene has no field for a package component and keeps it as inert custom data.

A script can check that the package is linked with if Rhythm == nil then return end.

Scene keyRust type
rhythm_gameRhythmGamepackage only: needs a v2 scene

Commands run in every runtime and return nothing; a package publishes what scripts read onto components (see the notes below). The flat name also works.

CallFlat name
Rhythm.start(e, chart)rhythm_start
Rhythm.stop(e)rhythm_stop
Rhythm.calibrate(e, song)rhythm_calibrate
Rhythm.pause(e, paused)rhythm_pause
Rhythm.input(e, pressed, held)rhythm_input
Rhythm.auto(e, on, error_ms)rhythm_auto
Rhythm.offset(e, ms)rhythm_offset
  • projects/arcade-classics/assets/scenes/rhythm.scene
  • projects/engine-test-lab/assets/scenes/80-rhythm.scene

From packages/jengine-rhythm/README.md.

Rhythm games: notes fall to a line in time with the music, and the player hits them on the beat. The package keeps time and judges. The game script draws the notes and plays the music.

Used by projects/arcade-classics (Neon Cadence) and Engine Test Lab scene 80.

ModuleWhat it is
chartSongs, tempo maps and note charts, read from the project’s data tables. A chart is a row of notes written in beats.
clockThe song clock. It runs on the frame time and is locked to the music’s own playhead.
judgeHit windows, judgements, combo, the life gauge, the score and the grade.
playOne play of one chart: presses, holds, misses, beats and the end of the song.

A chart timed by adding up frame dts slides off the song: a frame’s dt measures the last frame, a hitch skips time the music did not, and the audio device starts a track a few milliseconds after it is asked to.

The engine publishes the music’s playhead every frame, in every runtime. Scripts read it as Audio.get_music_position() and packages as jengine_core::audio::music_position(). It is the truth, but a coarse one: the device reports it in buffer steps of a few milliseconds, and it is the reading from the last frame.

So the clock advances by dt and is pulled toward each fresh reading. It eases toward a reading within 60 ms and jumps to one further away (a resync). It takes the first reading within half a second of zero as its track, so the last song’s playhead does not count as the new one’s. With no music playing, it runs on dt alone.

Inputs are stamped when the script hands them over (jengine_core::profiling::now_ms()), not when the package steps. The native runner runs packages after scripts and the editor viewport runs them before, so otherwise a press would land a frame later in the editor.

The player’s offset (offset_ms) shifts every judgement and every note on screen. It covers the delay between the audio clock and the ear, which differs from machine to machine. Calibration measures it: tap along to a click track and the package suggests the offset to set.

The package reads two .data tables through the engine’s published data registry, the same tables scripts read with Data.get. By default they are data/songs and data/charts; the component’s tables can name others.

  • Song rows have title, artist and clip (the music, e.g. sounds/x.ogg), plus:
    • bpm, and offset: the time of beat 0 in the audio, in seconds
    • tempo: changes, as { beat, bpm }
    • beats_per_measure, length in seconds, and preview (where a song select starts)
  • Chart rows have song, difficulty, level, lanes (up to 16) and notes, each { beat, lane, hold }. hold is how many beats the note is held; 0 is a tap.

Loading places every note in time on its song’s tempo map and checks the chart. A lane out of range, two notes at one moment in one lane, or a note inside a hold in its lane is an error at load (RhythmState.load_error), naming the chart and the beat.

JudgementWindow (either side)PointsLife
perfect45 ms1+1.5
great90 ms0.7+1
good135 ms0.4+0.25
miss-0-6
  • Presses. A press hits the earliest waiting note in its lane that is inside the widest window. A press near no note is a ghost, and costs nothing.
  • Holds. A hold’s head is judged like a tap. Its tail counts once more: kept to within 120 ms of the end is ok, and let go earlier is ng (it breaks the combo).
  • Score. The score is out of 1,000,000: points over judgements (taps, heads and tails).
  • Grades. Grades go by accuracy: S+ (every note perfect), S 95%, A 90%, B 80%, C 70%, D.
  • Life. The gauge starts at 50 of 100. With fail on, an empty gauge ends the play.
  • Timing counts. Early and late hits (outside perfect) are counted, and their mean offset is what a results screen can suggest a calibration from.
"rhythm_game": { "tables": { "songs": "data/songs", "charts": "data/charts" },
"windows": { "perfect": 0.045, "great": 0.09, "good": 0.135, "hold_grace": 0.12 },
"offset_ms": 0, "look_ahead": 2.5, "fail": true, "auto": false, "auto_error_ms": 0 }
CallDoes
Rhythm.start(e, chart)Starts a chart from its first second. Call Audio.play_music with its song’s clip in the same frame.
Rhythm.stop(e)Stops.
Rhythm.pause(e, paused)Holds the clock. Pause the music with it.
Rhythm.input(e, pressed, held)This frame’s lanes as bit masks (lane 0 is 1, lane 1 is 2, …): pressed this frame, and held down. Send it when either changes.
Rhythm.auto(e, on, error_ms)The autoplayer, hitting each note up to error_ms off. Use it for attract modes and demos.
Rhythm.offset(e, ms)Sets the player’s calibration.
Rhythm.calibrate(e, song)Measures taps against the beats of song, with no notes.

RhythmState, every frame:

  • phase: loading, idle, playing, paused, finished, failed, calibrate or error.
  • The chart: chart, song, title, artist, difficulty, level, lanes, length and progress.
  • The time: time (heard), beat, bpm, beats_per_measure and measure_beat.
  • The clock: locked, clock_error_ms and resyncs.
  • The score: score, combo, max_combo, accuracy, life, grade, full_combo, counts (perfect, great, good, miss, ok, ng), early, late and mean_offset_ms.
  • held: the lanes held down.
  • notes: the notes to draw. Each has id, lane, t and tail (seconds from now to its head and its end) and state (pending or holding). A note is listed until it is judged.
  • calibration while calibrating: taps, suggested_offset_ms and last_ms.
  • summary: the last play’s score, kept after it ends.

RhythmEvents.events, what happened this frame:

  • hit (lane, note, judgement, offset_ms, combo) and miss
  • ghost, hold_start, hold_ok and hold_ng
  • combo, at every 50
  • beat (beat, downbeat)
  • finished and failed, each with a summary
  • tap (offset_ms), while calibrating

RhythmLibrary.songs, once the tables load: each song with its charts (id, difficulty, level, notes, holds, length). This is what a song select lists.

  • Unit tests:
    • src/chart.rs: tempo maps round trip across a change, and charts are sorted and checked.
    • src/clock.rs: the clock follows a fast frame clock against a 10 ms audio clock, ignores a stale track and snaps to a jump.
    • src/judge.rs: the windows and the points.
    • src/play.rs: taps, holds, misses, the end of a song and failing.
  • tests/arcade_charts.rs, on the arcade’s real songs and charts:
    • Every chart loads, fits its song and gets harder with the difficulty.
    • A perfect player scores 1,000,000, a player within 70 ms scores between 600,000 and 990,000 with a full combo, and one who misses every third note fails.
  • tests/neon_scene.rs, on the arcade’s scene: a chart is played through commands on a frame clock running 2% slow against an audio clock that reports in 12 ms steps. At least 95% of the notes land perfect, and a beat event comes for every beat.
  • tests/test_lab_scene.rs: scene 80 on the lab’s own chart.