Devlog10 min read

Building Last Ember With No Image or Audio Files

How Last Ember paints its textures, synthesizes its sound effects and plays its soundtrack live with Web Audio at load time, with no image or audio files.

By Mutiny Games
Building Last Ember With No Image or Audio Files

Last Ember ships with no image files and no audio files. Every ground texture is painted from noise when the game loads, every sound effect is synthesized with Web Audio when it plays, and the soundtrack is composed and performed live by a small sequencer in the browser. The 3D models are built from code too, and the Classic mode's pixel art is drawn from tiny pixel maps at load. Here is how each part works, with the real numbers from the source.

The two outside resources are fonts from Google Fonts and the interface icons from game-icons.net (CC BY 3.0), which live in the code as SVG path strings rather than files.

Why build a game with no asset files?

Because Last Ember is a browser game, and every file is a download before you can play. Generating everything from code keeps the download small and makes every map, surface and sound tweakable with a number instead of a trip to an art tool. It also means the same recipe can produce variations, like a red desert rock and a mossy forest rock, from one function.

How are the textures generated?

Each texture is a function that takes a point on a unit square and returns a color plus a height. The engine calls that function for every pixel of a canvas, then builds a normal map from the heights so the surface catches light. The whole pipeline lives in one file, `r3d/tex.js`, and its first comment sums it up: everything is painted at load time from tileable noise, so the game ships with no image files.

Step 1: tileable noise

The base is value noise on a 256 x 256 lattice of random numbers from a seeded generator (mulberry32). The trick that makes it tile is wrapping the lattice coordinates by a period, so the right edge of the texture uses the same random values as the left edge. Without that, every repeat of the texture would show a seam.

On top of that sits fractal noise (fBm): several octaves of the same noise, each at double the frequency and half the strength of the last. Four or five octaves give you soft large shapes with fine grain on top, which is most of what natural surfaces look like.

Step 2: a recipe per surface

Each surface is a short recipe that mixes noise layers into colors. A few examples from the source:

  • Forest floor dirt: packed earth from fBm, pebbles where a high-frequency noise passes a threshold, and three stretched noise layers at different angles that become fallen pine needles crossing every way.
  • Rock: cell noise (Worley), which measures the distance to the nearest and second-nearest scattered point. The gap between those two distances draws dark seams between plates. Some areas break into smaller plates, each plate gets a domed face, and moss settles into the seams.
  • Desert sand: a sine wave bent by noise makes long wind ripples, with darker troughs and a scatter of red grit.
  • Basalt: dark stone with thin glowing orange cracks where a noise value sits very close to the middle.
  • Bark: noise stretched along one axis plus a sharpened sine wave for ridges.
  • Wood: sine grain warped by noise, with a dark line every quarter for plank gaps.

The ground set covers grass, dirt, rock, gravel, sand, leaf litter, snow, mud, basalt and ash. Materials for models add bark, pine needles, branches, leaves, cloth, leather, fur, metal, wood and stone. Every recipe uses fixed seeds, so the game paints the same textures on every load and on every player's machine.

Step 3: normal maps from height

The height channel each recipe returns gets turned into a normal map by taking the difference between each pixel's left and right neighbors and its upper and lower neighbors (wrapping at the edges, so it tiles too). Each recipe sets a strength: snow is soft at 2, rock is deep at 6. The result is that pebbles, seams and ridges catch the low sun and the campfire.

Step 4: hand it to the GPU

The finished canvases become Three.js canvas textures with repeat wrapping, mipmaps and anisotropic filtering, and each one is cached by name so it is only painted once. Ground textures are 512 x 512 and model materials are 256 x 256.

One map, one set of recipes

Each of the six maps picks its own base, accent and rock recipes, sometimes with new colors. Pinewood uses grass, dirt and gray mossy rock. Frostfall uses snow, cold gravel and blue-gray rock. Blackmire is mud over dark grass. Ashfall is basalt and ash with no moss. Amberwood is golden grass with leaf litter. Sunscar is sand, red gravel and red rock. Read about each place in the Last Ember maps guide.

Effect textures

Smaller textures handle effects: a soft dot for particles, a ring, a scorch decal, a flowing noise pattern for the lava shader, a water normal map, glowing cracks for charred trees, and a lens dirt texture. The lens dirt is painted with soft smudges, hexagonal bokeh rings, fine specks and a few wipe streaks. It stays invisible until bloom passes over it, the way dust on a real lens only shows when a light hits it.

How are the sound effects made?

Every sound effect is built from two primitives in `audio.js`: a tone and a burst of filtered noise. At startup the game fills a one-second buffer with white noise once. After that, every sound is a handful of numbers.

  • tone(frequency, duration, wave type, volume, slide) starts an oscillator, fades its volume down exponentially, and can slide the pitch up or down over the sound.
  • noise(duration, volume, filter frequency, filter type) plays a random slice of the noise buffer through a bandpass, lowpass or highpass filter, with the same fade.

Combine them and you get the whole game. A few real recipes:

SoundRecipe
Hit60 ms of noise through a 2,400 Hz bandpass
Level upFour triangle tones, 523, 659, 784 and 1,046 Hz, 70 ms apart (a C major arpeggio)
MiningA 1,500 Hz triangle, a 2,300 Hz sine and a short highpass noise tick
Thunder1.6 seconds of noise through a 120 Hz lowpass
Boss roarA 90 Hz sawtooth sliding down for 1.2 seconds over lowpass noise
NightfallA 55 Hz sawtooth and an 82 Hz sine, 2.5 seconds each

Busy sounds have a gate so a crowd of enemies does not turn into a wall of noise. A hit cannot replay within 45 ms of the last one, an explosion within 60 ms, and so on.

How does the procedural music work?

The soundtrack is played live by Web Audio from `music.js`. There are no recordings. A sequencer counts sixteenth notes and schedules each one about 0.3 seconds ahead on a timer that wakes every 90 ms, which keeps the timing tight even when the game is busy.

Keys, moods and tempos

Each map has its own key and mode, chosen for its mood:

MapKey and mode
PinewoodA minor
AmberwoodG dorian
FrostfallD minor, played high
BlackmireC harmonic minor
AshfallE phrygian
SunscarD phrygian dominant

Each moment of the game has its own eight-chord progression and tempo: the menu at 66 BPM, day at 84, night at 70, Blood Moon at 80, a boss at 132, and a boss's enraged second phase at 150. Changes crossfade instead of cutting. Learn what triggers those boss phases in the Last Ember bosses guide.

Instruments from oscillators

  • Pads use a custom waveform built from a list of harmonic strengths, played as two voices detuned 7 cents apart and panned left and right. Each chord is voiced close to the last one so the harmony moves smoothly. A very slow wobble (0.07 Hz) on the pad's filter makes it breathe.
  • Harp is a sine with a soft triangle an octave up: quick to speak, slow to fade.
  • Guitar is two plucked strings (a triangle and a slightly detuned sawtooth) through a lowpass filter that closes quickly after the attack.
  • Bells are a sine plus two out-of-tune partials at 2.76 and 5.4 times the pitch, which is what makes a bell sound like a bell.
  • Drums are pitch-dropping sines for kicks and toms, and filtered noise for snares, hats and shakers.

The room is generated too. The reverb is a 3.2-second burst of noise that decays to silence, fed to a convolver. The melody also goes through an echo set to three quarters of a beat, and a compressor on the output makes sure nothing clips.

Melodies that sound written

Random notes sound random, so the melody works in motifs. The game picks one of five rhythm templates, walks a short line of scale steps from it, and always lands the last note on a chord tone. That two-bar motif plays, repeats over the next chords, bends a little the third time, and resolves in the last two bars of the eight-bar phrase. After a couple of phrases a new motif takes over.

Three styles and battle music

Settings offers three styles. Cinematic has string-like pads, a harp that plays the motifs, and drums when it counts. Campfire has a fingerpicked guitar over soft pads with hand drums and a shaker. Ambient is slow pads and bells with no drums at all, and plays a little slower.

Battle music can follow the danger. In dynamic mode a tension value rises with the enemies around you. In Cinematic, past a point a heartbeat comes in, then a kick, then hats and a snare. Bosses get full drums, and the enraged phase adds a low string stab at the top of each bar, plus a war drum on every beat in the styles that have drums. You can also set battle music to bosses only, or off.

What about the look of the 3D world?

The cinematic look comes from a post-processing chain in `r3d/post.js`: volumetric light (sun and moon shafts through the trees and glowing haze around every fire), heat shimmer over flames and lava, depth of field, and a filmic grade with lens dirt, chromatic fringing that grows toward the edges, a light sharpening pass, split toning (cool shadows, warm highlights), and contrast applied in log space the way film grading does it.

The quality presets decide how much of it runs. The volumetric light marches 32 steps on Ultra, 22 on High and 12 on Medium, and Low turns it off. Ultra and High keep depth of field and lens effects.

No hitches, steady frame rate

Two more pieces in `r3d/engine.js` keep it smooth. While the world is being built, the engine compiles every shader the world can use, hidden effects included, so the first explosion or boss slam never stalls a frame. During play it watches frame times: after about 3 seconds of slow frames it drops the 3D resolution by 10 percent (down to 55 percent at the lowest), and after about 10 seconds of fast frames it gives 10 percent back. If a step up does not hold, it waits before trying again, so it never seesaws.

What we learned

  • Seeds are everything. Fixed seeds mean a texture looks the same on every load and for every player. Even the lens dirt texture gets its own seed, so everyone sees the same smudges.
  • Tileable noise is the foundation. Get the wrapping right once and every recipe tiles for free.
  • Two sound primitives go a long way. A tone and a filtered noise burst cover dozens of effects.
  • Structure beats randomness in music. Motifs, voice leading and fixed progressions are what make generated music sound composed.

If you are curious how the co-op side works, read how our room codes work. For the studio's other shader work, see how we built the ocean.

Frequently asked questions

Does Last Ember use any image or audio files?

No. Textures are painted from code at load, sound effects are synthesized with Web Audio, and the music is played live by a sequencer. Fonts come from Google Fonts and the interface icons are SVG paths from game-icons.net stored in the code.

Is Last Ember's music the same every time?

No. The keys, chords and tempos are fixed per map and mood, but the melodies are built from motifs the game generates as it plays, so the details change each session.

Can I change Last Ember's music style?

Yes. Settings has three styles (Cinematic, Campfire and Ambient), and battle music can be dynamic, bosses only, or off. Menu music can be turned off too.

What engine does Last Ember use?

Last Ember is built with Vite and plain JavaScript, with Three.js for the 3D renderer and a 2D canvas for the Classic pixel-art mode.