jengine-rhythm
Rhythm games: a beat clock locked to the music, note charts from data tables, hit windows, holds and scoring.
| Kind | Project package: linked into the builds of the projects that list it |
| Source | packages/jengine-rhythm |
| Version | 0.1.0 |
| Entry point | jengine_rhythm::RhythmPackage |
| Script namespace | Rhythm |
| Listed by | projects/arcade-classics, projects/engine-test-lab |
Using it
Section titled “Using it”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.
Components
Section titled “Components”| Scene key | Rust type | |
|---|---|---|
rhythm_game | RhythmGame | package only: needs a v2 scene |
Script commands
Section titled “Script commands”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.
| Call | Flat 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 |
Scenes that use it
Section titled “Scenes that use it”projects/arcade-classics/assets/scenes/rhythm.sceneprojects/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.
| Module | What it is |
|---|---|
chart | Songs, tempo maps and note charts, read from the project’s data tables. A chart is a row of notes written in beats. |
clock | The song clock. It runs on the frame time and is locked to the music’s own playhead. |
judge | Hit windows, judgements, combo, the life gauge, the score and the grade. |
play | One play of one chart: presses, holds, misses, beats and the end of the song. |
The clock
Section titled “The clock”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.
Content is data
Section titled “Content is data”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,artistandclip(the music, e.g.sounds/x.ogg), plus:bpm, andoffset: the time of beat 0 in the audio, in secondstempo: changes, as{ beat, bpm }beats_per_measure,lengthin seconds, andpreview(where a song select starts)
- Chart rows have
song,difficulty,level,lanes(up to 16) andnotes, each{ beat, lane, hold }.holdis 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.
Judging
Section titled “Judging”| Judgement | Window (either side) | Points | Life |
|---|---|---|---|
| perfect | 45 ms | 1 | +1.5 |
| great | 90 ms | 0.7 | +1 |
| good | 135 ms | 0.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 isng(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
failon, 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.
Component
Section titled “Component”"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 }Script commands
Section titled “Script commands”| Call | Does |
|---|---|
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. |
What scripts read
Section titled “What scripts read”RhythmState, every frame:
phase:loading,idle,playing,paused,finished,failed,calibrateorerror.- The chart:
chart,song,title,artist,difficulty,level,lanes,lengthandprogress. - The time:
time(heard),beat,bpm,beats_per_measureandmeasure_beat. - The clock:
locked,clock_error_msandresyncs. - The score:
score,combo,max_combo,accuracy,life,grade,full_combo,counts(perfect,great,good,miss,ok,ng),early,lateandmean_offset_ms. held: the lanes held down.notes: the notes to draw. Each hasid,lane,tandtail(seconds from now to its head and its end) andstate(pendingorholding). A note is listed until it is judged.calibrationwhile calibrating:taps,suggested_offset_msandlast_ms.summary: the last play’s score, kept after it ends.
RhythmEvents.events, what happened this frame:
hit(lane,note,judgement,offset_ms,combo) andmissghost,hold_start,hold_okandhold_ngcombo, at every 50beat(beat,downbeat)finishedandfailed, each with asummarytap(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.