How to play a different step sound based on the terrain type in a 2D top down game?

In 2D top down games there is no reason to play the same step sound when walking on different terrain types. While it requires a certain workflow when working with tile maps it is pretty easy to achieve.

*In the video there is a slight delay as the movement speed is a bit higher than it should so the player moves faster than the next step occurs. Sorry about that :)

 

Tilemap workflow

I will assume that you are using tilemaps in your project. If not the setup will be probably simpler since its all about colliders and Z position. Yes Z position. I mention this because Unity 2D physics doesn’t use Z axis and it certainly doesn’t allow us to shoot a default recast in Z direction - Physics2D.Raycast() only works on XY plane. Ok lets start with setting up the tilemaps.

First we need to split our tilemaps so that our sand terrain is on a separate tilemap than the grass or anything else:

 

Ok with that done all we need is a collider on each terrain type (tilemap) and we are good to go? Not exactly:

The problem is that all the tilemaps are on the same Z position. How will we know which one is on top?

The problem is that all the tilemaps are on the same Z position. How will we know which one is on top?

 

Our tilemaps uses sorting layers to inform unity which on should be rendered on top. The z position doesn’t influence sorting layers. The problem is that we can have 2 tilemaps with the same sorting order so we can’t simply loop through a list of tilemaps to find the one that we need. We need to put the tilemaps each one on a different Z position. This is because we will want to use Physics2D.OverlapPoint() which:

Checks if a Collider overlaps a point in space.

(…)

If more than one Collider overlaps the point then the one returned will be the one with the lowest Z coordinate value.

Keep in mind that unity uses left-handed coordinate system so Z axis goes into the screen (positive values goes into the screen) and the camera is on z = -10. All it means is that if our player is at Z = 0 we need to put the tilemaps on Z > 0. so the collider with “lowest z coordinate value” is closest to the camera:

 

The result in 3D view should look something like this:

 
Top blue area and colourful blobs are the “sea” tilemap, trees and player. Next below we have wood / bones tilemaps.Next we have grass tilemap.Lastly we have sand tilemap.

Top blue area and colourful blobs are the “sea” tilemap, trees and player.

Next below we have wood / bones tilemaps.

Next we have grass tilemap.

Lastly we have sand tilemap.

 

Last thing that we need is an addition of a collider for each tilemap:

 

First (1) we need to add a Tilemap Collider 2D so that we can detect the tilemaps hence the terrain type. Next (2) for efficiency purpose we will use Composite Collider 2D that we want to set as Trigger. It will automatically add for us the Rigidbody2D. Set the latter (4) to be static. Make sure (3) that you check “Used By composite” in the Tilemap Collider 2D. Last thing that you want to set (5) is on the GameObject itself the checkbox Static.

Now while using Composite Collider 2D will make our game more efficient we need to set a specific setting in it (otherwise we will not be able to detect this collider using the Physics2D.OverlapPoint() or any other type of raycast):

Make sure to set the “Geometry type” to Polygons otherwise the Physics2D.OverlapPoint() that we want to use will not detect the tilemap colliders.

Make sure to set the “Geometry type” to Polygons otherwise the Physics2D.OverlapPoint() that we want to use will not detect the tilemap colliders.

 
Last thing to make our detection easier - set all the terrain tilemaps to have the same LayerMask.

Last thing to make our detection easier - set all the terrain tilemaps to have the same LayerMask.

 

Detectiong the terrain type

Ok now we need to detect the terrain type to play a specific sound. I will add an animation event to call a method responsible for this detection only when my run animation plays the step frames:

 
The name InvokeAnimationAction is just a way to reuse the events for different purpose across all the animations. You can learn more by going through my Make a 2D platformer course using Design Patterns course.

The name InvokeAnimationAction is just a way to reuse the events for different purpose across all the animations. You can learn more by going through my Make a 2D platformer course using Design Patterns course.

At the end I will have a script called “Step Sound Feedback” that will have a reference to an Audio
 

At the end we will have a new script called “Step Sound Feedback” which will have a LayerMask to know what to shoo at. Ignore Min/Max Z depth as this is from my trial and error development process. We will also have a reference transform which will be used as the detection point for the Physics2D.OverlapPoint(). For me its Agent as this game object actually moves around the map. We will also have an AudioSource which will play the sound.

Here is the script:

public class StepSoundFeedback : MonoBehaviour { [SerializeField] private LayerMask layerMask; [SerializeField] private Transform agent; [SerializeField] private AudioSource audioSource; public void PlayStepSound() { Collider2D collider = Physics2D.OverlapPoint(agent.position, layerMask); if (collider != null) { StepSoundData data = collider.GetComponent<StepSoundData>(); if ( data != null && (audioSource.isPlaying == false || audioSource.clip != data.StepClip || audioSource.time / audioSource.clip.length > 0.2f)) { audioSource.clip = data.StepClip; audioSource.pitch = Random.Range(0.95f, 1.05f); audioSource.Play(); } } } }

The PlayStepSound() method will be played when the step animation event gets triggered. We will use Physics2D.OverlapPoint() to detect the collider with a smallest Z axis value (as I have told you Z goes into the screen so the furthest collider will have the highest Z axis value).

soulution_11_split.PNG
 

If collider is not null we will get StepSoundData component (code will be shown later). The idea is that it contains the audio clip and to keep our setup easily extensible we add a new tilemap and add to it this component. Than all we need is to assign the audio clip and it will work with our current setup without any changes.

if (collider != null) { StepSoundData data = collider.GetComponent<StepSoundData>(); (...)

Ok. Lets assume that the data is not null. In the next if statement I will add some specific if statement checks. I am using Blend tree for my 2d animation so to prevent the steo sound being player 2 times when the animations gets blended I am checking if we are not trying to replay the same step sound and if the sound has been already played for 20% before we play the next one:

if ( data != null && (audioSource.isPlaying == false || audioSource.clip != data.StepClip || audioSource.time / audioSource.clip.length > 0.2f ) )

Otherwise when you change the movement direction just after starting to play the step sound you will have the step sound played a second time causing strange audio artefacts.

If everything went well we will set the clip, randomize the pitch (to add some variability to the sounds) and play the clip. Easy.

audioSource.clip = data.StepClip; audioSource.pitch = Random.Range(0.95f, 1.05f); audioSource.Play();

Ok here is the StepDoundData class:

public class StepSoundData : MonoBehaviour { [SerializeField] private List<AudioClip> stepClips; public AudioClip StepClip { get { if(stepClips.Count == 1) { return stepClips[0]; } else if(stepClips.Count == 0) { return null; } return stepClips[Random.Range(0, stepClips.Count)]; } } }

We have a StepClip public property that depending on the size of the List<AudioClip> will return null, first clip or a random one if we have more than 1 clip.

Here is how you would use it:

 
 
 

Again this way we can easily add a new tilemap and if we put on it the StepSoundData script and assign to it the AudioClips the previously written system will work with it. It’s much better than having “if-else if” block where you check for each type of terrain to select the specific AudioClip for it.

If all went well the next time you play your game it should be tears / gaps free :)

 
Collider is “BaseGround” tilemap

Collider is “BaseGround” tilemap

 
 

Now you should be able to hear different sound when stepping on different terrain type. My setup could be improved as

 

Want to learn more about making 2D games in Unity ?

To learn more on how to improve the way you write code by making 2D games from scratch check out my Make a 2D platformer using Design Patterns video courses :

 
 

You can also support me through Patreon:

 

If you agree or disagree let me know by joining the Sunny Valley Studio discord channel :)

Thanks for reading!

Peter

Previous
Previous

How to take a screenshot in Unity?

Next
Next

How to remove tears appearing between tiles