A note can look perfectly aligned with a target and still feel wrong when the player presses a key. In a browser rhythm-game prototype, it helps to keep three responsibilities separate: song time, note drawing, and input judgement.
This small example focuses on the judgement layer. It is not an implementation walkthrough of an existing FNF engine.
Give every note a timestamp
Represent a note as a lane and a time in milliseconds, for example { lane: "left", timeMs: 2000 }. Its position on screen is a view of that timestamp, rather than the source of truth for scoring.
For a Web Audio prototype, derive song time from the audio context and the time at which playback was scheduled:
const songMs = (audioContext.currentTime - scheduledStartTime) * 1000;
MDN documents currentTime as an audio timeline in seconds. It is separate from wall-clock time and stops advancing when the context is suspended. This makes the timeline useful, but it does not automatically compensate for speaker, display, or input latency.
Make judgement a pure function
function judge(songMs, noteMs, offsetMs = 0) {
const deltaMs = songMs - noteMs - offsetMs;
const distance = Math.abs(deltaMs);
const grade = distance <= 45 ? "tight"
: distance <= 90 ? "good"
: distance <= 140 ? "loose" : "outside";
return { grade, deltaMs };
}
The windows are illustrative design choices, not recommended universal values or the scoring rules of Friday Night Funkin'. Negative deltaMs means early; positive means late. With this convention, a positive offset corrects a consistently late measurement.
Keep that sign convention visible in the calibration UI. A slider named “offset” without explaining what positive values do is difficult to debug.
Test boundaries before tuning the feel
The useful cases are not just exact hits:
| Song time | Note time | Offset | Result |
|---|---|---|---|
| 1000 | 1000 | 0 | tight, delta 0 |
| 1045 | 1000 | 0 | tight, delta +45 |
| 1046 | 1000 | 0 | good, delta +46 |
| 910 | 1000 | 0 | good, delta −90 |
| 1141 | 1000 | 0 | outside, delta +141 |
| 1080 | 1000 | 80 | tight, delta 0 |
Nine Node assertions were run for this example, covering exact hits, both sides of the 45/90/140 boundaries, an early hit, and the offset convention. These are arithmetic checks, not a device-latency benchmark.
Keep selection and rendering separate
On input, choose an eligible unconsumed note in the pressed lane, apply judgement once, and mark a hit as consumed. Ignore keyboard auto-repeat for tap notes. An early press outside the window should not automatically consume a future note. A separate update can expire notes that are already too late.
Render note positions from remaining song time on each frame. If a frame is delayed, the next render can catch up without redefining when the note was meant to be hit. Pause/resume, held notes, overlapping hit windows, and device calibration need their own explicit rules.
For a concrete reference to the genre, FnfYa's Spanish FNF catalogue groups browser-playable Friday Night Funkin' games and mods. The timing example above is independent of those games; it makes no claim about their internal engines.
Disclosure: This article was prepared with AI assistance on behalf of the linked FnfYa project. The example was checked with the stated assertions; no original-game authorship or performance benchmark is claimed.
Top comments (0)