HTML6 GamesFree Web Games / devlog

No install. Just play.

Free mini games right in your browser. Sign in to save your records to your account and pick up on any device.

All games
All games
All dev log posts

Making engine, wind and tire sounds with Web Audio and no sound files

Nov 1, 2026 Β· Art and sound Β· ν•œκ΅­μ–΄

This site's sound effects never had sound files. Short sounds like scoring, a hit or game over are all made on the spot with the Web Audio API. Turn on one oscillator (a sound source that plays a set waveform), raise and lower its volume quickly, and stop it about 0.2 seconds later. By changing only pitch and waveform, I made thirteen of these sounds.

That wasn't enough for Highway Driver. A racing game needs sound that plays the whole time you drive. The engine has to rise with speed and drop when you shift gears, the wind has to grow louder as you go faster, and the tires have to squeal on hard turns. I could have played a recorded engine sound at varying playback speeds, but I decided to synthesize it, like the short sounds.

What I used from Web Audio

The Web Audio API builds a graph by wiring nodes together. Sound starts at a node that produces it, passes through nodes that change it, and reaches the speakers. This post uses only a few kinds.

Nodes are joined with connect(). connect() returns the node it connected to, so a chain fits on one line.

const ctx = new AudioContext();
const osc = ctx.createOscillator();
const filter = ctx.createBiquadFilter();
const amp = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.value = 110;
filter.type = 'lowpass';
filter.frequency.value = 800;
amp.gain.value = 0.1;
osc.connect(filter).connect(amp).connect(ctx.destination);
osc.start();

// wobble the volume 8 times a second with an LFO
const lfo = ctx.createOscillator(), depth = ctx.createGain();
lfo.frequency.value = 8;
depth.gain.value = 0.05;
lfo.connect(depth).connect(amp.gain);
lfo.start();

That's enough for a continuous "vroom" of a filtered sawtooth wave with a wobbling volume. The engine, wind and tire sounds below are all this same combination with different ingredients and numbers.

A shared feature for sounds that keep playing

Game code doesn't use the browser's audio directly; it only goes through the api the site provides. That way the site can silence things when sound is turned off, the game is paused, or you switch tabs. Short sounds end quickly, so that was never a problem, but continuous sounds are different. During a pause the game loop stops and no longer updates the sound, so unless the site turns it off, the engine keeps running on the pause screen.

So I added a new shared feature, api.hum. One sound is built like this:

A game picks the ingredients once to create a sound, then passes pitch, filter frequency, volume and wobble every frame.

const engine = api.hum({
  voices: [
    { type: 'sawtooth' },
    { type: 'sawtooth', mult: 1.007, gain: 0.8 },
    { type: 'square', mult: 0.5, gain: 0.5 },
  ],
  filter: 'lowpass', q: 4,
});

// every frame
engine.set({ freq, cutoff, gain, wob, wobFreq });

Values aren't changed instantly; setTargetAtTime makes them follow the target over a short time. If values jumped in steps every frame, you'd hear clicking. On the site side, each sound's last volume is remembered and restored when sound is turned back on or the game resumes. On restart and exit, all nodes are stopped.

set({ freq, cutoff, gain }, smooth = 0.03) {
  const t = audio.currentTime;
  if (freq != null) for (const v of voices) v.osc.frequency.setTargetAtTime(freq * v.mult, t, smooth);
  if (cutoff != null) filter.frequency.setTargetAtTime(cutoff, t, smooth);
  if (gain != null) { volume = gain; gate(); }
},
gate() { // called on mute, pause and tab switch too
  const open = soundOn && !paused && !document.hidden;
  out.gain.setTargetAtTime(open ? volume * 0.08 : 0, audio.currentTime, 0.04);
},

Engine: two sawtooth waves and a square wave

The engine is made of three oscillators: a sawtooth wave, a second sawtooth 0.7% higher, and a square wave half an octave down (0.5Γ—). Two sounds at almost the same pitch drift slightly against each other and beat, which thickens the sound. The square wave supports the low end. Sawtooth and square waves are harsh with all their harmonics, so a lowpass filter cuts the highs. The filter's Q is raised to 4 to slightly emphasize the area around the cutoff.

The engine's state is a single value, rpm, between 0 and 1. Three things move together with it.

The wobble shakes the volume by 0.15 at half the base pitch. That's where the rattle of the exhaust comes from.

Shifting up drops the revs

Turning speed straight into pitch makes a sound that only rises, like a siren. To sound like an engine it needs gears. Gear boundaries are set as speed ratios, and within a gear the revs rise from 0.5 to 1. Shifting to the next gear drops the revs back to 0.5. Only first gear starts from an idle of 0.18.

// gear boundaries as speed / 260 km/h (top speed rises to 420, so 6th and 7th exist)
const GEARS = [0, 0.16, 0.33, 0.52, 0.74, 1.0, 1.3, 1.62];

function rpmOf(v) {
  let g = 0;
  while (g < GEARS.length - 2 && v >= GEARS[g + 1]) g++;
  const lo = g === 0 ? 0.18 : 0.5; // idle only in first gear
  return Math.min(1.05, lo + ((1 - lo) * (v - GEARS[g])) / (GEARS[g + 1] - GEARS[g]));
}

In this game the top speed rises by 20km/h at each checkpoint, from 260km/h up to 420km/h. So there are also 6th and 7th gears above 260km/h. Accelerating, the sound rises and drops over and over, climbing in "vroom, vroom" steps. A log of the pitch values showed it rising to about 280Hz and dropping to about 183Hz at every gear.

Wind and tires from white noise

The wind uses only white noise, no oscillator. Passing white noise through a bandpass filter leaves one band, which sounds like a "whoosh". Q is kept low at 0.7 for a wide band. As speed rises the band moves up (500Hz + km/h Γ— 3), and the volume grows with the square of speed (0.6 Γ— (km/h Γ· 420)Β²). It's barely audible when slow and swells as you speed up.

The tire squeal mixes white noise with a 1300 to 1500Hz triangle wave and feeds it to a bandpass passing 2300 to 2800Hz. Every frame the pitch and filter frequency jitter randomly within those ranges, and the volume shakes hard 17 times a second. That shaking is what gives the feel of tires sliding and scraping the road. The squeal plays under the same conditions as the tire smoke: braking while driving fast on the road, or turning for more than 0.2 seconds at speed.

Listening with the first values, the squeal was buried under the engine and barely audible. At first the filter Q was 6, which made the passband narrow, and the volume was low. Logging the actual output volumes while braking showed 0.045 for the engine and 0.028 for the squeal. I lowered Q to 3 to widen the band and made it about 4 times louder. I also lowered the engine to 0.55Γ— while the squeal plays. In the same situation the engine is now 0.028 and the squeal 0.118, so the squeal is louder than the engine. Rather than only making the squeal louder, the engine briefly steps aside.

At the end, and at the very start

When time runs out, the car slides to a stop over 1.8 seconds before the results appear. The sound fades to 0 over 1.6 seconds, so the results screen is quiet.

Browsers don't let a page make sound as soon as it opens. Because of autoplay policies, sound can play only after a tap or key press. This game shows the car idling at the start line before you begin, and that idle sound is heard only after the first input.

Checking sound without listening

The screen can be checked by reading text, but sound can't. So in the browser I wrapped AudioParam.prototype.setTargetAtTime and collected the pitch and volume the game passes into an array.

const log = [];
const orig = AudioParam.prototype.setTargetAtTime;
AudioParam.prototype.setTargetAtTime = function (value, time, k) {
  log.push({ value, time });
  return orig.call(this, value, time, k);
};

Whether the pitch rises and drops with each gear, whether the volume goes to 0 on pause and comes back on resume, and how loud the engine and squeal are while braking were all checked from this log. The numbers above came from it. The numbers were checked with the log; whether it sounded good was decided by actually listening.

What was good about synthesis instead of sound files

Of course it isn't as convincing as a recorded engine. Being made from a few waveforms, it keeps something of an old game console. For a game that needs to sound like a real car, a recording is better.

References

Games from this post

Highway DriverRace down a winding highway and reach each checkpoint in time! Play

More posts

Add to Home Screen

  1. Tap the Share button at the bottom of the screen (top on iPad). It looks like a square with an arrow pointing up.
  2. Scroll down and tap "Add to Home Screen".
  3. Tap "Add" at the top right. The HTML6 Games icon appears on your home screen.

This works in Safari. Opened from the home screen, it runs without the address bar, like an app, and even offline.