sfx
Everything in FIRST LIGHT that makes a noise goes through here.
game.sound.sfx — the sound library
Everything in FIRST LIGHT that makes a noise goes through here.
local sfx = require("game.sound.sfx")
sfx.play("helm_off") -- a one-shot, heard flat
sfx.play("footstep", { at = footEntity }) -- a one-shot, heard from where it is
sfx.play("lava_bubble", { at = { x, y, z }, pitch = 0.8, volume = 0.6 })
local hold = sfx.loop("free_hold") -- a sustained sound
sfx.loop("free_hold", { pitch = 1.2 }) -- same source, moved
sfx.stop(hold)
sfx.bed("rim_wind") -- crossfade the ambience
sfx.duck(2.5) -- get out of a spoken line's way
sfx.music("music_dawn") -- crossfade the score
Guard the require if your slice must survive the library being absent:
if asset.exists("game.sound.sfx", "module") then
sfx = require("game.sound.sfx")
end
The bank
| name | when |
|---|---|
ui_move ui_confirm ui_back | menu focus, a control taken, a card closed |
helm_off helm_on | the one verb — the world arriving, and shutting |
footstep jump land | the pilgrim on the rock |
crust_crack plate_collapse | a plate warning, and a plate going |
ember_hit fumarole | ember weather on a bare head, gas out of the rock |
knight_free lava_bubble | one of the lost released; the tide, close |
win_sting lose_sting | first light, and being taken back |
narrator_bed | a floor for a voice to stand on |
lava_rumble rim_wind free_hold | looping beds |
music_title music_climb music_dawn music_drone | the score |
sfx.clip(name) hands back the .soundClip; sfx.missing() lists any the
bank could not resolve, so a caller can tell a complete library from a
partial one.
The mix
Every clip carries a default level, so a caller that passes no volume still
lands in a mix rather than at full scale. The helm, the knights and the ends
of a round are loud; footsteps, ambience and interface sit under them.
One-shots overlap freely, but the same clip will not start twice inside a
short window — three copies of a two-second vent running together is one
waveform at three times the amplitude, which is a comb filter and a peak
rather than a louder vent. Interface ticks and footsteps are effectively
unguarded; the long textural clips hold each other off for a fraction of
their length. A refused start returns false from sfx.play.
The master sits at 0.72 rather than 1, because audio.levels().peak is
measured after the master and the loud moments of a full mix — the score, two
beds, a sting and a spoken line at once — go over full scale without it. It is
stated once and never re-asserted, so a settings screen can set its own.
Three mixer channels are in play. music and ambient are owned here and
move with the round — they fall to about a quarter under duck. The sfx
channel is deliberately left at unity, because that is where a spoken line
plays and it is the thing the duck exists to make room for.
What owns what
game.sound.Sound (mounted on mount_sound) ticks this library and drives
the automatic mix from L.state — the tide below rising with the lava's
proximity, the air at the rim rising with L.dawn(), the score following the
phase. It re-states that mix every frame, which is what makes a hot reload
harmless.
An explicit sfx.bed(name) from another slice therefore holds the ambience
for a few seconds before the automatic mix takes it back; sfx.play,
sfx.loop, sfx.duck and sfx.music are not overridden at all.
The emitters live under a temporary SoundEmitters entity and are found by
name, so a hot reload re-attaches to the sources already standing instead of
spawning a second set.
Where the sound comes from
Every clip is synthesised on the CPU by game.sound.synth and baked into a
.soundClip under game/sound/clips/; the three scored tracks are generated,
then trimmed and folded into seamless loops by the same module. Loops are
stored as codec = "pcm" so the head of the clip survives decoding and the
seam does not click.
Scoped to this part · feeds back into the world's score.