Part 04 — When sound and motion disagree
You will build: the diagnosis — which clock did the sound ride — and the fix: the hit sound moved onto the damage signal. You'll also meet the boundary of the shelf: the latency that lives on the player's hardware, which no game code owns. You'll learn: Godot's two clocks and the audio's third · why the swing's whoosh is allowed to be late and the hit's thock isn't · the measurement that separates "wrong frame" from "my headphones".
Why this exists#
A sound that's a few milliseconds off doesn't read as "a few milliseconds off." It reads as wrong — the thock arrives before the sprite flashes, the pickup chime lands after the coin is gone, and the player's ear files the complaint under "this game feels cheap" while every frame budget in the project is green. The clock argument is the one place audio bugs hide, because the code is correct, the timing is wrong, and the difference between the two is a frame.
Build it#
A — The clocks, drawn#
Godot runs two clocks, and the audio hardware runs a third that the game never touches directly:
- The physics clock —
_physics_process,Timeron the physics step,move_and_slide. 60 Hz by project setting. This is where the game's truth happens: the hitbox opens on a tick, the damage is dealt on a tick, the float text spawns on a tick. - The render clock —
_process, frames,AnimationPlayeron its default callback mode, and — crucially — the moment a sound'splay()is audible, because the audio server mixes on the frame. AnAnimationPlayerleft on its defaults and aTimerleft onTIMER_PROCESS_IDLEare on this clock, and it is not phase-locked to the physics ticks: it lands where the frame lands. - The audio clock — 44,100 or 48,000 samples per second, the device's rate. The game schedules when a sound starts; the audio clock runs the samples. You don't fight this clock; you pick which of the other two clocks picks the start.
The diagram is one hit, one window of 60 ms, and the thock riding the animation — the shape part 01 warned about, now with positions:
Read it the way a player's ear does: the fact (the hitbox opening, the damage dealt, the float text spawned) is on the 400 ms tick. The presentation (the skin's first swing frame) lands at 402.78 — two and three-quarter milliseconds later, because the render frame that draws the animation's frame 6 is not the physics tick that opened the hitbox. And the thock, triggered from the animation, inherits the presentation's clock. The sound is 2.78 ms late by construction, on every hit, forever — and 2.78 ms is inside the range where an ear registers "off" without registering "how".
(The numbers are a representative interleaving, captioned as one — the almanac's architecture doc is explicit that a simulation of three clocks would assert the determinism the lesson denies. On your machine the gap is your machine's, measured the same way: it exists, it's small, and it's consistent.)
B — The fix: the sound rides the signal#
Part 01 put the thock on damage_received. If it's still there, this bug doesn't exist —
the fix is to keep it there, and the diagram's sfx event moves onto the 400 ms tick,
because the signal fires on the physics frame and the play() called from that frame is
mixed into that frame's audio. The gap line disappears: the sound and the fact share a
clock, and the presentation (the animation, the flash) is allowed to be two milliseconds
behind the truth, because the presentation is what the eye checks and the truth is what the
ear holds.
The general rule, which is the overview's rule now with a mechanism:
Facts ride the physics clock; promises may ride the render clock. The thock, the hurt sting, the level-up chime, the harvest click — facts, signals, ticks. The whoosh, the footstep, the swing's air — promises, presentation, frames. A whoosh that's two milliseconds early is wind; a thock that's two milliseconds late is a lie.
The boss fights shelf's attack anatomy is the design-side version: the tell (windup) is a promise the player is invited to read, and the active frame is the fact the player is punished or rewarded by. Audio splits the same way — the whoosh sells the tell, the thock confirms the hit — and a game that plays the thock on the windup is a game that confirms its attacks before they happen.
C — The measurement that ends the argument#
"Is my sound late?" has exactly one honest answer, and it's a measurement, not a feel:
- Solo the SFX bus (part 02's board) — the score out, the world quiet.
- Slow the engine:
Engine.time_scale = 0.2from the debug console (the survivors stage 08'sDebugregistry is a natural home for aslow-moaction). - Watch one hit. At 0.2×, a 2.78 ms gap is a 13.9 ms gap — five render frames of visible separation between the flash and the thock. If the thock still leads or trails the flash at slow-mo, it's on the wrong clock, and part B is the fix. If they're locked at slow-mo but "feel off" at full speed, the offset is smaller than a frame — you're in section D's territory, and the game's code is exonerated.
Engine.time_scale scales the process clocks (physics and render deltas), which is why
it stretches the gap without stretching the audio (the audio clock runs samples, not game
time — the thock plays at full speed while the world stutters, which is the hit-stop's exact behaviour, and the reason a hit-stop "punches
through" the sound instead of smearing it).
D — The boundary: the latency you don't own#
Stretch the gap to zero, lock the thock to the tick, and the player still says "the sound is late." That residual is output latency — the milliseconds between the sample leaving Godot and the air moving in the room, owned by the audio driver, the OS mixer, the headphones, and the Bluetooth stack (Bluetooth adds tens of milliseconds; wired is single- digit). It is uniform across every sound, which is exactly why it's unfixable in game code and exactly why it's unnoticeable in a normal game — a constant offset doesn't create a disagreement between the flash and the thock, because both are shifted the same amount.
It becomes the entire game in one genre, and the almanac has a curriculum for it: the rhythm game is built on the fact that when the player is timing their input to the audio, the constant offset is no longer invisible — it's the calibration screen's whole job, a player-adjustable millisecond offset that shifts the judgment against the track. The architecture doc lists audio latency as one of the three permanent refusals of any in-page simulation, for the reason stated there: it's measured on the reader's hardware, and a simulation of it would assert a determinism the lesson denies.
So the shelf's boundary, precisely: the game owns the frame the sound is scheduled on; the hardware owns the milliseconds after. Part B fixes everything the game owns. If, after the fix, the disagreement the player reports is between their input and the track — not between the flash and the thock — you don't have a clock bug, you have a calibration requirement, and the rhythm game's stage 04 is the design doc.
E — The pause, revisited#
One clock question the pause raises: the survivors level-up freezes the world (stage 07).
The facts stop — no ticks, no damage, no thocks. But part 02 kept the score alive
(When Paused), and the sting plays on SFX. At the moment of the pause, the last in-flight
thock (scheduled on the frame before) finishes its tail in real audio time while the world
holds still — and that's correct, because the audio clock never paused; only the game's
scheduling did. A thock that cut off mid-tail at the pause would be the clock bug wearing a
menu's costume. Listen for the tail the next time your menu pauses: if it's there, the
boundary is honest.
F — Commit#
git add . && git commit -m "audio 04: hit sound on the physics clock, slow-mo measurement, latency boundary"
Checkpoint — definition of done#
- The thock is triggered from the damage signal, and the slow-mo test (part C) shows it locked to the flash at 0.2× — the five-frame separation is gone
- The whoosh (if you have one) is triggered from the swing's start, and you can say why its two-millisecond earlyness is wind while the thock's would be a lie
- You measured the residual at full speed after the fix and named what owns it (driver/OS/headphones — the output latency), and why a constant offset creates no disagreement
- The paused menu's last thock finishes its tail — the audio clock ran, the game clock stopped, and the boundary between the two is the design
- You can draw the three clocks from memory and say which one
AnimationPlayer,Timer, and the audio device each live on - Zero warnings; committed
Stretch (no instructions)#
A calibration action in the debug registry: a menu where the player shifts the thock's
schedule by ±N ms in 1 ms steps (a play() delayed by a Timer of N ms, or N ms of
await) and finds the setting where the flash and the thock lock at slow-mo. You will find
that the winning N is not zero on most machines — that non-zero is the output latency,
measured, and it's the rhythm game's calibration screen in embryo. (This is the one audio
feature the shelf builds knowing it's a calibration, not a fix — the game is aligning
itself to hardware it doesn't own, the way the rhythm game does.)
If you get stuck#
- The thock is locked at slow-mo but "feels off" at full speed, and the residual is not
constant (it varies with frame time) → the sound is riding a variable clock: a
Timeron the idle process, or aplay()called from_processafter aawait. Part A's list is the audit — name the node that callsplay()and the clock it's on. - Slow-mo made the sound stutter →
Engine.time_scaledoesn't touch the audio clock, but at 0.2× the dispatch is 5× sparser and the pool's voices (part 03) are being cut on a stretched schedule. The stutter is the pool, not the clock — restore full speed and re-run part C with the solo-SFX bus to separate the two. - The whoosh and the thock are on the same signal and both feel late → the whoosh was never a promise; it was a fact wearing a costume. Split them: the whoosh on the swing's start (the animation's frame 0 — the render clock, allowed), the thock on the damage (the tick). One signal, two sounds, two clocks — the split is the fix.
- Moving the
play()to the signal changed nothing → the signal fires on the tick, but theplay()is on a player whose bus is routed through an effect with lookahead (the limiter, part 03 — its lookahead delay is real, sub-millisecond, and uniform). If the measurement still shows a frame of gap, the routing isn't the cause; if it shows a constant sub-frame offset, the limiter's lookahead is, and it's the honest kind — uniform, owned by the mix, and the reason the ceiling is −1 dB and not 0.