Tag Archives: audio engineering

Arcade game screen showing SCORE: 125,480, LEVEL: 3, LIFE, BOMBS, and SHIELD: 88%.

Creating Doppler Sound Effects in Vertical Scrolling Games

Lessons from building the obstacle pass-by effect in a vertical scrolling game (Defold), and how to apply the same technique in any engine.


The problem

Your player races forward. Cars, trucks, hazards scroll toward them from the top of the screen, scream past on either side, and vanish off the bottom. Right now they’re silent — or worse, every one of them plays the same loop at full volume, producing a wall of noise.

You want the feeling of speed: an approaching engine that rises in pitch, flips into a whoosh at the moment of the pass, and drops away behind you. A real Doppler shift.

The good news: you don’t need physics simulation, DSP, or a middleware audio programmer. You need about four small functions and one design decision.

The key insight: pitch follows velocity, volume follows distance

A common mistake is to derive everything from distance. Don’t. If pitch tracks distance, cars sound like they’re shifting gears as they approach instead of holding a steady tone.

The trick used by Mario Kart (and documented by Audiokinetic’s Wwise team) is:

  • Pitch = relative velocity. While the object closes on the listener, hold the pitch high. After it passes, hold it low. Slide between the two only in a narrow band around the pass point.
  • Volume = live distance. Full volume inside a small radius, smooth falloff out to a “hear bubble” edge.
  • Pan = lateral offset. Left/right from horizontal separation.

Because your objects scroll at a known speed, “relative velocity” collapses to a single number: the sign and magnitude of object_y - listener_y. That’s the whole effect.

-- Pitch: held high while closing, smooth flip at the pass, held low after.
function pitch_ratio(dy, cfg)
    local hi = cfg.pitch_approach   -- e.g. 1.03
    local lo = cfg.pitch_recede     -- e.g. 0.95
    local cross = cfg.cross_span    -- whoosh width in px, e.g. 72
    if dy >= cross then return hi end
    if dy <= -cross then return lo end
    local t = (cross - dy) / (2 * cross)
    t = t * t * (3 - 2 * t)         -- smoothstep: no gear-step click
    return hi + (lo - hi) * t
end

Note how narrow the ratio range is: 1.03 → 0.95. Wide ranges make engine loops sound like gear changes. Subtle is convincing.

Volume: arcade proximity, not inverse-square

Real-world falloff sounds wrong in games. Use the classic arcade curve:

  1. Min-distance sphere — full volume inside a radius (e.g. 55 px).
  2. Quadratic rolloff beyond it: (1 - t)² where t is normalized distance to the bubble edge.
  3. Extra lateral weighting so a car one lane over stays quiet even when level with you:
local lat = 1 / (1 + (dx / lane_width)^2)
gain = radial * (0.28 + 0.72 * lat)

That last term is what makes two lanes away “a whisper” instead of “half volume” — critical when lanes are only ~170 px apart.

Pan and the camera-shake trap

Pan is trivially clamp(dx / pan_width, -1, 1).

But here’s a bug that cost us a debugging round: if you sample positions in world space, any screen shake modulates gain and pan every frame — the audio wobbles whenever the camera jolts. Sample the object’s position relative to its layout parent (local space) for the lateral axis, and world/screen space only for the vertical axis that actually scrolls.

Voice management: nearest-N wins

You can’t play a loop per spawned object. Keep a mixer tick (once per frame) that:

  1. Collects live objects inside the hear bubble.
  2. Sorts them: still-on-screen first, then nearest.
  3. Keeps only MAX_VOICES (3 works well).
  4. Mutes anything that dropped out; starts anything new.

Two lifecycle details that matter:

  • Let finished voices linger. An object culled off-screen should keep playing until its fade completes, otherwise the sound chops mid-note. Extend the recede window by a fraction (KEEP_FRAC ≈ 1.15) of travel time past the cull line.
  • Never set gain to exactly zero mid-play. Most engines (Defold included) drop zero-gain voices entirely, and later gain changes are then silent. Clamp to a tiny floor (~0.002) and use explicit stop for real mutes.

One voice per instance, not shared slots

Tempting shortcut: a pool of N loop players, reassigned each frame. Don’t. When the pool reassigns, one car steals another’s voice mid-pass and the mix stutters. Give each spawner its own component/channel and address it directly — memory is cheap, glitches are not.

Make late levels stay bright

If your game speeds up over time, a fixed pitch curve makes fast passes feel identical to slow ones. Scale the held pitch ends with scroll pace:

local pace = clamp(scroll_px_per_sec / reference_speed, 1, max_pace)
hi = hi + (pace - 1) * lift_per_pace   -- +0.08 per extra 1x start speed
lo = lo - (pace - 1) * drop_per_pace   -- +0.025 → stronger whoosh contrast

Cap both ends (e.g. 1.28 / 0.88). Now deep runs genuinely sound faster.

Architecture checklist

Whatever your engine, structure it like this — it keeps the math testable and the system reskinnable:

LayerResponsibilityTestable?
Pure math modulepitch/gain/pan/phase curves, voice selectionYes — plain unit tests
Mixer/tickper-frame orchestration, lifecycle, loggingMostly
Transportactual play/stop/pause calls, engine quirksNo
Snapshotmaps game state to {id, x, y, lane} recordsYes

Keep all tunables in one config table with guards in tests (e.g. “recede window < hear window”, “whoosh width ≤ 100 px”). Future-you will tune this constantly; a config table plus validation beats hunting magic numbers.

Tuning cheat sheet

Start here, adjust by ear:

hear_bubble      = 520 px     approach window
min_dist         = 55 px      full-volume sphere
max_dist         = 520 px     silent at edge
pitch_approach   = 1.03       held while closing
pitch_recede     = 0.95       held after passing
whoosh_width     = 72 px      (or 0.12 s of travel, whichever is larger)
pan_width        = 2 x lane spacing
max_voices       = 3
lane_detune      = +/-0.4 %   separates side-by-side sources

Summary

The “Doppler” effect is three decoupled curves — pitch from velocity sign, volume from 2D distance, pan from lateral offset — driven once per frame by a nearest-N voice mixer. No physics, no DSP. The hard parts aren’t the math; they’re the engineering around it: local-vs-world coordinates, zero-gain voice drops, lingering fades, and per-instance voices. Get those right and a stock engine loop becomes a convincing rush of traffic.

Technique references: Mario Kart-style relative-velocity pitch (Lilley/Scruffy write-up), Audiokinetic Wwise Doppler blog, arcade min/max distance falloff (GameMaker / shmup tradition).