Sound
Everything First Light is heard through. One instance, on the scene's `sound` entity.
Sound
Everything First Light is heard through. One instance, on the scene's sound
entity.
It builds its emitters under a single sound_root child and rebuilds them
whole if a scene rebuild takes them away. It makes no gameplay decision and
writes nothing into FL.state: it listens to the bus and it sounds.
What is where
| Emitter | Clip | Channel | Spatial |
|---|---|---|---|
snd_bed | night_bed | ambient | no — the wind is everywhere |
snd_score | night_score | music | no |
snd_well | well_loop | ambient | at the Eternal Well |
snd_fire_<id> ×3 | ember_loop | ambient | at each altar |
snd_tend | tend_draw / tend_feed | sfx | no |
snd_cue | one-shot host | sfx | no |
snd_shot | one-shot host | sfx | moved to the point first |
snd_ui | one-shot host | ui | no |
snd_music | dawn swell, the three endings | music | no |
snd_voice | the Old Keeper | voice | no |
The three fires and the well are spatial, so where a fire is and how it is
doing are things the player hears from the direction of the altar. A fire's
source volume follows FL.state.fires[id].heat and drops to almost nothing
when it goes out; its pitch rises with the heat.
The bus it answers
say · ui.action · ui.focus · tend · flare · fire.died ·
fire.relit · scorch · stagger · daeva.spawned · daeva.banished ·
dawn · result · reset, plus FL.state.phase, which it reads every
frame rather than taking from the phase event — so a hot reload lands on
whatever the night is already doing.
ui.focus is offered rather than required: a screen that moves a controller
focus can FL.emit("ui.focus") and get the quiet tick, and nothing breaks if
nobody ever does.
The Old Keeper
He is the only voice. The WORDS are FL.lines, written and keyed by the loop
slice, which fires them at their moments with FL.speak(key). The READINGS
are .soundClip assets under game/audio/clips/, one per line, named
vo_<key> — eleven of them, generated once through audio_gen.speech by
game.audio.bake and cached, so a hot reload costs nothing.
game.audio.keeper holds the mapping and a verbatim snapshot of what each
cached reading says. Keeper.audit() compares that snapshot against
FL.lines and reports which readings are fresh, which are of older words, and
which are missing — so a line rewritten after its clip was made shows up as a
mismatch instead of playing the wrong reading under the new caption.
A line with no cached reading is captioned but not spoken, and the component logs which one. The bed still ducks for as long as the caption holds, so a missing reading costs the delivery and not the line.
The mix
Channel levels are set at build: ambient 0.75, music 0.55, sfx 0.78, ui 0.55, voice 1.0, and master 0.72 for headroom. The bed and the fires duck to 38% while the Keeper speaks and come back over about half a second, and the whole mix sits at 40% while the game is paused.
Measured against the master meter with audio.peakSince: the night itself
runs about 0.20 at the median, the Keeper's whole eleven-line script peaked at
0.80 with the bed and score under it, and nothing reached 1.0. A stagger over
a full mix reached 1.16 before the master came down — which is what the 0.72
is for.
Two things that go quiet without an error
A source told to autoplay can come up neverStarted or noDevice — a browser
will not start audio before the page has been touched — so the bed, the well
and the three fires are asked twice a second whether they are sounding and
started again if they are not.
The event bus lives on the game.FL module table, and editing that file
hot-reloads it into a NEW table with a NEW bus: every subscription taken on
the old one is left listening to something nobody emits on, and all of this
component's cues go silent with nothing in the log. The module is therefore
compared rather than remembered — twice a second — and the subscriptions are
retaken when it changes.
Re-baking
Nothing here creates a clip: asset.create on the gameplay path files into
the ephemeral runtime store, which would re-buy the Keeper's voice on every
load. The library is made once from a tool —
local B = require("game.audio.bake")
B.synth() -- free, deterministic, always works
B.speech() -- 2 credits a line, skips what is already on disk
B.effects({ "flare" }) -- always spends; name what to buy
Scoped to this part · feeds back into the world's score.