There is no audio folder in this repository. Nothing is fetched, nothing is decoded, and the network panel stays empty while a game fires a shot. Every sound on the site is built from an oscillator at the moment it plays and thrown away when it stops.
The engine is a single object literal of forty-five lines, copied into each game rather than shared, because sharing it would mean a module or an import and this site is built on the rule that there is no build step. It weighs 1,842 bytes as written, which is about 21 milliseconds of 16-bit mono audio at 44.1 kHz.
A sound is three nodes and a handful of numbers: one OscillatorNode for the tone, one GainNode for the shape, and a connection to the destination. The oscillator's defaults are 440 Hz and a sine wave, so every call in the engine overrides at least the frequency and most of them override the waveform as well.
Every sound is an oscillator, a gain and a stop time
The tone function in Block Stack takes five arguments: a starting frequency, a duration, a waveform name, a peak gain, and an optional frequency to slide to. The frequency is pinned with setValueAtTime at the context's current time, and the slide, when there is one, is scheduled as an exponential ramp across the full duration. Then the envelope is scheduled on the gain, and the oscillator is started and stopped.
The stop time is the duration plus thirty milliseconds. That tail is deliberate: it lets the amplitude ramp finish its decay before the node goes silent, so a sound that ends is a sound that faded rather than one that was cut off mid-waveform.
An oscillator can only be started once. A second call to start() throws InvalidStateError even if the node has already stopped, which the specification is explicit about, so nothing here tries to reuse a node. Each sound allocates a fresh oscillator and a fresh gain and abandons both. The garbage collector is the price of the design, and with a move sound firing on every key repeat it has not shown up in the frame time.
The envelope ramp that throws on zero
The one thing that failed loudly was the fade. The obvious way to write a click is to ramp the gain from zero up to a peak and back down to zero, and an exponential ramp will not do either end. The specification requires a RangeError when the target value is exactly zero, and it says separately that if the value at the start of the ramp is zero, the ramp produces no change at all. A fade to silence is simply not expressible with this method.
So the engine never writes a zero into an exponential ramp. It writes 0.0001, and it writes it twice:
amp.gain.setValueAtTime(0.0001, now);
amp.gain.exponentialRampToValueAtTime(gain || 0.06, now + 0.01);
amp.gain.exponentialRampToValueAtTime(0.0001, now + dur);
The first line matters as much as the third, and it is the one I left out initially. Without it the ramp starts from whatever the parameter happens to hold, and a freshly built gain node holds the default of 1, which is loud enough that the attack clicks. Pinning the floor first means the rise takes ten milliseconds from near silence and the fall takes the length of the sound.
0.0001 is minus 80 decibels. The loudest sound in the set peaks at 0.1, so the residual tail sits 60 dB below the peak, which is under the noise floor of anything a browser game is played through. For a 60 ms blip there is nothing to hear at the end, and the cheap floor beats scheduling a second node.
The specification does offer a proper solution for a long fade: setTargetAtTime approaches a target exponentially and never reaches it, so it has no zero problem. It also takes a time constant and runs forever, which is the wrong shape for a sound that is over in sixty milliseconds.
The context starts suspended and stays there
The specification says a newly created AudioContext always begins in the suspended state, and that a user agent may only allow the move to running when the page has sticky activation, meaning a gesture has already happened. MDN compresses the same rule into one instruction: create or resume the context from inside a user gesture.
The games here never have to resume anything, because the context is not created until the first gesture arrives. Sound.unlock() is wired to the keydown handler and to the click on the sound button, and the constructor runs on the first of those and never again. There is no context object sitting suspended at load, and no promise to wait on.
That decision has a cost that took a while to notice. Because there is no resume() call anywhere in the engine, a context that the browser suspends later — an interruption from a phone call, a laptop lid, a tab that iOS decides to park — stays suspended. The game carries on drawing and the sound does not come back until the page is reloaded. Doing it properly would mean listening for the state change and resuming, and on a site where every sound is optional that has not yet been worth the lines.
Everything is also wrapped in a try and a catch behind a dead flag. A browser with no AudioContext sets the flag once and is never asked again, and a throw from anywhere inside the tone function is swallowed. Silence is an acceptable outcome; an exception in the middle of a keydown handler is not.
Nine sounds, and what samples would have cost
Each sound is a frequency, an end frequency, a length and a waveform. That is the entire specification of every noise in the game, and there is nothing else to store. Read back as samples, the same nine sounds would be 1.68 seconds of audio, which is 147,735 bytes at 16-bit mono and 44.1 kHz. The current cost is the 1,842-byte object, once per game.
| Sound | Waveform | Starts at | Slides to | Length |
|---|---|---|---|---|
| drop | sawtooth | 260 Hz | 90 Hz | 70 ms |
| lock | triangle | 180 Hz | 120 Hz | 90 ms |
| blocked | square | 150 Hz | 110 Hz | 60 ms |
| swing | triangle | 400 Hz | 900 Hz | 180 ms |
| level | square | 520 Hz | 1040 Hz | 300 ms |
| over | sawtooth | 300 Hz | 40 Hz | 800 ms |
Pitch is the part that is easy to get wrong, because hertz are not linear and the ear is. The chain sound takes a step number and computes its frequencies by adding to a base, 400 + n * 110 at the start and 900 + n * 160 at the end, which means the interval between steps shrinks as the chain grows. The first step spans 4.2 semitones and the eighth about 1.6. It sounds like a run that is flattening out, which happens to suit a combo that is running out of room, so the wart stayed in.
The three waveforms do the rest of the character work. Square carries the mechanical events, because its hard edges survive being shortened to thirty-five milliseconds. Triangle is used where an attack should be softer, and sawtooth is kept for the two sounds that end something, since a full harmonic series reads as a buzz when it falls. The same Sound object, with different numbers in it, is what makes the noise in Vector Storm.
Pros and cons
Pros
- Zero bytes of audio over the network, nothing to decode, no loading state and no failed request to handle.
- Frequency and length are variables, so a chain sound can rise with the counter without a second asset.
- The engine fits in one object literal that can be pasted into a game file, which keeps the no-build-step arrangement honest.
- Every failure path ends in a game that still plays, because a missing sound is the one missing feature nobody notices.
Cons
- There is no resume anywhere, so an interruption silences the game until the page is reloaded.
- Two nodes are allocated and dropped per sound with no pooling, which is work the garbage collector does instead of the audio thread.
- A waveform and an envelope is all there is to work with, so a convincing click takes more iterations than choosing a sample would.
- There is no recorded fallback, because there is no recording.
Sources
- Web Audio API specification — the RangeError on a zero target for an exponential ramp, the rule that a zero starting value produces no change, the suspended initial state and sticky activation, and the InvalidStateError on a second
start(). - MDN: AudioParam.exponentialRampToValueAtTime() — the worked example that ramps to 0.01 rather than zero, with the note explaining why, which is where the 0.0001 floor came from.
- MDN: Web Audio API best practices — the autoplay policy stated as one instruction, and the suspended-to-running transition that a click handler is there to trigger.
- MDN: OscillatorNode — the 440 Hz and sine defaults, and the set of waveform types the engine picks from.