No Sound Files, Just Code: Procedural Audio in a React Asteroids Game
Adding Procedural Audio to a React Asteroids Game with mod
Mod Playground with Asteroids — Adding Procedural Audio to a React Asteroids Game with @mode-7/mod
I wanted to do something specific: take an existing React Asteroids clone and replace “sound files + play()” with procedural audio driven by gameplay state.
I started from this repo:
- NASAteroids (base game): https://github.com/Billgonzo123/NASAteroids
It’s a fun project, but it includes server/auth scaffolding that I didn’t need for this experiment. I ripped that out and turned it into a straightforward client-side app that runs like a normal Create React App.
My fork (with procedural audio + a MIDI bed):
- mod-asteroids: https://github.com/zhagan/mod-asteroids
- Play it here: https://zackhagan.com/mod-asteroids
- The Mod Playground: https://zackhagan.com/mod/playground
The audio layer is built with mod (@mode-7/mod), which lets you express Web Audio graphs as React components. The key idea in this tutorial is:
Don’t call playSound(). Render an audio rack, and drive it with React state.
A quick terminology note (so nobody gets hung up on words): the SFX racks are procedural synth patches (oscillators/noise/filters/envelopes). The music bed is reactively controlled audio (MIDI → Fluidsynth), but it uses the exact same “render it as a component, control it with props” architecture.
Let’s walk through how I wired it.
Architecture: Player Intent vs World Events
Player intent sounds (continuous / control-driven) live with the Player component:
- Thrust (sustained; depends on W or up arrow)
- Turn (continuous tick/noise; depends on A/D, or left and right arrows)
World event sounds (discrete / collision-driven) live with the GameWindow component:
- Fire (one-shot; triggered when a bullet is spawned)
- Bullet collisions (one-shot)
- Ship collisions (one-shot)
- UFO presence (clocked pattern that runs while UFO exists)
- MIDI bed (starts on game start, stops on quit)
This split is the whole architecture: Player owns intent, GameWindow owns events.
1) Install mod and set up audio context
In your game project:
npm install @mode-7/mod
Note: you’ll still need a “resume audio” moment (Start click, Play button, etc.) because browsers require user gesture before audio starts.
2) Create “Audio Racks” as React components
Instead of thinking in audio files, think in reusable patches:
- ThrustRack
- RetroTurnRack
- RetroFireRack
- RetroCollisionRack
- RetroUFORack
- RetroMidiRack
These are just React components that internally build Web Audio graphs using mod.
The important part is how you use them:
- Mount them
- Drive them with props like active, trigger, intensity, and volume
3) Player sounds: drive racks from input state
Here’s the core of the current Player integration (trimmed to audio-relevant bits):
import { ThrustRack } from '../RetroThrust';
import { RetroTurnRack } from '../RetroTurn';
const Player = ({ globalPlayer, intensity, turnVolume = 1, thrustVolume = 1 }) => {
return (<>
{/* ...player sprites... */}
<RetroTurnRack
trigger={!!globalPlayer.pressD || !!globalPlayer.pressA}
intensity={intensity}
volume={turnVolume}
/>
<ThrustRack
active={!!globalPlayer.pressW}
intensity={intensity}
volume={thrustVolume}
/>
</>
);
};A) React props become “control voltage”
Instead of “play turn sound,” you do:
trigger={pressA || pressD}The rack decides what “turn” means sonically.
B) Sustained sounds use active/gate, not one-shot triggers
Thrust is a continuous signal:
active={pressW}That’s how you get a real engine sound instead of a repeated loop.
4) Compute intensity once at the world layer
const playerSpeed = Math.sqrt(globalPlayer.vx ** 2 + globalPlayer.vy ** 2);
const maxSpeed = 10;
const intensity = Math.min(playerSpeed / maxSpeed, 1);Then I hand it down:
<Player globalPlayer={globalPlayer} intensity={intensity} />Now every sound patch can be parameterized by the same signal.
5) World event sounds: the rule is “counter triggers”
Here’s the rule I ended up using everywhere for one-shots:
Don’t use boolean triggers for discrete events. Use incrementing counters.
A counter changes exactly once per event, which makes audio deterministic and avoids “it retriggered for 3 frames” bugs.
Fire: bullet spawn drives a pulse counter
<RetroFireRack
trigger={playBullet}
intensity={intensity}
volume={soundVolumes.fire}
/>Collisions: collision systems increment counters
Collisions belong in GameWindow because they’re world events produced by collision systems.
Get Zack Hagan’s stories in your inbox
Join Medium for free to get updates from this writer.
The collision utilities increment counters on globalPlayer (instead of setting booleans):
- globalPlayer.bulletCollision increments on bullet impacts
- globalPlayer.collision increments on ship impacts
<RetroCollisionRack
trigger={globalPlayer.bulletCollision}
intensity={intensity}
volume={soundVolumes.collision}
/>
<RetroCollisionRack
trigger={globalPlayer.collision}
intensity={intensity}
volume={soundVolumes.collision}
/>That’s it:
- collision happens → counter increments
- rack sees trigger change → envelope retriggers
- sound plays once, reliably
This is one of those patterns that feels “too simple,” and then you realize it scales insanely well.
6) UFO sound: world-state drives a sequencer (not just a sound)
A lot of game audio is “fire and forget”:
- laser: one-shot transient
- collision: short noise burst
- thrust: sustained noise + filter
The UFO is different. It isn’t just an event — it’s a presence. When the UFO enters the playfield, you want an audio cue that:
- persists while it’s active
- feels “alive”
- has motion (rhythm + pitch changes)
- stops cleanly when the UFO leaves
In GameWindow, the integration stays simple:
<RetroUFORack
trigger={isUfo.current}
intensity={intensity}
volume={soundVolumes.ufo}
/>…but inside the rack, trigger becomes transport control: it starts/stops a Clock and gates the synth output. That separates:
- game simulation clock (visual + physics)
- audio/musical clock (sequencing)
7) MIDI bed: controlled like everything else (and it stops on Quit)
RetroMidiRack is still the same idea: render an audio graph and control it with props. It just happens to be MIDI-driven (MIDI → Fluidsynth) rather than oscillator/noise-driven.
It starts when the game starts, and on Quit it stops immediately (so the music doesn’t keep running under the game over screen).
Addendum: Patch Walkthrough — Procedural Collision Sound (RetroCollisionRack)
Collisions are the most important “juice” sound in Asteroids. They happen often, and if they’re boring or repetitive, the whole game feels flat.
Instead of playing a single hit.wav, I built the collision sound as a small modular patch: Triangle oscillator (thud) + noise (crunch) → mix → lowpass filter → VCA (envelope) → output
The key procedural trick: on each trigger, the patch randomizes its mix/filter characteristics so it doesn’t sound like the same sample over and over.
1) Allocate patch cables (useModStream())
At the top of the rack I create streams (think “virtual patch cables”) for each stage of the graph:
const osc = useModStream();
const noise = useModStream();
const noise1 = useModStream();
const mixed = useModStream();
const filtered = useModStream();
const vca = useModStream();
const env = useModStream();This gives you explicit endpoints to route signals:
env= envelope control voltageosc/noise/noise1= sound sourcesmixed/filtered/vca= intermediate buses
2) Make intensity a musical control signal
The rack accepts intensity (0..1), which is computed from gameplay elsewhere (in your GameWindow you derive it from ship speed). Now collision sounds can scale naturally with energy.
You use intensity to shape the envelope:
decay: 0.22 + 0.12 * (1 - intensity)And you also use it to adjust the oscillator pitch and pitch drop amount:
const baseFreq = 110 + 60 * intensity
const oscPitchMod = 180 + 60 * intensityThat “pitch drop” is important — it’s what makes impacts feel physical.
3) Use an ADSR as both amplitude and pitch control
This is a classic synth trick: use the same envelope to:
- open/close volume (via VCA)
- bend pitch down at the start (via oscillator CV)
In your patch:
<ADSR output={env} {...adsrParams}>
{(controls) => { controlsRef.current = controls; return null; }}
</ADSR><ToneGenerator
output={osc}
waveform="triangle"
frequency={baseFreq}
cv={env}
cvAmount={-oscPitchMod}
/>Because cvAmount is negative, the oscillator starts higher and falls — that gives you the “thud” transient without needing a clicky sample.
4) Add noise for crunch (white + pink)
A pure oscillator “thud” sounds too clean. Impacts have texture. So you blend in two noise sources:
<NoiseGenerator output={noise} type="white" />
<NoiseGenerator output={noise1} type="pink" />White noise gives sharpness; pink noise gives “weight.”
5) Randomize mix levels on every collision
This is the part that makes it procedural instead of “synthetic but repetitive”:
const [randomSeed, setRandomSeed] = React.useState([
Math.random(), Math.random(), Math.random()
]);<Mixer
inputs={[osc, noise, noise1]}
output={mixed}
levels={[
0.6 * randomSeed[0],
1.0 * randomSeed[1],
1.0 * randomSeed[2]
]}
/>Every collision changes the ratio of body vs crunch, so the game feels alive even when collisions happen frequently.
6) Filter it for “retro thud” character
Then you lowpass the result:
<Filter
input={mixed}
output={filtered}
type="lowpass"
cutoff={100 + 1000 * randomSeed[3]}
resonance={0.5}
cutoffCv={env}
cutoffCvAmount={-700}
/>Two cool things are happening here:
- Static cutoff sets the overall brightness (randomized each hit)
- Envelope CV modulates the cutoff over time, so the sound evolves (usually “opens then closes” or “closes fast” depending on sign)
That movement is what makes it feel like an impact rather than a static noise burst.
7) Use the envelope to control amplitude via VCA
Finally:
<VCA input={filtered} output={vca} cv={env} />
<Monitor input={vca} />So the envelope is literally shaping the sound’s loudness.
Triggering: turning collision events into envelope retriggers
This is where the rack connects back into the game.
You pass trigger={...} from GameWindow, and inside the rack you treat it as “retrigger the envelope.”
useEffect(() => {
if (trigger && controlsRef.current) {
setRandomSeed([Math.random(), Math.random(), Math.random()]);controlsRef.current.release = 1.0 * randomSeed[0];
controlsRef.current.sustain = 1.0 * randomSeed[1];controlsRef.current.trigger();setTimeout(() => {
controlsRef.current.releaseGate();
}, 10 * randomSeed[1]);
}
}, [trigger, randomSeed]);Conceptually:
- collision happens →
triggerprop changes - rack randomizes its “seed”
- rack retriggers ADSR (impact sound plays)
- rack releases gate shortly after so it doesn’t hang
- That’s the procedural “hit” in a nutshell.










