Advertisement
Game Audio

Adding a Heal Sound Effect to a Defold Game: Asset Pipeline and Playback Triggers

Getting a heal sound into Defold: file formats, component setup, playback messages and the trigger points that keep the cue in sync with the pickup.

The heal cue plays for about 90 ms and then stops dead. The pickup prefab holds the sound component, the collision handler calls go.delete(), and the tail dies with the game object.

Nothing in the WAV was wrong. The asset imported, the message posted, the sound started, and then the component that owned it was destroyed at the end of the frame.

The cue still has to land inside roughly 300 ms of the health change, and the timing budget for that is covered in health pickup cue design. What follows is the other half: getting the file in, addressing a component that outlives the pickup, and firing the cue at the right point in the heal logic.

Get the File In Without Surprises

Defold plays WAV and Ogg Vorbis. For a 200 to 300 ms cue, 16-bit PCM WAV at 44.1 kHz mono is the boring choice and the right one: no decode step, nothing for the codec to smear near the transient, about 22 KB for 250 ms.

Ogg is the better fit when the cue carries a printed tail of a second or more. Vorbis is a transform codec, and a hard attack on near silence is what it handles worst: pre-echo can fog the first 10 to 20 ms, which is the part that says something happened.

Trim the head to 5 ms or less. At 60 fps a frame is 16.7 ms, so 40 ms of lead-in is two and a half frames of nothing while the player keeps running. Keep a 20 to 30 ms fade at the tail so the file does not end on a click, peak around -3 dBFS, and leave the final balance to gain in the engine.

Drop the file into the project and create a .sound resource pointing at it; recent editor versions create the .sound when you drag audio into the Assets pane. The sound component references the .sound, not the WAV. Looping off. Mono for anything positioned by listener distance, stereo only for 2D cues, because a stereo file played positionally gets collapsed and stops sounding like what you auditioned.

The Component Has to Outlive the Cue

go.delete() is deferred to the end of the update, so the sequence looks safe: post play_sound, delete the pickup, frame ends. The component is destroyed in the same frame, and a cue that started 0 ms ago has 240 ms left to run. Platforms differ on whether you get a hard cut or a fragment; neither is the cue you auditioned.

Three placements, in order of how often they bite:

  • On the pickup prefab. Works only while the cue is shorter than a frame or the pickup is pooled instead of deleted, and that constraint gets forgotten.
  • On the player. Survives the pickup and is easy to address with msg.post("#heal", "play_sound"), but one component means co-op players share it.
  • On an audio object in the bootstrap collection. Outlives every level, accepts posts from anywhere with a full path such as msg.post("/audio#heal", "play_sound").

The relative form #heal resolves against the sender object, so it only works when the posting script shares a game object with the component. The absolute form /audio#heal works from any loaded collection, and sound.play() accepts the same url shape.

Message, Gain and Speed

The call takes a properties table: msg.post("#heal", "play_sound", { play_properties = { gain = 0.8, speed = 1.0 } }). play_properties is the part people miss. Gain passed at the top level of the message does nothing, and nothing warns you.

gain is a linear multiplier, so 0.5 is about -6 dB and 0.707 is -3 dB. A value of 0.8 is close to a 2 dB trim, which is usually enough to sit a pickup under gunfire without flattening its attack. delay is in seconds and is for lining a cue up with an animation, not for covering logic that fires late.

msg.post goes through the queue; sound.play() is a direct call. Inside one on_message the difference is small, but posting and then mutating the same component in the same function can run in an order you did not write.

Speed Is the Cheapest Pitch Control You Have

speed is a playback rate multiplier that transposes as it goes. 2.0 is up an octave, 0.5 down an octave, 1.0595 up a semitone, 1.1225 up two. The relation is semitones = 12 * log2(speed).

It is not a pitch shifter, and duration scales with it: a 280 ms cue at 1.1225 finishes in about 249 ms. For a short heal cue that is fine, since the cue clears the mix sooner. It is not fine if the decay was tuned by ear or carries a music bed.

Use it for stacking and character variants: second and third pickup in a burst step up a semitone each, capped at +3, a heavy character at 0.94, a fast one at 1.06. Tie the variation to a rule rather than a random number, or the cue stops reading as the same event. Keep the ratio inside about plus or minus three semitones; past that the resampling thins the body and a 500 Hz fundamental starts sounding like a smaller object.

If you are synthesising instead of importing, the rules do not change — procedural audio in Defold still needs a live component and a play message; only the source of the samples moves.

Groups, Priority and Pickup Spam

Groups are the bus structure: pickups in one, music in another, ducked with sound.set_group_gain() for pause menus. Check the units before trusting a value, because play_properties gain is linear while the group call in the Lua API is in dB. Feeding 0.5 to a call that expects dB is a quiet way to land far from where you meant.

Priority is yours to build; the engine does not arbitrate two cues that start on the same frame. First in wins sets a boolean before playing, clears it in the complete function, and ignores a pickup that arrives inside the window. Honest, but a second pickup then feels unresponsive in a room full of drops.

Last in wins stops the running cue with sound.stop() and restarts. Overlap goes away, the attack restarts, and two restarts inside 100 ms read as a stutter. Stepping the pitch is usually the better compromise: last in wins, one semitone per overlapping instance, capped so the third pickup does not turn into a squeak.

Once several cues need a shared entry point, the addressing and lifetime patterns for that are in building a sound system in Defold.

Ordering the Cue Against the Heal Logic

The order that avoids the usual bugs: resolve the overlap, check that the player is not at full health, apply and clamp the heal, post the cue, remove the pickup.

The health check comes first because a heal sound at full health is a lie at the worst possible moment, and a louder rejection cue is a different asset rather than the same one at a lower gain.

Applying the heal before the cue attaches the sound to the state change rather than the collision. When healing runs through a shared module, a network check or a regen tick, the cue waits for the value to move, and a 100 ms gap is audible on a 250 ms cue because the health bar flash has already finished.

Removing the pickup last only matters when the cue is posted relative to it. Once the component lives on the player or the audio object, removal order stops being load-bearing, which is the second argument for moving it off the prefab.

What a Collection Proxy Changes

A proxied level is not in memory until msg.post("#levelproxy", "load") returns proxy_loaded, then enable starts it and init runs on its scripts; unload tears it down. Two consequences for audio.

First, sounds inside the proxied collection exist only while it is loaded, so unloading a level with a cue still ringing cuts it. Second, the components only become addressable after the load completes, and an early post fails quietly.

Keep the heal cue and the other short pickup cues in the bootstrap collection or a shared audio collection loaded once at startup, and let the level proxy carry geometry, enemies and level assets. The cue is then always one message away. The cost is resident memory: nothing for a handful of 20 KB WAVs, a problem for a three-minute music track.

One more detail: the first play after a collection finishes loading can arrive late while the engine settles. If a pickup can be collected in the first second, warm the cue at gain 0 during the load screen rather than finding the gap in playtest.

The Test That Catches Most of This

Build one test level: the prefab, a player, and a spawner that drops five health items at the player feet inside two seconds. Run it with the component on the prefab, then move the component to the audio object and run it again.

Then check three things: the cue finishes after the prefab is gone, the second pickup inside 250 ms is stepped or skipped on purpose rather than doubled, and the cue survives a level unload. If all three hold, the pipeline is done and the rest is editing the WAV.

Advertisement