A rhythm game can feel unfair even when its note chart is correct. The usual cause is timing logic that mixes wall-clock time, animation frames, and audio playback position. A fair judgement system needs one authoritative clock and explicit windows for each result.
This walkthrough builds a compact JavaScript model suitable for a browser rhythm game or prototype.
Use the audio clock
Do not advance song time by adding the duration of each animation frame. Frames can be delayed by background tabs, garbage collection, or a busy device. Instead, sample the playback clock on every frame.
function songTime(audioContext, startedAt, offset = 0) {
return audioContext.currentTime - startedAt + offset;
}
The same clock should drive note positions and input judgement. The renderer can interpolate smoothly, but it must not become the source of truth.
Represent notes and windows explicitly
A chart note needs a timestamp, lane, and state. Judgement windows should be data so they can be tuned and tested.
const windows = [
{ name: "sick", maxErrorMs: 45, score: 350 },
{ name: "good", maxErrorMs: 90, score: 200 },
{ name: "bad", maxErrorMs: 135, score: 50 }
];
function judgeNote(noteTime, inputTime) {
const errorMs = Math.abs(inputTime - noteTime) * 1000;
const result = windows.find(window => errorMs <= window.maxErrorMs);
return result
? { ...result, errorMs }
: { name: "miss", score: 0, errorMs };
}
Using the absolute error makes early and late hits share the same grade, while retaining the signed error separately is useful for calibration feedback.
Pick the nearest eligible note
When two notes are close, judging only the first item in an array can punish a valid input. Filter by lane and unresolved state, then choose the nearest timestamp.
function nearestNote(notes, lane, inputTime, missWindowMs = 160) {
return notes
.filter(note => !note.resolved && note.lane === lane)
.map(note => ({
note,
distance: Math.abs(note.time - inputTime) * 1000
}))
.filter(candidate => candidate.distance <= missWindowMs)
.sort((a, b) => a.distance - b.distance)[0]?.note;
}
Resolve the selected note exactly once. Keyboard repeat events, touch duplication, and simultaneous listeners should never score the same note twice.
Account for device latency
A calibration offset belongs at the input boundary:
const calibratedInputTime = rawInputTime - inputOffsetMs / 1000;
Store the offset locally, show whether a player tends to hit early or late, and let them reset it. Avoid silently changing judgement windows according to performance; that makes scores hard to compare.
For a browser-playable reference when checking lane layout, keyboard controls, and feedback pacing, FNF Online is a useful unofficial fan-game example. It should be treated as a gameplay reference, not as an official release.
Test timing without real audio
The judgement function is pure, so boundary tests are straightforward.
console.assert(judgeNote(10, 10.044).name === "sick");
console.assert(judgeNote(10, 10.046).name === "good");
console.assert(judgeNote(10, 10.091).name === "bad");
console.assert(judgeNote(10, 10.200).name === "miss");
Also test equal-distance notes, two lanes at the same time, repeated keydown events, pause/resume, playback-rate changes, and losing browser focus.
A reliable rhythm engine is less about elaborate math than about consistent ownership of time. Once audio position, note selection, calibration, and scoring are separated, the system becomes predictable for both players and tests.
Top comments (0)