SvitsGames

Phaser 4's volume setter isn't broken

Short version: in Phaser 4.1.0 the WebAudioSound volume setter works. It just doesn’t read back right away, so it looks dead. Write only the gain node instead and Phaser’s own currentConfig.volume stays at 1 — then re-applies itself on every resume(). Our music came back 3.13x too loud. Set both.

Here’s how we got there, because the wrong turn is the interesting part.

The symptom: Phaser music that shouted after every ad

We’re building Cook Line, a kitchen-rush game where you plate orders against a timer. Music sits at 0.32 in the mix. Quiet. It’s a bed, not a feature — you should notice it when it stops, not while it plays.

Then you hit pause. Or an ad rolls. And when you come back, the music is right there in your face. Not slightly loud. Comically loud, the kind where you lunge for the volume key on your laptop.

Pause again, resume again, same thing. Every single time.

You know that feeling when a bug is perfectly reproducible and you’re still annoyed, because reproducible means it’s your fault and you’re about to find out how? Yeah.

The wrong diagnosis: “the volume setter is a no-op”

Weeks earlier — back when we first added music to the game — we’d written this about Phaser’s audio in the project rules:

WebAudioSound.volume setter and setVolume() are no-ops. The getter reads the underlying GainNode, so volume looks pinned at 1 while every write silently does nothing.

That note came from an honest test. Set the volume, read it back, get the old number. Do it again. Same. It really does look like the setter goes nowhere.

So we worked around it. Grabbed the underlying gain node and wrote to it directly:

if (track.volumeNode) track.volumeNode.gain.value = level;

That worked! Music played at the level we asked for. Fades ran. Ship it.

Except the diagnosis was wrong, and the workaround was the thing that broke resume.

Why Phaser 4’s volume setter looks dead

Phaser 4.1.0’s WebAudioSound.volume setter is not a no-op. It’s eleven lines of WebAudioSound.js, and it does two things: stores your value in currentConfig.volume, then calls setValueAtTime(value, 0) on the gain node.

set: function (value) {
  this.currentConfig.volume = value;
  this.volumeNode.gain.setValueAtTime(value, 0);
  this.emit(Events.VOLUME, this, value);
}

And there’s the trap. setValueAtTime is scheduling, not assignment. You’re telling the Web Audio graph “make this the value,” and the audio thread gets around to it. Read .value back on the same tick and you get the old number, because nothing has landed yet.

The Phaser getter reads the gain node directly — return this.volumeNode.gain.value. So:

track.volume = 0.32;
console.log(track.volume); // 1

Which reads exactly like a dead setter. Same evidence, two completely different causes, and we picked the wrong one.

I don’t think that test was dumb, honestly. Assign, read back, compare — that’s the test you’d write. It’s just that Web Audio doesn’t play by those rules, and nothing warns you.

The actual bug: currentConfig.volume stays at 1

Writing only the gain node, and never the setter, leaves Phaser holding a stale copy of your volume — and it hands that copy back to you on every resume.

currentConfig.volume is Phaser’s own record of how loud the sound should be. Bypass the setter and it never updates. It stays at its default of 1, while the gain node quietly holds your real 0.32.

Two sources of truth. Fine, until they meet.

And they meet, because Phaser doesn’t keep one buffer source alive forever. Both play() and resume() call createAndStartBufferSource(), which builds a fresh source and then calls applyConfig(). That lands in BaseSound.applyConfig, which is six assignments in a row:

this.mute   = this.currentConfig.mute;
this.volume = this.currentConfig.volume;   // ← here
this.rate   = this.currentConfig.rate;
// ...

Look at that second line. It writes back through the very setter you decided was broken — using the config value you never updated. The loop closes on itself.

So: pause the game, come back, and Phaser restores the volume you never set. 1. Against a bed mixed at 0.32, that’s 3.13x louder.

Which is precisely the pause screen and precisely the ad break. The two places it happened. The two places we’d been squinting at pause logic for, because the bug shows up where the resume happens, not where the mistake lives.

The fix: set track.volume and the gain node

To set volume reliably on a WebAudioSound in Phaser 4.1.0, write both — the setter to keep currentConfig.volume honest, the gain node because that’s the half guaranteed to take effect on this frame:

private applyGain(track: MusicTrack): void {
  // Only exists once play() has been called.
  if (!track.volumeNode) return;
  const target = (this.levels.get(track) ?? 0) * audioSettings.musicVolume;
  track.volume = target;                 // keeps currentConfig.volume in step
  track.volumeNode.gain.value = target;  // takes effect immediately
}

Two lines that look redundant and absolutely are not. If someone deletes one in six months, the mix breaks in a way nobody notices until a player mentions it. So there’s a comment above them in the real file explaining exactly this.

Then belt and braces — re-apply on the sound’s own RESUME event:

track.on(Phaser.Sound.Events.RESUME, () => this.applyGain(track));

The event fires after applyConfig(), so this always wins. Now the level self-heals against any other internal path that rebuilds the source, including ones we haven’t found yet. Costs nothing. Sleep better.

Three more Phaser 4.1.0 audio gotchas

  • volumeNode doesn’t exist until play() has been called. Set the volume after you start the track, or it bursts in at full scale for a frame. An audible pop, not a theoretical one.
  • The volume option passed to sound.add() really is ignored. That part of the original note was right. Configure it, then set it properly anyway.
  • Tween a proxy object into the gain, not the sound. Tweening track.volume fights the same scheduling behaviour every frame.

All of this is Phaser 4.1.0 specifically — every line number and snippet above is from the v4.1.0 tag, so you can check me. If you’re on something newer, re-test before you copy any of it. The shape of the fix might have changed, and a workaround for a bug that no longer exists is its own kind of mess. Which is sort of the whole point of this post.

What I took from this: write down what you saw

The lesson isn’t “read the source,” though sure, read the source.

It’s that a workaround built on a wrong diagnosis doesn’t fail loudly. It works. It ships. It sits there being subtly wrong until some unrelated code path — a pause screen, an ad — walks past and knocks it over, and by then you’ve forgotten the workaround exists and you’re debugging the wrong file entirely.

When you write “X is broken, here’s the workaround” in your project notes, put the evidence next to it. What did you actually observe? Not what you concluded — what you saw. Ours said “the setter is a no-op.” If it had said “assigning volume and reading it back returns the old value,” someone would have spotted the Web Audio scheduling thing in about four seconds.

We fixed the note the same day we fixed the code. That felt like the more important commit.

The same engine caught us the same way a second time, in a different subsystem: Scale.RESIZE can’t use device pixels, and for a while we thought the problem was the font. Same shape — evidence that supports two explanations, and the obvious one is wrong. A third, the scene instance Phaser reuses across restart(), went the other way: the evidence pointed at content, and the cause was a lifetime we had never thought about. And once, the explanation we’d written down was simply wrong — no bug at all, just a confident comment nobody had tested.