Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The Gameplay Clock

Every timing-sensitive system in scored gameplay — where a falling note currently is, whether it’s inside its hit window, what the HUD displays, where the phrase/song-progress overlays are drawn — reads its notion of “now” from exactly one place: GameplayClock (gameplay/clock.rs). This chapter explains what that clock actually is, why it isn’t simply “the real-time elapsed since the song started,” and the API design that makes its one hard invariant difficult to violate by accident.

Why not just use real elapsed time?

The naive approach — track Instant::now() - song_start and treat that as the authoritative song position — breaks the moment the audio itself doesn’t advance in perfect lock-step with wall-clock time. In practice it never does: audio decoders have startup latency, backends occasionally hitch, and a bevy_audio/rodio sink’s own reported playback position is the one source of truth for “where is the actual sound the player is hearing right now.” A note judged against wall-clock time while the audio is a few milliseconds ahead or behind reads as mistimed even when the player’s timing was perfect — the judgment is comparing the player against the wrong reference.

So GameplayClock doesn’t track wall-clock time directly; once music starts, it tracks the audio sink’s own position, with wall-clock delta as a fallback for the periods where there’s no sink to anchor to at all (the pre-song countdown, Jam Session, before the sink has produced its first position report).

The anchoring algorithm

Two constants make this concrete: MAX_RATE_ADJUST (0.5%) caps how much the clock’s rate — not a one-off jump — can deviate from real time while gently correcting toward the sink, expressed as a rate rather than a fixed per-frame step specifically so the correction doesn’t bias every judged hit offset by a constant amount for as long as it’s active, and so it doesn’t over- or under-correct depending on the actual frame rate. SNAP_THRESHOLD_SECS (0.5s) is the line between “ordinary jitter, worth correcting gently” and “a real discontinuity” (a decoder stall, a backend seek) that should be corrected immediately rather than converged toward over several seconds of visibly-desynced notes.

Jam Session deliberately never anchors. There’s no long, fixed-length track to drift against in free play — Jam Session’s music is either a generated backing loop or a picked song played on repeat, and nothing about that experience benefits from the sink-anchoring machinery. should_anchor_to_sink excludes GameplayMode::JamSession explicitly.

The encapsulation: why the inner value is private

GameplayClock’s inner f64 is a private tuple field. The type exposes exactly three ways to change it, and each one documents (or, for the anchored case, actively enforces) the invariant that matters:

The invariant all three exist to protect: anything that jumps the clock must also seek the music sink, or suspend anchoring — because if it doesn’t, the very next tick_clock pass sees the sink still sitting at its old position, computes a large “drift,” and drags the clock right back toward where it just jumped from. This is a genuinely easy bug to write by hand (assign the new time, forget the sink is now stale) and a confusing one to debug (the symptom is “my rewind gets silently undone one frame later,” with no error or panic pointing at why) — which is exactly the shape of bug a private field plus a small, invariant-carrying API is good at ruling out at the type level rather than relying on every future caller remembering a rule from a comment.

rewind_to is the one both existing callers that jump the clock already use — handle_loop_boundary (an A–B loop or a chart’s own loop section reaching its end point) is the only one in the codebase today, but the doc comment above is explicit that this is also the contract any future practice-speed-change or manual seek feature must follow, not an incidental detail of the current feature set.

Reading the clock: the ordering invariant

The other half of the contract, enforced by convention rather than the type system: every system that reads the clock must be ordered .after(GameplayLogic) (the SystemSet that ticks it — see The Plugin Architecture). Bevy’s scheduler is free to run unordered systems in any relative sequence, including parallelized; a clock-reading system that isn’t explicitly ordered after the tick can read last frame’s value on some frames and this frame’s on others, which manifests as visible note-movement stutter rather than a crash — the kind of bug that’s easy to introduce and easy to miss in testing if it only shows up as an occasional single-frame jitter.

Practice speed and pausing

Two more free-running cases fall out of the same should_anchor_to_sink predicate rather than needing special-case code: practice speed below 100% (real time-stretched audio isn’t implemented, so the sink is simply paused and the clock free-runs on Time::delta scaled by the practice-speed factor instead — returning to 100% re-seeks the sink to the clock’s current position via rewind_to before resuming it, since the sink sat still the whole time the clock kept moving), and the wait-for-note freeze (wait_freeze_overlay — the chart holds at the next unjudged note until the player plays it; tick_clock pauses the sink and skips advancing the clock at all while a note is “due” under that mode, and Jam Session never populates SongNotes, so the freeze condition is always trivially false there regardless).