Good sound design can set a game's tone, give feedback when things happen, and otherwise enrich an experience which would otherwise be limited to the visual and tactile senses.
In dotbigbang, sound is controlled primarily using the TS API. Playing sounds have associated state, which you can use to manipulate their gain, pitch, or spatial audio properties.
The TS API functions and types referenced on this page were introduced in API version 0.1.3, so make sure you are on that version or a later one to use them.
Contents
There are three methods for playing a sound from TS API:
These will be explained in the examples below.
Playing a sound "simply" (that is, without any special settings), is very easy:
soundRef = new SoundRef();
start() {
const sound = this.soundRef.get();
if (!sound) throw new Error("soundRef must be set in the Game Editor!");
// Play the sound at default gain and pitch.
this.game.audio.play(sound);
// Play the sound at half gain (volume).
this.game.audio.play(sound, 0.5);
// Play the sound at half pitch (speed).
this.game.audio.play(sound, undefined, 0.5);
}
Use this in cases where you want to play a sound without spatialization, like when clicking a UI button or something.
Sometimes, you may want to play a sound with a random pitch modifier within some range. For example, maybe you're making a combat game, and you don't want each sword hit to sound exactly the same.
In such cases, it's simple to make a pool of sounds to choose from, and play them with a random pitch, like so:
sound1 = new SoundRef();
sound2 = new SoundRef();
sound3 = new SoundRef();
sound4 = new SoundRef();
minPitch = 0.75;
maxPitch = 1.25;
private _sounds: Sound[] = [];
start() {
// Build the pool of sounds.
for (let i = 1; i <= 4; ++i) {
const sound = (this as any)[`sound${i}`]?.get();
if (sound) {
this._sounds.push(sound);
}
}
if (!this._sounds.length) {
console.log("WARNING: no sounds provided!");
}
this.playRandomSound();
}
playRandomSound() {
// Early-out if we have no sounds to play.
// Needed because Random.index throws with a length of zero.
if (!this._sounds.length) {
return;
}
// Choose a sound from the pool.
const index = Random.index(this._sounds.length);
const sound = this._sounds[index];
if (sound) {
// Choose a random pitch within our range.
const pitch = Random.numberInRange(this.minPitch, this.maxPitch);
// Play the sound.
this.game.audio.play(sound, 1.0, pitch);
}
}
In this example, the sound will be played at full gain with a pitch multiplier (speed) in the range [0.75, 1.25].
In games with 3D gameplay, you often want sounds to seem as if they are "in the world" of the game, attenuating with distance from the camera, and panning between the left and right speakers based on their orientation to it.
There are two methods to play a spatial sound in dotbigbang:
Use playFromEntity
when you want a sound to be "attached" to a specific Entity
and follow it in 3D space. For example, say you have a moving enemy, and you
want its growling sound to follow it around the world. For another example, say
you have a spaceship, and you want it to make engine sounds as it flies.
Use playFromLocation
when you want a sound to be attached to a specific point
in 3D space. For example, you may want to play footstep or weapon sounds wherever
the corresponding gameplay events happened in the game world.
In this example, we wait a random amount of time in a specified range, then play a growling sound from our Entity. Then, we reset the timer for the next growl.
If the previous growl is still playing when it's time to start the next one, we stop it early, so the two sounds won't overlap each other.
The sound will "follow" the Entity as it moves around in space.
soundGrowl = new SoundRef();
minWait = 3;
maxWait = 6;
private _timeNextGrowl = 0;
private _playingSound: SoundPlayback|null = null;
start() {
this._scheduleGrowl();
}
postCollisionTick() {
if (this.game.frameTime >= this._timeNextGrowl) {
const sound = this.soundGrowl.get();
if (sound) {
// If a previous growl is playing, stop it first.
this._playingSound?.stop();
// Play our sound from this entity.
this._playingSound = this.game.audio.playFromEntity(sound, this.entity);
// Schedule the next one.
this._scheduleGrowl();
}
}
}
private _scheduleGrowl() {
this._timeNextGrowl =
this.game.frameTime + Random.numberInRange(this.minWait, this.maxWait);
}
In this example we specify two sets of footstep sounds to play when we walk on Entities with an associated tag. We build a list of sounds for each "material", and then play a random one with a random pitch when "stepping" on it.
The sound will remain at the location where the footstep occurred.
// How far in world units we must travel before playing the next footstep sound.
stride = 30;
// Sounds for stepping on grass.
grass1 = new SoundRef();
grass2 = new SoundRef();
grass3 = new SoundRef();
// Sounds for stepping on wood.
wood1 = new SoundRef();
wood2 = new SoundRef();
wood3 = new SoundRef();
private _tags: string[] = ['grass', 'wood'];
private _sounds: Record<string, Sound[]> = {};
private _lastStepLocation = new Vector3();
start() {
// Build a list of sounds for each tag.
for (const tag of this._tags) {
this._sounds[tag] = this._buildListFor(tag);
}
}
tick() {
// Check if we moved far enough to play a footstep sound.
const movedEnough = this.entity.worldTransform.position
.distanceToSquared(this._lastStepLocation) >= this.stride ** 2;
if (!movedEnough) {
return;
}
// Track where the last footstep sound happened.
this._lastStepLocation.copy(this.entity.worldTransform.position);
// Check if we stepped on something we have a sound for.
const hitTag = this._findHitTag();
if (!hitTag) {
return;
}
const sounds = this._sounds[hitTag];
if (!sounds?.length) {
return;
}
// Choose a random sound from the list.
const sound = sounds[Random.index(this._sounds[hitTag].length)];
if (!sound) {
return;
}
// Play the sound at our feet with a randomized pitch.
this.game.audio.playFromLocation(
sound,
this.entity.worldTransform.position,
0.2,
Random.numberInRange(0.8, 1.2)
);
}
// Build a list of sounds from our properties with a specific prefix.
private _buildListFor(prefix: string) {
const list: Sound[] = [];
for (let i = 1; i <= 3; ++i) {
const sound = (this as any)[`${prefix}${i}`]?.get();
if (sound) list.push(sound);
}
return list;
}
// Create a ray facing down.
private _ray = new Ray(undefined, new Vector3(0, -1, 0));
private _findHitTag() {
// Place the ray 20 units above our feet.
this._ray.origin.copy(this.entity.worldTransform.position);
this._ray.origin.y += 20;
// Look for what's beneath us.
const hits = this.game.getEntitiesWithColliderIntersectingRay(this._ray, 25);
for (const hit of hits) {
// See if we hit something with an associated sound.
for (const tag of this._tags) {
if (hit.entity.tags.has(tag)) {
return tag;
}
}
}
}
Sounds can be looped, and you can supply the start and end points of the loop, in seconds, when playing a sound.
Note: If you play a looping sound, and you don't keep up with the
SoundPlayback
instance associated with it, you will be unable to stop the
sound!
This script models a simple piston or platform that moves up and down, waiting for a time at the top and bottom of its path. When it begins to move or stops moving, it plays a "one off" sound, and during motion it plays a looping sound.
enum State {
WaitBottom,
WaitTop,
MoveDown,
MoveUp,
}
export class Script extends UserScriptComponent {
startSound = new SoundRef();
stopSound = new SoundRef();
moveSound = new SoundRef();
moveY = 30;
waitTime = 2;
moveTime = 5;
// Which part of the cycle we're currently in.
private _state = State.WaitBottom;
// The frameTime when this phase ends and the next begins.
private _timerEnds = 0;
private _startPos = new Vector3();
private _targetPos = new Vector3();
// To hold the playing, looped sound during movement.
private _moveSound: SoundPlayback|null = null;
start() {
// Store where we are and where we'll be going.
this._startPos.copy(this.entity.localTransform.position);
this._targetPos.copy(this._startPos);
this._targetPos.y += this.moveY;
// Set the timer for the first phase.
this._timerEnds = this.game.frameTime + this.waitTime;
}
tick() {
switch (this._state) {
case State.WaitBottom:
if (this.game.frameTime > this._timerEnds) {
this._state = State.MoveUp;
this._timerEnds = this.game.frameTime + this.moveTime;
this._playSound(this.startSound);
this._moveSound = this._playLoopingSound(this.moveSound, 0, 1);
}
break;
case State.WaitTop:
if (this.game.frameTime > this._timerEnds) {
this._state = State.MoveDown;
this._timerEnds = this.game.frameTime + this.moveTime;
this._playSound(this.startSound);
this._moveSound = this._playLoopingSound(this.moveSound, 0, 1);
}
break;
case State.MoveUp:
if (this.game.frameTime > this._timerEnds) {
this._state = State.WaitTop;
this._timerEnds = this.game.frameTime + this.waitTime;
this._playSound(this.stopSound);
this._moveSound?.stop();
this.entity.worldTransform.position.copy(this._targetPos);
} else {
// Animate moving up to the target position.
const t = 1.0 - (this._timerEnds - this.game.frameTime) / this.moveTime;
this.entity.worldTransform.position
.lerpVectors(this._startPos, this._targetPos, t);
}
break;
case State.MoveDown:
if (this.game.frameTime > this._timerEnds) {
this._state = State.WaitBottom;
this._timerEnds = this.game.frameTime + this.waitTime;
this._playSound(this.stopSound);
this._moveSound?.stop();
this.entity.worldTransform.position.copy(this._startPos);
} else {
// Animate moving down to the start position.
const t = 1.0 - (this._timerEnds - this.game.frameTime) / this.moveTime;
this.entity.worldTransform.position
.lerpVectors(this._targetPos, this._startPos, t);
}
break;
}
}
private _playSound(ref: SoundRef) {
const sound = ref.get();
if (sound) this.game.audio.playFromEntity(sound, this.entity);
}
private _playLoopingSound(ref: SoundRef, loopStart: number, loopEnd: number) {
const sound = ref.get();
if (sound) {
return this.game.audio.playFromEntity(sound, this.entity,
undefined, // stop policy
undefined, // gain
undefined, // pitch
undefined, // offset
true, // looping
loopStart,
loopEnd
);
}
return null;
}
}
All playback methods return a SoundPlayback
instance, and you can use it to
change the properties of your sound as it plays. For example, you could change
the gain or pitch of the sound.
You could use this to great effect when playing engine sounds for a vehicle, or by slowing down music in a cutscene to make it out of key.
If you are playing a spatial sound, and you want to manipulate its spatial
properties during playback, you will need to store a SoundPositionalPlayback
instead of a normal SoundPlayback
—otherwise, storing a regular SoundPlayback
is fine.
In this example, we change a sound's pitch based on a sine wave as time passes. This results in the sound slowing down and speeding up in a smooth curve.
soundRef = new SoundRef();
// How long the sine wave should be, in seconds.
period = 6;
private _timer = 0;
private _playback: SoundPlayback | null = null;
start() {
// Start a looping sound.
const sound = this.soundRef.get();
if (sound) {
this._playback = this.game.audio.play(
sound, // the sound to play
0.5, // gain
1.0, // starting pitch
0, // offset from start (in seconds)
true // looping
);
}
}
tick() {
if (!this._playback) {
return;
}
this._timer += this.game.frameDeltaTime;
// Change the sound's pitch in a sine wave while it plays.
this._playback.pitch = 1.0 + Math.sin(this._timer/this.period * Math.PI) * 0.5;
}
Use this.game.audio.setListenerPosition
to set the position in 3D space where
the player's "ear" should be considered for spatial sounds. This will affect
the attenuation of spatial sounds.
Use this.game.audio.setListenerOrientation
to specify how the player's "ear"
is rotated in the world. This will affect the panning of spatial sounds.
Most games come with a camera Entity which already has the UserScript
dbb_spatialized_audio_listener
on it, which will call the methods mentioned
above to update the audio listener as you play.
In some games, the option onlyUpdateIfEntityIsActiveCamera
is turned on, so
that this.game.cameraEntity
can be swapped out for cutscenes etc, and the
listener will follow the new camera seamlessly.
In other games, the player Entity itself is the audio listener, and in others still, the player Entity updates the listener's position, but the camera Entity updates the listener's orientation.
For most types of games, it's fine to leave the active camera Entity as the audio listener.
You can set this.game.audio.masterGain
to a value between zero and one,
inclusive, to limit the volume of all sounds in the game. This is a simple
multiplication: Whatever gain the sound would have is multiplied with
masterGain
for playback.
That is, a masterGain
of 0.5
will play all audio in the game at half volume.
Setting this value affects all currently playing sounds, and all sounds played after.
There are several parameters for spatial sounds left unexplored here, and you can find those documented on this page.
Using them, you can specify how the sound should attenuate as the listener moves farther away from it, what radii are used for those calculations, and more.